Skip to content
7 changes: 7 additions & 0 deletions .changeset/honor-archive-data-dir.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@jmfederico/pi-web": patch
---

Store session archive metadata and archived session files under `PI_WEB_DATA_DIR` when configured, and automatically migrate a legacy archive on the first eligible session-daemon startup after upgrading.

Migration runs only when `PI_WEB_DATA_DIR` explicitly selects a different root, the legacy index and every referenced file form a complete valid archive, and the destination archive is pristine. PI WEB copies and verifies files across filesystem boundaries, rewrites their `archivePath` values, atomically commits the destination index, and only then removes legacy archive state. Ambiguous, invalid, partial, or coexisting layouts are left untouched instead of being merged or overwritten; active Pi session files are never moved.
8 changes: 7 additions & 1 deletion docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ Rows with JSON key `—` are runtime-only environment variables, not config-file
| Project config version | `version` | — | Project | Project-local only; must be `1` when present | Next project-config read |
| **Runtime-only environment variables** | | | | | |
| Global config file path | — | `PI_WEB_CONFIG` (`XDG_CONFIG_HOME` affects the default path) | Process/env | Selects the global config file; not a project config | Restart services/processes after changing env |
| Managed data directory | — | `PI_WEB_DATA_DIR` | Process/env | Not supported locally | Restart services before changing; moves managed state location |
| Managed data directory | — | `PI_WEB_DATA_DIR` | Process/env | Not supported locally | Restart web/API and session daemon |
| Session daemon socket | — | `PI_WEB_SESSIOND_SOCKET` | Web/API + session daemon env | Not supported locally | Restart daemon and web/API; both must match |
| Session daemon TCP port | — | `PI_WEB_SESSIOND_PORT` | Session daemon env | Not supported locally | Restart session daemon; set `PI_WEB_SESSIOND_URL` for web/API too |
| Session daemon TCP host | — | `PI_WEB_SESSIOND_HOST` | Session daemon env | Not supported locally | Restart session daemon |
Expand All @@ -128,6 +128,12 @@ Rows with JSON key `—` are runtime-only environment variables, not config-file

## Key details

### Managed data directory

`PI_WEB_DATA_DIR` sets the root for PI WEB-managed runtime state and defaults to `~/.pi-web`. Unless a more specific path override is configured, PI WEB stores its project and machine registries, locally discovered plugins, default session-daemon socket, and session archives beneath this root.

This setting does not change the PI WEB config file selected by `PI_WEB_CONFIG` or Pi-owned state such as the active session files selected by `PI_CODING_AGENT_SESSION_DIR`.

### External path access

`pathAccess.allowedPaths` grants PI WEB's file explorer and absolute `@` path completions access to specific filesystem roots outside the current workspace.
Expand Down
128 changes: 69 additions & 59 deletions src/server/sessiond.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,73 +20,83 @@ import { registerTerminalRoutes } from "./terminals/terminalRoutes.js";
import { getPiWebRuntimeComponent } from "./piWebStatus.js";
import { SESSIOND_RUNTIME_CAPABILITIES } from "../shared/capabilities.js";
import { effectivePiWebConfig, maxUploadBytes, spawnSessionsEnabled, subsessionsEnabled } from "../config.js";
import { runSessionDaemonStartup } from "./sessiond/sessionDaemonStartup.js";

const { config } = effectivePiWebConfig();
const app = Fastify({ logger: true, bodyLimit: maxUploadBytes(process.env, config) });
await app.register(fastifyWebsocket);

