From 8bdf11aa801eb2cd8fe551c55b799cdee04c52bd Mon Sep 17 00:00:00 2001 From: Jeff Scott Ward Date: Thu, 25 Jun 2026 13:29:30 -0400 Subject: [PATCH 1/3] refactor: add session route service seam --- src/server/sessions/piSessionService.ts | 10 +-- src/server/sessions/sessionRoutes.test.ts | 80 ++++++++++++++++------- src/server/sessions/sessionRoutes.ts | 10 +-- src/server/sessions/sessionService.ts | 56 ++++++++++++++++ 4 files changed, 121 insertions(+), 35 deletions(-) create mode 100644 src/server/sessions/sessionService.ts diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts index fa5f783d..5b6f0e6b 100644 --- a/src/server/sessions/piSessionService.ts +++ b/src/server/sessions/piSessionService.ts @@ -15,7 +15,7 @@ import { type CreateAgentSessionRuntimeFactory, type EditToolDetails, } from "@earendil-works/pi-coding-agent"; -import type { ClientArchiveSessionsResponse, ClientCommand, ClientCommandResult, ClientMessagePage, ClientSession, ClientSessionCleanupExecuteResponse, ClientSessionCleanupPreviewResponse, ClientSessionModel, ClientSessionRef, ClientSessionStatus, ClientThinkingLevel, SessionUiEvent } from "../types.js"; +import type { ClientArchiveSessionsResponse, ClientCommand, ClientCommandResult, ClientMessagePage, ClientSession, ClientSessionCleanupExecuteResponse, ClientSessionCleanupPreviewResponse, ClientSessionModel, ClientSessionStatus, ClientThinkingLevel, SessionUiEvent } from "../types.js"; import { pageMessagesAtSafeBoundary } from "./messagePaging.js"; import type { SessionEventHub } from "../realtime/sessionEventHub.js"; import { BUILTIN_COMMANDS } from "./builtinCommands.js"; @@ -30,6 +30,7 @@ import { createPiSessionManagerGateway } from "./piSessionManagerGateway.js"; import { attachmentsToInlineImages, saveAttachmentsToWorkspace } from "./attachmentService.js"; import { parsePromptAttachments } from "../../shared/promptAttachments.js"; import type { SavedPromptAttachment, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef } from "../../shared/apiTypes.js"; +import type { SessionRouteLookup, SessionRouteRef, SessionRouteService } from "./sessionService.js"; import { cwdPathsEqual } from "../workingDirectory.js"; import type { WorkspaceActivityService } from "../activity/workspaceActivityService.js"; @@ -126,9 +127,8 @@ type SessionArchiveRepository = Pick Promise; }; -export type PiSessionRef = ClientSessionRef; - -type PiSessionLookup = string | PiSessionRef; +export type PiSessionRef = SessionRouteRef; +type PiSessionLookup = SessionRouteLookup; export interface PiSessionListEntry { id: string; @@ -364,7 +364,7 @@ export interface PiSessionServiceDependencies { now?: () => Date; } -export class PiSessionService { +export class PiSessionService implements SessionRouteService { private readonly active = new Map>(); private readonly activities = new Map(); private readonly heartbeat: NodeJS.Timeout; diff --git a/src/server/sessions/sessionRoutes.test.ts b/src/server/sessions/sessionRoutes.test.ts index abef6398..134764ba 100644 --- a/src/server/sessions/sessionRoutes.test.ts +++ b/src/server/sessions/sessionRoutes.test.ts @@ -4,7 +4,8 @@ import fastifyWebsocket from "@fastify/websocket"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import type { SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkMutationRef, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse } from "../../shared/apiTypes.js"; import { SessionEventHub } from "../realtime/sessionEventHub.js"; -import { PiSessionService, type PiSessionManagerGateway, type PiSessionRef } from "./piSessionService.js"; +import { PiSessionService, type PiSessionManagerGateway } from "./piSessionService.js"; +import type { SessionRouteLookup, SessionRouteService } from "./sessionService.js"; import { registerSessionRoutes } from "./sessionRoutes.js"; import type { NormalizedSessionCleanupRequest } from "./sessionCleanup.js"; @@ -39,7 +40,7 @@ describe("session routes", () => { const routeApp = Fastify({ logger: false }); await routeApp.register(fastifyWebsocket); const eventHub = new SessionEventHub(); - const routeService = new CapturingRouteSessionService(eventHub); + const routeService = new CapturingRouteSessionService(); registerSessionRoutes(routeApp, routeService, eventHub); try { @@ -59,7 +60,7 @@ describe("session routes", () => { const routeApp = Fastify({ logger: false }); await routeApp.register(fastifyWebsocket); const eventHub = new SessionEventHub(); - const routeService = new CapturingRouteSessionService(eventHub); + const routeService = new CapturingRouteSessionService(); registerSessionRoutes(routeApp, routeService, eventHub); const attachments = [{ kind: "image", mimeType: "image/png", data: "QUJD", name: "shot.png" }]; @@ -81,7 +82,7 @@ describe("session routes", () => { const routeApp = Fastify({ logger: false }); await routeApp.register(fastifyWebsocket); const eventHub = new SessionEventHub(); - const routeService = new CapturingRouteSessionService(eventHub); + const routeService = new CapturingRouteSessionService(); registerSessionRoutes(routeApp, routeService, eventHub); try { @@ -104,7 +105,7 @@ describe("session routes", () => { const routeApp = Fastify({ logger: false }); await routeApp.register(fastifyWebsocket); const eventHub = new SessionEventHub(); - const routeService = new CapturingRouteSessionService(eventHub); + const routeService = new CapturingRouteSessionService(); registerSessionRoutes(routeApp, routeService, eventHub); try { @@ -124,7 +125,7 @@ describe("session routes", () => { const routeApp = Fastify({ logger: false }); await routeApp.register(fastifyWebsocket); const eventHub = new SessionEventHub(); - const routeService = new CapturingRouteSessionService(eventHub); + const routeService = new CapturingRouteSessionService(); routeService.reloadError = new Error("Stop current session activity before reloading"); registerSessionRoutes(routeApp, routeService, eventHub); @@ -143,7 +144,7 @@ describe("session routes", () => { const routeApp = Fastify({ logger: false }); await routeApp.register(fastifyWebsocket); const eventHub = new SessionEventHub(); - const routeService = new CapturingRouteSessionService(eventHub); + const routeService = new CapturingRouteSessionService(); registerSessionRoutes(routeApp, routeService, eventHub); try { @@ -164,7 +165,7 @@ describe("session routes", () => { const routeApp = Fastify({ logger: false }); await routeApp.register(fastifyWebsocket); const eventHub = new SessionEventHub(); - const routeService = new CapturingRouteSessionService(eventHub); + const routeService = new CapturingRouteSessionService(); registerSessionRoutes(routeApp, routeService, eventHub); try { @@ -183,7 +184,7 @@ describe("session routes", () => { const routeApp = Fastify({ logger: false }); await routeApp.register(fastifyWebsocket); const eventHub = new SessionEventHub(); - const routeService = new CapturingRouteSessionService(eventHub); + const routeService = new CapturingRouteSessionService(); registerSessionRoutes(routeApp, routeService, eventHub); try { @@ -207,7 +208,7 @@ describe("session routes", () => { const routeApp = Fastify({ logger: false }); await routeApp.register(fastifyWebsocket); const eventHub = new SessionEventHub(); - const routeService = new CapturingRouteSessionService(eventHub); + const routeService = new CapturingRouteSessionService(); registerSessionRoutes(routeApp, routeService, eventHub); try { @@ -223,46 +224,50 @@ describe("session routes", () => { }); }); -class CapturingRouteSessionService extends PiSessionService { +class CapturingRouteSessionService implements SessionRouteService { readonly calls: unknown[] = []; - readonly reloadCalls: (string | PiSessionRef)[] = []; + readonly reloadCalls: SessionRouteLookup[] = []; readonly cleanupPreviewCalls: NormalizedSessionCleanupRequest[] = []; readonly cleanupCalls: NormalizedSessionCleanupRequest[] = []; readonly bulkArchiveCalls: SessionBulkMutationRef[][] = []; readonly bulkDeleteCalls: SessionBulkMutationRef[][] = []; reloadError: Error | undefined; - constructor(eventHub: SessionEventHub) { - super(eventHub, { sessionManager: new RejectingSessionManager(), heartbeatIntervalMs: 60_000 }); - } - - override cleanupPreview(request: NormalizedSessionCleanupRequest): Promise { + cleanupPreview(request: NormalizedSessionCleanupRequest): Promise { this.cleanupPreviewCalls.push(request); return Promise.resolve({ generatedAt: "2026-06-25T00:00:00.000Z", thresholds: request.thresholds, projects: [], totals: { archiveCount: 0, deleteCount: 0 } }); } - override cleanup(request: NormalizedSessionCleanupRequest): Promise { + cleanup(request: NormalizedSessionCleanupRequest): Promise { this.cleanupCalls.push(request); return Promise.resolve({ generatedAt: "2026-06-25T00:00:00.000Z", thresholds: request.thresholds, projects: [], totals: { archiveCount: 0, deleteCount: 0 }, archivedSessionIds: [], deletedSessionIds: [] }); } - override archiveMany(refs: readonly SessionBulkMutationRef[]): Promise { + archiveMany(refs: readonly SessionBulkMutationRef[]): Promise { this.bulkArchiveCalls.push([...refs]); return Promise.resolve({ archived: true, archivedSessionIds: refs.map((ref) => ref.id), failures: [], generatedAt: "2026-06-25T00:00:00.000Z" }); } - override deleteArchivedMany(refs: readonly SessionBulkMutationRef[]): Promise { + deleteArchivedMany(refs: readonly SessionBulkMutationRef[]): Promise { this.bulkDeleteCalls.push([...refs]); return Promise.resolve({ deleted: true, deletedSessionIds: refs.map((ref) => ref.id), failures: [], generatedAt: "2026-06-25T00:00:00.000Z" }); } - override reload(lookup: string | PiSessionRef): Promise { + reload(lookup: SessionRouteLookup): Promise { this.reloadCalls.push(lookup); if (this.reloadError !== undefined) return Promise.reject(this.reloadError); return Promise.resolve(); } - override status(lookup: string | PiSessionRef) { + dispose(): Promise { + return Promise.resolve(); + } + + list(): never { throw unusedRouteMethod("list"); } + start(): never { throw unusedRouteMethod("start"); } + messages(): Promise { return Promise.resolve([]); } + + status(lookup: SessionRouteLookup) { this.calls.push(lookup); return Promise.resolve({ sessionId: sessionIdFromLookup(lookup), @@ -276,12 +281,20 @@ class CapturingRouteSessionService extends PiSessionService { }); } - override prompt(lookup: string | PiSessionRef, text: unknown, _streamingBehavior?: unknown, attachments?: unknown): Promise { + availableModels(): Promise<[]> { return Promise.resolve([]); } + setModel(): never { throw unusedRouteMethod("setModel"); } + cycleModel(): never { throw unusedRouteMethod("cycleModel"); } + availableThinkingLevels(): Promise<[]> { return Promise.resolve([]); } + setThinkingLevel(): never { throw unusedRouteMethod("setThinkingLevel"); } + cycleThinkingLevel(): never { throw unusedRouteMethod("cycleThinkingLevel"); } + commands(): Promise<[]> { return Promise.resolve([]); } + + prompt(lookup: SessionRouteLookup, text: unknown, _streamingBehavior?: unknown, attachments?: unknown): Promise { this.calls.push(attachments === undefined ? { lookup, text } : { lookup, text, attachments }); return Promise.resolve(); } - override saveAttachments(_lookup: string | PiSessionRef, attachments: unknown, folder?: string) { + saveAttachments(_lookup: SessionRouteLookup, attachments: unknown, folder?: string) { const list = Array.isArray(attachments) ? attachments : []; return Promise.resolve(list.map((attachment: { mimeType: string; data: string; name?: string }) => ({ path: `${folder ?? ".pi-web/attachments"}/${attachment.name ?? "file.png"}`, @@ -289,6 +302,19 @@ class CapturingRouteSessionService extends PiSessionService { size: Buffer.from(attachment.data, "base64").byteLength, }))); } + + shell(): never { throw unusedRouteMethod("shell"); } + runCommand(): never { throw unusedRouteMethod("runCommand"); } + respondToCommand(): never { throw unusedRouteMethod("respondToCommand"); } + abort(): never { throw unusedRouteMethod("abort"); } + stop(): never { throw unusedRouteMethod("stop"); } + archive(): never { throw unusedRouteMethod("archive"); } + archiveTree(): never { throw unusedRouteMethod("archiveTree"); } + restore(): never { throw unusedRouteMethod("restore"); } + deleteArchived(): never { throw unusedRouteMethod("deleteArchived"); } + + + detachParent(): never { throw unusedRouteMethod("detachParent"); } } class RejectingSessionManager implements PiSessionManagerGateway { @@ -315,6 +341,10 @@ class RejectingSessionManager implements PiSessionManagerGateway { } } -function sessionIdFromLookup(lookup: string | PiSessionRef): string { +function sessionIdFromLookup(lookup: SessionRouteLookup): string { return typeof lookup === "string" ? lookup : lookup.id; } + +function unusedRouteMethod(name: string): Error { + return new Error(`Route test did not expect ${name} to be called`); +} diff --git a/src/server/sessions/sessionRoutes.ts b/src/server/sessions/sessionRoutes.ts index 3aeef8a9..65c9a770 100644 --- a/src/server/sessions/sessionRoutes.ts +++ b/src/server/sessions/sessionRoutes.ts @@ -2,10 +2,10 @@ import type { FastifyInstance } from "fastify"; import type { SessionBulkMutationRequest, SessionBulkMutationRef, SessionCleanupRequest } from "../../shared/apiTypes.js"; import { normalizeRequestCwd } from "../workingDirectory.js"; import type { SessionEventHub } from "../realtime/sessionEventHub.js"; -import type { PiSessionRef, PiSessionService } from "./piSessionService.js"; +import type { SessionRouteLookup, SessionRouteService } from "./sessionService.js"; import { normalizeSessionCleanupRequest } from "./sessionCleanup.js"; -type SessionLookup = string | PiSessionRef; +type SessionLookup = SessionRouteLookup; interface SessionQuery { cwd?: string; @@ -29,7 +29,7 @@ interface AttachmentsRequestBody { folder?: unknown; } -export function registerSessionRoutes(app: FastifyInstance, sessions: PiSessionService, eventHub: SessionEventHub, prefix = ""): void { +export function registerSessionRoutes(app: FastifyInstance, sessions: SessionRouteService, eventHub: SessionEventHub, prefix = ""): void { 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" }); try { @@ -220,9 +220,9 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: PiSessionS } }); - app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown } | undefined }>(`${prefix}/sessions/:sessionId/stop`, (request, reply) => { + app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown } | undefined }>(`${prefix}/sessions/:sessionId/stop`, async (request, reply) => { try { - sessions.stop(sessionLookupFromBody(request.params.sessionId, optionalRecord(request.body))); + await sessions.stop(sessionLookupFromBody(request.params.sessionId, optionalRecord(request.body))); return { stopped: true }; } catch (error) { return reply.code(mutationErrorStatus(error)).send({ error: errorMessage(error) }); diff --git a/src/server/sessions/sessionService.ts b/src/server/sessions/sessionService.ts new file mode 100644 index 00000000..2ba8e2af --- /dev/null +++ b/src/server/sessions/sessionService.ts @@ -0,0 +1,56 @@ +import type { SavedPromptAttachment, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkMutationRef } from "../../shared/apiTypes.js"; +import type { + ClientArchiveSessionsResponse, + ClientCommand, + ClientCommandResult, + ClientMessagePage, + ClientSession, + ClientSessionCleanupExecuteResponse, + ClientSessionCleanupPreviewResponse, + ClientSessionModel, + ClientSessionRef, + ClientSessionStatus, + ClientThinkingLevel, +} from "../types.js"; +import type { NormalizedSessionCleanupRequest } from "./sessionCleanup.js"; + +export type SessionRouteRef = ClientSessionRef; +export type SessionRouteLookup = string | SessionRouteRef; + +/** + * Route-facing session contract for PI WEB's HTTP/WebSocket API. + * + * Keep this surface neutral: implementations may be backed by the native Pi SDK, + * an out-of-process agent bridge, or another daemon. Pi-specific lifecycle hooks + * such as auth-change handling and daemon shutdown stay on the concrete service. + */ +export interface SessionRouteService { + list(cwd: string): Promise; + start(cwd: string): Promise; + messages(ref: SessionRouteLookup, page?: { before?: number; limit?: number }): Promise; + status(ref: SessionRouteLookup): Promise; + availableModels(ref: SessionRouteLookup): Promise; + setModel(ref: SessionRouteLookup, provider: string, modelId: string): Promise; + cycleModel(ref: SessionRouteLookup, direction: "forward" | "backward"): Promise; + availableThinkingLevels(ref: SessionRouteLookup): Promise; + setThinkingLevel(ref: SessionRouteLookup, level: string): Promise; + cycleThinkingLevel(ref: SessionRouteLookup): Promise; + commands(ref: SessionRouteLookup): Promise; + prompt(ref: SessionRouteLookup, text: unknown, streamingBehavior?: unknown, attachments?: unknown): Promise; + saveAttachments(ref: SessionRouteLookup, attachments: unknown, folder?: string): Promise; + cleanupPreview(request: NormalizedSessionCleanupRequest): Promise; + cleanup(request: NormalizedSessionCleanupRequest): Promise; + archiveMany(refs: readonly SessionBulkMutationRef[]): Promise; + deleteArchivedMany(refs: readonly SessionBulkMutationRef[]): Promise; + shell(ref: SessionRouteLookup, text: string): Promise; + runCommand(ref: SessionRouteLookup, text: string): Promise; + respondToCommand(ref: SessionRouteLookup, requestId: string, value: string): Promise; + abort(ref: SessionRouteLookup): Promise; + stop(ref: SessionRouteLookup): void | Promise; + archive(ref: SessionRouteLookup): Promise; + archiveTree(ref: SessionRouteLookup): Promise; + restore(ref: SessionRouteLookup): Promise; + deleteArchived(ref: SessionRouteLookup): Promise; + reload(ref: SessionRouteLookup): Promise; + detachParent(ref: SessionRouteLookup): Promise; +} From f11f95ed40fd59f25510d978ec6bc7d01358ce46 Mon Sep 17 00:00:00 2001 From: Jeff Scott Ward Date: Fri, 26 Jun 2026 02:35:54 -0400 Subject: [PATCH 2/3] feat: add OMP runtime support --- .changeset/omp-agent-runtime.md | 5 + docs/config.html | 84 +++++++++++-- docs/config.md | 39 +++++- src/cli.test.ts | 41 ++++++- src/cli.ts | 26 ++-- src/client/src/api/parsers.test.ts | 12 +- src/client/src/api/parsers.ts | 21 +++- src/client/src/components/AuthDialog.ts | 4 +- .../settings/SettingsSessiondPanel.ts | 63 ++++++++++ .../settings/settingsConfigDraft.test.ts | 2 + .../settings/settingsConfigDraft.ts | 1 + src/config.test.ts | 47 +++++++- src/config.ts | 114 +++++++++++++++++- src/server/app.test.ts | 2 +- src/server/app.ts | 10 +- src/server/configRoutes.test.ts | 3 +- src/server/configRoutes.ts | 45 ++++++- src/server/piWebStatus.test.ts | 51 +++++++- src/server/piWebStatus.ts | 59 +++++---- src/server/sessiond.ts | 8 +- src/server/sessions/authService.test.ts | 27 ++++- src/server/sessions/authService.ts | 9 +- .../sessions/piSessionManagerGateway.test.ts | 25 ++++ .../sessions/piSessionManagerGateway.ts | 15 ++- src/server/sessions/piSessionService.ts | 4 +- src/shared/apiTypes.ts | 12 ++ 26 files changed, 645 insertions(+), 84 deletions(-) create mode 100644 .changeset/omp-agent-runtime.md diff --git a/.changeset/omp-agent-runtime.md b/.changeset/omp-agent-runtime.md new file mode 100644 index 00000000..c270f40a --- /dev/null +++ b/.changeset/omp-agent-runtime.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": minor +--- + +Add configurable Pi-compatible agent runtime settings so PI WEB can target Oh My Pi (`omp`) state, auth, sessions, diagnostics, and update checks. diff --git a/docs/config.html b/docs/config.html index 81a64436..01a9935a 100644 --- a/docs/config.html +++ b/docs/config.html @@ -81,7 +81,7 @@

Configure PI WEB where your agents work.

PI WEB configuration covers the machine-local and project-local settings you usually need: bind address, trusted development-host settings, UI preferences, PI WEB plugin enablement, file-explorer path access, - manual upload defaults, upload limits, and session-daemon tools. + manual upload defaults, upload limits, agent runtime selection, and session-daemon tools.

@@ -97,6 +97,7 @@

Configure PI WEB where your agents work.

Config matrix External path access Manual uploads + Agent runtime Session tools Completion tools @@ -145,13 +146,16 @@

Precedence and reloads

Environment overrides include PI_WEB_HOST, PI_WEB_PORT / PORT, - PI_WEB_ALLOWED_HOSTS, PI_WEB_MAX_UPLOAD_BYTES, PI_WEB_SPAWN_SESSIONS, - and PI_WEB_SUBSESSIONS. + PI_WEB_ALLOWED_HOSTS, PI_WEB_MAX_UPLOAD_BYTES, PI_WEB_AGENT_COMMAND, + PI_WEB_AGENT_DIR, PI_WEB_AGENT_SESSION_DIR, PI_CODING_AGENT_DIR, + PI_CODING_AGENT_SESSION_DIR, OMP_CODING_AGENT_DIR, + OMP_CODING_AGENT_SESSION_DIR, PI_WEB_SPAWN_SESSIONS, and + PI_WEB_SUBSESSIONS.

  • host / port: restart the gateway web/API service or process.
  • maxUploadBytes: restart both the web/API process and the session daemon on that machine.
  • -
  • spawnSessions / subsessions: restart the session daemon on that machine.
  • +
  • agent.command / agent.dir / spawnSessions / subsessions: restart the session daemon on that machine.
  • pathAccess: applies on the next request; existing file views may need a browser refresh.
  • uploads.defaultFolder: applies to newly opened Files upload dialogs and new direct drag/drop batches after config/workspace refresh.
  • plugins: reload the browser tab after changing PI WEB plugin enablement.
  • @@ -184,6 +188,10 @@

    Global config example

    "defaultFolder": ".pi-web/uploads" }, "maxUploadBytes": 67108864, + "agent": { + "command": "omp", + "dir": "~/.omp/agent" + }, "spawnSessions": true, "subsessions": false, "plugins": { @@ -243,8 +251,8 @@

    Config matrix

    it, and whether project-local config overrides or merges with global config. Rows with JSON key are runtime-only environment variables, not config-file keys. Global means machine-global. In Settings, selected-machine-safe global keys (pathAccess, uploads, - maxUploadBytes, spawnSessions, subsessions, and plugins) - are edited for the selected machine; gateway host/port/allowed-hosts, keyboard shortcuts, and machine + maxUploadBytes, agent, spawnSessions, subsessions, + and plugins) are edited for the selected machine; gateway host/port/allowed-hosts, keyboard shortcuts, and machine registry/tokens stay local.

    @@ -309,6 +317,22 @@

    Config matrix

    Not supported locally Restart web/API and session daemon on that machine + + Agent CLI command + agent.command + PI_WEB_AGENT_COMMAND + Global/session daemon + Not supported locally + Restart session daemon on that machine; affects doctor/status/update checks + + + Agent state directory + agent.dir + PI_WEB_AGENT_DIR, PI_CODING_AGENT_DIR, OMP_CODING_AGENT_DIR + Global/session daemon + Not supported locally + Restart session daemon on that machine; affects auth, models, settings, and sessions + Agent can spawn sessions spawnSessions @@ -415,18 +439,18 @@

    Config matrix

    Restart web/API; advanced state override - Pi session storage directory + Agent session storage directory — - PI_CODING_AGENT_SESSION_DIR - Pi/session daemon env + PI_WEB_AGENT_SESSION_DIR, PI_CODING_AGENT_SESSION_DIR, OMP_CODING_AGENT_SESSION_DIR + Session daemon env Not supported locally - Restart session daemon; follows Pi session priority + Restart session daemon; env-only session storage override - Pi agent config directory + Agent config directory — - PI_CODING_AGENT_DIR - Pi/Web/API/session daemon env + PI_WEB_AGENT_DIR, PI_CODING_AGENT_DIR, OMP_CODING_AGENT_DIR + Web/API + session daemon env Not supported locally Restart services @@ -496,6 +520,40 @@

    Manual upload defaults

    +
    +

    Agent runtime

    +

    + agent.command controls which Pi-compatible CLI PI WEB checks in doctor/status/update flows. + It defaults to pi; set it to another compatible command when this machine should use a Pi-compatible fork. +

    +

    + agent.dir controls which compatible agent state directory PI WEB reads for auth providers, + model settings, settings, and session metadata. It defaults to the selected agent's conventional + directory (~/.pi/agent for pi; set an explicit path for compatible forks). +

    +
    +
    {
    +  "agent": {
    +    "command": "pi",
    +    "dir": "~/.pi/agent"
    +  }
    +}
    +
    +

    + Environment variables take precedence over the config file. PI_WEB_AGENT_COMMAND selects the + command, PI_WEB_AGENT_DIR sets the state directory for any command, and command-specific + variables are honored for compatible runtimes. +

    +

    + Session directory overrides are environment-only. Set PI_WEB_AGENT_SESSION_DIR or the selected + command's session variable when you need to override session storage separately from agent.dir. +

    +
    + Restart the session daemon after changing agent settings. The web/API process can display the new config + immediately, but active session runtime ownership is intentionally long-lived. +
    +
    +

    Session daemon tools

    spawnSessions

    diff --git a/docs/config.md b/docs/config.md index 61553c4c..f10cd444 100644 --- a/docs/config.md +++ b/docs/config.md @@ -1,6 +1,6 @@ # PI WEB configuration reference -PI WEB configuration covers the machine-local and project-local settings you usually need: the web/API bind address, trusted development-host settings, UI preferences, plugin enablement, file-explorer path access, manual upload defaults, upload limits, and session-daemon tools. +PI WEB configuration covers the machine-local and project-local settings you usually need: the web/API bind address, trusted development-host settings, UI preferences, plugin enablement, file-explorer path access, manual upload defaults, upload limits, agent runtime selection, and session-daemon tools. This file is the markdown reference for agents and package consumers. The website page is . @@ -27,13 +27,13 @@ defaults → global config file → environment overrides Supported project-local settings are then applied for that project's workspaces. For upload defaults, `/.pi-web/config.json` overrides the global value. -Environment overrides include `PI_WEB_HOST`, `PI_WEB_PORT` / `PORT`, `PI_WEB_ALLOWED_HOSTS`, `PI_WEB_MAX_UPLOAD_BYTES`, `PI_WEB_SPAWN_SESSIONS`, and `PI_WEB_SUBSESSIONS`. +Environment overrides include `PI_WEB_HOST`, `PI_WEB_PORT` / `PORT`, `PI_WEB_ALLOWED_HOSTS`, `PI_WEB_MAX_UPLOAD_BYTES`, `PI_WEB_AGENT_COMMAND`, `PI_WEB_AGENT_DIR`, `PI_WEB_AGENT_SESSION_DIR`, `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR`, `OMP_CODING_AGENT_DIR`, `OMP_CODING_AGENT_SESSION_DIR`, `PI_WEB_SPAWN_SESSIONS`, and `PI_WEB_SUBSESSIONS`. Process restarts depend on the key: - `host` / `port`: restart the gateway web/API service or process. - `maxUploadBytes`: restart both the web/API process and the session daemon on that machine. -- `spawnSessions` / `subsessions`: restart the session daemon on that machine. +- `agent.command` / `agent.dir` / `spawnSessions` / `subsessions`: restart the session daemon on that machine. - `pathAccess`: applies on the next request; existing file views may need a browser refresh. - `uploads.defaultFolder`: applies to newly opened Files upload dialogs and new direct drag/drop batches after config/workspace refresh. - `plugins`: reload the browser tab after changing PI WEB plugin enablement. @@ -53,6 +53,10 @@ Process restarts depend on the key: "defaultFolder": ".pi-web/uploads" }, "maxUploadBytes": 67108864, + "agent": { + "command": "omp", + "dir": "~/.omp/agent" + }, "spawnSessions": true, "subsessions": false, "plugins": { @@ -91,7 +95,7 @@ Plugins may own separate project files, such as `.pi-web/tasks.json` for the bui ## Configuration matrix -Rows with JSON key `—` are runtime-only environment variables, not config-file keys. `Global` means machine-global. In Settings, selected-machine-safe global keys (`pathAccess`, `uploads`, `maxUploadBytes`, `spawnSessions`, `subsessions`, and `plugins`) are edited for the selected machine; gateway host/port/allowed-hosts, keyboard shortcuts, and machine registry/tokens stay local. +Rows with JSON key `—` are runtime-only environment variables, not config-file keys. `Global` means machine-global. In Settings, selected-machine-safe global keys (`pathAccess`, `uploads`, `maxUploadBytes`, `agent`, `spawnSessions`, `subsessions`, and `plugins`) are edited for the selected machine; gateway host/port/allowed-hosts, keyboard shortcuts, and machine registry/tokens stay local. | Config | JSON key | Env var | Scope | Project-local behavior | Applies / restart | | --- | --- | --- | --- | --- | --- | @@ -102,6 +106,8 @@ Rows with JSON key `—` are runtime-only environment variables, not config-file | External filesystem roots | `pathAccess.allowedPaths` | — | Global + project | **Merges**: global roots first, then project roots; duplicates removed | Next file request; refresh existing views if needed | | Manual file upload default folder | `uploads.defaultFolder` | — | Global + project | **Overrides**: project value wins for workspaces in that project; otherwise global/default applies | New Upload dialogs and direct drag/drop batches after config/workspace refresh | | Upload/body limit | `maxUploadBytes` | `PI_WEB_MAX_UPLOAD_BYTES` | Global | Not supported locally | Restart web/API and session daemon on that machine | +| Agent CLI command | `agent.command` | `PI_WEB_AGENT_COMMAND` | Global/session daemon | Not supported locally | Restart session daemon on that machine; affects doctor/status/update checks | +| Agent state directory | `agent.dir` | `PI_WEB_AGENT_DIR`, `PI_CODING_AGENT_DIR`, `OMP_CODING_AGENT_DIR` | Global/session daemon | Not supported locally | Restart session daemon on that machine; affects auth, models, settings, and sessions | | Agent can spawn sessions | `spawnSessions` | `PI_WEB_SPAWN_SESSIONS` | Global/session daemon | Not supported locally | Restart session daemon on that machine | | Tracked subsessions (beta) | `subsessions` | `PI_WEB_SUBSESSIONS` | Global/session daemon | Not supported locally; also requires `spawnSessions` | Restart session daemon on that machine | | Plugin enablement/settings | `plugins..enabled`, `plugins..settings` | — | Global | Not core local config; plugins may read their own project files | Reload browser tab | @@ -116,8 +122,8 @@ Rows with JSON key `—` are runtime-only environment variables, not config-file | Web-to-daemon URL | — | `PI_WEB_SESSIOND_URL` | Web/API env | Not supported locally | Restart web/API | | Projects storage file | — | `PI_WEB_PROJECTS_FILE` | Web/API + session daemon env | Not supported locally | Restart services; advanced state override | | Remote machines storage file | — | `PI_WEB_MACHINES_FILE` | Web/API env | Not supported locally | Restart web/API; advanced state override | -| Pi session storage directory | — | `PI_CODING_AGENT_SESSION_DIR` | Pi/session daemon env | Not supported locally | Restart session daemon; follows Pi session priority | -| Pi agent config directory | — | `PI_CODING_AGENT_DIR` | Pi/Web/API/session daemon env | Not supported locally | Restart services | +| Agent session storage directory | — | `PI_WEB_AGENT_SESSION_DIR`, `PI_CODING_AGENT_SESSION_DIR`, `OMP_CODING_AGENT_SESSION_DIR` | Session daemon env | Not supported locally | Restart session daemon; env-only session storage override | +| Agent config directory | — | `PI_WEB_AGENT_DIR`, `PI_CODING_AGENT_DIR`, `OMP_CODING_AGENT_DIR` | Web/API + session daemon env | Not supported locally | Restart services | | Skip update checks | — | `PI_WEB_SKIP_VERSION_CHECK`, `PI_WEB_OFFLINE`, `PI_SKIP_VERSION_CHECK`, `PI_OFFLINE` | Web/API env | Not supported locally | Restart web/API after env changes | ## Key details @@ -165,6 +171,27 @@ For machine federation, Settings saves the global upload default on the selected The per-request size limit is still controlled by `maxUploadBytes` / `PI_WEB_MAX_UPLOAD_BYTES` on the machine serving the upload. +### Agent runtime selection + +`agent.command` controls which Pi-compatible CLI PI WEB checks in doctor/status/update flows. It defaults to `pi`; set it to `omp` when this machine should use Oh My Pi. + +`agent.dir` controls which compatible agent state directory PI WEB reads for auth providers, model settings, settings, and session metadata. It defaults to the selected agent's conventional directory (`~/.pi/agent` for `pi`, `~/.omp/agent` for `omp`). + +```json +{ + "agent": { + "command": "omp", + "dir": "~/.omp/agent" + } +} +``` + +Environment variables take precedence over the config file. `PI_WEB_AGENT_COMMAND` selects the command, `PI_WEB_AGENT_DIR` sets the state directory for any command, and command-specific variables such as `OMP_CODING_AGENT_DIR` are honored when the selected command is `omp`. + +Session directory overrides are environment-only. Set `PI_WEB_AGENT_SESSION_DIR` or the selected command's session variable (for example `OMP_CODING_AGENT_SESSION_DIR`) when you need to override session storage separately from `agent.dir`. + +Restart the session daemon after changing agent settings. The web/API process can display the new config immediately, but active session runtime ownership is intentionally long-lived. + ### Session daemon tools `spawnSessions` controls whether agents receive the `spawn_session` tool. It defaults to `true`; set it to `false` if you do not want an agent to start independent PI WEB sessions. diff --git a/src/cli.test.ts b/src/cli.test.ts index 72da8f87..065e55ae 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -2,9 +2,11 @@ import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { commandWithVersionCheck, isCliEntrypoint } from "./cli.js"; +import { agentCommandForChecks, commandWithVersionCheck, isCliEntrypoint } from "./cli.js"; const originalShell = process.env["SHELL"]; +const originalPiWebConfig = process.env["PI_WEB_CONFIG"]; +const originalPiWebAgentCommand = process.env["PI_WEB_AGENT_COMMAND"]; afterEach(() => { if (originalShell === undefined) { @@ -12,25 +14,56 @@ afterEach(() => { } else { process.env["SHELL"] = originalShell; } + if (originalPiWebConfig === undefined) { + delete process.env["PI_WEB_CONFIG"]; + } else { + process.env["PI_WEB_CONFIG"] = originalPiWebConfig; + } + if (originalPiWebAgentCommand === undefined) { + delete process.env["PI_WEB_AGENT_COMMAND"]; + } else { + process.env["PI_WEB_AGENT_COMMAND"] = originalPiWebAgentCommand; + } }); describe("commandWithVersionCheck", () => { it("emits a POSIX subshell group for bash", () => { process.env["SHELL"] = "/bin/bash"; - expect(commandWithVersionCheck("npm")).toBe("command -v npm && (npm --version 2>&1 || true)"); + expect(commandWithVersionCheck("npm")).toBe("command -v 'npm' && ('npm' --version 2>&1 || true)"); }); it("emits a POSIX subshell group for zsh", () => { process.env["SHELL"] = "/bin/zsh"; - expect(commandWithVersionCheck("pi")).toBe("command -v pi && (pi --version 2>&1 || true)"); + expect(commandWithVersionCheck("pi")).toBe("command -v 'pi' && ('pi' --version 2>&1 || true)"); }); it("uses fish begin/end grouping instead of a POSIX subshell", () => { process.env["SHELL"] = "/usr/local/bin/fish"; const command = commandWithVersionCheck("npm"); - expect(command).toBe("command -v npm && begin; npm --version 2>&1 || true; end"); + expect(command).toBe("command -v 'npm' && begin; 'npm' --version 2>&1 || true; end"); expect(command).not.toContain("("); }); + + it("shell-quotes command words", () => { + process.env["SHELL"] = "/bin/bash"; + expect(commandWithVersionCheck("/tmp/agent's/omp")).toBe("command -v '/tmp/agent'\\''s/omp' && ('/tmp/agent'\\''s/omp' --version 2>&1 || true)"); + }); +}); + +describe("agentCommandForChecks", () => { + it("reads the configured agent command for doctor checks", () => { + const dir = mkdtempSync(join(tmpdir(), "pi-web-cli-test-")); + try { + const configPath = join(dir, "config.json"); + writeFileSync(configPath, `${JSON.stringify({ agent: { command: "omp" } })}\n`); + process.env["PI_WEB_CONFIG"] = configPath; + delete process.env["PI_WEB_AGENT_COMMAND"]; + + expect(agentCommandForChecks()).toBe("omp"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); }); describe("isCliEntrypoint", () => { diff --git a/src/cli.ts b/src/cli.ts index 00f1409d..90fa4196 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -5,7 +5,7 @@ import { mkdir, rm, writeFile } from "node:fs/promises"; import { homedir, userInfo } from "node:os"; import { basename, dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { defaultPiWebConfigPath, defaultPiWebDataDir, examplePiWebConfig } from "./config.js"; +import { defaultPiWebConfigPath, defaultPiWebDataDir, effectiveAgentConfig, effectivePiWebConfig, examplePiWebConfig } from "./config.js"; import { packageVersion, printPiWebVersionReport } from "./piWebVersionReport.js"; import { checkNodePtyDarwinSpawnHelper, formatNodePtyDarwinSpawnHelperCheck } from "./server/diagnostics/nodePtySpawnHelper.js"; @@ -178,7 +178,7 @@ function runQuiet(command: string, args: string[]): number { } function hasCommand(command: string): boolean { - return capture("/usr/bin/env", ["sh", "-c", `command -v ${command}`]).status === 0; + return capture("/usr/bin/env", ["sh", "-c", `command -v ${shellQuote(command)}`]).status === 0; } function isLingerEnabled(): boolean | undefined { @@ -898,16 +898,21 @@ function systemdUserServiceShellCommand(command: string, cwd?: string): string[] ]; } +function shellQuote(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'`; +} + function commandCheck(command: string): string { - return `command -v ${command}`; + return `command -v ${shellQuote(command)}`; } export function commandWithVersionCheck(command: string): string { const found = commandCheck(command); + const commandWord = shellQuote(command); if (detectServiceShell().name === "fish") { - return `${found} && begin; ${command} --version 2>&1 || true; end`; + return `${found} && begin; ${commandWord} --version 2>&1 || true; end`; } - return `${found} && (${command} --version 2>&1 || true)`; + return `${found} && (${commandWord} --version 2>&1 || true)`; } function nodeVersionCheck(): string { @@ -917,14 +922,19 @@ function nodeVersionCheck(): string { ].join(" && "); } +export function agentCommandForChecks(env: NodeJS.ProcessEnv = process.env): string { + return effectiveAgentConfig(env, effectivePiWebConfig({ env }).config).command; +} + function doctorChecks(): Check[] { const shell = serviceShellLabel(); const backend = currentServiceBackend(); + const agentCommand = agentCommandForChecks(); if (backend === undefined) { return [ [`${shell} can find node >= 22`, serviceShellCommand(nodeVersionCheck())], [`${shell} can find npm`, serviceShellCommand(commandWithVersionCheck("npm"))], - [`${shell} can find pi`, serviceShellCommand(commandWithVersionCheck("pi"))], + [`${shell} can find ${agentCommand}`, serviceShellCommand(commandWithVersionCheck(agentCommand))], ]; } @@ -932,12 +942,12 @@ function doctorChecks(): Check[] { ...backendAvailabilityChecks(backend), ...baseShellChecks(backend), [`${shell} can find npm`, serviceShellCommand(commandWithVersionCheck("npm"))], - [`${shell} can find pi`, serviceShellCommand(commandWithVersionCheck("pi"))], + [`${shell} can find ${agentCommand}`, serviceShellCommand(commandWithVersionCheck(agentCommand))], ]; const executables = resolveServiceExecutables(backend); checks.push(...executables.web.checks, ...executables.sessiond.checks); if (backend.kind === "systemd") { - checks.push([`systemd user ${shell} can find pi`, systemdUserServiceShellCommand(commandWithVersionCheck("pi"))]); + checks.push([`systemd user ${shell} can find ${agentCommand}`, systemdUserServiceShellCommand(commandWithVersionCheck(agentCommand))]); } return checks; } diff --git a/src/client/src/api/parsers.test.ts b/src/client/src/api/parsers.test.ts index eb155427..edea46aa 100644 --- a/src/client/src/api/parsers.test.ts +++ b/src/client/src/api/parsers.test.ts @@ -7,15 +7,15 @@ describe("API parsers", () => { expect(parsePiWebConfigResponse({ path: "/tmp/config.json", exists: true, - config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "manual/uploads" }, maxUploadBytes: 1234 }, - effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: ".pi-web/uploads" } }, - envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false }, + config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "manual/uploads" }, maxUploadBytes: 1234, agent: { command: "omp", dir: "~/.omp/agent" } }, + effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: ".pi-web/uploads" }, agent: { command: "omp", dir: "/Users/dev/.omp/agent" } }, + envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: true, agentSessionDir: false }, })).toEqual({ path: "/tmp/config.json", exists: true, - config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "manual/uploads" }, maxUploadBytes: 1234 }, - effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: ".pi-web/uploads" } }, - envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false }, + config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "manual/uploads" }, maxUploadBytes: 1234, agent: { command: "omp", dir: "~/.omp/agent" } }, + effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: ".pi-web/uploads" }, agent: { command: "omp", dir: "/Users/dev/.omp/agent" } }, + envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: true, agentSessionDir: false }, }); }); diff --git a/src/client/src/api/parsers.ts b/src/client/src/api/parsers.ts index c71df776..1ca6267e 100644 --- a/src/client/src/api/parsers.ts +++ b/src/client/src/api/parsers.ts @@ -562,11 +562,21 @@ function parsePiWebConfigValues(value: unknown): PiWebConfigValues { ...optionalField("pathAccess", optionalPathAccess(record["pathAccess"])), ...optionalField("uploads", optionalUploads(record["uploads"])), ...optionalField("maxUploadBytes", optionalNumber(record, "maxUploadBytes")), + ...optionalField("agent", optionalAgent(record["agent"])), ...optionalField("spawnSessions", optionalBoolean(record, "spawnSessions")), ...optionalField("subsessions", optionalBoolean(record, "subsessions")), }; } +function optionalAgent(value: unknown): PiWebConfigValues["agent"] | undefined { + if (value === undefined) return undefined; + if (!isRecord(value) || Array.isArray(value)) throw new Error("Invalid PI WEB agent field"); + return { + ...optionalField("command", optionalString(value, "command")), + ...optionalField("dir", optionalString(value, "dir")), + }; +} + function optionalAllowedHosts(value: unknown): PiWebConfigValues["allowedHosts"] | undefined { if (value === undefined) return undefined; if (value === true) return true; @@ -629,7 +639,16 @@ function optionalPlugins(value: unknown): PiWebPluginConfigMap | undefined { function parsePiWebConfigEnvOverrides(value: unknown): PiWebConfigEnvOverrides { const record = requireRecord(value); - return { host: requireBoolean(record, "host"), port: requireBoolean(record, "port"), allowedHosts: requireBoolean(record, "allowedHosts"), spawnSessions: requireBoolean(record, "spawnSessions"), subsessions: requireBoolean(record, "subsessions") }; + return { + host: requireBoolean(record, "host"), + port: requireBoolean(record, "port"), + allowedHosts: requireBoolean(record, "allowedHosts"), + spawnSessions: requireBoolean(record, "spawnSessions"), + subsessions: requireBoolean(record, "subsessions"), + agentCommand: optionalBoolean(record, "agentCommand") ?? false, + agentDir: optionalBoolean(record, "agentDir") ?? false, + agentSessionDir: optionalBoolean(record, "agentSessionDir") ?? false, + }; } export function parsePiPackagesResponse(value: unknown): PiPackagesResponse { diff --git a/src/client/src/components/AuthDialog.ts b/src/client/src/components/AuthDialog.ts index 28a5698c..6cb0bbf5 100644 --- a/src/client/src/components/AuthDialog.ts +++ b/src/client/src/components/AuthDialog.ts @@ -54,13 +54,13 @@ export class AuthDialog extends LitElement { case "method": return html`
    - +
    `; case "providers": return html`
    ${state.providers.length === 0 ? html`
    No providers available.
    ` : state.providers.map((provider) => this.renderProviderButton(provider))}
    `; case "apiKey": return html`
    -

    Enter the API key for ${state.provider.name}. It will be stored by pi in auth.json.

    +

    Enter the API key for ${state.provider.name}. It will be stored in the configured agent auth.json.

    { if (event.target instanceof HTMLInputElement) this.onApiKeyInput?.(event.target.value); }}> ${state.error !== undefined && state.error !== "" ? html`
    ${state.error}
    ` : null}
    diff --git a/src/client/src/components/settings/SettingsSessiondPanel.ts b/src/client/src/components/settings/SettingsSessiondPanel.ts index ff02d411..f3414efe 100644 --- a/src/client/src/components/settings/SettingsSessiondPanel.ts +++ b/src/client/src/components/settings/SettingsSessiondPanel.ts @@ -25,6 +25,9 @@ export class SettingsSessiondPanel extends LitElement { const subsessionsOverridden = config?.envOverrides.subsessions === true; // Beta, off by default; also requires spawn to be enabled. const effectiveSubsessions = config?.effectiveConfig.subsessions === true && effectiveSpawn; + const agentCommandOverridden = config?.envOverrides.agentCommand === true; + const agentDirOverridden = config?.envOverrides.agentDir === true; + const effectiveAgent = config?.effectiveConfig.agent; return html` Config file ${config.path}
    +
    + + Agent command for diagnostics + ${agentCommandOverridden ? html`environment override` : null} + + { void this.saveAgentField("command", event); }} + > + Use a Pi-compatible CLI command for doctor/status/update checks. PI WEB's embedded session runtime still uses the bundled SDK path, so this does not load a different agent implementation. +
    +
    + + Agent state directory + ${agentDirOverridden ? html`environment override` : null} + + { void this.saveAgentField("dir", event); }} + > + Choose which compatible auth, models, settings, and sessions PI WEB reads. Non-pi commands require an explicit state directory, then a session daemon restart. +
    Allow agents to start sessions @@ -75,6 +112,8 @@ export class SettingsSessiondPanel extends LitElement {

    Effective after environment overrides

    +
    Agent command
    ${effectiveAgent?.command ?? html`pi default`}
    +
    Agent state
    ${effectiveAgent?.dir ?? html`~/.pi/agent default`}
    Spawn sessions
    ${effectiveSpawn ? "Enabled" : html`Disabled`}
    Subsessions
    ${effectiveSubsessions ? "Enabled" : html`Disabled`}
    @@ -102,6 +141,28 @@ export class SettingsSessiondPanel extends LitElement { return html`
    ${this.loading ? "Loading configuration…" : "Configuration is unavailable. Reload to try again."}
    `; } + private async saveAgentField(field: "command" | "dir", event: Event): Promise { + if (!(event.target instanceof HTMLInputElement)) return; + const value = event.target.value.trim(); + const baseConfig = this.configResponse?.config ?? {}; + const nextConfig: PiWebConfigValues = { ...baseConfig }; + const nextAgent: NonNullable = { ...(baseConfig.agent ?? {}) }; + if (field === "command") { + if (value === "") delete nextAgent.command; + else nextAgent.command = value; + } else if (value === "") { + delete nextAgent.dir; + } else { + nextAgent.dir = value; + } + if (nextAgent.command === undefined && nextAgent.dir === undefined) { + delete nextConfig.agent; + } else { + nextConfig.agent = nextAgent; + } + await this.onSave?.(nextConfig); + } + private async toggleSpawnSessions(event: Event): Promise { const enabled = event.target instanceof HTMLInputElement && event.target.checked; await this.onSave?.(spawnSessionsConfigPatch(enabled)); @@ -128,6 +189,8 @@ export class SettingsSessiondPanel extends LitElement { .field-heading { display: flex; align-items: center; gap: 8px; } .toggle { display: flex; align-items: center; gap: 9px; cursor: pointer; } .toggle input { width: 16px; height: 16px; } + .text-input { width: 100%; box-sizing: border-box; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-bg); color: var(--pi-text); padding: 8px 9px; font: 13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } + .text-input:disabled { opacity: .65; cursor: not-allowed; } .toggle input:disabled { cursor: not-allowed; } .override-badge { border: 1px solid var(--pi-warning-border); border-radius: 999px; color: var(--pi-warning); background: var(--pi-warning-surface); padding: 2px 7px; font-size: 11px; font-weight: 600; text-transform: none; } .beta-badge { border: 1px solid var(--pi-border); border-radius: 999px; color: var(--pi-muted); background: var(--pi-bg); padding: 2px 7px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; } diff --git a/src/client/src/components/settings/settingsConfigDraft.test.ts b/src/client/src/components/settings/settingsConfigDraft.test.ts index 6e13e75a..3a82c273 100644 --- a/src/client/src/components/settings/settingsConfigDraft.test.ts +++ b/src/client/src/components/settings/settingsConfigDraft.test.ts @@ -43,6 +43,7 @@ describe("settings config drafts", () => { maxUploadBytes: 1234, spawnSessions: true, subsessions: false, + agent: { command: "custom-agent", dir: "/tmp/custom-agent" }, })).toEqual({ host: "gateway.local", port: 9000, @@ -54,6 +55,7 @@ describe("settings config drafts", () => { maxUploadBytes: 1234, spawnSessions: true, subsessions: false, + agent: { command: "custom-agent", dir: "/tmp/custom-agent" }, }); expect(gatewayServerConfigFromDraft({ diff --git a/src/client/src/components/settings/settingsConfigDraft.ts b/src/client/src/components/settings/settingsConfigDraft.ts index 917b9d45..01ffcef9 100644 --- a/src/client/src/components/settings/settingsConfigDraft.ts +++ b/src/client/src/components/settings/settingsConfigDraft.ts @@ -68,6 +68,7 @@ function preservedGatewayConfigRemainder(baseConfig: PiWebConfigValues): PiWebCo ...(baseConfig.maxUploadBytes === undefined ? {} : { maxUploadBytes: baseConfig.maxUploadBytes }), ...(baseConfig.spawnSessions === undefined ? {} : { spawnSessions: baseConfig.spawnSessions }), ...(baseConfig.subsessions === undefined ? {} : { subsessions: baseConfig.subsessions }), + ...(baseConfig.agent === undefined ? {} : { agent: baseConfig.agent }), }; } diff --git a/src/config.test.ts b/src/config.test.ts index 617eb406..31104e30 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { DEFAULT_MAX_UPLOAD_BYTES, DEFAULT_UPLOADS_FOLDER, effectivePiWebConfig, loadPiWebConfig, maxUploadBytes, savePiWebConfig, spawnSessionsEnabled, subsessionsEnabled } from "./config.js"; +import { DEFAULT_MAX_UPLOAD_BYTES, DEFAULT_UPLOADS_FOLDER, effectiveAgentConfig, effectivePiWebConfig, loadPiWebConfig, maxUploadBytes, savePiWebConfig, spawnSessionsEnabled, subsessionsEnabled } from "./config.js"; let tempDir: string; let configPath: string; @@ -63,6 +63,51 @@ describe("PI WEB config persistence", () => { expect(loadPiWebConfig(testOptions()).config.maxUploadBytes).toBe(1234); }); + it("persists and reads custom agent runtime settings", () => { + savePiWebConfig({ agent: { command: "omp", dir: "~/.omp/agent" } }, testOptions()); + + expect(loadPiWebConfig(testOptions()).config.agent).toEqual({ command: "omp", dir: "~/.omp/agent" }); + }); + + it("resolves OMP agent defaults from the configured command", () => { + expect(effectiveAgentConfig({ HOME: join(tempDir, ".home") }, { agent: { command: "omp" } })).toMatchObject({ + command: "omp", + dir: join(tempDir, ".home", ".omp", "agent"), + sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR", "OMP_CODING_AGENT_SESSION_DIR", "PI_CODING_AGENT_SESSION_DIR"], + }); + }); + + it("lets PI WEB agent environment overrides take precedence", () => { + expect(effectiveAgentConfig({ + PI_WEB_AGENT_COMMAND: "omp", + PI_WEB_AGENT_DIR: join(tempDir, "env-agent"), + }, { agent: { command: "pi", dir: join(tempDir, "config-agent") } })).toMatchObject({ + command: "omp", + dir: join(tempDir, "env-agent"), + }); + }); + + it("lets command-specific agent environment directories override config", () => { + expect(effectiveAgentConfig({ + HOME: join(tempDir, ".home"), + OMP_CODING_AGENT_DIR: join(tempDir, "omp-env-agent"), + }, { agent: { command: "omp", dir: join(tempDir, "config-agent") } })).toMatchObject({ + command: "omp", + dir: join(tempDir, "omp-env-agent"), + }); + }); + + it("normalizes omp.exe to OMP environment keys", () => { + expect(effectiveAgentConfig({ + HOME: join(tempDir, ".home"), + OMP_CODING_AGENT_DIR: join(tempDir, "omp-exe-env-agent"), + }, { agent: { command: "omp.exe", dir: join(tempDir, "config-agent") } })).toMatchObject({ + command: "omp.exe", + dir: join(tempDir, "omp-exe-env-agent"), + sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR", "OMP_CODING_AGENT_SESSION_DIR", "PI_CODING_AGENT_SESSION_DIR"], + }); + }); + it("exposes the default upload folder in the effective config", () => { expect(effectivePiWebConfig(testOptions()).config.uploads).toEqual({ defaultFolder: DEFAULT_UPLOADS_FOLDER }); }); diff --git a/src/config.ts b/src/config.ts index b770fb1f..8754c01f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -35,6 +35,42 @@ export const DEFAULT_MAX_UPLOAD_BYTES = 64 * 1024 * 1024; export const DEFAULT_UPLOADS_FOLDER = ".pi-web/uploads"; +export const DEFAULT_AGENT_COMMAND = "pi"; +export const PI_WEB_AGENT_COMMAND_ENV = "PI_WEB_AGENT_COMMAND"; +export const PI_WEB_AGENT_DIR_ENV = "PI_WEB_AGENT_DIR"; +export const PI_WEB_AGENT_SESSION_DIR_ENV = "PI_WEB_AGENT_SESSION_DIR"; +export const PI_CODING_AGENT_DIR_ENV = "PI_CODING_AGENT_DIR"; +export const PI_CODING_AGENT_SESSION_DIR_ENV = "PI_CODING_AGENT_SESSION_DIR"; + +export interface EffectivePiWebAgentConfig { + command: string; + dir: string; + sessionDirEnvKeys: string[]; +} + +export function effectiveAgentConfig(env: NodeJS.ProcessEnv = process.env, config: Pick = {}, cwd = process.cwd()): EffectivePiWebAgentConfig { + const command = parseAgentCommand(env[PI_WEB_AGENT_COMMAND_ENV] ?? config.agent?.command ?? DEFAULT_AGENT_COMMAND, "agent.command", "environment"); + const commandDirEnv = commandAgentDirEnv(command); + const configuredDir = env[PI_WEB_AGENT_DIR_ENV] ?? env[commandDirEnv] ?? config.agent?.dir ?? defaultAgentDirForCommand(command, env); + return { + command, + dir: resolveAgentDirPath(configuredDir, env, cwd, "agent.dir", "environment"), + sessionDirEnvKeys: agentSessionDirEnvKeys(command), + }; +} + +export function agentSessionDirEnvKeys(command = DEFAULT_AGENT_COMMAND): string[] { + return uniqueStrings([PI_WEB_AGENT_SESSION_DIR_ENV, commandSessionDirEnv(command), PI_CODING_AGENT_SESSION_DIR_ENV]); +} + +export function hasAgentDirEnvOverride(env: NodeJS.ProcessEnv, command = DEFAULT_AGENT_COMMAND): boolean { + return isEnvSet(env[PI_WEB_AGENT_DIR_ENV]) || isEnvSet(env[commandAgentDirEnv(command)]); +} + +export function hasAgentSessionDirEnvOverride(env: NodeJS.ProcessEnv, command = DEFAULT_AGENT_COMMAND): boolean { + return agentSessionDirEnvKeys(command).some((key) => isEnvSet(env[key])); +} + export function effectiveUploadsConfig(config: Pick = {}): NonNullable { return { defaultFolder: config.uploads?.defaultFolder ?? DEFAULT_UPLOADS_FOLDER }; } @@ -79,7 +115,7 @@ export function effectivePiWebConfig(options: LoadOptions = {}): LoadedPiWebConf const port = env["PI_WEB_PORT"] ?? env["PORT"]; const allowedHosts = env["PI_WEB_ALLOWED_HOSTS"]; const maxUpload = env["PI_WEB_MAX_UPLOAD_BYTES"]; - + const agent = effectiveAgentConfig(env, loaded.config, options.cwd ?? process.cwd()); return { ...loaded, config: { @@ -94,6 +130,7 @@ export function effectivePiWebConfig(options: LoadOptions = {}): LoadedPiWebConf spawnSessions: spawnSessionsEnabled(env, loaded.config), // Beta capability, resolved off by default. subsessions: subsessionsEnabled(env, loaded.config), + agent: { command: agent.command, dir: agent.dir }, }, }; } @@ -113,6 +150,7 @@ export function savePiWebConfig(config: PiWebConfig, options: LoadOptions = {}): delete existing["maxUploadBytes"]; delete existing["spawnSessions"]; delete existing["subsessions"]; + delete existing["agent"]; const merged = { ...existing, ...piWebConfigRecord(normalized) }; mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, `${JSON.stringify(merged, null, 2)}\n`, "utf8"); @@ -138,6 +176,7 @@ function piWebConfigRecord(config: PiWebConfig): Record { ...(config.maxUploadBytes !== undefined ? { maxUploadBytes: config.maxUploadBytes } : {}), ...(config.spawnSessions !== undefined ? { spawnSessions: config.spawnSessions } : {}), ...(config.subsessions !== undefined ? { subsessions: config.subsessions } : {}), + ...(config.agent !== undefined ? { agent: config.agent } : {}), }; } @@ -153,6 +192,7 @@ function parsePiWebConfig(value: Record, path: string): PiWebCo ...(value["maxUploadBytes"] !== undefined ? { maxUploadBytes: parseMaxUploadBytes(value["maxUploadBytes"], "maxUploadBytes", path) } : {}), ...(value["spawnSessions"] !== undefined ? { spawnSessions: parseSpawnSessions(value["spawnSessions"], path) } : {}), ...(value["subsessions"] !== undefined ? { subsessions: parseSubsessions(value["subsessions"], path) } : {}), + ...(value["agent"] !== undefined ? { agent: parseAgentConfig(value["agent"], path) } : {}), }; } @@ -203,6 +243,35 @@ function parseString(value: unknown, key: string, path: string): string { return value; } +function parseAgentConfig(value: unknown, path: string): NonNullable { + if (!isRecord(value)) throw new Error(`PI WEB config agent must be an object: ${path}`); + const command = value["command"]; + const dir = value["dir"]; + return { + ...(command !== undefined ? { command: parseAgentCommand(command, "agent.command", path) } : {}), + ...(dir !== undefined ? { dir: parseAgentDir(dir, "agent.dir", path) } : {}), + }; +} + +function parseAgentCommand(value: unknown, key: string, path: string): string { + const command = parseString(value, key, path).trim(); + if (command === "") throw new Error(`PI WEB config ${key} must be a non-empty string: ${path}`); + if (/[\s;&|`$<>]/u.test(command)) throw new Error(`PI WEB config ${key} must be a single command name or path without shell metacharacters: ${path}`); + return command; +} + +function parseAgentDir(value: unknown, key: string, path: string): string { + const dir = parseString(value, key, path); + if (!isAbsoluteOrHomePath(dir)) throw new Error(`PI WEB config ${key} must be an absolute path or start with ~: ${path}`); + return dir; +} + +function resolveAgentDirPath(value: string, env: NodeJS.ProcessEnv, cwd: string, key: string, path: string): string { + const parsed = parseAgentDir(value, key, path); + const expanded = expandHomePath(parsed, env); + return isAbsoluteLike(expanded) ? expanded : resolve(cwd, expanded); +} + function parsePort(value: unknown, key: string, path = "environment"): number { const port = typeof value === "number" ? value : typeof value === "string" && value !== "" ? Number(value) : NaN; if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`PI WEB config ${key} must be an integer from 1 to 65535: ${path}`); @@ -252,6 +321,49 @@ function parseWorkspaceRelativeFolder(value: unknown, key: string, path: string) return parts.join("/"); } + +function isAbsoluteOrHomePath(value: string): boolean { + return value === "~" || value.startsWith("~/") || value.startsWith("~\\") || isAbsoluteLike(value); +} + +function expandHomePath(value: string, env: NodeJS.ProcessEnv): string { + const home = env["HOME"] !== undefined && env["HOME"] !== "" ? env["HOME"] : homedir(); + if (value === "~") return home; + if (value.startsWith("~/") || value.startsWith("~\\")) return join(home, value.slice(2)); + return value; +} + +function defaultAgentDirForCommand(command: string, env: NodeJS.ProcessEnv): string { + return expandHomePath(isOmpCommand(command) ? "~/.omp/agent" : "~/.pi/agent", env); +} + +function commandAgentDirEnv(command: string): string { + const prefix = agentEnvPrefix(command); + return prefix === "PI" ? PI_CODING_AGENT_DIR_ENV : `${prefix}_CODING_AGENT_DIR`; +} + +function commandSessionDirEnv(command: string): string { + return `${agentEnvPrefix(command)}_CODING_AGENT_SESSION_DIR`; +} + +function agentEnvPrefix(command: string): string { + const name = command.split(/[\\/]/u).at(-1) ?? command; + const normalized = name.replace(/(?:\.[cm]?js|\.exe)$/iu, "").replace(/[^A-Za-z0-9]+/gu, "_").replace(/^_+|_+$/gu, "").toUpperCase(); + return normalized === "" ? "PI" : normalized; +} + +function isOmpCommand(command: string): boolean { + const name = command.split(/[\\/]/u).at(-1)?.toLowerCase(); + return name === "omp" || name === "omp.exe"; +} + +function isEnvSet(value: string | undefined): boolean { + return value !== undefined && value !== ""; +} + +function uniqueStrings(values: readonly string[]): string[] { + return [...new Set(values)]; +} function isAbsoluteLike(value: string): boolean { const withForwardSlashes = value.replace(/\\/g, "/"); return isAbsolute(value) || withForwardSlashes.startsWith("/") || /^[A-Za-z]:\//.test(withForwardSlashes); diff --git a/src/server/app.test.ts b/src/server/app.test.ts index 34b03788..efde7925 100644 --- a/src/server/app.test.ts +++ b/src/server/app.test.ts @@ -1076,7 +1076,7 @@ function piWebConfigResponse(config: PiWebConfigValues): PiWebConfigResponse { exists: false, config, effectiveConfig: config, - envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false }, + envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: false, agentSessionDir: false }, }; } diff --git a/src/server/app.ts b/src/server/app.ts index aeb4913c..05348555 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -24,6 +24,7 @@ import { createDefaultPiPackageService, type PiPackageService } from "./piPackag import { registerPiPackageRoutes } from "./piPackageRoutes.js"; import { createPiWebStatusCache } from "./piWebStatusCache.js"; import { getPiWebRuntime, getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js"; +import { effectiveAgentConfig, effectivePiWebConfig } from "../config.js"; import { MachineService } from "./machines/machineService.js"; import { registerMachineRoutes } from "./machines/machineRoutes.js"; import { registerMachineProxyRoutes } from "./machines/machineProxyRoutes.js"; @@ -124,11 +125,12 @@ export async function buildApp(deps: AppDependencies = {}): Promise getPiWebStatus(sessionDaemon), { + const piWebStatusCache = createPiWebStatusCache(() => getPiWebStatus(sessionDaemon, { agentCommand: agent.command, agentDir: agent.dir }), { onError: (error) => { app.log.warn({ err: error }, "failed to refresh PI WEB status cache"); }, }); const machines = deps.machines ?? new MachineService(undefined, { @@ -146,7 +148,7 @@ export async function buildApp(deps: AppDependencies = {}): Promise piWebStatusCache.get()); - app.get("/api/pi-web/version", async () => getPiWebVersionStatus(sessionDaemon)); + app.get("/api/pi-web/version", async () => getPiWebVersionStatus(sessionDaemon, { agentCommand: agent.command, agentDir: agent.dir })); app.get("/api/pi-web/runtime", async () => getPiWebRuntime(sessionDaemon)); app.get("/api/plugins", async () => piWebPlugins.plugins()); app.get("/api/machines/local/plugins", async () => piWebPlugins.plugins()); diff --git a/src/server/configRoutes.test.ts b/src/server/configRoutes.test.ts index 0bb60c14..9b80c474 100644 --- a/src/server/configRoutes.test.ts +++ b/src/server/configRoutes.test.ts @@ -41,6 +41,7 @@ describe("config routes", () => { allowedHosts: true, spawnSessions: true, subsessions: true, + agent: { command: "custom-agent", dir: "/tmp/custom-agent" }, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { note: "hidden" } } }, pathAccess: { allowedPaths: ["/tmp"] }, @@ -217,6 +218,6 @@ function responseFor(config: PiWebConfigValues, exists: boolean): PiWebConfigRes exists, config, effectiveConfig: config, - envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false }, + envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: false, agentSessionDir: false }, }; } diff --git a/src/server/configRoutes.ts b/src/server/configRoutes.ts index 0acf896d..ee7d507a 100644 --- a/src/server/configRoutes.ts +++ b/src/server/configRoutes.ts @@ -1,5 +1,5 @@ import type { FastifyInstance } from "fastify"; -import { effectivePiWebConfig, loadPiWebConfig, parseUploadsConfig, savePiWebConfig, type LoadOptions, type PiWebConfig } from "../config.js"; +import { effectiveAgentConfig, effectivePiWebConfig, hasAgentDirEnvOverride, hasAgentSessionDirEnvOverride, loadPiWebConfig, parseUploadsConfig, savePiWebConfig, type LoadOptions, type PiWebConfig } from "../config.js"; import type { PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js"; import { isPiWebPluginId } from "../shared/pluginIds.js"; @@ -15,6 +15,7 @@ export const SELECTED_MACHINE_CONFIG_KEYS = [ "maxUploadBytes", "spawnSessions", "subsessions", + "agent", ] as const satisfies readonly (keyof PiWebConfigValues)[]; const SELECTED_MACHINE_CONFIG_KEY_SET = new Set(SELECTED_MACHINE_CONFIG_KEYS); @@ -38,7 +39,7 @@ export function currentPiWebConfigResponse(options: LoadOptions = {}): PiWebConf exists: loaded.exists, config: loaded.config, effectiveConfig: effective.config, - envOverrides: piWebConfigEnvOverrides(env), + envOverrides: piWebConfigEnvOverrides(env, loaded.config), }; } @@ -130,6 +131,7 @@ function parseConfigRequest(value: unknown): PiWebConfig { const maxUploadBytes = value["maxUploadBytes"]; const spawnSessions = value["spawnSessions"]; const subsessions = value["subsessions"]; + const agent = value["agent"]; if (host !== undefined) { if (typeof host !== "string") throw new Error("PI WEB config host must be a string"); config.host = host; @@ -152,6 +154,7 @@ function parseConfigRequest(value: unknown): PiWebConfig { if (typeof subsessions !== "boolean") throw new Error("PI WEB config subsessions must be a boolean"); config.subsessions = subsessions; } + if (agent !== undefined) config.agent = parseAgentRequest(agent); return config; } @@ -163,6 +166,7 @@ function pickSelectedMachineConfig(config: PiWebConfigValues): PiWebConfig { ...(config.maxUploadBytes !== undefined ? { maxUploadBytes: config.maxUploadBytes } : {}), ...(config.spawnSessions !== undefined ? { spawnSessions: config.spawnSessions } : {}), ...(config.subsessions !== undefined ? { subsessions: config.subsessions } : {}), + ...(config.agent !== undefined ? { agent: config.agent } : {}), }; } @@ -212,6 +216,30 @@ function parseMaxUploadBytesRequest(value: unknown): number { return value; } +function parseAgentRequest(value: unknown): NonNullable { + if (!isRecord(value)) throw new Error("PI WEB config agent must be an object"); + const command = value["command"]; + const dir = value["dir"]; + return { + ...(command === undefined ? {} : { command: parseAgentCommandRequest(command) }), + ...(dir === undefined ? {} : { dir: parseAgentDirRequest(dir) }), + }; +} + +function parseAgentCommandRequest(value: unknown): string { + if (typeof value !== "string" || value.trim() === "") throw new Error("PI WEB config agent.command must be a non-empty string"); + const command = value.trim(); + if (/[\s;&|`$<>]/u.test(command)) throw new Error("PI WEB config agent.command must be a single command name or path without shell metacharacters"); + return command; +} + +function parseAgentDirRequest(value: unknown): string { + if (typeof value !== "string" || value.trim() === "") throw new Error("PI WEB config agent.dir must be a non-empty string"); + const dir = value.trim(); + if (!isAbsoluteOrHomePath(dir)) throw new Error("PI WEB config agent.dir must be an absolute path or start with ~"); + return dir; +} + function parsePluginsRequest(value: unknown): NonNullable { if (!isRecord(value) || Array.isArray(value)) throw new Error("PI WEB config plugins must be an object"); return Object.fromEntries(Object.entries(value).map(([pluginId, config]) => { @@ -233,6 +261,9 @@ function parsePiWebConfigEnvOverridesResponse(value: unknown, source: string): P allowedHosts: requireResponseBoolean(record, "allowedHosts", source), spawnSessions: requireResponseBoolean(record, "spawnSessions", source), subsessions: requireResponseBoolean(record, "subsessions", source), + agentCommand: requireResponseBoolean(record, "agentCommand", source), + agentDir: requireResponseBoolean(record, "agentDir", source), + agentSessionDir: requireResponseBoolean(record, "agentSessionDir", source), }; } @@ -253,13 +284,17 @@ function requireResponseBoolean(record: Record, key: string, so return value; } -function piWebConfigEnvOverrides(env: NodeJS.ProcessEnv): PiWebConfigEnvOverrides { +function piWebConfigEnvOverrides(env: NodeJS.ProcessEnv, config: PiWebConfig = {}): PiWebConfigEnvOverrides { + const agent = effectiveAgentConfig(env, config); return { host: isEnvSet(env["PI_WEB_HOST"]), port: isEnvSet(env["PI_WEB_PORT"]) || isEnvSet(env["PORT"]), allowedHosts: isEnvSet(env["PI_WEB_ALLOWED_HOSTS"]), spawnSessions: isEnvSet(env["PI_WEB_SPAWN_SESSIONS"]), subsessions: isEnvSet(env["PI_WEB_SUBSESSIONS"]), + agentCommand: isEnvSet(env["PI_WEB_AGENT_COMMAND"]), + agentDir: hasAgentDirEnvOverride(env, agent.command), + agentSessionDir: hasAgentSessionDirEnvOverride(env, agent.command), }; } @@ -275,6 +310,10 @@ function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } +function isAbsoluteOrHomePath(value: string): boolean { + return value === "~" || value.startsWith("~/") || value.startsWith("~\\") || value.startsWith("/") || value.startsWith("\\") || /^[A-Za-z]:[\\/]/u.test(value); +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } diff --git a/src/server/piWebStatus.test.ts b/src/server/piWebStatus.test.ts index 15cd1a57..6bfd9437 100644 --- a/src/server/piWebStatus.test.ts +++ b/src/server/piWebStatus.test.ts @@ -2,9 +2,9 @@ import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { comparePackageVersions, getPiWebRuntime, getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js"; +import { comparePackageVersions, getPiWebRuntime, getPiWebStatus, getPiWebVersionStatus, updateCommandFor } from "./piWebStatus.js"; import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js"; -import type { PiWebComponentStatus } from "../shared/apiTypes.js"; +import type { PiWebComponentStatus, PiWebRuntimeComponent } from "../shared/apiTypes.js"; import { PI_WEB_CAPABILITIES } from "../shared/capabilities.js"; const originalSkipVersionCheck = process.env["PI_WEB_SKIP_VERSION_CHECK"]; @@ -69,6 +69,26 @@ describe("PI WEB status", () => { expect(runtime.capabilities).toEqual(expect.arrayContaining([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings])); }); + it("detects session daemon package installs from the configured agent dir for runtime responses", async () => { + const agentDir = await tempHome(); + try { + await installConfiguredPiWebPackage(agentDir); + const daemon = daemonWithRuntime({ + component: "sessiond", + label: "Session daemon", + runtimeVersion: "1.202605.7", + available: true, + capabilities: [], + }); + + const status = await getPiWebVersionStatus(daemon, { agentCommand: "custom-agent", agentDir }); + + expect(status.components.sessiond.installation).toMatchObject({ kind: "pi-package", source: process.cwd(), scope: "user" }); + } finally { + await rm(agentDir, { recursive: true, force: true }); + } + }); + it("reports stale session daemon versions as messages", async () => { process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1"; disableDockerRuntimeEnv(); @@ -90,6 +110,19 @@ describe("PI WEB status", () => { expect(status.messages.map((message) => message.id)).toContain("sessiond-stale"); }); + it("shell-quotes pi-package agent update commands", async () => { + const updateCommand = await updateCommandFor( + { kind: "pi-package", source: "npm:@jmfederico/pi-web", scope: "user", path: "/tmp/pi-web" }, + "pi-web restart", + { + agentCommand: "/tmp/agent's/custom-agent", + hasCommand: (command) => Promise.resolve(command === "/tmp/agent's/custom-agent"), + }, + ); + + expect(updateCommand).toBe("'/tmp/agent'\\''s/custom-agent' update 'npm:@jmfederico/pi-web' && pi-web restart"); + }); + it.skipIf(process.platform !== "linux")("suggests native systemd commands for local development services", async () => { process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1"; disableDockerRuntimeEnv(); @@ -202,6 +235,16 @@ function daemonWithComponent(component: PiWebComponentStatus): SessionDaemonClie return daemon; } +function daemonWithRuntime(component: PiWebRuntimeComponent): SessionDaemonClient { + const daemon = new SessionDaemonClient(); + vi.spyOn(daemon, "request").mockResolvedValue({ + statusCode: 200, + headers: { "content-type": "application/json" }, + body: JSON.stringify(component), + }); + return daemon; +} + function staleLocalSessiond(): PiWebComponentStatus { return { component: "sessiond", @@ -230,6 +273,10 @@ async function installExecutable(dir: string, name: string): Promise { await chmod(path, 0o755); } +async function installConfiguredPiWebPackage(agentDir: string): Promise { + await writeFile(join(agentDir, "settings.json"), `${JSON.stringify({ packages: [process.cwd()] }, null, 2)}\n`, "utf8"); +} + function disableDockerRuntimeEnv(): void { process.env["PI_WEB_DOCKER_RUNTIME"] = "0"; Reflect.deleteProperty(process.env, "PI_WEB_DOCKER_MODE"); diff --git a/src/server/piWebStatus.ts b/src/server/piWebStatus.ts index 73857836..dfeae9fa 100644 --- a/src/server/piWebStatus.ts +++ b/src/server/piWebStatus.ts @@ -5,12 +5,13 @@ import { promisify } from "node:util"; import { homedir } from "node:os"; import { dirname, join, relative, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; -import { DefaultPackageManager, getAgentDir, SettingsManager } from "@earendil-works/pi-coding-agent"; +import { DefaultPackageManager, SettingsManager } from "@earendil-works/pi-coding-agent"; import type { PiWebCapability, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebStatusMessage, PiWebStatusResponse, PiWebVersionResponse } from "../shared/apiTypes.js"; import { effectivePiWebCapabilities, WEB_RUNTIME_CAPABILITIES } from "../shared/capabilities.js"; import { piWebDockerCommand } from "../docker/piWebDockerCommandPlan.js"; import { parsePiWebComponentStatus, parsePiWebRuntimeComponent } from "../shared/piWebStatusParsing.js"; import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js"; +import { effectiveAgentConfig } from "../config.js"; const PI_WEB_PACKAGE_NAME = "@jmfederico/pi-web"; const PI_WEB_NPM_SOURCE = `npm:${PI_WEB_PACKAGE_NAME}`; @@ -74,6 +75,22 @@ interface PiWebStatusDaemon { request(method: string, path: string, body?: unknown): Promise<{ statusCode: number; headers: Record; body: string }>; } +interface PiWebStatusOptions { + agentCommand?: string; + agentDir?: string; + hasCommand?: (command: string) => Promise; +} + +function effectiveStatusAgentConfig(options: PiWebStatusOptions): { command: string; dir: string } { + const agent = effectiveAgentConfig(process.env, { + agent: { + ...(options.agentCommand === undefined ? {} : { command: options.agentCommand }), + ...(options.agentDir === undefined ? {} : { dir: options.agentDir }), + }, + }); + return { command: agent.command, dir: agent.dir }; +} + let latestReleaseCache: { checkedAtMs: number; latestVersion?: string; error?: string } | undefined; const runtimePackageInfo = readPackageInfoSync(); @@ -99,10 +116,10 @@ export async function getPiWebRuntime(daemon: PiWebStatusDaemon = new SessionDae }; } -export async function getPiWebComponentStatus(component: PiWebServiceComponent): Promise { +export async function getPiWebComponentStatus(component: PiWebServiceComponent, options: PiWebStatusOptions = {}): Promise { const [installed, installation] = await Promise.all([ readInstalledPackageInfo(), - detectPiWebInstallation(), + detectPiWebInstallation(options.agentDir), ]); const runtimeVersion = runtimePackageInfo?.version ?? DEFAULT_VERSION; const installedVersion = installed?.version; @@ -117,10 +134,10 @@ export async function getPiWebComponentStatus(component: PiWebServiceComponent): }; } -export async function getPiWebVersionStatus(daemon: PiWebStatusDaemon = new SessionDaemonClient()): Promise { +export async function getPiWebVersionStatus(daemon: PiWebStatusDaemon = new SessionDaemonClient(), options: PiWebStatusOptions = {}): Promise { const [web, sessiond] = await Promise.all([ - getPiWebComponentStatus("web"), - getSessiondComponentStatus(daemon), + getPiWebComponentStatus("web", options), + getSessiondComponentStatus(daemon, options), ]); return { packageName: PI_WEB_PACKAGE_NAME, @@ -129,12 +146,13 @@ export async function getPiWebVersionStatus(daemon: PiWebStatusDaemon = new Sess }; } -export async function getPiWebStatus(daemon: PiWebStatusDaemon = new SessionDaemonClient()): Promise { - const versionStatus = await getPiWebVersionStatus(daemon); +export async function getPiWebStatus(daemon: PiWebStatusDaemon = new SessionDaemonClient(), options: PiWebStatusOptions = {}): Promise { + const agent = effectiveStatusAgentConfig(options); + const versionStatus = await getPiWebVersionStatus(daemon, { ...options, agentDir: agent.dir }); const { web, sessiond } = versionStatus.components; const release = await getLatestReleaseStatus(web.installedVersion ?? web.runtimeVersion ?? DEFAULT_VERSION); const components = { web, sessiond }; - const commands = await commandsFor(components); + const commands = await commandsFor(components, { agentCommand: agent.command, hasCommand: options.hasCommand ?? hasCommand }); const messages = buildMessages(components, release, commands); return { ...versionStatus, @@ -188,12 +206,12 @@ function parsePackageInfo(value: unknown, path: string): PackageInfo | undefined return { name, version, path }; } -async function detectPiWebInstallation(): Promise { +async function detectPiWebInstallation(agentDir = effectiveAgentConfig().dir): Promise { const docker = detectDockerInstallation(); if (docker !== undefined) return docker; const root = packageRootPath(); const realRoot = await realPathOrSelf(root); - const piPackage = await detectPiPackageInstallation(realRoot, root); + const piPackage = await detectPiPackageInstallation(realRoot, root, agentDir); if (piPackage !== undefined) return piPackage; const npmGlobal = await detectNpmGlobalInstallation(realRoot, root); if (npmGlobal !== undefined) return npmGlobal; @@ -240,9 +258,8 @@ function isTruthyEnv(key: string): boolean { return value !== undefined && value !== "" && value !== "0" && value.toLowerCase() !== "false"; } -async function detectPiPackageInstallation(realRoot: string, displayPath: string): Promise { +async function detectPiPackageInstallation(realRoot: string, displayPath: string, agentDir: string): Promise { try { - const agentDir = getAgentDir(); const packageManager = new DefaultPackageManager({ cwd: process.cwd(), agentDir, @@ -310,7 +327,7 @@ async function getSessiondRuntimeComponent(daemon: PiWebStatusDaemon): Promise

    { +async function getSessiondComponentStatus(daemon: PiWebStatusDaemon, options: PiWebStatusOptions = {}): Promise { try { const upstream = await daemon.request("GET", "/runtime"); if (upstream.statusCode < 200 || upstream.statusCode >= 300) { @@ -321,7 +338,7 @@ async function getSessiondComponentStatus(daemon: PiWebStatusDaemon): Promise { return version; } -async function commandsFor(components: PiWebStatusResponse["components"]): Promise { +async function commandsFor(components: PiWebStatusResponse["components"], options: { agentCommand: string; hasCommand: (command: string) => Promise }): Promise { const installation = preferredInstallation(components); if (installation?.kind === "docker") return dockerCommands(installation); @@ -430,7 +447,7 @@ async function commandsFor(components: PiWebStatusResponse["components"]): Promi const restartWeb = serviceCommands.restartWeb ?? cliCommands.restart; const restartSessiond = serviceCommands.restartSessiond ?? cliCommands.restart; const status = serviceCommands.status ?? cliCommands.status; - const update = await updateCommandFor(installation, restart); + const update = await updateCommandFor(installation, restart, options); return { ...(update === undefined ? {} : { update }), @@ -469,11 +486,11 @@ function restartCommandFor(installation: PiWebInstallationInfo | undefined, serv return cliCommands.restart ?? serviceCommands.restart; } -async function updateCommandFor(installation: PiWebInstallationInfo | undefined, restartCommand: string | undefined): Promise { +export async function updateCommandFor(installation: PiWebInstallationInfo | undefined, restartCommand: string | undefined, options: { agentCommand: string; hasCommand: (command: string) => Promise }): Promise { if (restartCommand === undefined) return undefined; if (installation?.kind === "pi-package") { - if (!(await hasCommand("pi"))) return undefined; - return `pi update ${installation.source ?? PI_WEB_NPM_SOURCE} && ${restartCommand}`; + if (!(await options.hasCommand(options.agentCommand))) return undefined; + return `${shellQuote(options.agentCommand)} update ${shellQuote(installation.source ?? PI_WEB_NPM_SOURCE)} && ${restartCommand}`; } if (installation?.kind === "local" && installation.path !== undefined) { if (!(await hasCommand("npm")) || !(await isGitCheckoutWithUpstream(installation.path))) return undefined; @@ -553,7 +570,7 @@ async function isGitCheckoutWithUpstream(path: string): Promise { } function hasCommand(command: string): Promise { - return commandSucceeds("/usr/bin/env", ["sh", "-c", `command -v ${command}`]); + return commandSucceeds("/usr/bin/env", ["sh", "-c", `command -v ${shellQuote(command)}`]); } async function commandSucceeds(command: string, args: string[]): Promise { diff --git a/src/server/sessiond.ts b/src/server/sessiond.ts index f4cc36eb..579eed29 100644 --- a/src/server/sessiond.ts +++ b/src/server/sessiond.ts @@ -9,6 +9,7 @@ import { SessionEventHub } from "./realtime/sessionEventHub.js"; import { AuthService } from "./sessions/authService.js"; import { registerAuthRoutes } from "./sessions/authRoutes.js"; import { PiSessionService } from "./sessions/piSessionService.js"; +import { createPiSessionManagerGateway } from "./sessions/piSessionManagerGateway.js"; import { registerSessionRoutes } from "./sessions/sessionRoutes.js"; import { ProjectScopedSpawnTargetResolver } from "./sessions/spawnTargetResolver.js"; import { ProjectService } from "./projects/projectService.js"; @@ -19,24 +20,27 @@ import { TerminalService } from "./terminals/terminalService.js"; 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 { effectiveAgentConfig, effectivePiWebConfig, maxUploadBytes, spawnSessionsEnabled, subsessionsEnabled } from "../config.js"; const { config } = effectivePiWebConfig(); +const agent = effectiveAgentConfig(process.env, config); 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 auth = new AuthService({ agentDir: agent.dir }); 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, + agentDir: agent.dir, workspaceActivity, logger: app.log, ...(spawnTargets === undefined ? {} : { spawnTargets }), subsessionsEnabled: spawnTargets !== undefined && subsessionsEnabled(process.env, config), + sessionManager: createPiSessionManagerGateway({ agentDir: agent.dir, sessionDirEnvKeys: agent.sessionDirEnvKeys }), }); auth.subscribe((change) => { sessions.applyAuthChange(change); }); const terminals = new TerminalService(eventHub, workspaceActivity); diff --git a/src/server/sessions/authService.test.ts b/src/server/sessions/authService.test.ts index 3218b9d9..2a04f6c0 100644 --- a/src/server/sessions/authService.test.ts +++ b/src/server/sessions/authService.test.ts @@ -1,9 +1,18 @@ +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { OAuthFlowState } from "../../shared/apiTypes.js"; import { AuthService, type AuthChange } from "./authService.js"; import { OAuthLoginFlowService } from "./oauthLoginFlowService.js"; +const tempDirs: string[] = []; + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + describe("AuthService", () => { it("saves API keys and emits a global auth change", () => { const { auth, authStorage, changes } = createAuthService(); @@ -65,6 +74,16 @@ describe("AuthService", () => { auth.dispose(); expect(authFlows.disposed).toBe(true); }); + + it("stores credentials in the configured agent directory", async () => { + const agentDir = await tempAgentDir(); + const auth = new AuthService({ agentDir }); + + auth.saveApiKey("anthropic", "sk-custom"); + + await expect(readFile(join(agentDir, "auth.json"), "utf8")).resolves.toContain("sk-custom"); + auth.dispose(); + }); }); function createAuthService(data: Parameters[0] = {}) { @@ -89,3 +108,9 @@ class CapturingOAuthLoginFlowService extends OAuthLoginFlowService { this.disposed = true; } } + +async function tempAgentDir(): Promise { + const dir = await mkdtemp(join(tmpdir(), "pi-web-auth-agent-")); + tempDirs.push(dir); + return dir; +} diff --git a/src/server/sessions/authService.ts b/src/server/sessions/authService.ts index c884af9c..3cb13054 100644 --- a/src/server/sessions/authService.ts +++ b/src/server/sessions/authService.ts @@ -1,3 +1,4 @@ +import { join } from "node:path"; import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent"; import type { AuthProvidersResponse, AuthType, OAuthFlowState } from "../../shared/apiTypes.js"; import { getLoginProviderOptions, getLogoutProviderOptions } from "./authProviderOptions.js"; @@ -11,17 +12,23 @@ type AuthChangeListener = (change: AuthChange) => void; type ModelRegistryInstance = ReturnType; export interface AuthServiceDependencies { + agentDir?: string; modelRegistry?: ModelRegistryInstance; authFlows?: OAuthLoginFlowService; } +export function createModelRegistryForAgentDir(agentDir: string): ModelRegistryInstance { + const authStorage = AuthStorage.create(join(agentDir, "auth.json")); + return ModelRegistry.create(authStorage, join(agentDir, "models.json")); +} + export class AuthService { readonly modelRegistry: ModelRegistryInstance; private readonly authFlows: OAuthLoginFlowService; private readonly listeners = new Set(); constructor(deps: AuthServiceDependencies = {}) { - this.modelRegistry = deps.modelRegistry ?? ModelRegistry.create(AuthStorage.create()); + this.modelRegistry = deps.modelRegistry ?? (deps.agentDir === undefined ? ModelRegistry.create(AuthStorage.create()) : createModelRegistryForAgentDir(deps.agentDir)); this.authFlows = deps.authFlows ?? new OAuthLoginFlowService(); } diff --git a/src/server/sessions/piSessionManagerGateway.test.ts b/src/server/sessions/piSessionManagerGateway.test.ts index d787ae65..eda4d739 100644 --- a/src/server/sessions/piSessionManagerGateway.test.ts +++ b/src/server/sessions/piSessionManagerGateway.test.ts @@ -59,6 +59,16 @@ describe("SessionDirResolver", () => { expect(resolver.resolve(cwd)).toMatchObject({ source: "env", sessionDir: envDir, usesConfiguredSessionDir: true }); }); + + it("uses OMP sessionDir environment overrides before settings", async () => { + const envDir = join(tempDir, "omp-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: { OMP_CODING_AGENT_SESSION_DIR: envDir }, sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR", "OMP_CODING_AGENT_SESSION_DIR", "PI_CODING_AGENT_SESSION_DIR"] }); + + expect(resolver.resolve(cwd)).toMatchObject({ source: "env", sessionDir: envDir, usesConfiguredSessionDir: true }); + }); }); describe("Pi session manager gateway", () => { @@ -82,6 +92,21 @@ describe("Pi session manager gateway", () => { await expect(gateway.listAll()).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: "default-session", cwd }), expect.objectContaining({ id: "env-session", cwd })])); }); + it("includes command-specific env session directories in global listing", async () => { + for (const envKey of ["PI_WEB_AGENT_SESSION_DIR", "OMP_CODING_AGENT_SESSION_DIR"]) { + const envSessionDir = join(tempDir, `${envKey.toLowerCase()}-sessions`); + await writeSessionFile(envSessionDir, `${envKey.toLowerCase()}-session`, cwd); + const gateway = createPiSessionManagerGateway({ + agentDir, + env: { [envKey]: envSessionDir }, + sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR", "OMP_CODING_AGENT_SESSION_DIR", "PI_CODING_AGENT_SESSION_DIR"], + }); + + if (gateway.listAll === undefined) throw new Error("Expected legacy listing support"); + await expect(gateway.listAll()).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: `${envKey.toLowerCase()}-session`, cwd })])); + } + }); + 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"); diff --git a/src/server/sessions/piSessionManagerGateway.ts b/src/server/sessions/piSessionManagerGateway.ts index 57a2446c..7b06eaff 100644 --- a/src/server/sessions/piSessionManagerGateway.ts +++ b/src/server/sessions/piSessionManagerGateway.ts @@ -19,15 +19,18 @@ export interface SessionDirResolution { export interface SessionDirResolverOptions { agentDir?: string; env?: NodeJS.ProcessEnv; + sessionDirEnvKeys?: readonly string[]; } export class SessionDirResolver { private readonly agentDir: string; private readonly env: NodeJS.ProcessEnv; + private readonly sessionDirEnvKeys: readonly string[]; constructor(options: SessionDirResolverOptions = {}) { this.agentDir = options.agentDir ?? getAgentDir(); this.env = options.env ?? process.env; + this.sessionDirEnvKeys = options.sessionDirEnvKeys ?? [PI_SESSION_DIR_ENV]; } defaultSessionsRoot(): string { @@ -35,15 +38,15 @@ export class SessionDirResolver { } globalEnvSessionDir(): string | undefined { - const envSessionDir = this.env[PI_SESSION_DIR_ENV]; - if (envSessionDir === undefined || envSessionDir === "") return undefined; + const envSessionDir = this.envSessionDir(); + if (envSessionDir === undefined) return undefined; const expanded = expandTildePath(envSessionDir); return isAbsolute(expanded) ? expanded : undefined; } resolve(cwd: string): SessionDirResolution { - const envSessionDir = this.env[PI_SESSION_DIR_ENV]; - if (envSessionDir !== undefined && envSessionDir !== "") { + const envSessionDir = this.envSessionDir(); + if (envSessionDir !== undefined) { return { source: "env", sessionDir: resolveConfiguredPath(envSessionDir, cwd), usesConfiguredSessionDir: true }; } @@ -54,6 +57,10 @@ export class SessionDirResolver { return { source: "pi-default", sessionDir: defaultPiSessionDir(cwd, this.agentDir), usesConfiguredSessionDir: false }; } + + private envSessionDir(): string | undefined { + return this.sessionDirEnvKeys.map((key) => this.env[key]).find((value) => value !== undefined && value !== ""); + } } export type PiSessionManagerGatewayOptions = SessionDirResolverOptions; diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts index 5b6f0e6b..e37ef2cf 100644 --- a/src/server/sessions/piSessionService.ts +++ b/src/server/sessions/piSessionService.ts @@ -23,7 +23,7 @@ import { SessionCommandService } from "./sessionCommandService.js"; import { SessionArchiveStore, type ArchivedSessionRecord, type ArchiveSessionInput } from "./sessionArchiveStore.js"; import { findArchiveCandidateByIdOrPrefix, planSessionArchiveTree, type SessionArchiveTreeCandidate } from "./sessionArchiveTree.js"; import type { ActiveSession } from "./sessionRuntimeStore.js"; -import type { AuthChange } from "./authService.js"; +import { createModelRegistryForAgentDir, type AuthChange } from "./authService.js"; import { deterministicSessionName, fallbackSessionName, generateShortSessionName } from "./sessionNameGenerator.js"; import { computeEditPreview, type EditPreviewResult } from "./editPreview.js"; import { createPiSessionManagerGateway } from "./piSessionManagerGateway.js"; @@ -401,7 +401,7 @@ export class PiSessionService implements SessionRouteService { this.archiveStore = deps.archiveStore ?? new SessionArchiveStore(); this.agentDir = deps.agentDir ?? getAgentDir(); this.sessionManager = deps.sessionManager ?? createPiSessionManagerGateway({ agentDir: this.agentDir }); - this.modelRegistry = deps.modelRegistry ?? ModelRegistry.create(AuthStorage.create()); + this.modelRegistry = deps.modelRegistry ?? createModelRegistryForAgentDir(this.agentDir); this.spawnTargets = deps.spawnTargets; this.logger = deps.logger ?? noopLogger; this.now = deps.now ?? (() => new Date()); diff --git a/src/shared/apiTypes.ts b/src/shared/apiTypes.ts index ee258232..0e7a62ea 100644 --- a/src/shared/apiTypes.ts +++ b/src/shared/apiTypes.ts @@ -65,6 +65,13 @@ export interface PiWebUploadsConfig { defaultFolder?: string; } +export interface PiWebAgentConfig { + /** Agent CLI command used for diagnostics and package-managed updates. */ + command?: string; + /** Agent config/state directory containing auth.json, models.json, settings.json, and sessions/. */ + dir?: string; +} + export interface PiWebConfigValues { host?: string; port?: number; @@ -86,6 +93,8 @@ export interface PiWebConfigValues { * while the capability stabilizes. Requires spawnSessions to be enabled. */ subsessions?: boolean; + /** Agent runtime state used by the session daemon (Pi by default; OMP compatible). */ + agent?: PiWebAgentConfig; } export type PiWebPluginScope = "bundled" | "local" | "user" | "project"; @@ -146,6 +155,9 @@ export interface PiWebConfigEnvOverrides { allowedHosts: boolean; spawnSessions: boolean; subsessions: boolean; + agentCommand: boolean; + agentDir: boolean; + agentSessionDir: boolean; } export interface PiWebConfigResponse { From 48f3d449a9be61c48ae50fe95bd01de3642f042d Mon Sep 17 00:00:00 2001 From: Jeff Scott Ward Date: Sun, 28 Jun 2026 11:51:07 -0400 Subject: [PATCH 3/3] fix: generalize agent runtime config --- .changeset/agent-runtime-config.md | 5 ++ .changeset/omp-agent-runtime.md | 5 -- docs/config.html | 53 +++++++++++------- docs/config.md | 31 ++++++----- src/cli.test.ts | 6 +-- src/client/src/api/clients.test.ts | 2 +- src/client/src/api/parsers.test.ts | 8 +-- .../src/components/SettingsDialog.test.ts | 2 +- .../settings/SettingsGeneralPanel.test.ts | 2 +- .../settings/SettingsPluginsPanel.test.ts | 2 +- .../settings/SettingsSessiondPanel.test.ts | 14 ++++- .../settings/SettingsSessiondPanel.ts | 37 ++++++------- .../settings/SettingsShortcutsPanel.test.ts | 2 +- .../settings/settingsConfigDraft.test.ts | 4 +- .../settings/settingsDataLoading.test.ts | 2 +- .../settingsMachineAccessConfig.test.ts | 2 +- .../settings/settingsPluginConfig.test.ts | 2 +- .../settings/settingsSessiondConfig.test.ts | 9 +++- .../settings/settingsSessiondConfig.ts | 3 ++ src/config.test.ts | 54 ++++++++++++------- src/config.ts | 36 ++++++++----- src/server/configRoutes.test.ts | 27 +++++++++- src/server/piWebStatus.test.ts | 8 +-- src/server/sessions/authService.test.ts | 4 +- .../sessions/piSessionManagerGateway.test.ts | 10 ++-- src/shared/apiTypes.ts | 2 +- 26 files changed, 208 insertions(+), 124 deletions(-) create mode 100644 .changeset/agent-runtime-config.md delete mode 100644 .changeset/omp-agent-runtime.md diff --git a/.changeset/agent-runtime-config.md b/.changeset/agent-runtime-config.md new file mode 100644 index 00000000..3696b47a --- /dev/null +++ b/.changeset/agent-runtime-config.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Add configurable Pi-compatible agent command and state-directory settings for diagnostics, update checks, auth, models, sessions, and settings. diff --git a/.changeset/omp-agent-runtime.md b/.changeset/omp-agent-runtime.md deleted file mode 100644 index c270f40a..00000000 --- a/.changeset/omp-agent-runtime.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": minor ---- - -Add configurable Pi-compatible agent runtime settings so PI WEB can target Oh My Pi (`omp`) state, auth, sessions, diagnostics, and update checks. diff --git a/docs/config.html b/docs/config.html index 01a9935a..795ff4e2 100644 --- a/docs/config.html +++ b/docs/config.html @@ -147,15 +147,17 @@

    Precedence and reloads

    Environment overrides include PI_WEB_HOST, PI_WEB_PORT / PORT, PI_WEB_ALLOWED_HOSTS, PI_WEB_MAX_UPLOAD_BYTES, PI_WEB_AGENT_COMMAND, - PI_WEB_AGENT_DIR, PI_WEB_AGENT_SESSION_DIR, PI_CODING_AGENT_DIR, - PI_CODING_AGENT_SESSION_DIR, OMP_CODING_AGENT_DIR, - OMP_CODING_AGENT_SESSION_DIR, PI_WEB_SPAWN_SESSIONS, and - PI_WEB_SUBSESSIONS. + PI_WEB_AGENT_DIR, PI_WEB_AGENT_SESSION_DIR, + PI_CODING_AGENT_DIR / PI_CODING_AGENT_SESSION_DIR for the default + pi command, the selected non-pi command's + <COMMAND>_CODING_AGENT_DIR / <COMMAND>_CODING_AGENT_SESSION_DIR, + PI_WEB_SPAWN_SESSIONS, and PI_WEB_SUBSESSIONS.

    • host / port: restart the gateway web/API service or process.
    • maxUploadBytes: restart both the web/API process and the session daemon on that machine.
    • -
    • agent.command / agent.dir / spawnSessions / subsessions: restart the session daemon on that machine.
    • +
    • agent.command / agent.dir: restart the web/API process and the session daemon on that machine.
    • +
    • spawnSessions / subsessions: restart the session daemon on that machine.
    • pathAccess: applies on the next request; existing file views may need a browser refresh.
    • uploads.defaultFolder: applies to newly opened Files upload dialogs and new direct drag/drop batches after config/workspace refresh.
    • plugins: reload the browser tab after changing PI WEB plugin enablement.
    • @@ -189,8 +191,8 @@

      Global config example

      }, "maxUploadBytes": 67108864, "agent": { - "command": "omp", - "dir": "~/.omp/agent" + "command": "pi", + "dir": "~/.pi/agent" }, "spawnSessions": true, "subsessions": false, @@ -323,15 +325,15 @@

      Config matrix

      PI_WEB_AGENT_COMMAND Global/session daemon Not supported locally - Restart session daemon on that machine; affects doctor/status/update checks + Restart web/API and session daemon on that machine; affects doctor/status/update checks Agent state directory agent.dir - PI_WEB_AGENT_DIR, PI_CODING_AGENT_DIR, OMP_CODING_AGENT_DIR + PI_WEB_AGENT_DIR, PI_CODING_AGENT_DIR for pi, selected non-pi command's <COMMAND>_CODING_AGENT_DIR Global/session daemon Not supported locally - Restart session daemon on that machine; affects auth, models, settings, and sessions + Restart web/API and session daemon on that machine; affects auth, models, settings, and sessions Agent can spawn sessions @@ -441,7 +443,7 @@

      Config matrix

      Agent session storage directory — - PI_WEB_AGENT_SESSION_DIR, PI_CODING_AGENT_SESSION_DIR, OMP_CODING_AGENT_SESSION_DIR + PI_WEB_AGENT_SESSION_DIR, PI_CODING_AGENT_SESSION_DIR for pi, selected non-pi command's <COMMAND>_CODING_AGENT_SESSION_DIR Session daemon env Not supported locally Restart session daemon; env-only session storage override @@ -449,7 +451,7 @@

      Config matrix

      Agent config directory — - PI_WEB_AGENT_DIR, PI_CODING_AGENT_DIR, OMP_CODING_AGENT_DIR + PI_WEB_AGENT_DIR, PI_CODING_AGENT_DIR for pi, selected non-pi command's <COMMAND>_CODING_AGENT_DIR Web/API + session daemon env Not supported locally Restart services @@ -524,33 +526,46 @@

      Manual upload defaults

      Agent runtime

      agent.command controls which Pi-compatible CLI PI WEB checks in doctor/status/update flows. - It defaults to pi; set it to another compatible command when this machine should use a Pi-compatible fork. + It defaults to pi. PI WEB's embedded session runtime still uses the bundled SDK path; changing + this command does not load a different session implementation.

      agent.dir controls which compatible agent state directory PI WEB reads for auth providers, - model settings, settings, and session metadata. It defaults to the selected agent's conventional - directory (~/.pi/agent for pi; set an explicit path for compatible forks). + model settings, settings, and session metadata. It defaults to ~/.pi/agent only when the + selected command resolves to pi. For any other command, set agent.dir, + PI_WEB_AGENT_DIR, or the selected command's <COMMAND>_CODING_AGENT_DIR explicitly.

      {
         "agent": {
      -    "command": "pi",
      -    "dir": "~/.pi/agent"
      +    "command": "my-pi-fork",
      +    "dir": "/opt/my-pi-fork/agent"
         }
       }
      +

      + For example, an OMP setup can use "command": "omp" with + "dir": "~/.omp/agent" and, if needed, OMP_CODING_AGENT_DIR / + OMP_CODING_AGENT_SESSION_DIR environment overrides. +

      Environment variables take precedence over the config file. PI_WEB_AGENT_COMMAND selects the command, PI_WEB_AGENT_DIR sets the state directory for any command, and command-specific variables are honored for compatible runtimes.

      +

      + Command-specific environment names are derived from the selected command's basename by stripping common + executable extensions, replacing non-alphanumeric characters with underscores, and uppercasing the result. + For example, my-pi-fork uses MY_PI_FORK_CODING_AGENT_DIR and + MY_PI_FORK_CODING_AGENT_SESSION_DIR. +

      Session directory overrides are environment-only. Set PI_WEB_AGENT_SESSION_DIR or the selected command's session variable when you need to override session storage separately from agent.dir.

      - Restart the session daemon after changing agent settings. The web/API process can display the new config - immediately, but active session runtime ownership is intentionally long-lived. + Restart the web/API process and the session daemon after changing agent settings. The web/API process + captures diagnostics/package state at startup, and active session runtime ownership is intentionally long-lived.
    diff --git a/docs/config.md b/docs/config.md index f10cd444..2a39e523 100644 --- a/docs/config.md +++ b/docs/config.md @@ -27,13 +27,14 @@ defaults → global config file → environment overrides Supported project-local settings are then applied for that project's workspaces. For upload defaults, `/.pi-web/config.json` overrides the global value. -Environment overrides include `PI_WEB_HOST`, `PI_WEB_PORT` / `PORT`, `PI_WEB_ALLOWED_HOSTS`, `PI_WEB_MAX_UPLOAD_BYTES`, `PI_WEB_AGENT_COMMAND`, `PI_WEB_AGENT_DIR`, `PI_WEB_AGENT_SESSION_DIR`, `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR`, `OMP_CODING_AGENT_DIR`, `OMP_CODING_AGENT_SESSION_DIR`, `PI_WEB_SPAWN_SESSIONS`, and `PI_WEB_SUBSESSIONS`. +Environment overrides include `PI_WEB_HOST`, `PI_WEB_PORT` / `PORT`, `PI_WEB_ALLOWED_HOSTS`, `PI_WEB_MAX_UPLOAD_BYTES`, `PI_WEB_AGENT_COMMAND`, `PI_WEB_AGENT_DIR`, `PI_WEB_AGENT_SESSION_DIR`, `PI_CODING_AGENT_DIR` / `PI_CODING_AGENT_SESSION_DIR` for the default `pi` command, the selected non-`pi` command's `_CODING_AGENT_DIR` / `_CODING_AGENT_SESSION_DIR`, `PI_WEB_SPAWN_SESSIONS`, and `PI_WEB_SUBSESSIONS`. Process restarts depend on the key: - `host` / `port`: restart the gateway web/API service or process. - `maxUploadBytes`: restart both the web/API process and the session daemon on that machine. -- `agent.command` / `agent.dir` / `spawnSessions` / `subsessions`: restart the session daemon on that machine. +- `agent.command` / `agent.dir`: restart the web/API process and the session daemon on that machine. +- `spawnSessions` / `subsessions`: restart the session daemon on that machine. - `pathAccess`: applies on the next request; existing file views may need a browser refresh. - `uploads.defaultFolder`: applies to newly opened Files upload dialogs and new direct drag/drop batches after config/workspace refresh. - `plugins`: reload the browser tab after changing PI WEB plugin enablement. @@ -54,8 +55,8 @@ Process restarts depend on the key: }, "maxUploadBytes": 67108864, "agent": { - "command": "omp", - "dir": "~/.omp/agent" + "command": "pi", + "dir": "~/.pi/agent" }, "spawnSessions": true, "subsessions": false, @@ -106,8 +107,8 @@ Rows with JSON key `—` are runtime-only environment variables, not config-file | External filesystem roots | `pathAccess.allowedPaths` | — | Global + project | **Merges**: global roots first, then project roots; duplicates removed | Next file request; refresh existing views if needed | | Manual file upload default folder | `uploads.defaultFolder` | — | Global + project | **Overrides**: project value wins for workspaces in that project; otherwise global/default applies | New Upload dialogs and direct drag/drop batches after config/workspace refresh | | Upload/body limit | `maxUploadBytes` | `PI_WEB_MAX_UPLOAD_BYTES` | Global | Not supported locally | Restart web/API and session daemon on that machine | -| Agent CLI command | `agent.command` | `PI_WEB_AGENT_COMMAND` | Global/session daemon | Not supported locally | Restart session daemon on that machine; affects doctor/status/update checks | -| Agent state directory | `agent.dir` | `PI_WEB_AGENT_DIR`, `PI_CODING_AGENT_DIR`, `OMP_CODING_AGENT_DIR` | Global/session daemon | Not supported locally | Restart session daemon on that machine; affects auth, models, settings, and sessions | +| Agent CLI command | `agent.command` | `PI_WEB_AGENT_COMMAND` | Global/session daemon | Not supported locally | Restart web/API and session daemon on that machine; affects doctor/status/update checks | +| Agent state directory | `agent.dir` | `PI_WEB_AGENT_DIR`, `PI_CODING_AGENT_DIR` for `pi`, selected non-`pi` command's `_CODING_AGENT_DIR` | Global/session daemon | Not supported locally | Restart web/API and session daemon on that machine; affects auth, models, settings, and sessions | | Agent can spawn sessions | `spawnSessions` | `PI_WEB_SPAWN_SESSIONS` | Global/session daemon | Not supported locally | Restart session daemon on that machine | | Tracked subsessions (beta) | `subsessions` | `PI_WEB_SUBSESSIONS` | Global/session daemon | Not supported locally; also requires `spawnSessions` | Restart session daemon on that machine | | Plugin enablement/settings | `plugins..enabled`, `plugins..settings` | — | Global | Not core local config; plugins may read their own project files | Reload browser tab | @@ -122,8 +123,8 @@ Rows with JSON key `—` are runtime-only environment variables, not config-file | Web-to-daemon URL | — | `PI_WEB_SESSIOND_URL` | Web/API env | Not supported locally | Restart web/API | | Projects storage file | — | `PI_WEB_PROJECTS_FILE` | Web/API + session daemon env | Not supported locally | Restart services; advanced state override | | Remote machines storage file | — | `PI_WEB_MACHINES_FILE` | Web/API env | Not supported locally | Restart web/API; advanced state override | -| Agent session storage directory | — | `PI_WEB_AGENT_SESSION_DIR`, `PI_CODING_AGENT_SESSION_DIR`, `OMP_CODING_AGENT_SESSION_DIR` | Session daemon env | Not supported locally | Restart session daemon; env-only session storage override | -| Agent config directory | — | `PI_WEB_AGENT_DIR`, `PI_CODING_AGENT_DIR`, `OMP_CODING_AGENT_DIR` | Web/API + session daemon env | Not supported locally | Restart services | +| Agent session storage directory | — | `PI_WEB_AGENT_SESSION_DIR`, `PI_CODING_AGENT_SESSION_DIR` for `pi`, selected non-`pi` command's `_CODING_AGENT_SESSION_DIR` | Session daemon env | Not supported locally | Restart session daemon; env-only session storage override | +| Agent config directory | — | `PI_WEB_AGENT_DIR`, `PI_CODING_AGENT_DIR` for `pi`, selected non-`pi` command's `_CODING_AGENT_DIR` | Web/API + session daemon env | Not supported locally | Restart services | | Skip update checks | — | `PI_WEB_SKIP_VERSION_CHECK`, `PI_WEB_OFFLINE`, `PI_SKIP_VERSION_CHECK`, `PI_OFFLINE` | Web/API env | Not supported locally | Restart web/API after env changes | ## Key details @@ -173,24 +174,28 @@ The per-request size limit is still controlled by `maxUploadBytes` / `PI_WEB_MAX ### Agent runtime selection -`agent.command` controls which Pi-compatible CLI PI WEB checks in doctor/status/update flows. It defaults to `pi`; set it to `omp` when this machine should use Oh My Pi. +`agent.command` controls which Pi-compatible CLI PI WEB checks in doctor/status/update flows. It defaults to `pi`. PI WEB's embedded session runtime still uses the bundled SDK path; changing this command does not load a different session implementation. -`agent.dir` controls which compatible agent state directory PI WEB reads for auth providers, model settings, settings, and session metadata. It defaults to the selected agent's conventional directory (`~/.pi/agent` for `pi`, `~/.omp/agent` for `omp`). +`agent.dir` controls which compatible agent state directory PI WEB reads for auth providers, model settings, settings, and session metadata. It defaults to `~/.pi/agent` only when the selected command resolves to `pi`. For any other command, set `agent.dir`, `PI_WEB_AGENT_DIR`, or the selected command's `_CODING_AGENT_DIR` explicitly. ```json { "agent": { - "command": "omp", - "dir": "~/.omp/agent" + "command": "my-pi-fork", + "dir": "/opt/my-pi-fork/agent" } } ``` +For example, an OMP setup can use `"command": "omp"` with `"dir": "~/.omp/agent"` and, if needed, `OMP_CODING_AGENT_DIR` / `OMP_CODING_AGENT_SESSION_DIR` environment overrides. + Environment variables take precedence over the config file. `PI_WEB_AGENT_COMMAND` selects the command, `PI_WEB_AGENT_DIR` sets the state directory for any command, and command-specific variables such as `OMP_CODING_AGENT_DIR` are honored when the selected command is `omp`. +Command-specific environment names are derived from the selected command's basename by stripping common executable extensions, replacing non-alphanumeric characters with underscores, and uppercasing the result. For example, `my-pi-fork` uses `MY_PI_FORK_CODING_AGENT_DIR` and `MY_PI_FORK_CODING_AGENT_SESSION_DIR`. + Session directory overrides are environment-only. Set `PI_WEB_AGENT_SESSION_DIR` or the selected command's session variable (for example `OMP_CODING_AGENT_SESSION_DIR`) when you need to override session storage separately from `agent.dir`. -Restart the session daemon after changing agent settings. The web/API process can display the new config immediately, but active session runtime ownership is intentionally long-lived. +Restart the web/API process and the session daemon after changing agent settings. The web/API process captures diagnostics/package state at startup, and active session runtime ownership is intentionally long-lived. ### Session daemon tools diff --git a/src/cli.test.ts b/src/cli.test.ts index 065e55ae..be297bef 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -46,7 +46,7 @@ describe("commandWithVersionCheck", () => { it("shell-quotes command words", () => { process.env["SHELL"] = "/bin/bash"; - expect(commandWithVersionCheck("/tmp/agent's/omp")).toBe("command -v '/tmp/agent'\\''s/omp' && ('/tmp/agent'\\''s/omp' --version 2>&1 || true)"); + expect(commandWithVersionCheck("/tmp/agent's/acme-agent")).toBe("command -v '/tmp/agent'\\''s/acme-agent' && ('/tmp/agent'\\''s/acme-agent' --version 2>&1 || true)"); }); }); @@ -55,11 +55,11 @@ describe("agentCommandForChecks", () => { const dir = mkdtempSync(join(tmpdir(), "pi-web-cli-test-")); try { const configPath = join(dir, "config.json"); - writeFileSync(configPath, `${JSON.stringify({ agent: { command: "omp" } })}\n`); + writeFileSync(configPath, `${JSON.stringify({ agent: { command: "acme-agent", dir: "/opt/acme-agent/state" } })}\n`); process.env["PI_WEB_CONFIG"] = configPath; delete process.env["PI_WEB_AGENT_COMMAND"]; - expect(agentCommandForChecks()).toBe("omp"); + expect(agentCommandForChecks()).toBe("acme-agent"); } finally { rmSync(dir, { recursive: true, force: true }); } diff --git a/src/client/src/api/clients.test.ts b/src/client/src/api/clients.test.ts index 8f679d48..508284ce 100644 --- a/src/client/src/api/clients.test.ts +++ b/src/client/src/api/clients.test.ts @@ -406,7 +406,7 @@ function piWebConfigResponse(config: PiWebConfigValues) { exists: true, config, effectiveConfig: config, - envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false }, + envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: false, agentSessionDir: false }, }; } diff --git a/src/client/src/api/parsers.test.ts b/src/client/src/api/parsers.test.ts index edea46aa..caf6308a 100644 --- a/src/client/src/api/parsers.test.ts +++ b/src/client/src/api/parsers.test.ts @@ -7,14 +7,14 @@ describe("API parsers", () => { expect(parsePiWebConfigResponse({ path: "/tmp/config.json", exists: true, - config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "manual/uploads" }, maxUploadBytes: 1234, agent: { command: "omp", dir: "~/.omp/agent" } }, - effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: ".pi-web/uploads" }, agent: { command: "omp", dir: "/Users/dev/.omp/agent" } }, + config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "manual/uploads" }, maxUploadBytes: 1234, agent: { command: "acme-agent", dir: "/opt/acme-agent/state" } }, + effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: ".pi-web/uploads" }, agent: { command: "acme-agent", dir: "/Users/dev/acme-agent-state" } }, envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: true, agentSessionDir: false }, })).toEqual({ path: "/tmp/config.json", exists: true, - config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "manual/uploads" }, maxUploadBytes: 1234, agent: { command: "omp", dir: "~/.omp/agent" } }, - effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: ".pi-web/uploads" }, agent: { command: "omp", dir: "/Users/dev/.omp/agent" } }, + config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "manual/uploads" }, maxUploadBytes: 1234, agent: { command: "acme-agent", dir: "/opt/acme-agent/state" } }, + effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: ".pi-web/uploads" }, agent: { command: "acme-agent", dir: "/Users/dev/acme-agent-state" } }, envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: true, agentSessionDir: false }, }); }); diff --git a/src/client/src/components/SettingsDialog.test.ts b/src/client/src/components/SettingsDialog.test.ts index e4534927..708ad874 100644 --- a/src/client/src/components/SettingsDialog.test.ts +++ b/src/client/src/components/SettingsDialog.test.ts @@ -653,7 +653,7 @@ function configResponse(config: PiWebConfigValues): PiWebConfigResponse { exists: true, config, effectiveConfig: config, - envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false }, + envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: false, agentSessionDir: false }, }; } diff --git a/src/client/src/components/settings/SettingsGeneralPanel.test.ts b/src/client/src/components/settings/SettingsGeneralPanel.test.ts index 570c182c..83190e5a 100644 --- a/src/client/src/components/settings/SettingsGeneralPanel.test.ts +++ b/src/client/src/components/settings/SettingsGeneralPanel.test.ts @@ -230,6 +230,6 @@ function configResponse(config: PiWebConfigValues): PiWebConfigResponse { exists: true, config, effectiveConfig: config, - envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false }, + envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: false, agentSessionDir: false }, }; } diff --git a/src/client/src/components/settings/SettingsPluginsPanel.test.ts b/src/client/src/components/settings/SettingsPluginsPanel.test.ts index 5424cabf..8225d333 100644 --- a/src/client/src/components/settings/SettingsPluginsPanel.test.ts +++ b/src/client/src/components/settings/SettingsPluginsPanel.test.ts @@ -162,7 +162,7 @@ function configResponse(config: PiWebConfigValues): PiWebConfigResponse { exists: true, config, effectiveConfig: config, - envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false }, + envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: false, agentSessionDir: false }, }; } diff --git a/src/client/src/components/settings/SettingsSessiondPanel.test.ts b/src/client/src/components/settings/SettingsSessiondPanel.test.ts index 8b538928..a196ec74 100644 --- a/src/client/src/components/settings/SettingsSessiondPanel.test.ts +++ b/src/client/src/components/settings/SettingsSessiondPanel.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import type { TemplateResult } from "lit"; import type { PiWebConfigResponse, PiWebConfigValues } from "../../api"; -import { SettingsSessiondPanel } from "./SettingsSessiondPanel"; +import { agentConfigPatchForField, SettingsSessiondPanel } from "./SettingsSessiondPanel"; import type { SettingsNotice } from "./SettingsPanelFrame"; describe("settings-sessiond-panel layout", () => { @@ -55,6 +55,15 @@ describe("settings-sessiond-panel layout", () => { expect(rendered).not.toContain("Allow agents to start sessions"); expect(rendered).not.toContain("Effective after environment overrides"); }); + + it("builds agent field edits as narrow config patches", () => { + expect(agentConfigPatchForField( + { command: "pi", dir: "/opt/pi-agent/state" }, + "command", + " acme-agent ", + )).toEqual({ agent: { command: "acme-agent", dir: "/opt/pi-agent/state" } }); + expect(agentConfigPatchForField({ command: "pi" }, "command", "")).toEqual({ agent: {} }); + }); }); function flattenTemplateContent(template: TemplateResult): string { @@ -108,6 +117,7 @@ function countOccurrences(content: string, needle: string): number { return content.split(needle).length - 1; } + function templateStrings(template: TemplateResult): readonly string[] { const strings = Reflect.get(template, "strings"); if (!isStringArray(strings)) throw new Error("TemplateResult strings were unavailable"); @@ -138,6 +148,6 @@ function configResponse(config: PiWebConfigValues): PiWebConfigResponse { exists: true, config, effectiveConfig: config, - envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false }, + envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: false, agentSessionDir: false }, }; } diff --git a/src/client/src/components/settings/SettingsSessiondPanel.ts b/src/client/src/components/settings/SettingsSessiondPanel.ts index f3414efe..d7d43212 100644 --- a/src/client/src/components/settings/SettingsSessiondPanel.ts +++ b/src/client/src/components/settings/SettingsSessiondPanel.ts @@ -5,6 +5,20 @@ import "./SettingsPanelFrame"; import type { SettingsNotice } from "./SettingsPanelFrame"; import { spawnSessionsConfigPatch, subsessionsConfigPatch } from "./settingsSessiondConfig"; +export function agentConfigPatchForField(currentAgent: PiWebConfigValues["agent"] | undefined, field: "command" | "dir", rawValue: string): PiWebConfigValues { + const value = rawValue.trim(); + const nextAgent: NonNullable = { ...(currentAgent ?? {}) }; + if (field === "command") { + if (value === "") delete nextAgent.command; + else nextAgent.command = value; + } else if (value === "") { + delete nextAgent.dir; + } else { + nextAgent.dir = value; + } + return { agent: nextAgent }; +} + @customElement("settings-sessiond-panel") export class SettingsSessiondPanel extends LitElement { @property({ attribute: false }) configResponse: PiWebConfigResponse | undefined; @@ -131,7 +145,7 @@ export class SettingsSessiondPanel extends LitElement { notices.push({ type: "warning", title: `Restart required on ${this.targetLabel}`, - content: html`run pi-web restart on that machine (or restart its session daemon service) after changing these settings.`, + content: html`run pi-web restart on that machine after changing these settings. If restarting services manually, restart both web/API and session daemon after changing the agent command or state directory.`, }); } return notices; @@ -143,24 +157,7 @@ export class SettingsSessiondPanel extends LitElement { private async saveAgentField(field: "command" | "dir", event: Event): Promise { if (!(event.target instanceof HTMLInputElement)) return; - const value = event.target.value.trim(); - const baseConfig = this.configResponse?.config ?? {}; - const nextConfig: PiWebConfigValues = { ...baseConfig }; - const nextAgent: NonNullable = { ...(baseConfig.agent ?? {}) }; - if (field === "command") { - if (value === "") delete nextAgent.command; - else nextAgent.command = value; - } else if (value === "") { - delete nextAgent.dir; - } else { - nextAgent.dir = value; - } - if (nextAgent.command === undefined && nextAgent.dir === undefined) { - delete nextConfig.agent; - } else { - nextConfig.agent = nextAgent; - } - await this.onSave?.(nextConfig); + await this.onSave?.(agentConfigPatchForField(this.configResponse?.config.agent, field, event.target.value)); } private async toggleSpawnSessions(event: Event): Promise { @@ -207,5 +204,5 @@ export class SettingsSessiondPanel extends LitElement { } function sessiondDescription(targetLabel: string): string { - return `These settings affect the long-lived session runtime on ${targetLabel}. Changes are saved immediately but only take effect after the session daemon on that machine restarts.`; + return `These settings affect the long-lived session runtime on ${targetLabel}. Changes are saved immediately; agent command and state-directory changes also affect web/API diagnostics and package routes.`; } diff --git a/src/client/src/components/settings/SettingsShortcutsPanel.test.ts b/src/client/src/components/settings/SettingsShortcutsPanel.test.ts index 7d67c4c2..aba28752 100644 --- a/src/client/src/components/settings/SettingsShortcutsPanel.test.ts +++ b/src/client/src/components/settings/SettingsShortcutsPanel.test.ts @@ -258,6 +258,6 @@ function configResponse(config: PiWebConfigValues): PiWebConfigResponse { exists: true, config, effectiveConfig: config, - envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false }, + envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: false, agentSessionDir: false }, }; } diff --git a/src/client/src/components/settings/settingsConfigDraft.test.ts b/src/client/src/components/settings/settingsConfigDraft.test.ts index 3a82c273..c6663f07 100644 --- a/src/client/src/components/settings/settingsConfigDraft.test.ts +++ b/src/client/src/components/settings/settingsConfigDraft.test.ts @@ -43,7 +43,7 @@ describe("settings config drafts", () => { maxUploadBytes: 1234, spawnSessions: true, subsessions: false, - agent: { command: "custom-agent", dir: "/tmp/custom-agent" }, + agent: { command: "acme-agent", dir: "/opt/acme-agent/state" }, })).toEqual({ host: "gateway.local", port: 9000, @@ -55,7 +55,7 @@ describe("settings config drafts", () => { maxUploadBytes: 1234, spawnSessions: true, subsessions: false, - agent: { command: "custom-agent", dir: "/tmp/custom-agent" }, + agent: { command: "acme-agent", dir: "/opt/acme-agent/state" }, }); expect(gatewayServerConfigFromDraft({ diff --git a/src/client/src/components/settings/settingsDataLoading.test.ts b/src/client/src/components/settings/settingsDataLoading.test.ts index 64bb1798..bd32d485 100644 --- a/src/client/src/components/settings/settingsDataLoading.test.ts +++ b/src/client/src/components/settings/settingsDataLoading.test.ts @@ -8,7 +8,7 @@ const configResponse: PiWebConfigResponse = { exists: true, config: { host: "127.0.0.1" }, effectiveConfig: { host: "127.0.0.1" }, - envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false }, + envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: false, agentSessionDir: false }, }; const pluginsResponse: PiWebPluginsResponse = { plugins: [] }; diff --git a/src/client/src/components/settings/settingsMachineAccessConfig.test.ts b/src/client/src/components/settings/settingsMachineAccessConfig.test.ts index 499aaf63..34a04cad 100644 --- a/src/client/src/components/settings/settingsMachineAccessConfig.test.ts +++ b/src/client/src/components/settings/settingsMachineAccessConfig.test.ts @@ -81,6 +81,6 @@ function configResponse(config: PiWebConfigValues): PiWebConfigResponse { exists: true, config, effectiveConfig: config, - envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false }, + envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: false, agentSessionDir: false }, }; } diff --git a/src/client/src/components/settings/settingsPluginConfig.test.ts b/src/client/src/components/settings/settingsPluginConfig.test.ts index 52331075..e1233783 100644 --- a/src/client/src/components/settings/settingsPluginConfig.test.ts +++ b/src/client/src/components/settings/settingsPluginConfig.test.ts @@ -64,6 +64,6 @@ function configResponse(config: PiWebConfigValues): PiWebConfigResponse { exists: true, config, effectiveConfig: config, - envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false }, + envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: false, agentSessionDir: false }, }; } diff --git a/src/client/src/components/settings/settingsSessiondConfig.test.ts b/src/client/src/components/settings/settingsSessiondConfig.test.ts index f3451e31..cbe2f7e5 100644 --- a/src/client/src/components/settings/settingsSessiondConfig.test.ts +++ b/src/client/src/components/settings/settingsSessiondConfig.test.ts @@ -18,7 +18,7 @@ describe("session daemon settings config helpers", () => { spawnSessions: false, subsessions: false, }); - const selectedMachine = configResponse({ spawnSessions: true, subsessions: true }, { spawnSessions: true, subsessions: false }); + const selectedMachine = configResponse({ spawnSessions: true, subsessions: true, agent: { command: "acme-agent", dir: "/opt/acme-agent/state" } }, { spawnSessions: true, subsessions: false, agentCommand: true, agentDir: true, agentSessionDir: true }); expect(mergeSelectedMachineSessiondConfig(gateway, selectedMachine)).toEqual({ ...gateway, @@ -30,6 +30,7 @@ describe("session daemon settings config helpers", () => { plugins: { info: { enabled: true } }, spawnSessions: true, subsessions: true, + agent: { command: "acme-agent", dir: "/opt/acme-agent/state" }, }, effectiveConfig: { host: "127.0.0.1", @@ -39,11 +40,15 @@ describe("session daemon settings config helpers", () => { plugins: { info: { enabled: true } }, spawnSessions: true, subsessions: true, + agent: { command: "acme-agent", dir: "/opt/acme-agent/state" }, }, envOverrides: { host: false, port: false, allowedHosts: false, + agentCommand: true, + agentDir: true, + agentSessionDir: true, spawnSessions: true, subsessions: false, }, @@ -57,6 +62,6 @@ function configResponse(config: PiWebConfigValues, overrides: Partial { }); it("persists and reads custom agent runtime settings", () => { - savePiWebConfig({ agent: { command: "omp", dir: "~/.omp/agent" } }, testOptions()); + savePiWebConfig({ agent: { command: "acme-agent", dir: "/opt/acme-agent/state" } }, testOptions()); - expect(loadPiWebConfig(testOptions()).config.agent).toEqual({ command: "omp", dir: "~/.omp/agent" }); + expect(loadPiWebConfig(testOptions()).config.agent).toEqual({ command: "acme-agent", dir: "/opt/acme-agent/state" }); }); - it("resolves OMP agent defaults from the configured command", () => { - expect(effectiveAgentConfig({ HOME: join(tempDir, ".home") }, { agent: { command: "omp" } })).toMatchObject({ - command: "omp", - dir: join(tempDir, ".home", ".omp", "agent"), - sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR", "OMP_CODING_AGENT_SESSION_DIR", "PI_CODING_AGENT_SESSION_DIR"], + it("defaults to Pi agent state only for Pi commands and launchers", () => { + expect(effectiveAgentConfig({ HOME: join(tempDir, ".home") }, { agent: { command: "/tmp/pi.cmd" } })).toMatchObject({ + command: "/tmp/pi.cmd", + dir: join(tempDir, ".home", ".pi", "agent"), + sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR", "PI_CODING_AGENT_SESSION_DIR"], }); }); + it("requires an explicit agent directory for non-Pi commands", () => { + expect(() => effectiveAgentConfig({}, { agent: { command: "acme-agent" } })).toThrow("PI WEB config agent.dir, PI_WEB_AGENT_DIR, or ACME_AGENT_CODING_AGENT_DIR is required when agent.command is \"acme-agent\""); + expect(() => effectiveAgentConfig({}, { agent: { command: "_pi" } })).toThrow("PI WEB config agent.dir, PI_WEB_AGENT_DIR, or _PI_CODING_AGENT_DIR is required when agent.command is \"_pi\""); + }); + it("lets PI WEB agent environment overrides take precedence", () => { expect(effectiveAgentConfig({ - PI_WEB_AGENT_COMMAND: "omp", + PI_WEB_AGENT_COMMAND: "acme-agent", PI_WEB_AGENT_DIR: join(tempDir, "env-agent"), }, { agent: { command: "pi", dir: join(tempDir, "config-agent") } })).toMatchObject({ - command: "omp", + command: "acme-agent", dir: join(tempDir, "env-agent"), }); }); + it("ignores blank agent environment overrides", () => { + expect(effectiveAgentConfig({ + PI_WEB_AGENT_COMMAND: "", + PI_WEB_AGENT_DIR: "", + ACME_AGENT_CODING_AGENT_DIR: "", + }, { agent: { command: "acme-agent", dir: join(tempDir, "config-agent") } })).toMatchObject({ + command: "acme-agent", + dir: join(tempDir, "config-agent"), + }); + }); + it("lets command-specific agent environment directories override config", () => { expect(effectiveAgentConfig({ HOME: join(tempDir, ".home"), - OMP_CODING_AGENT_DIR: join(tempDir, "omp-env-agent"), - }, { agent: { command: "omp", dir: join(tempDir, "config-agent") } })).toMatchObject({ - command: "omp", - dir: join(tempDir, "omp-env-agent"), + ACME_AGENT_CODING_AGENT_DIR: join(tempDir, "acme-env-agent"), + }, { agent: { command: "acme-agent", dir: join(tempDir, "config-agent") } })).toMatchObject({ + command: "acme-agent", + dir: join(tempDir, "acme-env-agent"), }); }); - it("normalizes omp.exe to OMP environment keys", () => { + it("normalizes executable names to command-specific environment keys", () => { expect(effectiveAgentConfig({ HOME: join(tempDir, ".home"), - OMP_CODING_AGENT_DIR: join(tempDir, "omp-exe-env-agent"), - }, { agent: { command: "omp.exe", dir: join(tempDir, "config-agent") } })).toMatchObject({ - command: "omp.exe", - dir: join(tempDir, "omp-exe-env-agent"), - sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR", "OMP_CODING_AGENT_SESSION_DIR", "PI_CODING_AGENT_SESSION_DIR"], + ACME_AGENT_CODING_AGENT_DIR: join(tempDir, "acme-exe-env-agent"), + }, { agent: { command: "acme-agent.cmd", dir: join(tempDir, "config-agent") } })).toMatchObject({ + command: "acme-agent.cmd", + dir: join(tempDir, "acme-exe-env-agent"), + sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR", "ACME_AGENT_CODING_AGENT_SESSION_DIR"], }); }); diff --git a/src/config.ts b/src/config.ts index 8754c01f..939178e5 100644 --- a/src/config.ts +++ b/src/config.ts @@ -49,9 +49,9 @@ export interface EffectivePiWebAgentConfig { } export function effectiveAgentConfig(env: NodeJS.ProcessEnv = process.env, config: Pick = {}, cwd = process.cwd()): EffectivePiWebAgentConfig { - const command = parseAgentCommand(env[PI_WEB_AGENT_COMMAND_ENV] ?? config.agent?.command ?? DEFAULT_AGENT_COMMAND, "agent.command", "environment"); + const command = parseAgentCommand(envValue(env, PI_WEB_AGENT_COMMAND_ENV) ?? config.agent?.command ?? DEFAULT_AGENT_COMMAND, "agent.command", "environment"); const commandDirEnv = commandAgentDirEnv(command); - const configuredDir = env[PI_WEB_AGENT_DIR_ENV] ?? env[commandDirEnv] ?? config.agent?.dir ?? defaultAgentDirForCommand(command, env); + const configuredDir = envValue(env, PI_WEB_AGENT_DIR_ENV) ?? envValue(env, commandDirEnv) ?? config.agent?.dir ?? defaultAgentDirForCommand(command, env, commandDirEnv); return { command, dir: resolveAgentDirPath(configuredDir, env, cwd, "agent.dir", "environment"), @@ -60,7 +60,9 @@ export function effectiveAgentConfig(env: NodeJS.ProcessEnv = process.env, confi } export function agentSessionDirEnvKeys(command = DEFAULT_AGENT_COMMAND): string[] { - return uniqueStrings([PI_WEB_AGENT_SESSION_DIR_ENV, commandSessionDirEnv(command), PI_CODING_AGENT_SESSION_DIR_ENV]); + const keys = [PI_WEB_AGENT_SESSION_DIR_ENV, commandSessionDirEnv(command)]; + if (isPiCommand(command)) keys.push(PI_CODING_AGENT_SESSION_DIR_ENV); + return uniqueStrings(keys); } export function hasAgentDirEnvOverride(env: NodeJS.ProcessEnv, command = DEFAULT_AGENT_COMMAND): boolean { @@ -139,6 +141,7 @@ export function savePiWebConfig(config: PiWebConfig, options: LoadOptions = {}): const env = options.env ?? process.env; const path = piWebConfigPath(env, options.cwd ?? process.cwd()); const normalized = parsePiWebConfig(piWebConfigRecord(config), path); + effectiveAgentConfig(env, normalized, options.cwd ?? process.cwd()); const existing = readExistingConfigObject(path); delete existing["host"]; delete existing["port"]; @@ -333,28 +336,35 @@ function expandHomePath(value: string, env: NodeJS.ProcessEnv): string { return value; } -function defaultAgentDirForCommand(command: string, env: NodeJS.ProcessEnv): string { - return expandHomePath(isOmpCommand(command) ? "~/.omp/agent" : "~/.pi/agent", env); +function defaultAgentDirForCommand(command: string, env: NodeJS.ProcessEnv, commandDirEnv: string): string { + if (isPiCommand(command)) return expandHomePath("~/.pi/agent", env); + throw new Error(`PI WEB config agent.dir, ${PI_WEB_AGENT_DIR_ENV}, or ${commandDirEnv} is required when agent.command is ${JSON.stringify(command)}`); } function commandAgentDirEnv(command: string): string { - const prefix = agentEnvPrefix(command); - return prefix === "PI" ? PI_CODING_AGENT_DIR_ENV : `${prefix}_CODING_AGENT_DIR`; + if (isPiCommand(command)) return PI_CODING_AGENT_DIR_ENV; + return `${agentEnvPrefix(command)}_CODING_AGENT_DIR`; } function commandSessionDirEnv(command: string): string { - return `${agentEnvPrefix(command)}_CODING_AGENT_SESSION_DIR`; + return `${isPiCommand(command) ? "PI" : agentEnvPrefix(command)}_CODING_AGENT_SESSION_DIR`; } function agentEnvPrefix(command: string): string { const name = command.split(/[\\/]/u).at(-1) ?? command; - const normalized = name.replace(/(?:\.[cm]?js|\.exe)$/iu, "").replace(/[^A-Za-z0-9]+/gu, "_").replace(/^_+|_+$/gu, "").toUpperCase(); - return normalized === "" ? "PI" : normalized; + const normalized = name.replace(/(?:\.[cm]?js|\.exe|\.cmd)$/iu, "").replace(/[^A-Za-z0-9]+/gu, "_").toUpperCase(); + return normalized === "" ? "AGENT" : normalized; } -function isOmpCommand(command: string): boolean { - const name = command.split(/[\\/]/u).at(-1)?.toLowerCase(); - return name === "omp" || name === "omp.exe"; +function isPiCommand(command: string): boolean { + const name = command.split(/[\\/]/u).at(-1)?.toLowerCase() ?? command.toLowerCase(); + return name.replace(/(?:\.[cm]?js|\.exe|\.cmd)$/iu, "") === "pi"; +} + + +function envValue(env: NodeJS.ProcessEnv, key: string): string | undefined { + const value = env[key]; + return value === undefined || value === "" ? undefined : value; } function isEnvSet(value: string | undefined): boolean { diff --git a/src/server/configRoutes.test.ts b/src/server/configRoutes.test.ts index 9b80c474..48f84d94 100644 --- a/src/server/configRoutes.test.ts +++ b/src/server/configRoutes.test.ts @@ -1,6 +1,9 @@ import Fastify, { type FastifyInstance } from "fastify"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { registerConfigRoutes, registerLocalMachineConfigRoutes, type PiWebConfigService } from "./configRoutes.js"; +import { createFilePiWebConfigService, registerConfigRoutes, registerLocalMachineConfigRoutes, type PiWebConfigService } from "./configRoutes.js"; import type { PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js"; let app: FastifyInstance; @@ -41,7 +44,7 @@ describe("config routes", () => { allowedHosts: true, spawnSessions: true, subsessions: true, - agent: { command: "custom-agent", dir: "/tmp/custom-agent" }, + agent: { command: "acme-agent", dir: "/opt/acme-agent/state" }, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { note: "hidden" } } }, pathAccess: { allowedPaths: ["/tmp"] }, @@ -130,6 +133,7 @@ describe("config routes", () => { const selectedMachinePatch: PiWebConfigValues = { plugins: { info: { enabled: false } }, uploads: { defaultFolder: "uploads\\manual" }, + agent: { command: "acme-agent", dir: "/opt/acme-agent/state" }, spawnSessions: true, }; @@ -143,6 +147,7 @@ describe("config routes", () => { ...fullConfig(), plugins: { info: { enabled: false } }, uploads: { defaultFolder: "uploads/manual" }, + agent: { command: "acme-agent", dir: "/opt/acme-agent/state" }, spawnSessions: true, }; expect(response.statusCode).toBe(200); @@ -153,6 +158,7 @@ describe("config routes", () => { pathAccess: { allowedPaths: ["/srv/repos"] }, uploads: { defaultFolder: "uploads/manual" }, maxUploadBytes: 1024, + agent: { command: "acme-agent", dir: "/opt/acme-agent/state" }, spawnSessions: true, subsessions: false, }); @@ -184,6 +190,21 @@ describe("config routes", () => { expect(response.json<{ error: string }>().error).toContain("PI WEB selected-machine config spawnSessions must be a boolean"); expect(service.write).not.toHaveBeenCalled(); }); + + it("validates file-backed agent config before saving", async () => { + const dir = await mkdtemp(join(tmpdir(), "pi-web-config-route-test-")); + const configPath = join(dir, "config.json"); + const existing = `${JSON.stringify({ host: "127.0.0.1" })}\n`; + try { + await writeFile(configPath, existing, "utf8"); + const fileService = createFilePiWebConfigService({ env: { PI_WEB_CONFIG: configPath, HOME: join(dir, "home") } }); + + expect(() => fileService.write({ agent: { command: "acme-agent" } })).toThrow("PI WEB config agent.dir"); + await expect(readFile(configPath, "utf8")).resolves.toBe(existing); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); }); function fullConfig(): PiWebConfigValues { @@ -196,6 +217,7 @@ function fullConfig(): PiWebConfigValues { pathAccess: { allowedPaths: ["/srv/repos"] }, uploads: { defaultFolder: "uploads" }, maxUploadBytes: 1024, + agent: { command: "pi", dir: "/opt/pi-agent/state" }, spawnSessions: false, subsessions: false, }; @@ -207,6 +229,7 @@ function selectedMachineConfig(): PiWebConfigValues { pathAccess: { allowedPaths: ["/srv/repos"] }, uploads: { defaultFolder: "uploads" }, maxUploadBytes: 1024, + agent: { command: "pi", dir: "/opt/pi-agent/state" }, spawnSessions: false, subsessions: false, }; diff --git a/src/server/piWebStatus.test.ts b/src/server/piWebStatus.test.ts index 6bfd9437..428b47e5 100644 --- a/src/server/piWebStatus.test.ts +++ b/src/server/piWebStatus.test.ts @@ -81,7 +81,7 @@ describe("PI WEB status", () => { capabilities: [], }); - const status = await getPiWebVersionStatus(daemon, { agentCommand: "custom-agent", agentDir }); + const status = await getPiWebVersionStatus(daemon, { agentCommand: "acme-agent", agentDir }); expect(status.components.sessiond.installation).toMatchObject({ kind: "pi-package", source: process.cwd(), scope: "user" }); } finally { @@ -115,12 +115,12 @@ describe("PI WEB status", () => { { kind: "pi-package", source: "npm:@jmfederico/pi-web", scope: "user", path: "/tmp/pi-web" }, "pi-web restart", { - agentCommand: "/tmp/agent's/custom-agent", - hasCommand: (command) => Promise.resolve(command === "/tmp/agent's/custom-agent"), + agentCommand: "/tmp/agent's/acme-agent", + hasCommand: (command) => Promise.resolve(command === "/tmp/agent's/acme-agent"), }, ); - expect(updateCommand).toBe("'/tmp/agent'\\''s/custom-agent' update 'npm:@jmfederico/pi-web' && pi-web restart"); + expect(updateCommand).toBe("'/tmp/agent'\\''s/acme-agent' update 'npm:@jmfederico/pi-web' && pi-web restart"); }); it.skipIf(process.platform !== "linux")("suggests native systemd commands for local development services", async () => { diff --git a/src/server/sessions/authService.test.ts b/src/server/sessions/authService.test.ts index 2a04f6c0..ea07888b 100644 --- a/src/server/sessions/authService.test.ts +++ b/src/server/sessions/authService.test.ts @@ -79,9 +79,9 @@ describe("AuthService", () => { const agentDir = await tempAgentDir(); const auth = new AuthService({ agentDir }); - auth.saveApiKey("anthropic", "sk-custom"); + auth.saveApiKey("anthropic", "sk-configured-agent"); - await expect(readFile(join(agentDir, "auth.json"), "utf8")).resolves.toContain("sk-custom"); + await expect(readFile(join(agentDir, "auth.json"), "utf8")).resolves.toContain("sk-configured-agent"); auth.dispose(); }); }); diff --git a/src/server/sessions/piSessionManagerGateway.test.ts b/src/server/sessions/piSessionManagerGateway.test.ts index eda4d739..a7cd0290 100644 --- a/src/server/sessions/piSessionManagerGateway.test.ts +++ b/src/server/sessions/piSessionManagerGateway.test.ts @@ -60,12 +60,12 @@ describe("SessionDirResolver", () => { expect(resolver.resolve(cwd)).toMatchObject({ source: "env", sessionDir: envDir, usesConfiguredSessionDir: true }); }); - it("uses OMP sessionDir environment overrides before settings", async () => { - const envDir = join(tempDir, "omp-env-sessions"); + it("uses command-specific sessionDir environment overrides before settings", async () => { + const envDir = join(tempDir, "acme-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: { OMP_CODING_AGENT_SESSION_DIR: envDir }, sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR", "OMP_CODING_AGENT_SESSION_DIR", "PI_CODING_AGENT_SESSION_DIR"] }); + const resolver = new SessionDirResolver({ agentDir, env: { ACME_AGENT_CODING_AGENT_SESSION_DIR: envDir }, sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR", "ACME_AGENT_CODING_AGENT_SESSION_DIR"] }); expect(resolver.resolve(cwd)).toMatchObject({ source: "env", sessionDir: envDir, usesConfiguredSessionDir: true }); }); @@ -93,13 +93,13 @@ describe("Pi session manager gateway", () => { }); it("includes command-specific env session directories in global listing", async () => { - for (const envKey of ["PI_WEB_AGENT_SESSION_DIR", "OMP_CODING_AGENT_SESSION_DIR"]) { + for (const envKey of ["PI_WEB_AGENT_SESSION_DIR", "ACME_AGENT_CODING_AGENT_SESSION_DIR"]) { const envSessionDir = join(tempDir, `${envKey.toLowerCase()}-sessions`); await writeSessionFile(envSessionDir, `${envKey.toLowerCase()}-session`, cwd); const gateway = createPiSessionManagerGateway({ agentDir, env: { [envKey]: envSessionDir }, - sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR", "OMP_CODING_AGENT_SESSION_DIR", "PI_CODING_AGENT_SESSION_DIR"], + sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR", "ACME_AGENT_CODING_AGENT_SESSION_DIR"], }); if (gateway.listAll === undefined) throw new Error("Expected legacy listing support"); diff --git a/src/shared/apiTypes.ts b/src/shared/apiTypes.ts index 0e7a62ea..064ea1f6 100644 --- a/src/shared/apiTypes.ts +++ b/src/shared/apiTypes.ts @@ -93,7 +93,7 @@ export interface PiWebConfigValues { * while the capability stabilizes. Requires spawnSessions to be enabled. */ subsessions?: boolean; - /** Agent runtime state used by the session daemon (Pi by default; OMP compatible). */ + /** Agent command/check state and compatible agent data directory (Pi by default). */ agent?: PiWebAgentConfig; }