Archived
fix: respect Pi session directories by cwd
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Respect Pi session directory settings in pi-web sessions, including project-local Pi settings, while addressing session operations with their workspace context.
|
||||
@@ -272,6 +272,8 @@ Environment variables:
|
||||
- `PI_WEB_SESSIOND_URL` — daemon URL used by the web process when connecting over TCP, for example `http://127.0.0.1:3001`. If you set `PI_WEB_SESSIOND_PORT`, set this for the web process too.
|
||||
- `PI_WEB_PROJECTS_FILE` — optional override for the projects storage JSON file. Defaults to `$PI_WEB_DATA_DIR/projects.json`.
|
||||
- `PI_WEB_MACHINES_FILE` — optional override for the remote machine registry JSON file. Defaults to `$PI_WEB_DATA_DIR/machines.json`.
|
||||
- `PI_CODING_AGENT_SESSION_DIR` — Pi session storage directory. PI WEB follows the same session-location priority as Pi for web sessions: this environment variable, then `sessionDir` in Pi settings for the selected workspace, then Pi's default session directory.
|
||||
- `PI_CODING_AGENT_DIR` — Pi agent config directory. PI WEB uses this for Pi auth, settings, resources, and default session storage, matching Pi's own configuration layout.
|
||||
|
||||
## Development services
|
||||
|
||||
|
||||
@@ -241,6 +241,8 @@
|
||||
<li><code>PI_WEB_HOST</code>: web server bind host. Overrides the config file. Use <code>127.0.0.1</code> for local/tunnel-only access, or a specific VPN/private-network IP for trusted remote access.</li>
|
||||
<li><code>PI_WEB_DATA_DIR</code>: data directory, default <code>~/.pi-web</code>.</li>
|
||||
<li><code>PI_WEB_SESSIOND_SOCKET</code>: Unix socket path for daemon communication.</li>
|
||||
<li><code>PI_CODING_AGENT_SESSION_DIR</code>: Pi session storage directory. PI WEB follows Pi's priority for sessions: this environment variable, then <code>sessionDir</code> in Pi settings for the selected workspace, then Pi's default session directory.</li>
|
||||
<li><code>PI_CODING_AGENT_DIR</code>: Pi agent config directory for auth, settings, resources, and default session storage.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export { activityApi, api, configApi, filesApi, gitApi, machinesApi, piWebApi, pluginsApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients";
|
||||
export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets";
|
||||
export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineStatus, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, Project, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SessionActivity, SessionInfo, SessionModel, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
|
||||
export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineStatus, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, Project, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SessionActivity, SessionInfo, SessionRef, SessionModel, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { FileSuggestion, PiWebConfigValues, RunTerminalCommandInput, TerminalCommandRun, TerminalCommandRunFilter } from "../../../shared/apiTypes";
|
||||
import type { FileSuggestion, PiWebConfigValues, RunTerminalCommandInput, SessionRef, TerminalCommandRun, TerminalCommandRunFilter } from "../../../shared/apiTypes";
|
||||
import { request } from "./http";
|
||||
import {
|
||||
arrayOf,
|
||||
@@ -39,6 +39,15 @@ import { machineGitDiffUrl, messageUrl } from "./urls";
|
||||
|
||||
const machinePrefix = (machineId = "local") => `/api/machines/${encodeURIComponent(machineId)}`;
|
||||
|
||||
function sessionUrl(session: SessionRef, endpoint: string, machineId = "local"): string {
|
||||
return `${machinePrefix(machineId)}/sessions/${encodeURIComponent(session.id)}/${endpoint}`;
|
||||
}
|
||||
|
||||
function sessionQueryUrl(session: SessionRef, endpoint: string, machineId = "local"): string {
|
||||
const query = new URLSearchParams({ cwd: session.cwd }).toString();
|
||||
return `${sessionUrl(session, endpoint, machineId)}?${query}`;
|
||||
}
|
||||
|
||||
export const piWebApi = {
|
||||
piWebStatus: () => request("/api/pi-web/status", parsePiWebStatusResponse),
|
||||
};
|
||||
@@ -80,25 +89,25 @@ export const workspacesApi = {
|
||||
export const sessionsApi = {
|
||||
sessions: (cwd: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions?cwd=${encodeURIComponent(cwd)}`, arrayOf(parseSessionInfo)),
|
||||
startSession: (cwd: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions`, parseSessionInfo, { method: "POST", body: JSON.stringify({ cwd }) }),
|
||||
messages: (sessionId: string, options?: { limit?: number; before?: number }, machineId = "local") => request(messageUrl(sessionId, options, machineId), parseMessagePage),
|
||||
status: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/status`, parseSessionStatus),
|
||||
models: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/models`, parseModelSelectionResponse),
|
||||
setModel: (sessionId: string, provider: string, modelId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/model`, parseSessionStatus, { method: "POST", body: JSON.stringify({ provider, modelId }) }),
|
||||
cycleModel: (sessionId: string, direction: "forward" | "backward", machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/model/cycle`, parseSessionStatus, { method: "POST", body: JSON.stringify({ direction }) }),
|
||||
thinkingLevels: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/thinking-levels`, parseThinkingLevelsResponse),
|
||||
setThinkingLevel: (sessionId: string, level: "off" | "minimal" | "low" | "medium" | "high" | "xhigh", machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/thinking-level`, parseSessionStatus, { method: "POST", body: JSON.stringify({ level }) }),
|
||||
cycleThinkingLevel: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/thinking-level/cycle`, parseSessionStatus, { method: "POST" }),
|
||||
commands: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/commands`, arrayOf(parseSlashCommand)),
|
||||
prompt: (sessionId: string, text: string, streamingBehavior?: "steer" | "followUp", machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/prompt`, parseAccepted, { method: "POST", body: JSON.stringify(streamingBehavior === undefined ? { text } : { text, streamingBehavior }) }),
|
||||
shell: (sessionId: string, text: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/shell`, parseAccepted, { method: "POST", body: JSON.stringify({ text }) }),
|
||||
runCommand: (sessionId: string, text: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/commands/run`, parseCommandResult, { method: "POST", body: JSON.stringify({ text }) }),
|
||||
respondToCommand: (sessionId: string, requestId: string, value: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/commands/respond`, parseCommandResult, { method: "POST", body: JSON.stringify({ requestId, value }) }),
|
||||
abort: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/abort`, parseAborted, { method: "POST" }),
|
||||
stop: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/stop`, parseStopped, { method: "POST" }),
|
||||
archive: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/archive`, parseArchived, { method: "POST" }),
|
||||
archiveWithDescendants: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/archive-tree`, parseArchived, { method: "POST" }),
|
||||
restore: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/restore`, parseRestored, { method: "POST" }),
|
||||
detachParent: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/detach-parent`, parseDetached, { method: "POST" }),
|
||||
messages: (session: SessionRef, options?: { limit?: number; before?: number }, machineId = "local") => request(messageUrl(session, options, machineId), parseMessagePage),
|
||||
status: (session: SessionRef, machineId = "local") => request(sessionQueryUrl(session, "status", machineId), parseSessionStatus),
|
||||
models: (session: SessionRef, machineId = "local") => request(sessionQueryUrl(session, "models", machineId), parseModelSelectionResponse),
|
||||
setModel: (session: SessionRef, provider: string, modelId: string, machineId = "local") => request(sessionUrl(session, "model", machineId), parseSessionStatus, { method: "POST", body: JSON.stringify({ cwd: session.cwd, provider, modelId }) }),
|
||||
cycleModel: (session: SessionRef, direction: "forward" | "backward", machineId = "local") => request(sessionUrl(session, "model/cycle", machineId), parseSessionStatus, { method: "POST", body: JSON.stringify({ cwd: session.cwd, direction }) }),
|
||||
thinkingLevels: (session: SessionRef, machineId = "local") => request(sessionQueryUrl(session, "thinking-levels", machineId), parseThinkingLevelsResponse),
|
||||
setThinkingLevel: (session: SessionRef, level: "off" | "minimal" | "low" | "medium" | "high" | "xhigh", machineId = "local") => request(sessionUrl(session, "thinking-level", machineId), parseSessionStatus, { method: "POST", body: JSON.stringify({ cwd: session.cwd, level }) }),
|
||||
cycleThinkingLevel: (session: SessionRef, machineId = "local") => request(sessionUrl(session, "thinking-level/cycle", machineId), parseSessionStatus, { method: "POST", body: JSON.stringify({ cwd: session.cwd }) }),
|
||||
commands: (session: SessionRef, machineId = "local") => request(sessionQueryUrl(session, "commands", machineId), arrayOf(parseSlashCommand)),
|
||||
prompt: (session: SessionRef, text: string, streamingBehavior?: "steer" | "followUp", machineId = "local") => request(sessionUrl(session, "prompt", machineId), parseAccepted, { method: "POST", body: JSON.stringify(streamingBehavior === undefined ? { cwd: session.cwd, text } : { cwd: session.cwd, text, streamingBehavior }) }),
|
||||
shell: (session: SessionRef, text: string, machineId = "local") => request(sessionUrl(session, "shell", machineId), parseAccepted, { method: "POST", body: JSON.stringify({ cwd: session.cwd, text }) }),
|
||||
runCommand: (session: SessionRef, text: string, machineId = "local") => request(sessionUrl(session, "commands/run", machineId), parseCommandResult, { method: "POST", body: JSON.stringify({ cwd: session.cwd, text }) }),
|
||||
respondToCommand: (session: SessionRef, requestId: string, value: string, machineId = "local") => request(sessionUrl(session, "commands/respond", machineId), parseCommandResult, { method: "POST", body: JSON.stringify({ cwd: session.cwd, requestId, value }) }),
|
||||
abort: (session: SessionRef, machineId = "local") => request(sessionUrl(session, "abort", machineId), parseAborted, { method: "POST", body: JSON.stringify({ cwd: session.cwd }) }),
|
||||
stop: (session: SessionRef, machineId = "local") => request(sessionUrl(session, "stop", machineId), parseStopped, { method: "POST", body: JSON.stringify({ cwd: session.cwd }) }),
|
||||
archive: (session: SessionRef, machineId = "local") => request(sessionUrl(session, "archive", machineId), parseArchived, { method: "POST", body: JSON.stringify({ cwd: session.cwd }) }),
|
||||
archiveWithDescendants: (session: SessionRef, machineId = "local") => request(sessionUrl(session, "archive-tree", machineId), parseArchived, { method: "POST", body: JSON.stringify({ cwd: session.cwd }) }),
|
||||
restore: (session: SessionRef, machineId = "local") => request(sessionUrl(session, "restore", machineId), parseRestored, { method: "POST", body: JSON.stringify({ cwd: session.cwd }) }),
|
||||
detachParent: (session: SessionRef, machineId = "local") => request(sessionUrl(session, "detach-parent", machineId), parseDetached, { method: "POST", body: JSON.stringify({ cwd: session.cwd }) }),
|
||||
authProviders: (options?: { mode?: "login" | "logout"; authType?: "oauth" | "api_key"; machineId?: string }) => {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.mode !== undefined) params.set("mode", options.mode);
|
||||
|
||||
@@ -15,6 +15,7 @@ const workspace: Workspace = {
|
||||
isGitRepo: true,
|
||||
isGitWorktree: true,
|
||||
};
|
||||
const session = { id: "s 1", cwd: workspace.path };
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
@@ -40,25 +41,25 @@ describe("federated route contract", () => {
|
||||
ignoreParseFailure(gitApi.gitDiff("p 1", "w 1", { path: "README.md", staged: true }, machineId)),
|
||||
ignoreParseFailure(sessionsApi.sessions("/repo", machineId)),
|
||||
ignoreParseFailure(sessionsApi.startSession("/repo", machineId)),
|
||||
ignoreParseFailure(sessionsApi.messages("s 1", { limit: 20, before: 10 }, machineId)),
|
||||
ignoreParseFailure(sessionsApi.status("s 1", machineId)),
|
||||
ignoreParseFailure(sessionsApi.models("s 1", machineId)),
|
||||
ignoreParseFailure(sessionsApi.setModel("s 1", "openai", "gpt", machineId)),
|
||||
ignoreParseFailure(sessionsApi.cycleModel("s 1", "forward", machineId)),
|
||||
ignoreParseFailure(sessionsApi.thinkingLevels("s 1", machineId)),
|
||||
ignoreParseFailure(sessionsApi.setThinkingLevel("s 1", "medium", machineId)),
|
||||
ignoreParseFailure(sessionsApi.cycleThinkingLevel("s 1", machineId)),
|
||||
ignoreParseFailure(sessionsApi.commands("s 1", machineId)),
|
||||
ignoreParseFailure(sessionsApi.prompt("s 1", "hello", "followUp", machineId)),
|
||||
ignoreParseFailure(sessionsApi.shell("s 1", "ls", machineId)),
|
||||
ignoreParseFailure(sessionsApi.runCommand("s 1", "/help", machineId)),
|
||||
ignoreParseFailure(sessionsApi.respondToCommand("s 1", "req 1", "yes", machineId)),
|
||||
ignoreParseFailure(sessionsApi.abort("s 1", machineId)),
|
||||
ignoreParseFailure(sessionsApi.stop("s 1", machineId)),
|
||||
ignoreParseFailure(sessionsApi.archive("s 1", machineId)),
|
||||
ignoreParseFailure(sessionsApi.archiveWithDescendants("s 1", machineId)),
|
||||
ignoreParseFailure(sessionsApi.restore("s 1", machineId)),
|
||||
ignoreParseFailure(sessionsApi.detachParent("s 1", machineId)),
|
||||
ignoreParseFailure(sessionsApi.messages(session, { limit: 20, before: 10 }, machineId)),
|
||||
ignoreParseFailure(sessionsApi.status(session, machineId)),
|
||||
ignoreParseFailure(sessionsApi.models(session, machineId)),
|
||||
ignoreParseFailure(sessionsApi.setModel(session, "openai", "gpt", machineId)),
|
||||
ignoreParseFailure(sessionsApi.cycleModel(session, "forward", machineId)),
|
||||
ignoreParseFailure(sessionsApi.thinkingLevels(session, machineId)),
|
||||
ignoreParseFailure(sessionsApi.setThinkingLevel(session, "medium", machineId)),
|
||||
ignoreParseFailure(sessionsApi.cycleThinkingLevel(session, machineId)),
|
||||
ignoreParseFailure(sessionsApi.commands(session, machineId)),
|
||||
ignoreParseFailure(sessionsApi.prompt(session, "hello", "followUp", machineId)),
|
||||
ignoreParseFailure(sessionsApi.shell(session, "ls", machineId)),
|
||||
ignoreParseFailure(sessionsApi.runCommand(session, "/help", machineId)),
|
||||
ignoreParseFailure(sessionsApi.respondToCommand(session, "req 1", "yes", machineId)),
|
||||
ignoreParseFailure(sessionsApi.abort(session, machineId)),
|
||||
ignoreParseFailure(sessionsApi.stop(session, machineId)),
|
||||
ignoreParseFailure(sessionsApi.archive(session, machineId)),
|
||||
ignoreParseFailure(sessionsApi.archiveWithDescendants(session, machineId)),
|
||||
ignoreParseFailure(sessionsApi.restore(session, machineId)),
|
||||
ignoreParseFailure(sessionsApi.detachParent(session, machineId)),
|
||||
ignoreParseFailure(sessionsApi.authProviders({ mode: "login", authType: "oauth", machineId })),
|
||||
ignoreParseFailure(sessionsApi.saveApiKey("openai", "key", machineId)),
|
||||
ignoreParseFailure(sessionsApi.logoutProvider("openai", machineId)),
|
||||
@@ -94,7 +95,7 @@ describe("federated route contract", () => {
|
||||
vi.stubGlobal("WebSocket", FakeWebSocket);
|
||||
vi.stubGlobal("location", { protocol: "https:", host: "pi.example.test" });
|
||||
|
||||
sessionEvents("s 1", machineId);
|
||||
sessionEvents(session, machineId);
|
||||
globalSessionEvents(machineId);
|
||||
realtimeEvents(machineId);
|
||||
terminalSocket("p 1", "w 1", "t 1", { cols: 120, rows: 40 }, machineId);
|
||||
|
||||
@@ -19,12 +19,12 @@ afterEach(() => {
|
||||
|
||||
describe("machine-scoped socket urls", () => {
|
||||
it("defaults session sockets to the local machine scope", () => {
|
||||
sessionEvents("s1");
|
||||
sessionEvents({ id: "s1", cwd: "/repo" });
|
||||
globalSessionEvents();
|
||||
realtimeEvents();
|
||||
|
||||
expect(webSocketUrls).toEqual([
|
||||
"wss://pi.example.test/api/machines/local/sessions/s1/events",
|
||||
"wss://pi.example.test/api/machines/local/sessions/s1/events?cwd=%2Frepo",
|
||||
"wss://pi.example.test/api/machines/local/sessions/events",
|
||||
"wss://pi.example.test/api/machines/local/events",
|
||||
]);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
export function sessionEvents(sessionId: string, machineId = "local"): WebSocket {
|
||||
return new WebSocket(`${webSocketBaseUrl()}${machinePrefix(machineId)}/sessions/${sessionId}/events`);
|
||||
import type { SessionRef } from "../../../shared/apiTypes";
|
||||
|
||||
export function sessionEvents(session: SessionRef, machineId = "local"): WebSocket {
|
||||
const query = new URLSearchParams({ cwd: session.cwd }).toString();
|
||||
return new WebSocket(`${webSocketBaseUrl()}${machinePrefix(machineId)}/sessions/${encodeURIComponent(session.id)}/events?${query}`);
|
||||
}
|
||||
|
||||
export function globalSessionEvents(machineId = "local"): WebSocket {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { SessionRef } from "../../../shared/apiTypes";
|
||||
|
||||
export function gitDiffUrl(projectId: string, workspaceId: string, options?: { path?: string; staged?: boolean }): string {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.path !== undefined) params.set("path", options.path);
|
||||
@@ -14,12 +16,11 @@ export function machineGitDiffUrl(machineId: string, projectId: string, workspac
|
||||
return `/api/machines/${encodeURIComponent(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/git/diff${query ? `?${query}` : ""}`;
|
||||
}
|
||||
|
||||
export function messageUrl(sessionId: string, options?: { limit?: number; before?: number }, machineId = "local"): string {
|
||||
const params = new URLSearchParams();
|
||||
export function messageUrl(session: SessionRef, options?: { limit?: number; before?: number }, machineId = "local"): string {
|
||||
const params = new URLSearchParams({ cwd: session.cwd });
|
||||
if (options?.limit !== undefined) params.set("limit", String(options.limit));
|
||||
if (options?.before !== undefined) params.set("before", String(options.before));
|
||||
const query = params.toString();
|
||||
return `/api/machines/${encodeURIComponent(machineId)}/sessions/${sessionId}/messages${query ? `?${query}` : ""}`;
|
||||
return `/api/machines/${encodeURIComponent(machineId)}/sessions/${encodeURIComponent(session.id)}/messages?${params.toString()}`;
|
||||
}
|
||||
|
||||
export function workspaceImagePreviewUrl(projectId: string, workspaceId: string, path: string, options?: { modifiedAt?: string; machineId?: string }): string {
|
||||
|
||||
@@ -173,8 +173,8 @@ export class PromptEditor extends LitElement {
|
||||
this.completions = [];
|
||||
return;
|
||||
}
|
||||
if (trigger.kind === "command" && this.sessionId !== undefined && this.sessionId !== "") {
|
||||
const commands = await api.commands(this.sessionId, this.machineId).catch(emptySlashCommands);
|
||||
if (trigger.kind === "command" && this.sessionId !== undefined && this.sessionId !== "" && this.cwd !== undefined && this.cwd !== "") {
|
||||
const commands = await api.commands({ id: this.sessionId, cwd: this.cwd }, this.machineId).catch(emptySlashCommands);
|
||||
if (version !== this.requestVersion) return;
|
||||
this.completions = commands
|
||||
.filter((command) => command.name.toLowerCase().includes(trigger.query.toLowerCase()))
|
||||
|
||||
@@ -244,19 +244,19 @@ export class AuthController {
|
||||
}
|
||||
|
||||
private async refreshStatus(): Promise<void> {
|
||||
const sessionId = this.sessionId();
|
||||
if (sessionId === undefined) return;
|
||||
const session = this.session();
|
||||
if (session === undefined) return;
|
||||
try {
|
||||
this.applyStatus(await this.api.status(sessionId, selectedMachineId(this.getState())));
|
||||
this.applyStatus(await this.api.status(session, selectedMachineId(this.getState())));
|
||||
} catch {
|
||||
// Status refresh is opportunistic after login completes.
|
||||
}
|
||||
}
|
||||
|
||||
private sessionId(): string | undefined {
|
||||
private session() {
|
||||
const session = this.getState().selectedSession;
|
||||
if (session === undefined || session.archived === true) return undefined;
|
||||
return session.id;
|
||||
return session;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { api as defaultApi, type MessagePage, type SessionActivity, type SessionInfo, type SessionStatus, type Workspace } from "../api";
|
||||
import { api as defaultApi, type MessagePage, type SessionActivity, type SessionInfo, type SessionRef, type SessionStatus, type Workspace } from "../api";
|
||||
import { isCachedNewSessionInfo, loadCachedNewSessions, markCachedNewSessionInfo, rememberCachedNewSession } from "../cachedNewSessions";
|
||||
import { initialAppState, type AppState } from "../appState";
|
||||
import { machineSessionKey } from "../machineKeys";
|
||||
@@ -38,8 +38,8 @@ class MemoryStorage implements Storage {
|
||||
class FakeSocket implements SessionEventSocket {
|
||||
readonly connectedSessionIds: string[] = [];
|
||||
|
||||
connect(sessionId: string): void {
|
||||
this.connectedSessionIds.push(sessionId);
|
||||
connect(session: SessionRef): void {
|
||||
this.connectedSessionIds.push(session.id);
|
||||
}
|
||||
|
||||
setHandler(): void {
|
||||
@@ -179,11 +179,11 @@ describe("SessionController", () => {
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
startSession: () => Promise.resolve(replacementSession),
|
||||
messages: (sessionId) => {
|
||||
if (sessionId === oldSession.id) return Promise.reject(new Error("Session not found"));
|
||||
messages: (session) => {
|
||||
if (session.id === oldSession.id) return Promise.reject(new Error("Session not found"));
|
||||
return Promise.resolve(emptyPage);
|
||||
},
|
||||
status: (sessionId) => Promise.resolve(status(sessionId)),
|
||||
status: (session) => Promise.resolve(status(session.id)),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
@@ -220,7 +220,7 @@ describe("SessionController", () => {
|
||||
...defaultApi,
|
||||
respondToCommand: () => Promise.resolve({ type: "done", message: "Session forked", session: replacementSession, promptDraft: "fork me" }),
|
||||
messages: () => Promise.resolve(emptyPage),
|
||||
status: (sessionId) => Promise.resolve(status(sessionId)),
|
||||
status: (session) => Promise.resolve(status(session.id)),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
@@ -243,7 +243,7 @@ describe("SessionController", () => {
|
||||
...defaultApi,
|
||||
archive: () => Promise.resolve({ archived: true }),
|
||||
messages: () => Promise.resolve(emptyPage),
|
||||
status: (sessionId) => Promise.resolve(status(sessionId)),
|
||||
status: (session) => Promise.resolve(status(session.id)),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
@@ -272,7 +272,7 @@ describe("SessionController", () => {
|
||||
...defaultApi,
|
||||
archiveWithDescendants: () => Promise.resolve({ archived: true, sessionIds: [oldSession.id, childSession.id], archivedCount: 2, skippedAlreadyArchivedCount: 0 }),
|
||||
messages: () => Promise.resolve(emptyPage),
|
||||
status: (sessionId) => Promise.resolve(status(sessionId)),
|
||||
status: (session) => Promise.resolve(status(session.id)),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { api as defaultApi, type CommandResult, type SessionActivity, type SessionInfo, type SessionStatus, type ThinkingLevel } from "../api";
|
||||
import { api as defaultApi, type CommandResult, type SessionActivity, type SessionInfo, type SessionRef, type SessionStatus, type ThinkingLevel } from "../api";
|
||||
import type { AppState } from "../appState";
|
||||
import { forgetCachedNewSession, isCachedNewSessionInfo, markCachedNewSessionInfo, rememberCachedNewSession, stripCachedNewSessionMarker } from "../cachedNewSessions";
|
||||
import { textMessage } from "../chatMessages";
|
||||
@@ -14,7 +14,7 @@ import { selectedMachineId, type GetState, type SetState, type UpdateUrl } from
|
||||
const MESSAGE_PAGE_SIZE = 100;
|
||||
|
||||
export interface SessionEventSocket {
|
||||
connect(sessionId: string, onEvent: (event: SessionUiEvent) => void, onReconnect?: () => void, machineId?: string): void;
|
||||
connect(session: SessionRef, onEvent: (event: SessionUiEvent) => void, onReconnect?: () => void, machineId?: string): void;
|
||||
setHandler(onEvent: (event: SessionUiEvent) => void): void;
|
||||
close(): void;
|
||||
}
|
||||
@@ -116,7 +116,7 @@ export class SessionController {
|
||||
});
|
||||
try {
|
||||
if (session.archived === true) {
|
||||
const page = await this.api.messages(session.id, { limit: MESSAGE_PAGE_SIZE }, selectedMachineId(this.getState()));
|
||||
const page = await this.api.messages(session, { limit: MESSAGE_PAGE_SIZE }, selectedMachineId(this.getState()));
|
||||
if (seq !== this.selectionSeq || this.getState().selectedSession?.id !== session.id) return;
|
||||
const history = this.transcripts.mergeHistory(transcriptKey, page);
|
||||
this.setState({ ...history, isLoadingEarlierMessages: false, isReceivingPartialStream: false, status: undefined, activity: undefined });
|
||||
@@ -125,12 +125,12 @@ export class SessionController {
|
||||
}
|
||||
const buffered: SessionUiEvent[] = [];
|
||||
this.socket.connect(
|
||||
session.id,
|
||||
session,
|
||||
(event) => buffered.push(event),
|
||||
() => { void this.refreshSelectedSession(session.id); },
|
||||
selectedMachineId(this.getState()),
|
||||
);
|
||||
const [page, status] = await Promise.all([this.api.messages(session.id, { limit: MESSAGE_PAGE_SIZE }, selectedMachineId(this.getState())), this.api.status(session.id, selectedMachineId(this.getState()))]);
|
||||
const [page, status] = await Promise.all([this.api.messages(session, { limit: MESSAGE_PAGE_SIZE }, selectedMachineId(this.getState())), this.api.status(session, selectedMachineId(this.getState()))]);
|
||||
if (seq !== this.selectionSeq || this.getState().selectedSession?.id !== session.id) return;
|
||||
const history = this.transcripts.mergeHistory(transcriptKey, page);
|
||||
const isReceivingPartialStream = status.isStreaming;
|
||||
@@ -156,7 +156,7 @@ export class SessionController {
|
||||
if (!session || state.isLoadingEarlierMessages || state.messagePageStart <= 0) return;
|
||||
this.setState({ isLoadingEarlierMessages: true });
|
||||
try {
|
||||
const page = await this.api.messages(session.id, { before: state.messagePageStart, limit: MESSAGE_PAGE_SIZE }, selectedMachineId(this.getState()));
|
||||
const page = await this.api.messages(session, { before: state.messagePageStart, limit: MESSAGE_PAGE_SIZE }, selectedMachineId(this.getState()));
|
||||
if (this.getState().selectedSession?.id !== session.id) return;
|
||||
const history = this.transcripts.mergeHistory(this.sessionCacheKey(session.id), page);
|
||||
this.setState(history);
|
||||
@@ -174,7 +174,7 @@ export class SessionController {
|
||||
const session = this.getState().selectedSession;
|
||||
if (!session || session.archived === true) return;
|
||||
try {
|
||||
await this.api.prompt(session.id, text, streamingBehavior, selectedMachineId(this.getState()));
|
||||
await this.api.prompt(session, text, streamingBehavior, selectedMachineId(this.getState()));
|
||||
this.markCachedNewSessionPersisted(session);
|
||||
} catch (error) {
|
||||
this.setState({ error: String(error) });
|
||||
@@ -186,7 +186,7 @@ export class SessionController {
|
||||
if (!session || session.archived === true) return;
|
||||
this.setState({ messages: [...this.getState().messages, textMessage("user", text)] });
|
||||
try {
|
||||
await this.api.shell(session.id, text, selectedMachineId(this.getState()));
|
||||
await this.api.shell(session, text, selectedMachineId(this.getState()));
|
||||
this.markCachedNewSessionPersisted(session);
|
||||
} catch (error) {
|
||||
this.setState({ messages: [...this.getState().messages, textMessage("system", String(error))], error: String(error) });
|
||||
@@ -198,7 +198,7 @@ export class SessionController {
|
||||
if (!session || session.archived === true) return;
|
||||
this.setState({ messages: [...this.getState().messages, textMessage("user", text)] });
|
||||
try {
|
||||
this.applyCommandResult(await this.api.runCommand(session.id, text, selectedMachineId(this.getState())));
|
||||
this.applyCommandResult(await this.api.runCommand(session, text, selectedMachineId(this.getState())));
|
||||
this.markCachedNewSessionPersisted(session);
|
||||
} catch (error) {
|
||||
this.setState({ messages: [...this.getState().messages, textMessage("system", String(error))], error: String(error) });
|
||||
@@ -210,7 +210,7 @@ export class SessionController {
|
||||
if (!session) return;
|
||||
this.setState({ commandDialog: undefined });
|
||||
try {
|
||||
this.applyCommandResult(await this.api.respondToCommand(session.id, requestId, value, selectedMachineId(this.getState())));
|
||||
this.applyCommandResult(await this.api.respondToCommand(session, requestId, value, selectedMachineId(this.getState())));
|
||||
} catch (error) {
|
||||
this.setState({ error: String(error) });
|
||||
}
|
||||
@@ -231,7 +231,7 @@ export class SessionController {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.api.archive(session.id, selectedMachineId(this.getState()));
|
||||
await this.api.archive(session, selectedMachineId(this.getState()));
|
||||
const state = this.getState();
|
||||
const sessions = markSessionArchived(state.sessions, session.id, new Date().toISOString());
|
||||
const selectionChange = selectionAfterArchivingSession(sessions, state.selectedSession?.id, session.id);
|
||||
@@ -247,7 +247,7 @@ export class SessionController {
|
||||
async archiveSessionWithDescendants(session = this.getState().selectedSession) {
|
||||
if (!session || isCachedNewSessionInfo(session)) return;
|
||||
try {
|
||||
const response = await this.api.archiveWithDescendants(session.id, selectedMachineId(this.getState()));
|
||||
const response = await this.api.archiveWithDescendants(session, selectedMachineId(this.getState()));
|
||||
const archivedIds = response.sessionIds !== undefined && response.sessionIds.length > 0 ? response.sessionIds : [session.id];
|
||||
const state = this.getState();
|
||||
const sessions = markSessionsArchived(state.sessions, archivedIds, new Date().toISOString());
|
||||
@@ -263,7 +263,7 @@ export class SessionController {
|
||||
|
||||
async deleteCachedNewSession(session = this.getState().selectedSession) {
|
||||
if (!isCachedNewSessionInfo(session)) return;
|
||||
void this.api.stop(session.id, selectedMachineId(this.getState())).catch(() => {
|
||||
void this.api.stop(session, selectedMachineId(this.getState())).catch(() => {
|
||||
// Best-effort cleanup for browser-cached sessions that may not exist server-side anymore.
|
||||
});
|
||||
forgetCachedNewSession(session.id, selectedMachineId(this.getState()));
|
||||
@@ -282,7 +282,7 @@ export class SessionController {
|
||||
async restoreSession(session = this.getState().selectedSession) {
|
||||
if (!session) return;
|
||||
try {
|
||||
await this.api.restore(session.id, selectedMachineId(this.getState()));
|
||||
await this.api.restore(session, selectedMachineId(this.getState()));
|
||||
const restored = { ...session };
|
||||
delete restored.archived;
|
||||
delete restored.archivedAt;
|
||||
@@ -296,7 +296,7 @@ export class SessionController {
|
||||
async detachParent(session = this.getState().selectedSession) {
|
||||
if (session?.parentSessionPath === undefined) return;
|
||||
try {
|
||||
await this.api.detachParent(session.id, selectedMachineId(this.getState()));
|
||||
await this.api.detachParent(session, selectedMachineId(this.getState()));
|
||||
const detached = { ...session };
|
||||
delete detached.parentSessionPath;
|
||||
this.replaceSession(detached);
|
||||
@@ -309,7 +309,7 @@ export class SessionController {
|
||||
const session = this.getState().selectedSession;
|
||||
if (!session || session.archived === true) return [];
|
||||
try {
|
||||
return (await this.api.models(session.id, selectedMachineId(this.getState()))).models;
|
||||
return (await this.api.models(session, selectedMachineId(this.getState()))).models;
|
||||
} catch (error) {
|
||||
this.setState({ error: String(error) });
|
||||
return [];
|
||||
@@ -320,7 +320,7 @@ export class SessionController {
|
||||
const session = this.getState().selectedSession;
|
||||
if (!session || session.archived === true) return;
|
||||
try {
|
||||
this.applyStatus(await this.api.setModel(session.id, provider, modelId, selectedMachineId(this.getState())));
|
||||
this.applyStatus(await this.api.setModel(session, provider, modelId, selectedMachineId(this.getState())));
|
||||
} catch (error) {
|
||||
this.setState({ error: String(error) });
|
||||
}
|
||||
@@ -330,7 +330,7 @@ export class SessionController {
|
||||
const session = this.getState().selectedSession;
|
||||
if (!session || session.archived === true) return;
|
||||
try {
|
||||
this.applyStatus(await this.api.cycleModel(session.id, direction, selectedMachineId(this.getState())));
|
||||
this.applyStatus(await this.api.cycleModel(session, direction, selectedMachineId(this.getState())));
|
||||
} catch (error) {
|
||||
this.setState({ error: String(error) });
|
||||
}
|
||||
@@ -340,7 +340,7 @@ export class SessionController {
|
||||
const session = this.getState().selectedSession;
|
||||
if (!session || session.archived === true) return [];
|
||||
try {
|
||||
return (await this.api.thinkingLevels(session.id, selectedMachineId(this.getState()))).levels;
|
||||
return (await this.api.thinkingLevels(session, selectedMachineId(this.getState()))).levels;
|
||||
} catch (error) {
|
||||
this.setState({ error: String(error) });
|
||||
return [];
|
||||
@@ -351,7 +351,7 @@ export class SessionController {
|
||||
const session = this.getState().selectedSession;
|
||||
if (!session || session.archived === true) return;
|
||||
try {
|
||||
this.applyStatus(await this.api.setThinkingLevel(session.id, level, selectedMachineId(this.getState())));
|
||||
this.applyStatus(await this.api.setThinkingLevel(session, level, selectedMachineId(this.getState())));
|
||||
} catch (error) {
|
||||
this.setState({ error: String(error) });
|
||||
}
|
||||
@@ -361,7 +361,7 @@ export class SessionController {
|
||||
const session = this.getState().selectedSession;
|
||||
if (!session || session.archived === true) return;
|
||||
try {
|
||||
this.applyStatus(await this.api.cycleThinkingLevel(session.id, selectedMachineId(this.getState())));
|
||||
this.applyStatus(await this.api.cycleThinkingLevel(session, selectedMachineId(this.getState())));
|
||||
} catch (error) {
|
||||
this.setState({ error: String(error) });
|
||||
}
|
||||
@@ -371,7 +371,7 @@ export class SessionController {
|
||||
const session = this.getState().selectedSession;
|
||||
if (!session) return;
|
||||
try {
|
||||
await this.api.abort(session.id, selectedMachineId(this.getState()));
|
||||
await this.api.abort(session, selectedMachineId(this.getState()));
|
||||
} catch (error) {
|
||||
this.setState({ error: String(error) });
|
||||
}
|
||||
@@ -382,7 +382,7 @@ export class SessionController {
|
||||
if (sessionId === undefined || session?.id !== sessionId || session.archived === true) return;
|
||||
try {
|
||||
this.flushPendingTranscriptEvents();
|
||||
const [page, status] = await Promise.all([this.api.messages(sessionId, { limit: MESSAGE_PAGE_SIZE }, selectedMachineId(this.getState())), this.api.status(sessionId, selectedMachineId(this.getState()))]);
|
||||
const [page, status] = await Promise.all([this.api.messages(session, { limit: MESSAGE_PAGE_SIZE }, selectedMachineId(this.getState())), this.api.status(session, selectedMachineId(this.getState()))]);
|
||||
if (this.getState().selectedSession?.id !== sessionId) return;
|
||||
const history = this.transcripts.mergeHistory(this.sessionCacheKey(sessionId), page);
|
||||
this.setState({
|
||||
@@ -548,7 +548,9 @@ export class SessionController {
|
||||
|
||||
private async refreshMessages(sessionId: string) {
|
||||
try {
|
||||
const page = await this.api.messages(sessionId, { limit: MESSAGE_PAGE_SIZE }, selectedMachineId(this.getState()));
|
||||
const session = this.getState().selectedSession;
|
||||
if (session?.id !== sessionId) return;
|
||||
const page = await this.api.messages(session, { limit: MESSAGE_PAGE_SIZE }, selectedMachineId(this.getState()));
|
||||
if (this.getState().selectedSession?.id !== sessionId) return;
|
||||
this.setState(this.transcripts.mergeHistory(this.sessionCacheKey(sessionId), page));
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { globalSessionEvents, realtimeEvents, sessionEvents } from "./api";
|
||||
import type { GlobalSessionEvent, RealtimeEvent, SessionUiEvent } from "../../shared/apiTypes";
|
||||
import type { GlobalSessionEvent, RealtimeEvent, SessionRef, SessionUiEvent } from "../../shared/apiTypes";
|
||||
|
||||
export type { GlobalSessionEvent, RealtimeEvent, SessionUiEvent } from "../../shared/apiTypes";
|
||||
|
||||
export class SessionSocket {
|
||||
private socket: WebSocket | undefined;
|
||||
private sessionId: string | undefined;
|
||||
private session: SessionRef | undefined;
|
||||
private onEvent: ((event: SessionUiEvent) => void) | undefined;
|
||||
private reconnectTimer?: number;
|
||||
private reconnectDelay = 500;
|
||||
@@ -14,10 +14,10 @@ export class SessionSocket {
|
||||
private onReconnect: (() => void) | undefined;
|
||||
private machineId = "local";
|
||||
|
||||
connect(sessionId: string, onEvent: (event: SessionUiEvent) => void, onReconnect?: () => void, machineId = "local"): void {
|
||||
connect(session: SessionRef, onEvent: (event: SessionUiEvent) => void, onReconnect?: () => void, machineId = "local"): void {
|
||||
this.close();
|
||||
this.machineId = machineId;
|
||||
this.sessionId = sessionId;
|
||||
this.session = session;
|
||||
this.onEvent = onEvent;
|
||||
this.onReconnect = onReconnect;
|
||||
this.shouldReconnect = true;
|
||||
@@ -33,7 +33,7 @@ export class SessionSocket {
|
||||
window.clearTimeout(this.reconnectTimer);
|
||||
closeSocketQuietly(this.socket);
|
||||
this.socket = undefined;
|
||||
this.sessionId = undefined;
|
||||
this.session = undefined;
|
||||
this.onEvent = undefined;
|
||||
this.onReconnect = undefined;
|
||||
this.hasOpened = false;
|
||||
@@ -41,8 +41,8 @@ export class SessionSocket {
|
||||
}
|
||||
|
||||
private open(): void {
|
||||
if (this.sessionId === undefined || this.sessionId === "" || !this.shouldReconnect) return;
|
||||
const socket = sessionEvents(this.sessionId, this.machineId);
|
||||
if (this.session === undefined || this.session.id === "" || this.session.cwd === "" || !this.shouldReconnect) return;
|
||||
const socket = sessionEvents(this.session, this.machineId);
|
||||
this.socket = socket;
|
||||
socket.onopen = () => {
|
||||
this.reconnectDelay = 500;
|
||||
|
||||
@@ -24,7 +24,7 @@ export function registerSessionProxyRoutes(app: FastifyInstance, daemon: Session
|
||||
app.get(`${prefix}/sessiond/health`, (_request, reply) => proxy({ method: "GET", url: `${prefix}/health` }, reply));
|
||||
|
||||
app.get<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId/events`, { websocket: true }, (socket, request) => {
|
||||
bridgeSockets(socket, daemon.connectWebSocket(`/sessions/${request.params.sessionId}/events`));
|
||||
bridgeSockets(socket, daemon.connectWebSocket(stripPrefix(request.url, prefix)));
|
||||
});
|
||||
|
||||
app.get(`${prefix}/sessions/events`, { websocket: true }, (socket) => {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createPiSessionManagerGateway, defaultPiSessionDir, defaultPiSessionsRoot, SessionDirResolver } from "./piSessionManagerGateway.js";
|
||||
import type { PiSessionManager } from "./piSessionService.js";
|
||||
|
||||
let tempDir: string;
|
||||
let agentDir: string;
|
||||
let cwd: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "pi-web-session-gateway-test-"));
|
||||
agentDir = join(tempDir, "agent");
|
||||
cwd = join(tempDir, "workspace");
|
||||
await mkdir(cwd, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("SessionDirResolver", () => {
|
||||
it("uses Pi default session storage when no Pi override is configured", () => {
|
||||
const resolver = new SessionDirResolver({ agentDir, env: {} });
|
||||
|
||||
expect(resolver.resolve(cwd)).toMatchObject({ source: "pi-default", sessionDir: defaultPiSessionDir(cwd, agentDir), usesConfiguredSessionDir: false });
|
||||
expect(defaultPiSessionsRoot(agentDir)).toBe(join(agentDir, "sessions"));
|
||||
});
|
||||
|
||||
it("uses Pi sessionDir settings and resolves relative paths against the session cwd", async () => {
|
||||
await mkdir(agentDir, { recursive: true });
|
||||
await writeFile(join(agentDir, "settings.json"), `${JSON.stringify({ sessionDir: ".pi/sessions" }, null, 2)}\n`, "utf8");
|
||||
|
||||
const resolver = new SessionDirResolver({ agentDir, env: {} });
|
||||
|
||||
expect(resolver.resolve(cwd)).toMatchObject({ source: "settings", sessionDir: join(cwd, ".pi", "sessions"), usesConfiguredSessionDir: true });
|
||||
});
|
||||
|
||||
it("lets project-local Pi sessionDir settings override global Pi settings for that cwd", async () => {
|
||||
await mkdir(agentDir, { recursive: true });
|
||||
await mkdir(join(cwd, ".pi"), { recursive: true });
|
||||
await writeFile(join(agentDir, "settings.json"), `${JSON.stringify({ sessionDir: join(tempDir, "global-sessions") }, null, 2)}\n`, "utf8");
|
||||
await writeFile(join(cwd, ".pi", "settings.json"), `${JSON.stringify({ sessionDir: ".workspace-sessions" }, null, 2)}\n`, "utf8");
|
||||
|
||||
const resolver = new SessionDirResolver({ agentDir, env: {} });
|
||||
|
||||
expect(resolver.resolve(cwd)).toMatchObject({ source: "settings", sessionDir: join(cwd, ".workspace-sessions"), usesConfiguredSessionDir: true });
|
||||
});
|
||||
|
||||
it("lets the Pi sessionDir environment override Pi settings", async () => {
|
||||
const envDir = join(tempDir, "env-sessions");
|
||||
await mkdir(agentDir, { recursive: true });
|
||||
await writeFile(join(agentDir, "settings.json"), `${JSON.stringify({ sessionDir: join(tempDir, "settings-sessions") }, null, 2)}\n`, "utf8");
|
||||
|
||||
const resolver = new SessionDirResolver({ agentDir, env: { PI_CODING_AGENT_SESSION_DIR: envDir } });
|
||||
|
||||
expect(resolver.resolve(cwd)).toMatchObject({ source: "env", sessionDir: envDir, usesConfiguredSessionDir: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Pi session manager gateway", () => {
|
||||
it("lists only sessions for the requested cwd when a custom Pi sessionDir is shared", async () => {
|
||||
const sharedSessionDir = join(tempDir, "shared-sessions");
|
||||
const otherCwd = join(tempDir, "other-workspace");
|
||||
await writeSessionFile(sharedSessionDir, "session-a", cwd);
|
||||
await writeSessionFile(sharedSessionDir, "session-b", otherCwd);
|
||||
const gateway = createPiSessionManagerGateway({ agentDir, env: { PI_CODING_AGENT_SESSION_DIR: sharedSessionDir } });
|
||||
|
||||
await expect(gateway.list(cwd)).resolves.toMatchObject([{ id: "session-a", cwd }]);
|
||||
const created = gateway.create(cwd);
|
||||
expect(hasSessionDir(created)).toBe(true);
|
||||
if (!hasSessionDir(created)) throw new Error("Expected SDK session manager");
|
||||
expect(created.getSessionDir()).toBe(sharedSessionDir);
|
||||
});
|
||||
});
|
||||
|
||||
function hasSessionDir(manager: PiSessionManager): manager is PiSessionManager & { getSessionDir(): string } {
|
||||
return "getSessionDir" in manager && typeof manager.getSessionDir === "function";
|
||||
}
|
||||
|
||||
async function writeSessionFile(dir: string, id: string, sessionCwd: string): Promise<void> {
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(join(dir, `${id}.jsonl`), `${JSON.stringify({ type: "session", version: 3, id, timestamp: "2026-01-01T00:00:00.000Z", cwd: sessionCwd })}\n`, "utf8");
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, isAbsolute, join, resolve } from "node:path";
|
||||
import { getAgentDir, SessionManager, SettingsManager } from "@earendil-works/pi-coding-agent";
|
||||
import type { PiSessionListEntry, PiSessionManager, PiSessionManagerGateway } from "./piSessionService.js";
|
||||
|
||||
export const PI_SESSION_DIR_ENV = "PI_CODING_AGENT_SESSION_DIR";
|
||||
|
||||
type SessionDirSource = "env" | "settings" | "pi-default";
|
||||
|
||||
export interface SessionDirResolution {
|
||||
source: SessionDirSource;
|
||||
sessionDir: string;
|
||||
usesConfiguredSessionDir: boolean;
|
||||
}
|
||||
|
||||
export interface SessionDirResolverOptions {
|
||||
agentDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
export class SessionDirResolver {
|
||||
private readonly agentDir: string;
|
||||
private readonly env: NodeJS.ProcessEnv;
|
||||
|
||||
constructor(options: SessionDirResolverOptions = {}) {
|
||||
this.agentDir = options.agentDir ?? getAgentDir();
|
||||
this.env = options.env ?? process.env;
|
||||
}
|
||||
|
||||
resolve(cwd: string): SessionDirResolution {
|
||||
const envSessionDir = this.env[PI_SESSION_DIR_ENV];
|
||||
if (envSessionDir !== undefined && envSessionDir !== "") {
|
||||
return { source: "env", sessionDir: resolveConfiguredPath(envSessionDir, cwd), usesConfiguredSessionDir: true };
|
||||
}
|
||||
|
||||
const settingsSessionDir = SettingsManager.create(cwd, this.agentDir).getSessionDir();
|
||||
if (settingsSessionDir !== undefined && settingsSessionDir !== "") {
|
||||
return { source: "settings", sessionDir: resolveConfiguredPath(settingsSessionDir, cwd), usesConfiguredSessionDir: true };
|
||||
}
|
||||
|
||||
return { source: "pi-default", sessionDir: defaultPiSessionDir(cwd, this.agentDir), usesConfiguredSessionDir: false };
|
||||
}
|
||||
}
|
||||
|
||||
export type PiSessionManagerGatewayOptions = SessionDirResolverOptions;
|
||||
|
||||
export function createPiSessionManagerGateway(options: PiSessionManagerGatewayOptions = {}): PiSessionManagerGateway {
|
||||
return new SettingsAwarePiSessionManagerGateway(new SessionDirResolver(options));
|
||||
}
|
||||
|
||||
class SettingsAwarePiSessionManagerGateway implements PiSessionManagerGateway {
|
||||
constructor(private readonly resolver: SessionDirResolver) {}
|
||||
|
||||
async list(cwd: string): Promise<PiSessionListEntry[]> {
|
||||
const resolution = this.resolver.resolve(cwd);
|
||||
return filterSessionsForCwd(await listSessionsInDir(resolution.sessionDir), cwd);
|
||||
}
|
||||
|
||||
create(cwd: string): PiSessionManager {
|
||||
const resolution = this.resolver.resolve(cwd);
|
||||
return SessionManager.create(cwd, resolution.sessionDir);
|
||||
}
|
||||
|
||||
open(path: string): PiSessionManager {
|
||||
return SessionManager.open(path, dirname(path));
|
||||
}
|
||||
}
|
||||
|
||||
export async function listSessionsInDir(sessionDir: string): Promise<PiSessionListEntry[]> {
|
||||
return SessionManager.list("", sessionDir);
|
||||
}
|
||||
|
||||
export function filterSessionsForCwd(sessions: readonly PiSessionListEntry[], cwd: string): PiSessionListEntry[] {
|
||||
return sessions.filter((session) => session.cwd === cwd);
|
||||
}
|
||||
|
||||
export function defaultPiSessionsRoot(agentDir = getAgentDir()): string {
|
||||
return join(agentDir, "sessions");
|
||||
}
|
||||
|
||||
export function defaultPiSessionDir(cwd: string, agentDir = getAgentDir()): string {
|
||||
return sessionDirInDefaultPiStore(defaultPiSessionsRoot(agentDir), cwd);
|
||||
}
|
||||
|
||||
export function sessionDirInDefaultPiStore(storeRoot: string, cwd: string): string {
|
||||
const safePath = `--${cwd.replace(/^[/\\]/u, "").replace(/[/\\:]/gu, "-")}--`;
|
||||
return join(storeRoot, safePath);
|
||||
}
|
||||
|
||||
export function resolveConfiguredPath(path: string, cwd: string): string {
|
||||
const expanded = expandTildePath(path);
|
||||
return isAbsolute(expanded) ? expanded : resolve(cwd, expanded);
|
||||
}
|
||||
|
||||
function expandTildePath(path: string): string {
|
||||
if (path === "~") return homedir();
|
||||
if (path.startsWith("~/")) return join(homedir(), path.slice(2));
|
||||
return path;
|
||||
}
|
||||
|
||||
@@ -43,6 +43,10 @@ function sessionRecord(id: string, cwd = "/workspace") {
|
||||
return { id, path: `/sessions/${id}.jsonl`, cwd, created: new Date("2026-01-01T00:00:00.000Z"), modified: new Date("2026-01-01T00:01:00.000Z"), messageCount: 0, firstMessage: "", allMessagesText: "" };
|
||||
}
|
||||
|
||||
function sessionRef(id: string, cwd = "/workspace") {
|
||||
return { id, cwd };
|
||||
}
|
||||
|
||||
function fakeRuntime(sessionId = "session-1", patch: Partial<TestSession> = {}) {
|
||||
const promptCalls: { text: string; options: unknown }[] = [];
|
||||
const listeners: ((event: unknown) => void)[] = [];
|
||||
@@ -122,7 +126,6 @@ function sessionGateway(records: ReturnType<typeof sessionRecord>[]): SessionGat
|
||||
return {
|
||||
create: () => fakeSessionManager(),
|
||||
list: () => Promise.resolve(records),
|
||||
listAll: () => Promise.resolve(records),
|
||||
open: () => fakeSessionManager(),
|
||||
};
|
||||
}
|
||||
@@ -174,7 +177,7 @@ describe("PiSessionService", () => {
|
||||
heartbeatIntervalMs: 1_000,
|
||||
});
|
||||
|
||||
await service.status("idle-session");
|
||||
await service.status(sessionRef("idle-session"));
|
||||
hub.globalEvents.length = 0;
|
||||
listener?.({ type: "agent_start" });
|
||||
|
||||
@@ -209,7 +212,7 @@ describe("PiSessionService", () => {
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await service.status("completion-session");
|
||||
await service.status(sessionRef("completion-session"));
|
||||
hub.globalEvents.length = 0;
|
||||
listener?.({ type: "tool_execution_end", toolName: "read", isError: false });
|
||||
|
||||
@@ -235,7 +238,6 @@ describe("PiSessionService", () => {
|
||||
{ ...sessionRecord("active"), messageCount: 1, firstMessage: "hello", allMessagesText: "hello" },
|
||||
{ ...sessionRecord("archived"), messageCount: 2, firstMessage: "bye", allMessagesText: "bye" },
|
||||
]),
|
||||
listAll: () => Promise.resolve([]),
|
||||
open: () => fakeSessionManager(),
|
||||
},
|
||||
heartbeatIntervalMs: 60_000,
|
||||
@@ -262,7 +264,6 @@ describe("PiSessionService", () => {
|
||||
sessionManager: {
|
||||
create: () => fakeSessionManager(),
|
||||
list: () => Promise.resolve([{ ...sessionRecord("active"), messageCount: 1, firstMessage: "hello", allMessagesText: "hello" }]),
|
||||
listAll: () => Promise.resolve([]),
|
||||
open: () => fakeSessionManager(),
|
||||
},
|
||||
heartbeatIntervalMs: 60_000,
|
||||
@@ -301,13 +302,12 @@ describe("PiSessionService", () => {
|
||||
sessionManager: {
|
||||
create: () => fakeSessionManager(),
|
||||
list: (cwd) => Promise.resolve(cwd === "/workspace" ? [root, directChild, archivedChild, grandchild] : [otherWorkspaceChild]),
|
||||
listAll: () => Promise.resolve([root, directChild, archivedChild, grandchild, otherWorkspaceChild]),
|
||||
open: () => fakeSessionManager(),
|
||||
},
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await expect(service.archiveTree("root")).resolves.toEqual({
|
||||
await expect(service.archiveTree(sessionRef("root"))).resolves.toEqual({
|
||||
archived: true,
|
||||
sessionIds: ["root", "direct-child", "grandchild"],
|
||||
archivedCount: 3,
|
||||
@@ -331,7 +331,6 @@ describe("PiSessionService", () => {
|
||||
sessionManager: {
|
||||
create: () => fakeSessionManager(),
|
||||
list: () => Promise.resolve([]),
|
||||
listAll: () => Promise.resolve([]),
|
||||
open: () => fakeSessionManager(),
|
||||
},
|
||||
workspaceActivity: {
|
||||
@@ -360,7 +359,7 @@ describe("PiSessionService", () => {
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await service.prompt("prompt-session", "Build the thing");
|
||||
await service.prompt(sessionRef("prompt-session"), "Build the thing");
|
||||
|
||||
expect(fake.calls.prompt).toEqual([{ text: "Build the thing", options: undefined }]);
|
||||
await service.dispose();
|
||||
@@ -379,7 +378,7 @@ describe("PiSessionService", () => {
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await expect(service.status("status-session")).resolves.toMatchObject({
|
||||
await expect(service.status(sessionRef("status-session"))).resolves.toMatchObject({
|
||||
pendingMessageCount: 2,
|
||||
queuedMessages: [{ kind: "steer", text: "adjust this turn" }, { kind: "followUp", text: "then do this" }],
|
||||
messageCount: 2,
|
||||
@@ -399,7 +398,7 @@ describe("PiSessionService", () => {
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await service.prompt("dedupe-session", "already queued", "followUp");
|
||||
await service.prompt(sessionRef("dedupe-session"), "already queued", "followUp");
|
||||
|
||||
expect(fake.calls.prompt).toEqual([]);
|
||||
await service.dispose();
|
||||
@@ -414,7 +413,7 @@ describe("PiSessionService", () => {
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await service.prompt("queued-session", "Wait for the current turn", "followUp");
|
||||
await service.prompt(sessionRef("queued-session"), "Wait for the current turn", "followUp");
|
||||
|
||||
expect(fake.calls.prompt).toEqual([{ text: "Wait for the current turn", options: { streamingBehavior: "followUp" } }]);
|
||||
expect(hub.sessionEvents.some(({ event }) => event.type === "message.append")).toBe(false);
|
||||
@@ -439,12 +438,12 @@ describe("PiSessionService", () => {
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await service.prompt("compacting-session", "Start task 1", "followUp");
|
||||
await service.prompt("compacting-session", "Then task 2", "followUp");
|
||||
await service.prompt(sessionRef("compacting-session"), "Start task 1", "followUp");
|
||||
await service.prompt(sessionRef("compacting-session"), "Then task 2", "followUp");
|
||||
|
||||
expect(fake.calls.prompt).toEqual([]);
|
||||
expect(hub.sessionEvents.some(({ event }) => event.type === "message.append")).toBe(false);
|
||||
await expect(service.status("compacting-session")).resolves.toMatchObject({
|
||||
await expect(service.status(sessionRef("compacting-session"))).resolves.toMatchObject({
|
||||
pendingMessageCount: 2,
|
||||
queuedMessages: [{ kind: "followUp", text: "Start task 1" }, { kind: "followUp", text: "Then task 2" }],
|
||||
});
|
||||
@@ -455,7 +454,7 @@ describe("PiSessionService", () => {
|
||||
|
||||
expect(fake.calls.prompt).toEqual([{ text: "Start task 1", options: undefined }]);
|
||||
expect(hub.sessionEvents.some(({ event }) => event.type === "message.append" && JSON.stringify(event.message).includes("Start task 1"))).toBe(true);
|
||||
await expect(service.status("compacting-session")).resolves.toMatchObject({
|
||||
await expect(service.status(sessionRef("compacting-session"))).resolves.toMatchObject({
|
||||
pendingMessageCount: 1,
|
||||
queuedMessages: [{ kind: "followUp", text: "Then task 2" }],
|
||||
});
|
||||
@@ -467,7 +466,7 @@ describe("PiSessionService", () => {
|
||||
{ text: "Start task 1", options: undefined },
|
||||
{ text: "Then task 2", options: { streamingBehavior: "followUp" } },
|
||||
]);
|
||||
await expect(service.status("compacting-session")).resolves.toMatchObject({
|
||||
await expect(service.status(sessionRef("compacting-session"))).resolves.toMatchObject({
|
||||
pendingMessageCount: 0,
|
||||
queuedMessages: [],
|
||||
});
|
||||
@@ -483,8 +482,8 @@ describe("PiSessionService", () => {
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await service.status("abort-session");
|
||||
await service.abort("abort-session");
|
||||
await service.status(sessionRef("abort-session"));
|
||||
await service.abort(sessionRef("abort-session"));
|
||||
|
||||
expect(fake.calls.clearQueue).toBe(1);
|
||||
expect(fake.calls.abort).toBe(1);
|
||||
@@ -499,13 +498,13 @@ describe("PiSessionService", () => {
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await service.prompt("abort-compaction-session", "Do not deliver after abort", "followUp");
|
||||
await expect(service.status("abort-compaction-session")).resolves.toMatchObject({ pendingMessageCount: 1 });
|
||||
await service.abort("abort-compaction-session");
|
||||
await service.prompt(sessionRef("abort-compaction-session"), "Do not deliver after abort", "followUp");
|
||||
await expect(service.status(sessionRef("abort-compaction-session"))).resolves.toMatchObject({ pendingMessageCount: 1 });
|
||||
await service.abort(sessionRef("abort-compaction-session"));
|
||||
|
||||
expect(fake.calls.clearQueue).toBe(1);
|
||||
expect(fake.calls.prompt).toEqual([]);
|
||||
await expect(service.status("abort-compaction-session")).resolves.toMatchObject({ pendingMessageCount: 0, queuedMessages: [] });
|
||||
await expect(service.status(sessionRef("abort-compaction-session"))).resolves.toMatchObject({ pendingMessageCount: 0, queuedMessages: [] });
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
@@ -524,7 +523,7 @@ describe("PiSessionService", () => {
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await service.status("auth-session");
|
||||
await service.status(sessionRef("auth-session"));
|
||||
hub.sessionEvents.length = 0;
|
||||
hub.globalEvents.length = 0;
|
||||
|
||||
@@ -553,8 +552,8 @@ describe("PiSessionService", () => {
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await service.status("stop-session");
|
||||
service.stop("stop-session");
|
||||
await service.status(sessionRef("stop-session"));
|
||||
service.stop(sessionRef("stop-session"));
|
||||
|
||||
expect(fake.calls.clearQueue).toBe(1);
|
||||
await service.dispose();
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
type CreateAgentSessionRuntimeFactory,
|
||||
type EditToolDetails,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import type { ClientArchiveSessionsResponse, ClientCommand, ClientCommandResult, ClientMessagePage, ClientSession, ClientSessionModel, ClientSessionStatus, ClientThinkingLevel, SessionUiEvent } from "../types.js";
|
||||
import type { ClientArchiveSessionsResponse, ClientCommand, ClientCommandResult, ClientMessagePage, ClientSession, ClientSessionModel, ClientSessionRef, ClientSessionStatus, ClientThinkingLevel, SessionUiEvent } from "../types.js";
|
||||
import { pageMessagesAtSafeBoundary } from "./messagePaging.js";
|
||||
import type { SessionEventHub } from "../realtime/sessionEventHub.js";
|
||||
import { BUILTIN_COMMANDS } from "./builtinCommands.js";
|
||||
@@ -24,6 +24,7 @@ import type { ActiveSession } from "./sessionRuntimeStore.js";
|
||||
import type { AuthChange } from "./authService.js";
|
||||
import { fallbackSessionName, generateShortSessionName } from "./sessionNameGenerator.js";
|
||||
import { computeEditPreview, type EditPreviewResult } from "./editPreview.js";
|
||||
import { createPiSessionManagerGateway } from "./piSessionManagerGateway.js";
|
||||
import type { WorkspaceActivityService } from "../activity/workspaceActivityService.js";
|
||||
|
||||
function noop(): void {
|
||||
@@ -34,6 +35,18 @@ function authLossWarningKey(sessionId: string, provider: string, modelId: string
|
||||
return `${sessionId}:${provider}/${modelId}`;
|
||||
}
|
||||
|
||||
function sessionIdFromLookup(ref: PiSessionLookup): string {
|
||||
return typeof ref === "string" ? ref : ref.id;
|
||||
}
|
||||
|
||||
function isPiSessionRef(ref: PiSessionLookup): ref is PiSessionRef {
|
||||
return typeof ref !== "string";
|
||||
}
|
||||
|
||||
function lookupMatchesActiveSession(ref: PiSessionLookup, active: ActiveSession<PiSessionRuntime>): boolean {
|
||||
return !isPiSessionRef(ref) || active.runtime.cwd === ref.cwd;
|
||||
}
|
||||
|
||||
type QueuedPromptKind = "steer" | "followUp";
|
||||
|
||||
interface QueuedPrompt {
|
||||
@@ -42,7 +55,12 @@ interface QueuedPrompt {
|
||||
}
|
||||
|
||||
type SessionArchiveRepository = Pick<SessionArchiveStore, "list" | "get" | "archive" | "restore" | "isArchived">;
|
||||
interface PiSessionListEntry {
|
||||
|
||||
export type PiSessionRef = ClientSessionRef;
|
||||
|
||||
type PiSessionLookup = string | PiSessionRef;
|
||||
|
||||
export interface PiSessionListEntry {
|
||||
id: string;
|
||||
path: string;
|
||||
cwd: string;
|
||||
@@ -74,7 +92,6 @@ export interface PiSessionManager {
|
||||
export interface PiSessionManagerGateway {
|
||||
list(cwd: string): Promise<PiSessionListEntry[]>;
|
||||
create(cwd: string): PiSessionManager;
|
||||
listAll(): Promise<PiSessionListEntry[]>;
|
||||
open(path: string): PiSessionManager;
|
||||
}
|
||||
|
||||
@@ -201,7 +218,7 @@ export class PiSessionService {
|
||||
constructor(private readonly events: SessionEventHub, deps: PiSessionServiceDependencies = {}) {
|
||||
this.archiveStore = deps.archiveStore ?? new SessionArchiveStore();
|
||||
this.agentDir = deps.agentDir ?? getAgentDir();
|
||||
this.sessionManager = deps.sessionManager ?? SessionManager;
|
||||
this.sessionManager = deps.sessionManager ?? createPiSessionManagerGateway({ agentDir: this.agentDir });
|
||||
this.modelRegistry = deps.modelRegistry ?? ModelRegistry.create(AuthStorage.create());
|
||||
this.createRuntime = deps.createRuntime ?? createDefaultRuntimeFactory(this.modelRegistry.authStorage, this.modelRegistry);
|
||||
this.createAgentRuntime = deps.createAgentRuntime ?? defaultCreateAgentRuntime;
|
||||
@@ -277,17 +294,17 @@ export class PiSessionService {
|
||||
};
|
||||
}
|
||||
|
||||
async messages(sessionId: string, page?: { before?: number; limit?: number }): Promise<unknown[] | ClientMessagePage> {
|
||||
const session = await this.getOrOpen(sessionId);
|
||||
async messages(ref: PiSessionLookup, page?: { before?: number; limit?: number }): Promise<unknown[] | ClientMessagePage> {
|
||||
const session = await this.getOrOpen(ref);
|
||||
return pageMessagesAtSafeBoundary(historyMessages(session), page);
|
||||
}
|
||||
|
||||
async status(sessionId: string): Promise<ClientSessionStatus> {
|
||||
return this.statusFromSession(await this.getOrOpen(sessionId));
|
||||
async status(ref: PiSessionLookup): Promise<ClientSessionStatus> {
|
||||
return this.statusFromSession(await this.getOrOpen(ref));
|
||||
}
|
||||
|
||||
async availableModels(sessionId: string): Promise<ClientSessionModel[]> {
|
||||
const session = await this.getOrOpen(sessionId);
|
||||
async availableModels(ref: PiSessionLookup): Promise<ClientSessionModel[]> {
|
||||
const session = await this.getOrOpen(ref);
|
||||
session.modelRegistry.refresh();
|
||||
const models = session.scopedModels.length > 0
|
||||
? session.scopedModels.map((scoped) => scoped.model)
|
||||
@@ -295,9 +312,9 @@ export class PiSessionService {
|
||||
return models.map(modelToClientModel);
|
||||
}
|
||||
|
||||
async setModel(sessionId: string, provider: string, modelId: string): Promise<ClientSessionStatus> {
|
||||
await this.assertWritable(sessionId);
|
||||
const session = await this.getOrOpen(sessionId);
|
||||
async setModel(ref: PiSessionLookup, provider: string, modelId: string): Promise<ClientSessionStatus> {
|
||||
await this.assertWritable(ref);
|
||||
const session = await this.getOrOpen(ref);
|
||||
session.modelRegistry.refresh();
|
||||
const candidates = session.scopedModels.length > 0
|
||||
? session.scopedModels.map((scoped) => scoped.model)
|
||||
@@ -311,9 +328,9 @@ export class PiSessionService {
|
||||
return this.statusFromSession(session);
|
||||
}
|
||||
|
||||
async cycleModel(sessionId: string, direction: "forward" | "backward"): Promise<ClientSessionStatus> {
|
||||
await this.assertWritable(sessionId);
|
||||
const session = await this.getOrOpen(sessionId);
|
||||
async cycleModel(ref: PiSessionLookup, direction: "forward" | "backward"): Promise<ClientSessionStatus> {
|
||||
await this.assertWritable(ref);
|
||||
const session = await this.getOrOpen(ref);
|
||||
const result = await session.cycleModel(direction);
|
||||
if (result === undefined) throw new Error(session.scopedModels.length > 0 ? "Only one model in scope" : "Only one model available");
|
||||
this.publishActivity(session, `model: ${result.model.id}`, "idle", result.model.provider);
|
||||
@@ -321,23 +338,23 @@ export class PiSessionService {
|
||||
return this.statusFromSession(session);
|
||||
}
|
||||
|
||||
async availableThinkingLevels(sessionId: string): Promise<ClientThinkingLevel[]> {
|
||||
const session = await this.getOrOpen(sessionId);
|
||||
async availableThinkingLevels(ref: PiSessionLookup): Promise<ClientThinkingLevel[]> {
|
||||
const session = await this.getOrOpen(ref);
|
||||
return session.getAvailableThinkingLevels();
|
||||
}
|
||||
|
||||
async setThinkingLevel(sessionId: string, level: ClientThinkingLevel): Promise<ClientSessionStatus> {
|
||||
await this.assertWritable(sessionId);
|
||||
const session = await this.getOrOpen(sessionId);
|
||||
async setThinkingLevel(ref: PiSessionLookup, level: ClientThinkingLevel): Promise<ClientSessionStatus> {
|
||||
await this.assertWritable(ref);
|
||||
const session = await this.getOrOpen(ref);
|
||||
session.setThinkingLevel(level);
|
||||
this.publishActivity(session, `thinking: ${session.thinkingLevel}`, "idle");
|
||||
this.publishStatus(session);
|
||||
return this.statusFromSession(session);
|
||||
}
|
||||
|
||||
async cycleThinkingLevel(sessionId: string): Promise<ClientSessionStatus> {
|
||||
await this.assertWritable(sessionId);
|
||||
const session = await this.getOrOpen(sessionId);
|
||||
async cycleThinkingLevel(ref: PiSessionLookup): Promise<ClientSessionStatus> {
|
||||
await this.assertWritable(ref);
|
||||
const session = await this.getOrOpen(ref);
|
||||
const level = session.cycleThinkingLevel();
|
||||
if (level === undefined) throw new Error("Current model does not support thinking");
|
||||
this.publishActivity(session, `thinking: ${level}`, "idle");
|
||||
@@ -345,8 +362,8 @@ export class PiSessionService {
|
||||
return this.statusFromSession(session);
|
||||
}
|
||||
|
||||
async commands(sessionId: string): Promise<ClientCommand[]> {
|
||||
const session = await this.getOrOpen(sessionId);
|
||||
async commands(ref: PiSessionLookup): Promise<ClientCommand[]> {
|
||||
const session = await this.getOrOpen(ref);
|
||||
const commands: ClientCommand[] = [...BUILTIN_COMMANDS];
|
||||
for (const command of session.extensionRunner.getRegisteredCommands()) {
|
||||
commands.push({ name: command.invocationName, ...(command.description === undefined ? {} : { description: command.description }), source: "extension" });
|
||||
@@ -360,9 +377,9 @@ export class PiSessionService {
|
||||
return commands.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
async prompt(sessionId: string, text: string, streamingBehavior?: "steer" | "followUp"): Promise<void> {
|
||||
await this.assertWritable(sessionId);
|
||||
const session = await this.getOrOpen(sessionId);
|
||||
async prompt(ref: PiSessionLookup, text: string, streamingBehavior?: "steer" | "followUp"): Promise<void> {
|
||||
await this.assertWritable(ref);
|
||||
const session = await this.getOrOpen(ref);
|
||||
this.maybeGenerateSessionName(session, text);
|
||||
const isQueued = session.isStreaming || session.isCompacting;
|
||||
const behavior = isQueued ? streamingBehavior ?? "followUp" : undefined;
|
||||
@@ -398,9 +415,9 @@ export class PiSessionService {
|
||||
this.publishStatus(session);
|
||||
}
|
||||
|
||||
async shell(sessionId: string, text: string): Promise<void> {
|
||||
await this.assertWritable(sessionId);
|
||||
const active = await this.getActive(sessionId);
|
||||
async shell(ref: PiSessionLookup, text: string): Promise<void> {
|
||||
await this.assertWritable(ref);
|
||||
const active = await this.getActive(ref);
|
||||
const { session } = active.runtime;
|
||||
const isExcluded = text.startsWith("!!");
|
||||
const command = (isExcluded ? text.slice(2) : text.slice(1)).trim();
|
||||
@@ -433,26 +450,28 @@ export class PiSessionService {
|
||||
});
|
||||
}
|
||||
|
||||
async runCommand(sessionId: string, text: string): Promise<ClientCommandResult> {
|
||||
await this.assertWritable(sessionId);
|
||||
return this.commandService.run(sessionId, text);
|
||||
async runCommand(ref: PiSessionLookup, text: string): Promise<ClientCommandResult> {
|
||||
await this.assertWritable(ref);
|
||||
const active = await this.getActive(ref);
|
||||
return this.commandService.run(active.runtime.session.sessionId, text);
|
||||
}
|
||||
|
||||
async respondToCommand(sessionId: string, requestId: string, value: string): Promise<ClientCommandResult> {
|
||||
await this.assertWritable(sessionId);
|
||||
return this.commandService.respond(sessionId, requestId, value);
|
||||
async respondToCommand(ref: PiSessionLookup, requestId: string, value: string): Promise<ClientCommandResult> {
|
||||
await this.assertWritable(ref);
|
||||
const active = await this.getActive(ref);
|
||||
return this.commandService.respond(active.runtime.session.sessionId, requestId, value);
|
||||
}
|
||||
|
||||
async archive(sessionId: string): Promise<void> {
|
||||
const session = await this.getOrOpen(sessionId);
|
||||
async archive(ref: PiSessionLookup): Promise<void> {
|
||||
const session = await this.getOrOpen(ref);
|
||||
if (this.hasActiveWork(session)) throw new Error("Stop current session activity before archiving");
|
||||
const archiveInput = await this.archiveInputForSession(session);
|
||||
await this.closeActive(session.sessionId);
|
||||
await this.archiveStore.archive(archiveInput);
|
||||
}
|
||||
|
||||
async archiveTree(sessionId: string): Promise<ClientArchiveSessionsResponse> {
|
||||
const session = await this.getOrOpen(sessionId);
|
||||
async archiveTree(ref: PiSessionLookup): Promise<ClientArchiveSessionsResponse> {
|
||||
const session = await this.getOrOpen(ref);
|
||||
const catalog = await this.workspaceArchiveCandidates(session.sessionManager.getCwd());
|
||||
const root = findArchiveCandidateByIdOrPrefix(catalog, session.sessionId) ?? archiveCandidateFromActiveSession(session, false);
|
||||
const plan = planSessionArchiveTree(root, catalog);
|
||||
@@ -471,21 +490,24 @@ export class PiSessionService {
|
||||
};
|
||||
}
|
||||
|
||||
async restore(sessionId: string): Promise<void> {
|
||||
await this.closeActive(sessionId);
|
||||
await this.archiveStore.restore(sessionId);
|
||||
async restore(ref: PiSessionLookup): Promise<void> {
|
||||
const archived = await this.getArchived(ref);
|
||||
if (archived === undefined) throw new Error("Session not found");
|
||||
await this.closeActive(archived.sessionId);
|
||||
await this.archiveStore.restore(archived.sessionId);
|
||||
}
|
||||
|
||||
async detachParent(sessionId: string): Promise<void> {
|
||||
const session = await this.getOrOpen(sessionId);
|
||||
async detachParent(ref: PiSessionLookup): Promise<void> {
|
||||
const session = await this.getOrOpen(ref);
|
||||
const sessionFile = session.sessionFile;
|
||||
if (sessionFile === undefined || sessionFile === "") throw new Error("Session is not persisted");
|
||||
await clearParentSession(sessionFile);
|
||||
}
|
||||
|
||||
async abort(sessionId: string): Promise<void> {
|
||||
const active = this.active.get(sessionId);
|
||||
if (!active) return;
|
||||
async abort(ref: PiSessionLookup): Promise<void> {
|
||||
const active = this.activeForLookup(ref);
|
||||
if (active === undefined) return;
|
||||
const sessionId = active.runtime.session.sessionId;
|
||||
this.clearCompactionPromptQueue(sessionId);
|
||||
clearSessionQueue(active.runtime.session);
|
||||
await active.runtime.session.abort();
|
||||
@@ -493,8 +515,10 @@ export class PiSessionService {
|
||||
this.publishStatus(active.runtime.session);
|
||||
}
|
||||
|
||||
stop(sessionId: string): void {
|
||||
void this.closeActive(sessionId).catch(() => {
|
||||
stop(ref: PiSessionLookup): void {
|
||||
const active = this.activeForLookup(ref);
|
||||
if (active === undefined) return;
|
||||
void this.closeActive(active.runtime.session.sessionId).catch(() => {
|
||||
// Best-effort shutdown; callers that need errors await closeActive directly.
|
||||
});
|
||||
}
|
||||
@@ -591,26 +615,44 @@ export class PiSessionService {
|
||||
}
|
||||
}
|
||||
|
||||
private async assertWritable(sessionId: string): Promise<void> {
|
||||
if (await this.archiveStore.isArchived(sessionId)) throw new Error("Archived sessions are read-only. Restore the session to continue.");
|
||||
private async assertWritable(ref: PiSessionLookup): Promise<void> {
|
||||
if (await this.getArchived(ref) !== undefined) throw new Error("Archived sessions are read-only. Restore the session to continue.");
|
||||
}
|
||||
|
||||
private async getOrOpen(sessionId: string): Promise<PiAgentSession> {
|
||||
return (await this.getActive(sessionId)).runtime.session;
|
||||
private async getOrOpen(ref: PiSessionLookup): Promise<PiAgentSession> {
|
||||
return (await this.getActive(ref)).runtime.session;
|
||||
}
|
||||
|
||||
private async getActive(sessionId: string): Promise<ActiveSession<PiSessionRuntime>> {
|
||||
const active = this.active.get(sessionId);
|
||||
if (active) return active;
|
||||
private async getActive(ref: PiSessionLookup): Promise<ActiveSession<PiSessionRuntime>> {
|
||||
const active = this.activeForLookup(ref);
|
||||
if (active !== undefined) return active;
|
||||
|
||||
const archived = await this.archiveStore.get(sessionId);
|
||||
const archived = await this.getArchived(ref);
|
||||
if (archived?.archivePath !== undefined) return this.create(this.sessionManager.open(archived.archivePath), archived.cwd);
|
||||
|
||||
const match = (await this.sessionManager.listAll()).find((s) => s.id === sessionId || s.id.startsWith(sessionId));
|
||||
if (!isPiSessionRef(ref)) throw new Error("Session not found");
|
||||
const match = (await this.sessionManager.list(ref.cwd)).find((s) => s.id === ref.id || s.id.startsWith(ref.id));
|
||||
if (!match) throw new Error("Session not found");
|
||||
return this.create(this.sessionManager.open(match.path), match.cwd);
|
||||
}
|
||||
|
||||
private async getArchived(ref: PiSessionLookup): Promise<ArchivedSessionRecord | undefined> {
|
||||
const archived = await this.archiveStore.get(sessionIdFromLookup(ref));
|
||||
if (archived === undefined) return undefined;
|
||||
if (isPiSessionRef(ref) && archived.cwd !== ref.cwd) return undefined;
|
||||
return archived;
|
||||
}
|
||||
|
||||
private activeForLookup(ref: PiSessionLookup): ActiveSession<PiSessionRuntime> | undefined {
|
||||
const sessionId = sessionIdFromLookup(ref);
|
||||
const exact = this.active.get(sessionId);
|
||||
if (exact !== undefined && lookupMatchesActiveSession(ref, exact)) return exact;
|
||||
for (const [candidateId, active] of this.active.entries()) {
|
||||
if (candidateId.startsWith(sessionId) && lookupMatchesActiveSession(ref, active)) return active;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async create(sessionManager: PiSessionManager, cwd: string): Promise<ActiveSession<PiSessionRuntime>> {
|
||||
const runtime = await this.createAgentRuntime(this.createRuntime, { cwd, agentDir: this.agentDir, sessionManager });
|
||||
const active: ActiveSession<PiSessionRuntime> = { runtime, unsubscribe: noop };
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import type { SessionEventHub } from "../realtime/sessionEventHub.js";
|
||||
import type { PiSessionService } from "./piSessionService.js";
|
||||
import type { PiSessionRef, PiSessionService } from "./piSessionService.js";
|
||||
|
||||
interface SessionQuery {
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
interface MessageQuery extends SessionQuery {
|
||||
before?: string;
|
||||
limit?: string;
|
||||
}
|
||||
|
||||
class SessionRouteValidationError extends Error {}
|
||||
|
||||
export function registerSessionRoutes(app: FastifyInstance, sessions: PiSessionService, eventHub: SessionEventHub, prefix = ""): void {
|
||||
app.get<{ Querystring: { cwd?: string } }>(`${prefix}/sessions`, async (request, reply) => {
|
||||
app.get<{ Querystring: SessionQuery }>(`${prefix}/sessions`, async (request, reply) => {
|
||||
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);
|
||||
});
|
||||
@@ -12,164 +23,189 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: PiSessionS
|
||||
try {
|
||||
return await sessions.start(request.body.cwd);
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
return reply.code(400).send({ error: errorMessage(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.get<{ Params: { sessionId: string }; Querystring: { before?: string; limit?: string } }>(`${prefix}/sessions/:sessionId/messages`, async (request, reply) => {
|
||||
app.get<{ Params: { sessionId: string }; Querystring: MessageQuery }>(`${prefix}/sessions/:sessionId/messages`, async (request, reply) => {
|
||||
try {
|
||||
const page = { ...optionalField("before", optionalNumber(request.query.before)), ...optionalField("limit", optionalNumber(request.query.limit)) };
|
||||
return await sessions.messages(request.params.sessionId, page);
|
||||
return await sessions.messages(sessionRefFromQuery(request.params.sessionId, request.query), page);
|
||||
} catch (error) {
|
||||
return reply.code(404).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
return reply.code(readErrorStatus(error)).send({ error: errorMessage(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.get<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId/status`, async (request, reply) => {
|
||||
app.get<{ Params: { sessionId: string }; Querystring: SessionQuery }>(`${prefix}/sessions/:sessionId/status`, async (request, reply) => {
|
||||
try {
|
||||
return await sessions.status(request.params.sessionId);
|
||||
return await sessions.status(sessionRefFromQuery(request.params.sessionId, request.query));
|
||||
} catch (error) {
|
||||
return reply.code(404).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
return reply.code(readErrorStatus(error)).send({ error: errorMessage(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.get<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId/models`, async (request, reply) => {
|
||||
app.get<{ Params: { sessionId: string }; Querystring: SessionQuery }>(`${prefix}/sessions/:sessionId/models`, async (request, reply) => {
|
||||
try {
|
||||
return { models: await sessions.availableModels(request.params.sessionId) };
|
||||
return { models: await sessions.availableModels(sessionRefFromQuery(request.params.sessionId, request.query)) };
|
||||
} catch (error) {
|
||||
return reply.code(404).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
return reply.code(readErrorStatus(error)).send({ error: errorMessage(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Params: { sessionId: string }; Body: { provider: string; modelId: string } }>(`${prefix}/sessions/:sessionId/model`, async (request, reply) => {
|
||||
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown; provider?: unknown; modelId?: unknown } }>(`${prefix}/sessions/:sessionId/model`, async (request, reply) => {
|
||||
try {
|
||||
return await sessions.setModel(request.params.sessionId, request.body.provider, request.body.modelId);
|
||||
const body = requireRecord(request.body);
|
||||
return await sessions.setModel(sessionRefFromBody(request.params.sessionId, body), requireString(body, "provider"), requireString(body, "modelId"));
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
return reply.code(400).send({ error: errorMessage(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Params: { sessionId: string }; Body: { direction?: "forward" | "backward" } }>(`${prefix}/sessions/:sessionId/model/cycle`, async (request, reply) => {
|
||||
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown; direction?: "forward" | "backward" } }>(`${prefix}/sessions/:sessionId/model/cycle`, async (request, reply) => {
|
||||
try {
|
||||
return await sessions.cycleModel(request.params.sessionId, request.body.direction ?? "forward");
|
||||
const body = requireRecord(request.body);
|
||||
const direction = body["direction"];
|
||||
if (direction !== undefined && direction !== "forward" && direction !== "backward") throw new Error("direction must be forward or backward");
|
||||
return await sessions.cycleModel(sessionRefFromBody(request.params.sessionId, body), direction ?? "forward");
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
return reply.code(400).send({ error: errorMessage(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.get<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId/thinking-levels`, async (request, reply) => {
|
||||
app.get<{ Params: { sessionId: string }; Querystring: SessionQuery }>(`${prefix}/sessions/:sessionId/thinking-levels`, async (request, reply) => {
|
||||
try {
|
||||
return { levels: await sessions.availableThinkingLevels(request.params.sessionId) };
|
||||
return { levels: await sessions.availableThinkingLevels(sessionRefFromQuery(request.params.sessionId, request.query)) };
|
||||
} catch (error) {
|
||||
return reply.code(404).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
return reply.code(readErrorStatus(error)).send({ error: errorMessage(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Params: { sessionId: string }; Body: { level: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" } }>(`${prefix}/sessions/:sessionId/thinking-level`, async (request, reply) => {
|
||||
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown; level?: unknown } }>(`${prefix}/sessions/:sessionId/thinking-level`, async (request, reply) => {
|
||||
try {
|
||||
return await sessions.setThinkingLevel(request.params.sessionId, request.body.level);
|
||||
const body = requireRecord(request.body);
|
||||
return await sessions.setThinkingLevel(sessionRefFromBody(request.params.sessionId, body), requireThinkingLevel(body["level"]));
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
return reply.code(400).send({ error: errorMessage(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId/thinking-level/cycle`, async (request, reply) => {
|
||||
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown } }>(`${prefix}/sessions/:sessionId/thinking-level/cycle`, async (request, reply) => {
|
||||
try {
|
||||
return await sessions.cycleThinkingLevel(request.params.sessionId);
|
||||
const body = requireRecord(request.body);
|
||||
return await sessions.cycleThinkingLevel(sessionRefFromBody(request.params.sessionId, body));
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
return reply.code(400).send({ error: errorMessage(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.get<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId/commands`, async (request, reply) => {
|
||||
app.get<{ Params: { sessionId: string }; Querystring: SessionQuery }>(`${prefix}/sessions/:sessionId/commands`, async (request, reply) => {
|
||||
try {
|
||||
return await sessions.commands(request.params.sessionId);
|
||||
return await sessions.commands(sessionRefFromQuery(request.params.sessionId, request.query));
|
||||
} catch (error) {
|
||||
return reply.code(404).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
return reply.code(readErrorStatus(error)).send({ error: errorMessage(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Params: { sessionId: string }; Body: { text: string; streamingBehavior?: "steer" | "followUp" } }>(`${prefix}/sessions/:sessionId/prompt`, async (request, reply) => {
|
||||
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown; text?: unknown; streamingBehavior?: "steer" | "followUp" } }>(`${prefix}/sessions/:sessionId/prompt`, async (request, reply) => {
|
||||
try {
|
||||
await sessions.prompt(request.params.sessionId, request.body.text, request.body.streamingBehavior);
|
||||
const body = requireRecord(request.body);
|
||||
const streamingBehavior = body["streamingBehavior"];
|
||||
if (streamingBehavior !== undefined && streamingBehavior !== "steer" && streamingBehavior !== "followUp") throw new Error("streamingBehavior must be steer or followUp");
|
||||
await sessions.prompt(sessionRefFromBody(request.params.sessionId, body), requireString(body, "text"), streamingBehavior);
|
||||
return { accepted: true };
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
return reply.code(400).send({ error: errorMessage(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Params: { sessionId: string }; Body: { text: string } }>(`${prefix}/sessions/:sessionId/shell`, async (request, reply) => {
|
||||
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown; text?: unknown } }>(`${prefix}/sessions/:sessionId/shell`, async (request, reply) => {
|
||||
try {
|
||||
await sessions.shell(request.params.sessionId, request.body.text);
|
||||
const body = requireRecord(request.body);
|
||||
await sessions.shell(sessionRefFromBody(request.params.sessionId, body), requireString(body, "text"));
|
||||
return { accepted: true };
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
return reply.code(400).send({ error: errorMessage(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Params: { sessionId: string }; Body: { text: string } }>(`${prefix}/sessions/:sessionId/commands/run`, async (request, reply) => {
|
||||
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown; text?: unknown } }>(`${prefix}/sessions/:sessionId/commands/run`, async (request, reply) => {
|
||||
try {
|
||||
return await sessions.runCommand(request.params.sessionId, request.body.text);
|
||||
const body = requireRecord(request.body);
|
||||
return await sessions.runCommand(sessionRefFromBody(request.params.sessionId, body), requireString(body, "text"));
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
return reply.code(400).send({ error: errorMessage(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Params: { sessionId: string }; Body: { requestId: string; value: string } }>(`${prefix}/sessions/:sessionId/commands/respond`, async (request, reply) => {
|
||||
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown; requestId?: unknown; value?: unknown } }>(`${prefix}/sessions/:sessionId/commands/respond`, async (request, reply) => {
|
||||
try {
|
||||
return await sessions.respondToCommand(request.params.sessionId, request.body.requestId, request.body.value);
|
||||
const body = requireRecord(request.body);
|
||||
return await sessions.respondToCommand(sessionRefFromBody(request.params.sessionId, body), requireString(body, "requestId"), requireString(body, "value"));
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
return reply.code(400).send({ error: errorMessage(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId/abort`, async (request) => {
|
||||
await sessions.abort(request.params.sessionId);
|
||||
return { aborted: true };
|
||||
});
|
||||
|
||||
app.post<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId/stop`, (request) => {
|
||||
sessions.stop(request.params.sessionId);
|
||||
return { stopped: true };
|
||||
});
|
||||
|
||||
app.post<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId/archive`, async (request, reply) => {
|
||||
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown } }>(`${prefix}/sessions/:sessionId/abort`, async (request, reply) => {
|
||||
try {
|
||||
await sessions.archive(request.params.sessionId);
|
||||
await sessions.abort(sessionRefFromBody(request.params.sessionId, requireRecord(request.body)));
|
||||
return { aborted: true };
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: errorMessage(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown } }>(`${prefix}/sessions/:sessionId/stop`, (request, reply) => {
|
||||
try {
|
||||
sessions.stop(sessionRefFromBody(request.params.sessionId, requireRecord(request.body)));
|
||||
return { stopped: true };
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: errorMessage(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown } }>(`${prefix}/sessions/:sessionId/archive`, async (request, reply) => {
|
||||
try {
|
||||
await sessions.archive(sessionRefFromBody(request.params.sessionId, requireRecord(request.body)));
|
||||
return { archived: true };
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
return reply.code(400).send({ error: errorMessage(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId/archive-tree`, async (request, reply) => {
|
||||
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown } }>(`${prefix}/sessions/:sessionId/archive-tree`, async (request, reply) => {
|
||||
try {
|
||||
return await sessions.archiveTree(request.params.sessionId);
|
||||
return await sessions.archiveTree(sessionRefFromBody(request.params.sessionId, requireRecord(request.body)));
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
return reply.code(400).send({ error: errorMessage(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId/restore`, async (request, reply) => {
|
||||
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown } }>(`${prefix}/sessions/:sessionId/restore`, async (request, reply) => {
|
||||
try {
|
||||
await sessions.restore(request.params.sessionId);
|
||||
await sessions.restore(sessionRefFromBody(request.params.sessionId, requireRecord(request.body)));
|
||||
return { restored: true };
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
return reply.code(400).send({ error: errorMessage(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId/detach-parent`, async (request, reply) => {
|
||||
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown } }>(`${prefix}/sessions/:sessionId/detach-parent`, async (request, reply) => {
|
||||
try {
|
||||
await sessions.detachParent(request.params.sessionId);
|
||||
await sessions.detachParent(sessionRefFromBody(request.params.sessionId, requireRecord(request.body)));
|
||||
return { detached: true };
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
return reply.code(400).send({ error: errorMessage(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.get<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId/events`, { websocket: true }, (socket, request) => {
|
||||
eventHub.add(request.params.sessionId, socket);
|
||||
app.get<{ Params: { sessionId: string }; Querystring: SessionQuery }>(`${prefix}/sessions/:sessionId/events`, { websocket: true }, (socket, request) => {
|
||||
try {
|
||||
const ref = sessionRefFromQuery(request.params.sessionId, request.query);
|
||||
eventHub.add(ref.id, socket);
|
||||
} catch {
|
||||
socket.close();
|
||||
}
|
||||
});
|
||||
|
||||
app.get(`${prefix}/sessions/events`, { websocket: true }, (socket) => {
|
||||
@@ -181,6 +217,34 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: PiSessionS
|
||||
});
|
||||
}
|
||||
|
||||
function sessionRefFromQuery(id: string, query: SessionQuery): PiSessionRef {
|
||||
const cwd = query.cwd;
|
||||
if (cwd === undefined || cwd === "") throw new SessionRouteValidationError("cwd query parameter is required");
|
||||
return { id, cwd };
|
||||
}
|
||||
|
||||
function sessionRefFromBody(id: string, body: Record<string, unknown>): PiSessionRef {
|
||||
const cwd = body["cwd"];
|
||||
if (typeof cwd !== "string" || cwd === "") throw new Error("cwd field is required");
|
||||
return { id, cwd };
|
||||
}
|
||||
|
||||
function requireRecord(value: unknown): Record<string, unknown> {
|
||||
if (!isRecord(value)) throw new Error("request body must be an object");
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireString(record: Record<string, unknown>, field: string): string {
|
||||
const value = record[field];
|
||||
if (typeof value !== "string") throw new Error(`${field} field must be a string`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireThinkingLevel(value: unknown): "off" | "minimal" | "low" | "medium" | "high" | "xhigh" {
|
||||
if (value === "off" || value === "minimal" || value === "low" || value === "medium" || value === "high" || value === "xhigh") return value;
|
||||
throw new Error("level field is invalid");
|
||||
}
|
||||
|
||||
function optionalField<T>(key: string, value: T | undefined): Record<string, T> | object {
|
||||
return value === undefined ? {} : { [key]: value };
|
||||
}
|
||||
@@ -190,3 +254,15 @@ function optionalNumber(value: string | undefined): number | undefined {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function readErrorStatus(error: unknown): 400 | 404 {
|
||||
return error instanceof SessionRouteValidationError ? 400 : 404;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export type {
|
||||
Project,
|
||||
Workspace,
|
||||
SessionRef as ClientSessionRef,
|
||||
SessionInfo as ClientSession,
|
||||
ArchiveSessionsResponse as ClientArchiveSessionsResponse,
|
||||
MessagePage as ClientMessagePage,
|
||||
|
||||
@@ -86,10 +86,13 @@ export interface Workspace {
|
||||
isGitWorktree: boolean;
|
||||
}
|
||||
|
||||
export interface SessionInfo {
|
||||
export interface SessionRef {
|
||||
id: string;
|
||||
path: string;
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
export interface SessionInfo extends SessionRef {
|
||||
path: string;
|
||||
name?: string;
|
||||
created: string;
|
||||
modified: string;
|
||||
|
||||
Reference in New Issue
Block a user