const eventHub = new SessionEventHub();
const workspaceActivity = new WorkspaceActivityService(eventHub);
const auth = new AuthService();
const spawnTargets = spawnSessionsEnabled(process.env, config)
? new ProjectScopedSpawnTargetResolver({ projects: new ProjectService(new ProjectStore()), workspaces: new WorkspaceService() })
: undefined;
const sessions = new PiSessionService(eventHub, {
modelRegistry: auth.modelRegistry,
workspaceActivity,
await runSessionDaemonStartup({
logger: app.log,
...(spawnTargets === undefined ? {} : { spawnTargets }),
subsessionsEnabled: spawnTargets !== undefined && subsessionsEnabled(process.env, config),
});
auth.subscribe((change) => { sessions.applyAuthChange(change); });
const terminals = new TerminalService(eventHub, workspaceActivity);
registerWorkspaceActivityRoutes(app, workspaceActivity);
registerAuthRoutes(app, auth);
registerSessionRoutes(app, sessions, eventHub);
registerTerminalRoutes(app, terminals);

app.get("/health", () => {
const runtime = getPiWebRuntimeComponent("sessiond", SESSIOND_RUNTIME_CAPABILITIES);
return {
ok: true,
activeSessions: sessions.activeCount(),
checkedAt: new Date().toISOString(),
version: {
component: runtime.component,
label: runtime.label,
...(runtime.runtimeVersion === undefined ? {} : { runtimeVersion: runtime.runtimeVersion }),
stale: false,
available: runtime.available,
},
};
});
createRuntime() {
const eventHub = new SessionEventHub();
const workspaceActivity = new WorkspaceActivityService(eventHub);
const auth = new AuthService();
const spawnTargets = spawnSessionsEnabled(process.env, config)
? new ProjectScopedSpawnTargetResolver({ projects: new ProjectService(new ProjectStore()), workspaces: new WorkspaceService() })
: undefined;
const sessions = new PiSessionService(eventHub, {
modelRegistry: auth.modelRegistry,
workspaceActivity,
logger: app.log,
...(spawnTargets === undefined ? {} : { spawnTargets }),
subsessionsEnabled: spawnTargets !== undefined && subsessionsEnabled(process.env, config),
});
auth.subscribe((change) => { sessions.applyAuthChange(change); });
const terminals = new TerminalService(eventHub, workspaceActivity);
return { eventHub, workspaceActivity, auth, sessions, terminals };
},
registerRoutes({ eventHub, workspaceActivity, auth, sessions, terminals }) {
registerWorkspaceActivityRoutes(app, workspaceActivity);
registerAuthRoutes(app, auth);
registerSessionRoutes(app, sessions, eventHub);
registerTerminalRoutes(app, terminals);

app.get("/runtime", () => getPiWebRuntimeComponent("sessiond", SESSIOND_RUNTIME_CAPABILITIES));
app.get("/health", () => {
const runtime = getPiWebRuntimeComponent("sessiond", SESSIOND_RUNTIME_CAPABILITIES);
return {
ok: true,
activeSessions: sessions.activeCount(),
checkedAt: new Date().toISOString(),
version: {
component: runtime.component,
label: runtime.label,
...(runtime.runtimeVersion === undefined ? {} : { runtimeVersion: runtime.runtimeVersion }),
stale: false,
available: runtime.available,
},
};
});

let shuttingDown = false;
async function shutdown(signal: NodeJS.Signals): Promise<void> {
if (shuttingDown) return;
shuttingDown = true;
app.log.info({ signal }, "shutting down session daemon");
terminals.dispose();
auth.dispose();
await sessions.dispose();
await app.close();
}
app.get("/runtime", () => getPiWebRuntimeComponent("sessiond", SESSIOND_RUNTIME_CAPABILITIES));
},
async listen({ auth, sessions, terminals }) {
let shuttingDown = false;
async function shutdown(signal: NodeJS.Signals): Promise<void> {
if (shuttingDown) return;
shuttingDown = true;
app.log.info({ signal }, "shutting down session daemon");
terminals.dispose();
auth.dispose();
await sessions.dispose();
await app.close();
}

process.once("SIGINT", (signal) => { void shutdown(signal); });
process.once("SIGTERM", (signal) => { void shutdown(signal); });
process.once("SIGINT", (signal) => { void shutdown(signal); });
process.once("SIGTERM", (signal) => { void shutdown(signal); });

const portValue = process.env["PI_WEB_SESSIOND_PORT"];
const port = portValue !== undefined && portValue !== "" ? Number(portValue) : undefined;
const host = process.env["PI_WEB_SESSIOND_HOST"] ?? "127.0.0.1";
const portValue = process.env["PI_WEB_SESSIOND_PORT"];
const port = portValue !== undefined && portValue !== "" ? Number(portValue) : undefined;
const host = process.env["PI_WEB_SESSIOND_HOST"] ?? "127.0.0.1";

if (port !== undefined) {
await app.listen({ port, host });
} else {
const path = sessiondSocketPath();
await mkdir(dirname(path), { recursive: true });
await rm(path, { force: true });
await app.listen({ path });
process.on("exit", () => void rm(path, { force: true }));
}
if (port !== undefined) {
await app.listen({ port, host });
} else {
const path = sessiondSocketPath();
await mkdir(dirname(path), { recursive: true });
await rm(path, { force: true });
await app.listen({ path });
process.on("exit", () => void rm(path, { force: true }));
}
},
});
Loading