feat: apply agent profile settings atomically

This commit is contained in:
Federico Jaramillo Martinez
2026-07-14 00:11:39 +02:00
parent adc2e297a4
commit 8b5ccc2fd9
33 changed files with 794 additions and 163 deletions
+1 -1
View File
@@ -2,4 +2,4 @@ export { activityApi, api, configApi, filesApi, gitApi, machinesApi, piPackagesA
export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets";
export { DEFAULT_WORKSPACE_UPLOADS_FOLDER, effectiveWorkspaceUploadFolder, uploadWorkspaceFile, uploadWorkspaceFiles, workspaceEffectiveUploadFolder, workspaceUploadPath, WorkspaceUploadBatchError, WorkspaceUploadCancelledError } from "./api/workspaceUploads";
export type { UploadWorkspaceFileOptions, UploadWorkspaceFilesOptions, WorkspaceFileUploadProgress, WorkspaceUploadBatchFileProgress, WorkspaceUploadBatchProgress, WorkspaceUploadFileFailure, WorkspaceUploadFileInput, WorkspaceUploadFolderConfig, WorkspaceUploadTask, WorkspaceUploadXhr, WorkspaceUploadXhrFactory } from "./api/workspaceUploads";
export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, OAuthFlowState, PiPackageInfo, PiPackageInstallRequest, PiPackageMutationAction, PiPackageMutationResponse, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiPackagesResponse, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebDockerMode, PiWebInstallationInfo, PiWebInstallationKind, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebUploadsConfig, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SavedPromptAttachment, SessionActivity, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef, SessionBulkMutationRequest, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupRequest, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionRef, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
export type { ActiveAgentProfileDescriptor, ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, OAuthFlowState, PiPackageInfo, PiPackageInstallRequest, PiPackageMutationAction, PiPackageMutationResponse, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiPackagesResponse, PiWebAgentDirEnvSource, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebDockerMode, PiWebInstallationInfo, PiWebInstallationKind, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebUploadsConfig, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SavedPromptAttachment, SessionActivity, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef, SessionBulkMutationRequest, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupRequest, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionRef, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
+6 -2
View File
@@ -79,12 +79,16 @@ describe("machine-scoped runtime API", () => {
});
it("reads machine runtime through the gateway route", async () => {
const fetchMock = stubJsonFetch({ machineId: "remote a", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] });
const response = { machineId: "remote a", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] };
const fetchMock = stubSequenceFetch([jsonResponse(response), jsonResponse(response)]);
await machinesApi.runtime("remote a");
await machinesApi.runtime("remote a", true);
expect(fetchMock).toHaveBeenCalledOnce();
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchCall(fetchMock, 0)[0]).toBe("https://pi.example.test/api/machines/remote%20a/runtime");
expect(fetchCall(fetchMock, 1)[0]).toBe("https://pi.example.test/api/machines/remote%20a/runtime?refresh=1");
expect(fetchCall(fetchMock, 1)[1]?.cache).toBe("no-store");
});
});
+1 -1
View File
@@ -115,7 +115,7 @@ export const machinesApi = {
addMachine: (input: { name: string; baseUrl: string; token?: string }) => request("api/machines", parseMachine, { method: "POST", body: JSON.stringify(input) }),
deleteMachine: (machineId: string) => request(`api/machines/${encodeURIComponent(machineId)}`, (value) => value, { method: "DELETE" }),
health: (machineId: string) => request(`api/machines/${encodeURIComponent(machineId)}/health`, parseMachineHealth),
runtime: (machineId: string) => request(`api/machines/${encodeURIComponent(machineId)}/runtime`, parseMachineRuntime),
runtime: (machineId: string, refresh = false) => request(`api/machines/${encodeURIComponent(machineId)}/runtime${refresh ? "?refresh=1" : ""}`, parseMachineRuntime, refresh ? { cache: "no-store" } : {}),
};
function configPath(machineId?: string): string {
+65 -6
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMessagePage, parsePiPackageMutationResponse, parsePiPackagesResponse, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parsePiWebStatusResponse, parseSessionBulkArchiveResponse, parseSessionBulkDeleteArchivedResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionInfo, parseSessionStatus, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers";
import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMachineRuntime, parseMessagePage, parsePiPackageMutationResponse, parsePiPackagesResponse, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parsePiWebStatusResponse, parseSessionBulkArchiveResponse, parseSessionBulkDeleteArchivedResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionInfo, parseSessionStatus, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers";
describe("API parsers", () => {
it("parses PI WEB config responses", () => {
@@ -9,26 +9,85 @@ describe("API parsers", () => {
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: "agent-lab", dir: "~/agent-profiles/lab" } },
effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: ".pi-web/uploads" }, agent: { command: "agent-lab", dir: "/Users/dev/agent-profiles/lab" } },
envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: true, agentSessionDir: false },
envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: true, agentDirSource: "pi-compatibility", 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: "agent-lab", dir: "~/agent-profiles/lab" } },
effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: ".pi-web/uploads" }, agent: { command: "agent-lab", dir: "/Users/dev/agent-profiles/lab" } },
envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: true, agentSessionDir: false },
envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: true, agentDirSource: "pi-compatibility", agentSessionDir: false },
});
});
it("parses PI WEB runtime responses", () => {
it("parses PI WEB runtime responses including the daemon-owned active profile", () => {
expect(parsePiWebRuntimeResponse({
packageName: "@jmfederico/pi-web",
generatedAt: "now",
components: {
web: { component: "web", label: "Web/UI", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage, "future.capability"] },
sessiond: { component: "sessiond", label: "Session daemon", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] },
sessiond: {
component: "sessiond",
label: "Session daemon",
runtimeVersion: "1.0.0",
available: true,
capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived],
activeAgentProfile: {
schemaVersion: 1,
revision: `sha256:${"a".repeat(64)}`,
command: "agent-lab",
dir: "/srv/agent-lab",
sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR"],
},
},
},
capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage, "future.capability"],
})).toMatchObject({ capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage] });
})).toMatchObject({
capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage],
components: { sessiond: { activeAgentProfile: { command: "agent-lab", dir: "/srv/agent-lab" } } },
});
});
it("retains portable active profiles in machine runtime snapshots and rejects invalid ownership", () => {
const profile = {
schemaVersion: 1,
revision: `sha256:${"b".repeat(64)}`,
command: "C:\\tools\\pi.exe",
dir: "C:\\agent-profiles\\work",
sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR"],
};
const components = {
web: { component: "web", label: "Web/UI", available: true, capabilities: [] },
sessiond: { component: "sessiond", label: "Session daemon", available: true, capabilities: [], activeAgentProfile: profile },
};
const parsed = parseMachineRuntime({ machineId: "remote-a", ok: true, checkedAt: "now", components, capabilities: [] });
expect(parsed.components?.sessiond.activeAgentProfile).toMatchObject({ command: profile.command, dir: profile.dir });
expect(Object.isFrozen(parsed.components?.sessiond.activeAgentProfile)).toBe(true);
expect(() => parseMachineRuntime({
machineId: "remote-a",
ok: true,
checkedAt: "now",
components: { ...components, web: { ...components.web, activeAgentProfile: profile } },
capabilities: [],
})).toThrow("Invalid active agent profile descriptor");
expect(() => parseMachineRuntime({
machineId: "remote-a",
ok: true,
checkedAt: "now",
components: { ...components, sessiond: { ...components.sessiond, activeAgentProfile: { ...profile, token: "secret" } } },
capabilities: [],
})).toThrow("Invalid active agent profile descriptor");
});
it("rejects malformed agent directory override metadata", () => {
expect(() => parsePiWebConfigResponse({
path: "/tmp/config.json",
exists: true,
config: {},
effectiveConfig: {},
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentDirSource: "future" },
})).toThrow("Invalid PI WEB agentDirSource field");
});
it("parses Pi package list and mutation responses", () => {
+16 -2
View File
@@ -1,5 +1,6 @@
import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SavedPromptAttachment, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevelsResponse, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes";
import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileResponse, OAuthFlowState, PiWebAgentDirEnvSource, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SavedPromptAttachment, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevelsResponse, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes";
import type { PiPackageInfo, PiPackageMutationAction, PiPackageMutationResponse, PiPackageScope, PiPackagesResponse } from "../../../shared/apiTypes";
import { parseActiveAgentProfileDescriptor } from "../../../shared/activeAgentProfile";
import { parseKnownPiWebCapabilities } from "../../../shared/capabilities";
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -647,10 +648,18 @@ function parsePiWebConfigEnvOverrides(value: unknown): PiWebConfigEnvOverrides {
subsessions: requireBoolean(record, "subsessions"),
agentCommand: optionalBoolean(record, "agentCommand") ?? false,
agentDir: optionalBoolean(record, "agentDir") ?? false,
...optionalAgentDirSource(record),
agentSessionDir: optionalBoolean(record, "agentSessionDir") ?? false,
};
}
function optionalAgentDirSource(record: Record<string, unknown>): { agentDirSource?: PiWebAgentDirEnvSource } {
const value = record["agentDirSource"];
if (value === undefined) return {};
if (value !== "pi-web" && value !== "pi-compatibility") throw new Error("Invalid PI WEB agentDirSource field");
return { agentDirSource: value };
}
export function parsePiPackagesResponse(value: unknown): PiPackagesResponse {
const record = requireRecord(value);
return { packages: arrayOf(parsePiPackageInfo)(record["packages"]) };
@@ -752,12 +761,17 @@ function parsePiWebRuntimeComponents(value: unknown): PiWebRuntimeResponse["comp
function parsePiWebRuntimeComponent(value: unknown): PiWebRuntimeComponent {
const record = requireRecord(value);
const component = parsePiWebServiceComponent(record["component"]);
const activeAgentProfileValue = record["activeAgentProfile"];
const activeAgentProfile = activeAgentProfileValue === undefined ? undefined : parseActiveAgentProfileDescriptor(activeAgentProfileValue);
if (activeAgentProfileValue !== undefined && (component !== "sessiond" || activeAgentProfile === undefined)) throw new Error("Invalid active agent profile descriptor");
return {
component: parsePiWebServiceComponent(record["component"]),
component,
label: requireString(record, "label"),
...optionalField("runtimeVersion", optionalString(record, "runtimeVersion")),
available: requireBoolean(record, "available"),
capabilities: parsePiWebCapabilities(record["capabilities"]),
...optionalField("activeAgentProfile", activeAgentProfile),
...optionalField("error", optionalString(record, "error")),
};
}
+1 -1
View File
@@ -1947,7 +1947,7 @@ export class PiWebApp extends LitElement {
${state.machineDialogOpen ? html`<machine-dialog .error=${state.error} .onSubmit=${(input: MachineDialogSubmit) => this.submitMachineDialog(input)} .onCancel=${() => { this.setState({ machineDialogOpen: false }); }}></machine-dialog>` : null}
${this.sessionCleanupDialog !== undefined ? html`<session-cleanup-dialog .canCleanup=${this.canCleanupSessions()} .unavailableMessage=${this.sessionCleanupUnavailableMessage()} .preview=${this.sessionCleanupDialog.preview} .previewRequest=${this.sessionCleanupDialog.previewRequest} .result=${this.sessionCleanupDialog.result} .loading=${this.sessionCleanupDialog.loading === true} .running=${this.sessionCleanupDialog.running === true} .error=${this.sessionCleanupDialog.error ?? ""} .onPreview=${(request: SessionCleanupRequest) => { void this.previewSessionCleanup(request); }} .onRun=${(request: SessionCleanupRequest) => { void this.runSessionCleanup(request); }} .onClose=${() => { this.closeSessionCleanupDialog(); }}></session-cleanup-dialog>` : null}
${state.themeDialog !== undefined ? html`<command-picker title=${state.themeDialog.title} .options=${state.themeDialog.options} .selectedValue=${state.themeDialog.selectedValue} .onPick=${(value: string) => { this.pickTheme(value); }} .onCancel=${() => { this.setState({ themeDialog: undefined }); }}></command-picker>` : null}
${this.settingsSection !== undefined ? html`<settings-dialog .section=${this.settingsSection} .machine=${state.selectedMachine} .machineRuntime=${this.selectedMachineRuntime()} .actions=${this.getDefaultActions()} .onNavigate=${(section: SettingsSection) => { this.navigateSettings(section); }} .onClose=${() => { this.closeSettings(); }} .onConfigSaved=${(config: PiWebConfigValues) => { this.applyClientConfig(config); }}></settings-dialog>` : null}
${this.settingsSection !== undefined ? html`<settings-dialog .section=${this.settingsSection} .machine=${state.selectedMachine} .machineRuntime=${this.selectedMachineRuntime()} .actions=${this.getDefaultActions()} .onNavigate=${(section: SettingsSection) => { this.navigateSettings(section); }} .onClose=${() => { this.closeSettings(); }} .onConfigSaved=${(config: PiWebConfigValues) => { this.applyClientConfig(config); }} .onRefreshMachineRuntime=${(machineId: string) => this.machines.refreshMachineRuntime(machineId)}></settings-dialog>` : null}
</div>
`;
}
@@ -1,4 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
import { configApi, pluginsApi, type PiWebConfigResponse, type PiWebPluginsResponse } from "../api";
import { SettingsDialog } from "./SettingsDialog";
import { callDialogPromise, callDialogUpdated, configResponse, deferred, getDialogProperty, pluginInfo, pluginsResponse, remoteMachine, runtimeWithPackageManagement as runtimeWithoutSelectedMachineSettings, secondRemoteMachine, setDialogProperty, stubWindowTimers } from "./SettingsDialog.testSupport";
@@ -40,6 +41,21 @@ describe("settings-dialog session daemon machine targeting", () => {
expect(getDialogProperty(dialog, "sessiondLoading")).toBe(false);
});
it("reloads desired config and the active runtime descriptor together", async () => {
const config = configResponse({ agent: { command: "agent-lab", dir: "/srv/agent-lab" } });
const configSpy = vi.spyOn(configApi, "config").mockResolvedValue(config);
const runtimeRefresh = vi.fn(() => Promise.resolve());
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
dialog.onRefreshMachineRuntime = runtimeRefresh;
await callDialogPromise(dialog, "reloadSessiondState");
expect(configSpy).toHaveBeenCalledWith(remoteMachine.id);
expect(runtimeRefresh).toHaveBeenCalledWith(remoteMachine.id);
expect(getDialogProperty(dialog, "sessiondConfigResponse")).toBe(config);
});
it("saves local session-daemon config through the local machine alias and updates local daemon state", async () => {
stubWindowTimers();
const gatewayConfig = configResponse({ host: "127.0.0.1", spawnSessions: false, subsessions: false });
@@ -57,6 +73,43 @@ describe("settings-dialog session daemon machine targeting", () => {
expect(getDialogProperty(dialog, "saving")).toBe(false);
});
it("fails closed for a remote agent-profile save without granular support", async () => {
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(configResponse({ agent: { command: "agent-lab", dir: "/srv/agent-lab" } }));
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
dialog.machineRuntime = {
machineId: remoteMachine.id,
ok: true,
checkedAt: "now",
capabilities: [PI_WEB_CAPABILITIES.selectedMachineSettings],
};
await callDialogPromise(dialog, "saveSessiondConfig", { agent: { command: "agent-lab", dir: "/srv/agent-lab" } });
expect(saveSpy).not.toHaveBeenCalled();
expect(getDialogProperty(dialog, "sessiondError")).toBe("Agent profile settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
});
it("saves a remote agent profile when granular support is advertised", async () => {
stubWindowTimers();
const patch = { agent: { command: "agent-lab", dir: "/srv/agent-lab" } };
const saved = configResponse(patch);
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(saved);
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
dialog.machineRuntime = {
machineId: remoteMachine.id,
ok: true,
checkedAt: "now",
capabilities: [PI_WEB_CAPABILITIES.selectedMachineSettings, PI_WEB_CAPABILITIES.agentProfileConfig],
};
await callDialogPromise(dialog, "saveSessiondConfig", patch);
expect(saveSpy).toHaveBeenCalledWith(patch, remoteMachine.id);
expect(getDialogProperty(dialog, "sessiondConfigResponse")).toBe(saved);
});
it("ignores stale session-daemon load responses after the selected machine changes", async () => {
const load = deferred<PiWebConfigResponse>();
vi.spyOn(configApi, "config").mockReturnValue(load.promise);
+24 -3
View File
@@ -11,7 +11,7 @@ import "./settings/SettingsShortcutsPanel";
import { friendlyPiPackageErrorMessage, isPiPackageManagementUnsupported, piPackageManagementSupport, piPackageManagementSupportKey, piPackageMutationFollowUpMessage, piPackageTargetLabel, shouldRefreshGatewayPluginsAfterPiPackageMutation, type PiPackageManagementSupport, type PiPackageOperationState, type PiPackageTargetContext } from "./settings/piPackageSettings";
import { loadGatewaySettingsData, loadPiPackagesData } from "./settings/settingsDataLoading";
import { mergeSelectedMachineAccessConfig } from "./settings/settingsMachineAccessConfig";
import { friendlySelectedMachineSettingsErrorMessage, isSelectedMachineSettingsUnsupported, selectedMachineSettingsSupport, selectedMachineSettingsSupportKey, settingsMachineTarget, settingsMachineTargetLabel, type SelectedMachineSettingsSupport, type SettingsMachineTarget } from "./settings/settingsMachineTarget";
import { agentProfileSettingsSupport, friendlySelectedMachineSettingsErrorMessage, isAgentProfileSettingsSupported, isSelectedMachineSettingsUnsupported, selectedMachineSettingsSupport, selectedMachineSettingsSupportKey, settingsMachineTarget, settingsMachineTargetLabel, type AgentProfileSettingsSupport, type SelectedMachineSettingsSupport, type SettingsMachineTarget } from "./settings/settingsMachineTarget";
import { mergeSelectedMachinePluginConfig, pluginEnabledConfigPatch } from "./settings/settingsPluginConfig";
import { mergeSelectedMachineSessiondConfig } from "./settings/settingsSessiondConfig";
@@ -24,6 +24,7 @@ export class SettingsDialog extends LitElement {
@property({ attribute: false }) onNavigate?: (section: SettingsSection) => void;
@property({ attribute: false }) onClose?: () => void;
@property({ attribute: false }) onConfigSaved?: (config: PiWebConfigValues) => void;
@property({ attribute: false }) onRefreshMachineRuntime?: (machineId: string) => void | Promise<void>;
@state() private configResponse: PiWebConfigResponse | undefined;
@state() private accessConfigResponse: PiWebConfigResponse | undefined;
@state() private sessiondConfigResponse: PiWebConfigResponse | undefined;
@@ -57,7 +58,7 @@ export class SettingsDialog extends LitElement {
super.connectedCallback();
void this.loadConfig();
void this.loadAccessConfigForTarget();
void this.loadSessiondConfigForTarget();
void this.reloadSessiondState();
void this.loadPluginsForTarget();
void this.loadPackagesForTarget();
}
@@ -137,7 +138,9 @@ export class SettingsDialog extends LitElement {
.error=${this.sessiondError}
.savedMessage=${this.savedMessage}
.targetLabel=${settingsMachineTargetLabel(this.settingsTarget())}
.onReload=${() => this.loadSessiondConfigForTarget()}
.activeAgentProfile=${this.machineRuntime?.components?.sessiond.activeAgentProfile}
.agentProfileSupport=${this.agentProfileSettingsSupport()}
.onReload=${() => this.reloadSessiondState()}
.onSave=${(config: PiWebConfigValues) => this.saveSessiondConfig(config)}
></settings-sessiond-panel>
`;
@@ -264,6 +267,13 @@ export class SettingsDialog extends LitElement {
}
}
private async reloadSessiondState(target = this.settingsTarget()): Promise<void> {
await Promise.all([
this.loadSessiondConfigForTarget(target),
this.onRefreshMachineRuntime?.(target.id),
]);
}
private async loadSessiondConfigForTarget(target = this.settingsTarget()): Promise<void> {
const requestSeq = ++this.sessiondLoadRequestSeq;
const support = this.selectedMachineSettingsSupport(target);
@@ -424,6 +434,13 @@ export class SettingsDialog extends LitElement {
this.sessiondError = support.message ?? `Selected-machine settings are not available on ${settingsMachineTargetLabel(target)}.`;
return;
}
if (config.agent !== undefined) {
const profileSupport = this.agentProfileSettingsSupport(target);
if (!isAgentProfileSettingsSupported(profileSupport)) {
this.sessiondError = profileSupport.message ?? `Agent profile settings are not available on ${settingsMachineTargetLabel(target)}.`;
return;
}
}
this.saving = true;
this.sessiondError = "";
this.savedMessage = "";
@@ -521,6 +538,10 @@ export class SettingsDialog extends LitElement {
return selectedMachineSettingsSupport(target, this.machineRuntime);
}
private agentProfileSettingsSupport(target = this.settingsTarget()): AgentProfileSettingsSupport {
return agentProfileSettingsSupport(target, this.machineRuntime);
}
private selectedMachineSettingsSupportNeedsReload(previousRuntime: MachineRuntime | undefined, target: SettingsMachineTarget): boolean {
const previousSupport = selectedMachineSettingsSupport(target, previousRuntime);
const currentSupport = this.selectedMachineSettingsSupport(target);
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import type { TemplateResult } from "lit";
import type { PiWebConfigResponse, PiWebConfigValues } from "../../api";
import type { ActiveAgentProfileDescriptor, PiWebConfigResponse, PiWebConfigValues } from "../../api";
import { SettingsSessiondPanel } from "./SettingsSessiondPanel";
import type { SettingsNotice } from "./SettingsPanelFrame";
@@ -8,11 +8,12 @@ describe("settings-sessiond-panel layout", () => {
it("names the selected machine in the scope and restart notice when config is available", () => {
const panel = new SettingsSessiondPanel();
panel.targetLabel = "Lab Mac (remote machine)";
panel.configResponse = configResponse({
setPanelConfig(panel, configResponse({
agent: { command: "agent-lab", dir: "/srv/agent-lab" },
spawnSessions: true,
subsessions: false,
});
}));
panel.activeAgentProfile = activeProfile("pi", "/srv/pi");
const rendered = flattenTemplateContent(panel.render());
@@ -20,10 +21,10 @@ describe("settings-sessiond-panel layout", () => {
"Session daemon",
"These settings affect the long-lived session runtime on Lab Mac (remote machine).",
"Reload",
"Restart required on Lab Mac (remote machine)",
"run <code>pi-web restart</code> on that machine",
"Agent profile restart required on Lab Mac (remote machine)",
"Run <code>pi-web restart</code> on that machine",
"Config file",
"Agent command for diagnostics",
"Companion CLI command",
"agent-lab",
"Agent state directory",
"/srv/agent-lab",
@@ -33,7 +34,8 @@ describe("settings-sessiond-panel layout", () => {
it("orders save/load notices before the restart notice and settings content", () => {
const panel = new SettingsSessiondPanel();
panel.configResponse = configResponse({ spawnSessions: false });
setPanelConfig(panel, configResponse({ agent: { command: "agent-lab", dir: "/srv/agent-lab" }, spawnSessions: false }));
panel.activeAgentProfile = activeProfile("pi", "/srv/pi");
panel.error = "Failed to save session-daemon config.";
panel.savedMessage = "Session daemon settings saved.";
@@ -42,11 +44,55 @@ describe("settings-sessiond-panel layout", () => {
expectTextOrder(rendered, [
"Failed to save session-daemon config.",
"Session daemon settings saved.",
"Restart required on local (local gateway)",
"Agent profile restart required on local (local gateway)",
"Config file",
]);
});
it("shows the profile as active without restart guidance when desired and active match", () => {
const panel = new SettingsSessiondPanel();
setPanelConfig(panel, configResponse({ agent: { command: "agent-lab", dir: "/srv/agent-lab" } }));
panel.activeAgentProfile = activeProfile("agent-lab", "/srv/agent-lab");
const rendered = flattenTemplateContent(panel.render());
expect(rendered).toContain("Profile status");
expect(rendered).toContain("Active");
expect(rendered).not.toContain("restart required on");
});
it("submits command and directory together as one profile save", async () => {
const panel = new SettingsSessiondPanel();
const onSave = vi.fn();
setPanelConfig(panel, configResponse({ agent: { command: "pi", dir: "/srv/pi" } }));
setPanelProperty(panel, "agentDraft", { command: " alternate-agent ", dir: " /srv/alternate " });
panel.onSave = onSave;
const event = new Event("submit", { cancelable: true });
await callPanelPromise(panel, "saveAgentProfile", event);
expect(event.defaultPrevented).toBe(true);
expect(onSave.mock.calls).toEqual([[{ agent: { command: "alternate-agent", dir: "/srv/alternate" } }]]);
});
it("preserves a dirty profile draft when an unrelated daemon setting is saved", () => {
const panel = new SettingsSessiondPanel();
const initial = configResponse({ agent: { command: "pi", dir: "/srv/pi" }, spawnSessions: false });
setPanelConfig(panel, initial);
callPanelMethod(panel, "updateAgentDraft", { command: "alternate-agent", dir: "/srv/alternate" });
const toggled = configResponse({ agent: { command: "pi", dir: "/srv/pi" }, spawnSessions: true });
panel.configResponse = toggled;
callPanelMethod(panel, "willUpdate", new Map([["configResponse", initial]]));
expect(Reflect.get(panel, "agentDraft")).toEqual({ command: "alternate-agent", dir: "/srv/alternate" });
const saved = configResponse({ agent: { command: "alternate-agent", dir: "/srv/alternate" }, spawnSessions: true });
panel.configResponse = saved;
callPanelMethod(panel, "willUpdate", new Map([["configResponse", toggled]]));
expect(Reflect.get(panel, "agentDraftDirty")).toBe(false);
});
it("shows one blocked content state without restart guidance or toggles when config is unavailable", () => {
const panel = new SettingsSessiondPanel();
panel.targetLabel = "Lab Mac (remote machine)";
@@ -65,6 +111,37 @@ describe("settings-sessiond-panel layout", () => {
});
});
function activeProfile(command: string, dir: string): ActiveAgentProfileDescriptor {
return {
schemaVersion: 1,
revision: `sha256:${"a".repeat(64)}`,
command,
dir,
sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR"],
};
}
function setPanelConfig(panel: SettingsSessiondPanel, config: PiWebConfigResponse): void {
panel.configResponse = config;
callPanelMethod(panel, "willUpdate", new Map([["configResponse", undefined]]));
}
function setPanelProperty(panel: SettingsSessiondPanel, property: string, value: unknown): void {
if (!Reflect.set(panel, property, value)) throw new Error(`Failed to set SettingsSessiondPanel property ${property}`);
}
async function callPanelPromise(panel: SettingsSessiondPanel, methodName: string, ...args: readonly unknown[]): Promise<void> {
const result = callPanelMethod(panel, methodName, ...args);
if (!(result instanceof Promise)) throw new Error(`SettingsSessiondPanel.${methodName} did not return a promise`);
await result;
}
function callPanelMethod(panel: SettingsSessiondPanel, methodName: string, ...args: readonly unknown[]): unknown {
const method: unknown = Reflect.get(panel, methodName);
if (typeof method !== "function") throw new Error(`SettingsSessiondPanel.${methodName} is not callable`);
return Reflect.apply(method, panel, args);
}
function flattenTemplateContent(template: TemplateResult): string {
const chunks: string[] = [];
visitTemplate(template);
@@ -1,9 +1,11 @@
import { css, html, LitElement, type TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import type { PiWebConfigResponse, PiWebConfigValues } from "../../api";
import { css, html, LitElement, type PropertyValues, type TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import type { ActiveAgentProfileDescriptor, PiWebConfigResponse, PiWebConfigValues } from "../../api";
import "./SettingsPanelFrame";
import type { SettingsNotice } from "./SettingsPanelFrame";
import { agentFieldConfigPatch, spawnSessionsConfigPatch, subsessionsConfigPatch } from "./settingsSessiondConfig";
import { agentProfileConfigPatchFromDraft, agentProfileDraftFromConfig, agentProfileDraftMatchesConfig, emptyAgentProfileConfigDraft, type AgentProfileConfigDraft } from "./settingsConfigDraft";
import type { AgentProfileSettingsSupport } from "./settingsMachineTarget";
import { agentDirFieldOverridden, agentProfileActivationState, spawnSessionsConfigPatch, subsessionsConfigPatch } from "./settingsSessiondConfig";
@customElement("settings-sessiond-panel")
export class SettingsSessiondPanel extends LitElement {
@@ -13,8 +15,28 @@ export class SettingsSessiondPanel extends LitElement {
@property() error = "";
@property() savedMessage = "";
@property() targetLabel = "local (local gateway)";
@property({ attribute: false }) activeAgentProfile: ActiveAgentProfileDescriptor | undefined;
@property({ attribute: false }) agentProfileSupport: AgentProfileSettingsSupport = { state: "supported" };
@property({ attribute: false }) onReload?: () => void | Promise<void>;
@property({ attribute: false }) onSave?: (config: PiWebConfigValues) => void | Promise<void>;
@state() private agentDraft: AgentProfileConfigDraft = emptyAgentProfileConfigDraft();
@state() private agentDraftDirty = false;
@state() private agentLocalError = "";
protected override willUpdate(changed: PropertyValues<this>): void {
if (!changed.has("configResponse")) return;
if (this.configResponse === undefined) {
this.agentDraft = emptyAgentProfileConfigDraft();
this.agentDraftDirty = false;
this.agentLocalError = "";
return;
}
if (!this.agentDraftDirty || agentProfileDraftMatchesConfig(this.agentDraft, this.configResponse.config)) {
this.agentDraft = agentProfileDraftFromConfig(this.configResponse.config);
this.agentDraftDirty = false;
this.agentLocalError = "";
}
}
override render(): TemplateResult {
const config = this.configResponse;
@@ -26,8 +48,12 @@ export class SettingsSessiondPanel extends LitElement {
// 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 profileEditingSupported = this.agentProfileSupport.state === "supported";
const draftCommand = agentCommandOverridden ? (config.effectiveConfig.agent?.command ?? this.agentDraft.command) : this.agentDraft.command;
const agentDirLocked = agentDirFieldOverridden(config?.envOverrides, draftCommand);
const effectiveAgentDirOverridden = config?.envOverrides.agentDir === true;
const effectiveAgent = config?.effectiveConfig.agent;
const profileActivation = agentProfileActivationState(config, this.activeAgentProfile);
return html`
<settings-panel-frame
heading="Session daemon"
@@ -42,40 +68,46 @@ export class SettingsSessiondPanel extends LitElement {
<span>Config file</span>
<code>${config.path}</code>
</div>
<label class="field">
<span class="field-heading">
<span>Agent command for diagnostics</span>
${agentCommandOverridden ? html`<span class="override-badge">environment override</span>` : null}
</span>
<input
class="text-input"
type="text"
autocomplete="off"
spellcheck="false"
.value=${config.config.agent?.command ?? ""}
placeholder="pi"
?disabled=${this.loading || this.saving || agentCommandOverridden}
@change=${(event: Event) => { void this.saveAgentField("command", event); }}
>
<small>Set an alternate Pi-compatible CLI when doctor/update checks should target a different command. The embedded session runtime remains PI WEB's SDK path, so this does not dynamically load a different agent implementation.</small>
</label>
<label class="field">
<span class="field-heading">
<span>Agent state directory</span>
${agentDirOverridden ? html`<span class="override-badge">environment override</span>` : null}
</span>
<input
class="text-input"
type="text"
autocomplete="off"
spellcheck="false"
.value=${config.config.agent?.dir ?? ""}
placeholder="~/.pi/agent or ~/agent-profiles/work"
?disabled=${this.loading || this.saving || agentDirOverridden}
@change=${(event: Event) => { void this.saveAgentField("dir", event); }}
>
<small>Choose which compatible auth, models, settings, and sessions PI WEB reads. Non-<code>pi</code> commands require an explicit state directory, then a session daemon restart.</small>
</label>
<form class="profile-form" aria-label="Pi-compatible agent profile" @submit=${(event: Event) => { void this.saveAgentProfile(event); }}>
${profileEditingSupported ? null : html`<div class="profile-support-message">${this.agentProfileSupport.message ?? "Agent profile editing is unavailable for this machine."}</div>`}
<label class="field">
<span class="field-heading">
<span>Companion CLI command</span>
${agentCommandOverridden ? html`<span class="override-badge">environment override</span>` : null}
</span>
<input
class="text-input"
type="text"
autocomplete="off"
spellcheck="false"
.value=${this.agentDraft.command}
placeholder="pi"
?disabled=${this.loading || this.saving || !profileEditingSupported || agentCommandOverridden}
@input=${(event: Event) => { this.updateAgentDraft({ command: inputValue(event) }); }}
>
<small>Set the Pi-compatible companion CLI used for doctor and update checks. The embedded session runtime remains PI WEB's bundled Pi SDK.</small>
</label>
<label class="field">
<span class="field-heading">
<span>Agent state directory</span>
${effectiveAgentDirOverridden ? html`<span class="override-badge">environment override</span>` : null}
</span>
<input
class="text-input"
type="text"
autocomplete="off"
spellcheck="false"
.value=${this.agentDraft.dir}
placeholder="~/.pi/agent or ~/agent-profiles/work"
?disabled=${this.loading || this.saving || !profileEditingSupported || agentDirLocked}
@input=${(event: Event) => { this.updateAgentDraft({ dir: inputValue(event) }); }}
>
<small>Choose the compatible auth, models, settings, and sessions PI WEB reads. An alternate command and its required state directory are saved together.</small>
</label>
<footer class="form-actions">
<button class="primary" type="submit" ?disabled=${this.loading || this.saving || !profileEditingSupported || (agentCommandOverridden && agentDirLocked)}>${this.saving ? "Saving…" : "Save agent profile"}</button>
</footer>
</form>
<div class="field">
<span class="field-heading">
<span>Allow agents to start sessions</span>
@@ -109,11 +141,14 @@ export class SettingsSessiondPanel extends LitElement {
</label>
<small>Beta: agents can start child sessions they stay attached to (<code>spawn_subsession</code>, <code>list_subsessions</code>, <code>check_subsession</code>, <code>read_subsession</code>) and are notified when a child finishes. Requires "Allow agents to start sessions". Off by default.</small>
</div>
<section class="effective-card" aria-label="Effective configuration summary">
<h3>Effective after environment overrides</h3>
<section class="effective-card" aria-label="Desired and active session daemon configuration summary">
<h3>Desired after environment overrides</h3>
<dl>
<div><dt>Agent command</dt><dd>${effectiveAgent?.command ?? html`<span class="muted">pi default</span>`}</dd></div>
<div><dt>Agent state</dt><dd>${effectiveAgent?.dir ?? html`<span class="muted">~/.pi/agent default</span>`}</dd></div>
<div><dt>Desired command</dt><dd>${effectiveAgent?.command ?? html`<span class="muted">Unavailable</span>`}</dd></div>
<div><dt>Desired state</dt><dd>${effectiveAgent?.dir ?? html`<span class="muted">Unavailable</span>`}</dd></div>
<div><dt>Active command</dt><dd>${this.activeAgentProfile?.command ?? html`<span class="muted">Unavailable</span>`}</dd></div>
<div><dt>Active state</dt><dd>${this.activeAgentProfile?.dir ?? html`<span class="muted">Unavailable</span>`}</dd></div>
<div><dt>Profile status</dt><dd>${profileActivationLabel(profileActivation)}</dd></div>
<div><dt>Spawn sessions</dt><dd>${effectiveSpawn ? "Enabled" : html`<span class="muted">Disabled</span>`}</dd></div>
<div><dt>Subsessions</dt><dd>${effectiveSubsessions ? "Enabled" : html`<span class="muted">Disabled</span>`}</dd></div>
</dl>
@@ -125,13 +160,21 @@ export class SettingsSessiondPanel extends LitElement {
private panelNotices(config: PiWebConfigResponse | undefined): readonly SettingsNotice[] {
const notices: SettingsNotice[] = [];
if (this.error !== "") notices.push({ type: "error", content: this.error });
const error = this.agentLocalError || this.error;
if (error !== "") notices.push({ type: "error", content: error });
if (this.savedMessage !== "") notices.push({ type: "success", content: this.savedMessage });
if (config !== undefined) {
const activation = agentProfileActivationState(config, this.activeAgentProfile);
if (activation === "restart-required") {
notices.push({
type: "warning",
title: `Restart required on ${this.targetLabel}`,
content: html`run <code>pi-web restart</code> on that machine (or restart its session daemon service) after changing these settings.`,
title: `Agent profile restart required on ${this.targetLabel}`,
content: html`The desired profile differs from the active session-daemon profile. Run <code>pi-web restart</code> on that machine (or restart its session daemon service) to apply the command and state directory together.`,
});
} else if (config !== undefined && activation === "unavailable" && this.agentProfileSupport.state === "supported") {
notices.push({
type: "info",
title: `Active agent profile unavailable on ${this.targetLabel}`,
content: "PI WEB cannot compare the desired profile with the running session daemon. Reload after the daemon is available.",
});
}
return notices;
@@ -141,9 +184,20 @@ export class SettingsSessiondPanel extends LitElement {
return html`<div class="loading-card">${this.loading ? "Loading configuration…" : "Configuration is unavailable. Reload to try again."}</div>`;
}
private async saveAgentField(field: "command" | "dir", event: Event): Promise<void> {
if (!(event.target instanceof HTMLInputElement)) return;
await this.onSave?.(agentFieldConfigPatch(this.configResponse?.config ?? {}, field, event.target.value));
private async saveAgentProfile(event: Event): Promise<void> {
event.preventDefault();
this.agentLocalError = "";
try {
await this.onSave?.(agentProfileConfigPatchFromDraft(this.agentDraft));
} catch (error) {
this.agentLocalError = errorMessage(error);
}
}
private updateAgentDraft(patch: Partial<AgentProfileConfigDraft>): void {
this.agentDraft = { ...this.agentDraft, ...patch };
this.agentDraftDirty = true;
this.agentLocalError = "";
}
private async toggleSpawnSessions(event: Event): Promise<void> {
@@ -162,9 +216,13 @@ export class SettingsSessiondPanel extends LitElement {
button, input { font: inherit; }
button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; }
button:disabled { opacity: .55; cursor: not-allowed; }
.loading-card, .config-path-card, .effective-card { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; }
.loading-card, .config-path-card, .effective-card, .profile-support-message { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; }
.loading-card { color: var(--pi-muted); }
.config-path-card { display: grid; gap: 5px; }
.profile-form { display: grid; gap: 14px; }
.profile-support-message { color: var(--pi-muted); line-height: 1.45; }
.form-actions { display: flex; justify-content: flex-end; }
.primary { border-color: var(--pi-accent); background: var(--pi-accent); color: var(--pi-accent-contrast); }
.config-path-card span, .field-heading, dt { color: var(--pi-muted); font-size: 12px; font-weight: 700; text-transform: uppercase; }
code { border: 1px solid var(--pi-border-muted); border-radius: 5px; background: var(--pi-bg); padding: 1px 4px; color: var(--pi-text); font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; }
.field { display: grid; gap: 7px; }
@@ -201,6 +259,20 @@ export class SettingsSessiondPanel extends LitElement {
`;
}
function profileActivationLabel(state: ReturnType<typeof agentProfileActivationState>): string | TemplateResult {
if (state === "active") return "Active";
if (state === "restart-required") return "Restart required";
return html`<span class="muted">Unavailable</span>`;
}
function inputValue(event: Event): string {
return event.target instanceof HTMLInputElement ? event.target.value : "";
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
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.`;
}
@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest";
import {
agentProfileConfigPatchFromDraft,
agentProfileDraftFromConfig,
agentProfileDraftMatchesConfig,
gatewayServerConfigFromDraft,
gatewayServerDraftFromConfig,
machineAccessConfigPatchFromDraft,
@@ -29,6 +32,22 @@ describe("settings config drafts", () => {
expect(gatewayServerDraftFromConfig({ allowedHosts: true }).allowedHostsMode).toBe("all");
});
it("builds one atomic agent profile patch from both draft fields", () => {
expect(agentProfileDraftFromConfig({ agent: { command: "agent-lab", dir: "/srv/agent-lab" } })).toEqual({
command: "agent-lab",
dir: "/srv/agent-lab",
});
expect(agentProfileConfigPatchFromDraft({ command: " alternate-agent ", dir: " /srv/alternate-agent " })).toEqual({
agent: { command: "alternate-agent", dir: "/srv/alternate-agent" },
});
expect(agentProfileConfigPatchFromDraft({ command: " ", dir: " " })).toEqual({ agent: {} });
expect(agentProfileConfigPatchFromDraft({ command: " C:\\tools\\pi.exe ", dir: " C:\\agent-profiles\\work " })).toEqual({
agent: { command: "C:\\tools\\pi.exe", dir: "C:\\agent-profiles\\work" },
});
expect(agentProfileDraftMatchesConfig({ command: " agent-lab ", dir: " /srv/agent-lab " }, { agent: { command: "agent-lab", dir: "/srv/agent-lab" } })).toBe(true);
expect(agentProfileDraftMatchesConfig({ command: "agent-lab", dir: "/draft" }, { agent: { command: "agent-lab", dir: "/saved" } })).toBe(false);
});
it("builds gateway server saves without dropping preserved config values", () => {
expect(gatewayServerConfigFromDraft({
host: " gateway.local ",
@@ -12,6 +12,11 @@ export interface MachineAccessConfigDraft {
uploadDefaultFolder: string;
}
export interface AgentProfileConfigDraft {
command: string;
dir: string;
}
export function emptyGatewayServerConfigDraft(): GatewayServerConfigDraft {
return { host: "", port: "", allowedHostsMode: "list", allowedHostsText: "" };
}
@@ -20,6 +25,10 @@ export function emptyMachineAccessConfigDraft(): MachineAccessConfigDraft {
return { allowedPathsText: "", uploadDefaultFolder: "" };
}
export function emptyAgentProfileConfigDraft(): AgentProfileConfigDraft {
return { command: "", dir: "" };
}
export function gatewayServerDraftFromConfig(config: PiWebConfigValues): GatewayServerConfigDraft {
return {
host: config.host ?? "",
@@ -36,6 +45,30 @@ export function machineAccessDraftFromConfig(config: PiWebConfigValues): Machine
};
}
export function agentProfileDraftFromConfig(config: PiWebConfigValues): AgentProfileConfigDraft {
return {
command: config.agent?.command ?? "",
dir: config.agent?.dir ?? "",
};
}
export function agentProfileConfigPatchFromDraft(draft: AgentProfileConfigDraft): PiWebConfigValues {
const command = draft.command.trim();
const dir = draft.dir.trim();
return {
agent: {
...(command === "" ? {} : { command }),
...(dir === "" ? {} : { dir }),
},
};
}
export function agentProfileDraftMatchesConfig(draft: AgentProfileConfigDraft, config: PiWebConfigValues): boolean {
const normalizedDraft = agentProfileConfigPatchFromDraft(draft).agent ?? {};
const configured = config.agent ?? {};
return normalizedDraft.command === configured.command && normalizedDraft.dir === configured.dir;
}
export function gatewayServerConfigFromDraft(draft: GatewayServerConfigDraft, baseConfig: PiWebConfigValues = {}): PiWebConfigValues {
const config = preservedGatewayConfigRemainder(baseConfig);
const host = draft.host.trim();
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import type { Machine, MachineRuntime } from "../../api";
import { PI_WEB_CAPABILITIES } from "../../../../shared/capabilities";
import { friendlySelectedMachineSettingsErrorMessage, isSelectedMachineSettingsUnsupported, selectedMachineSettingsSupport, selectedMachineSettingsSupportKey, selectedMachineSettingsUnavailableMessage, settingsMachineTarget, settingsMachineTargetLabel } from "./settingsMachineTarget";
import { agentProfileSettingsSupport, friendlySelectedMachineSettingsErrorMessage, isAgentProfileSettingsSupported, isSelectedMachineSettingsUnsupported, selectedMachineSettingsSupport, selectedMachineSettingsSupportKey, selectedMachineSettingsUnavailableMessage, settingsMachineTarget, settingsMachineTargetLabel } from "./settingsMachineTarget";
const remoteMachine: Machine = {
id: "remote-a",
@@ -39,6 +39,27 @@ describe("selected-machine settings target helpers", () => {
expect(selectedMachineSettingsSupportKey(unsupported)).toBe(`unsupported:${selectedMachineSettingsUnavailableMessage(target)}`);
});
it("gates remote agent profile edits on their granular capability", () => {
const target = settingsMachineTarget(remoteMachine);
expect(agentProfileSettingsSupport({ id: "local", name: "local", kind: "local" }, undefined)).toEqual({ state: "supported" });
expect(agentProfileSettingsSupport(target, undefined)).toEqual({
state: "unknown",
message: "Agent profile support could not be verified on Lab Mac. Reload machine status before changing the profile.",
});
expect(agentProfileSettingsSupport(target, {
ok: true,
capabilities: [PI_WEB_CAPABILITIES.agentProfileConfig],
})).toEqual({ state: "supported" });
const unsupported = agentProfileSettingsSupport(target, { ok: true, capabilities: [PI_WEB_CAPABILITIES.selectedMachineSettings] });
expect(isAgentProfileSettingsSupported(unsupported)).toBe(false);
expect(unsupported).toEqual({
state: "unsupported",
message: "Agent profile settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.",
});
});
it("turns older remote config route failures into selected-machine compatibility guidance", () => {
const target = settingsMachineTarget(remoteMachine);
@@ -14,6 +14,8 @@ export interface SelectedMachineSettingsSupport {
message?: string;
}
export type AgentProfileSettingsSupport = SelectedMachineSettingsSupport;
export function settingsMachineTarget(machine: Pick<Machine, "id" | "name" | "kind"> | undefined): SettingsMachineTarget {
if (machine !== undefined) return { id: machine.id, name: machine.name, kind: machine.kind };
return { id: "local", name: "local", kind: "local" };
@@ -30,6 +32,21 @@ export function selectedMachineSettingsSupport(target: SettingsMachineTarget, ru
return { state: "unsupported", message: selectedMachineSettingsUnavailableMessage(target) };
}
export function agentProfileSettingsSupport(target: SettingsMachineTarget, runtime: Pick<MachineRuntime, "ok" | "capabilities"> | undefined): AgentProfileSettingsSupport {
if (target.kind === "local") return { state: "supported" };
if (runtime?.ok !== true) {
return {
state: "unknown",
message: `Agent profile support could not be verified on ${target.name}. Reload machine status before changing the profile.`,
};
}
if (supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.agentProfileConfig)) return { state: "supported" };
return {
state: "unsupported",
message: `Agent profile settings are not available on ${target.name}. Update and restart PI WEB on that machine, then try again.`,
};
}
export function selectedMachineSettingsSupportKey(support: SelectedMachineSettingsSupport): string {
return `${support.state}:${support.message ?? ""}`;
}
@@ -38,6 +55,10 @@ export function isSelectedMachineSettingsUnsupported(support: SelectedMachineSet
return support?.state === "unsupported";
}
export function isAgentProfileSettingsSupported(support: AgentProfileSettingsSupport | undefined): boolean {
return support?.state === "supported";
}
export function selectedMachineSettingsUnavailableMessage(target: SettingsMachineTarget): string {
return `Selected-machine settings are not available on ${target.name}. Update and restart PI WEB on that machine, then try again.`;
}
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { PiWebConfigResponse, PiWebConfigValues } from "../../api";
import { agentFieldConfigPatch, mergeSelectedMachineSessiondConfig, spawnSessionsConfigPatch, subsessionsConfigPatch } from "./settingsSessiondConfig";
import type { ActiveAgentProfileDescriptor, PiWebConfigResponse, PiWebConfigValues } from "../../api";
import { agentDirFieldOverridden, agentProfileActivationState, mergeSelectedMachineSessiondConfig, spawnSessionsConfigPatch, subsessionsConfigPatch } from "./settingsSessiondConfig";
describe("session daemon settings config helpers", () => {
it("builds daemon-only save patches for the sessiond toggles", () => {
@@ -8,20 +8,37 @@ describe("session daemon settings config helpers", () => {
expect(subsessionsConfigPatch(true)).toEqual({ subsessions: true });
});
it("builds agent-only patches while preserving sibling agent fields", () => {
const base = {
host: "127.0.0.1",
agent: { command: "agent-lab", dir: "/srv/agent-lab" },
};
it("compares the desired effective profile with the daemon-owned active profile", () => {
const config = configResponse(
{ agent: { command: "configured-agent", dir: "/configured" } },
{},
{ agent: { command: "effective-agent", dir: "/effective" } },
);
expect(agentFieldConfigPatch(base, "command", " alternate-agent ")).toEqual({
agent: { command: "alternate-agent", dir: "/srv/agent-lab" },
});
expect(agentFieldConfigPatch(base, "dir", " ")).toEqual({
agent: { command: "agent-lab" },
});
expect(agentFieldConfigPatch({ agent: { dir: "/srv/agent-lab" } }, "dir", "")).toEqual({ agent: {} });
expect(agentFieldConfigPatch(base, "command", "agent-lab")).not.toHaveProperty("host");
expect(agentProfileActivationState(config, activeProfile("effective-agent", "/effective"))).toBe("active");
expect(agentProfileActivationState(config, activeProfile("other-agent", "/effective"))).toBe("restart-required");
expect(agentProfileActivationState(config, activeProfile("effective-agent", "/other"))).toBe("restart-required");
expect(agentProfileActivationState(configResponse({}, {}, { agent: { command: "pi", dir: "/effective" } }), activeProfile("pi", "/effective"))).toBe("restart-required");
expect(agentProfileActivationState(configResponse({}, {}, { agent: { command: "pi", dir: "/effective" } }), activeProfile("pi", "/effective", ["PI_WEB_AGENT_SESSION_DIR", "PI_CODING_AGENT_SESSION_DIR"]))).toBe("active");
expect(agentProfileActivationState(config, undefined)).toBe("unavailable");
expect(agentProfileActivationState(undefined, activeProfile("effective-agent", "/effective"))).toBe("unavailable");
});
it("releases only Pi's compatibility directory override when the draft selects an alternate command", () => {
const baseOverrides = configResponse({}).envOverrides;
expect(agentDirFieldOverridden({ ...baseOverrides, agentDir: true, agentDirSource: "pi-compatibility" }, "pi")).toBe(true);
expect(agentDirFieldOverridden({ ...baseOverrides, agentDir: true, agentDirSource: "pi-compatibility" }, "pi.exe")).toBe(true);
expect(agentDirFieldOverridden({ ...baseOverrides, agentDir: true, agentDirSource: "pi-compatibility" }, "alternate-agent")).toBe(false);
expect(agentDirFieldOverridden({ ...baseOverrides, agentDir: true, agentDirSource: "pi-web" }, "alternate-agent")).toBe(true);
expect(agentDirFieldOverridden({ ...baseOverrides, agentDir: true }, "alternate-agent")).toBe(true);
});
it("does not leak the gateway agent directory source into a selected-machine response", () => {
const gateway = configResponse({}, { agentDir: true, agentDirSource: "pi-web" });
const selectedMachine = configResponse({}, { agentDir: false });
expect(mergeSelectedMachineSessiondConfig(gateway, selectedMachine).envOverrides.agentDirSource).toBeUndefined();
});
it("merges local selected-machine daemon config into gateway config without dropping gateway-only values", () => {
@@ -37,7 +54,7 @@ describe("session daemon settings config helpers", () => {
});
const selectedMachine = configResponse(
{ spawnSessions: true, subsessions: true, agent: { command: "machine-agent", dir: "/srv/machine-agent" } },
{ spawnSessions: true, subsessions: false, agentCommand: true, agentDir: false, agentSessionDir: true },
{ spawnSessions: true, subsessions: false, agentCommand: true, agentDir: false, agentDirSource: "pi-compatibility", agentSessionDir: true },
{ spawnSessions: true, subsessions: true, agent: { command: "env-agent", dir: "/srv/machine-agent" } },
);
@@ -71,12 +88,23 @@ describe("session daemon settings config helpers", () => {
subsessions: false,
agentCommand: true,
agentDir: false,
agentDirSource: "pi-compatibility",
agentSessionDir: true,
},
});
});
});
function activeProfile(command: string, dir: string, sessionDirEnvKeys: readonly string[] = ["PI_WEB_AGENT_SESSION_DIR"]): ActiveAgentProfileDescriptor {
return {
schemaVersion: 1,
revision: `sha256:${"a".repeat(64)}`,
command,
dir,
sessionDirEnvKeys,
};
}
function configResponse(
config: PiWebConfigValues,
overrides: Partial<PiWebConfigResponse["envOverrides"]> = {},
@@ -1,4 +1,7 @@
import type { PiWebConfigResponse, PiWebConfigValues } from "../../api";
import { usesPiCodingAgentStateCompatibility } from "../../../../shared/activeAgentProfile";
import type { ActiveAgentProfileDescriptor, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues } from "../../api";
export type AgentProfileActivationState = "active" | "restart-required" | "unavailable";
export function spawnSessionsConfigPatch(enabled: boolean): PiWebConfigValues {
return { spawnSessions: enabled };
@@ -8,36 +11,51 @@ export function subsessionsConfigPatch(enabled: boolean): PiWebConfigValues {
return { subsessions: enabled };
}
export function agentFieldConfigPatch(
baseConfig: PiWebConfigValues,
field: "command" | "dir",
rawValue: string,
): PiWebConfigValues {
const value = rawValue.trim();
const agent: NonNullable<PiWebConfigValues["agent"]> = { ...(baseConfig.agent ?? {}) };
if (field === "command") {
if (value === "") delete agent.command;
else agent.command = value;
} else if (value === "") {
delete agent.dir;
} else {
agent.dir = value;
}
return { agent };
export function agentProfileActivationState(
config: PiWebConfigResponse | undefined,
activeProfile: ActiveAgentProfileDescriptor | undefined,
): AgentProfileActivationState {
const desiredProfile = config?.effectiveConfig.agent;
if (desiredProfile?.command === undefined || desiredProfile.dir === undefined || activeProfile === undefined) return "unavailable";
const desiredSessionDirEnvKeys = [
"PI_WEB_AGENT_SESSION_DIR",
...(usesPiCodingAgentStateCompatibility(desiredProfile.command) ? ["PI_CODING_AGENT_SESSION_DIR"] : []),
];
return desiredProfile.command === activeProfile.command
&& desiredProfile.dir === activeProfile.dir
&& sameStrings(activeProfile.sessionDirEnvKeys, desiredSessionDirEnvKeys)
? "active"
: "restart-required";
}
export function agentDirFieldOverridden(envOverrides: PiWebConfigEnvOverrides | undefined, draftCommand: string): boolean {
if (envOverrides?.agentDirSource === "pi-web") return true;
if (envOverrides?.agentDirSource === "pi-compatibility") return usesPiCodingAgentStateCompatibility(draftCommand.trim() || "pi");
// Older remote responses do not identify the source. Keep their override
// read-only rather than incorrectly treating a PI_WEB_AGENT_DIR as conditional.
return envOverrides?.agentDir === true;
}
export function mergeSelectedMachineSessiondConfig(base: PiWebConfigResponse, selectedMachine: PiWebConfigResponse): PiWebConfigResponse {
const envOverrides: PiWebConfigEnvOverrides = {
...base.envOverrides,
spawnSessions: selectedMachine.envOverrides.spawnSessions,
subsessions: selectedMachine.envOverrides.subsessions,
agentCommand: selectedMachine.envOverrides.agentCommand,
agentDir: selectedMachine.envOverrides.agentDir,
agentSessionDir: selectedMachine.envOverrides.agentSessionDir,
};
if (selectedMachine.envOverrides.agentDirSource === undefined) delete envOverrides.agentDirSource;
else envOverrides.agentDirSource = selectedMachine.envOverrides.agentDirSource;
return {
...base,
config: { ...base.config, ...selectedMachine.config },
effectiveConfig: { ...base.effectiveConfig, ...selectedMachine.effectiveConfig },
envOverrides: {
...base.envOverrides,
spawnSessions: selectedMachine.envOverrides.spawnSessions,
subsessions: selectedMachine.envOverrides.subsessions,
agentCommand: selectedMachine.envOverrides.agentCommand,
agentDir: selectedMachine.envOverrides.agentDir,
agentSessionDir: selectedMachine.envOverrides.agentSessionDir,
},
envOverrides,
};
}
function sameStrings(left: readonly string[], right: readonly string[]): boolean {
return left.length === right.length && left.every((value, index) => value === right[index]);
}
@@ -93,7 +93,7 @@ describe("MachineController", () => {
expect(projects.loadProjects).toHaveBeenCalledOnce();
expect(updateUrl).toHaveBeenCalledOnce();
expect(health).toHaveBeenCalledWith(addedMachine.id);
expect(runtime).toHaveBeenCalledWith(addedMachine.id);
expect(runtime).toHaveBeenCalledWith(addedMachine.id, true);
});
it("preserves the current machine state when adding a machine fails", async () => {
@@ -102,7 +102,7 @@ export class MachineController {
async refreshMachineRuntime(machineId = this.getState().selectedMachine?.id ?? "local"): Promise<void> {
try {
const runtime = await api.runtime(machineId);
const runtime = await api.runtime(machineId, true);
this.setState({ machineRuntimes: { ...this.getState().machineRuntimes, [runtime.machineId]: runtime } });
} catch (error) {
this.setState({ error: String(error) });
+13 -5
View File
@@ -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, agentSessionDirEnvKeys, effectiveAgentConfig, effectivePiWebConfig, hasAgentDirEnvOverride, hasAgentSessionDirEnvOverride, loadPiWebConfig, maxUploadBytes, savePiWebConfig, spawnSessionsEnabled, subsessionsEnabled } from "./config.js";
import { DEFAULT_MAX_UPLOAD_BYTES, DEFAULT_UPLOADS_FOLDER, agentDirEnvSource, agentSessionDirEnvKeys, effectiveAgentConfig, effectivePiWebConfig, hasAgentDirEnvOverride, hasAgentSessionDirEnvOverride, loadPiWebConfig, maxUploadBytes, savePiWebConfig, spawnSessionsEnabled, subsessionsEnabled } from "./config.js";
let tempDir: string;
let configPath: string;
@@ -135,22 +135,30 @@ describe("PI WEB config persistence", () => {
});
it("uses explicit PI WEB agent directory env precedence", () => {
expect(effectiveAgentConfig({
const env = {
PI_WEB_AGENT_COMMAND: "acme-agent",
PI_WEB_AGENT_DIR: join(tempDir, "web-env-agent"),
PI_CODING_AGENT_DIR: join(tempDir, "pi-env-agent"),
}, { agent: { command: "pi", dir: join(tempDir, "config-agent") } })).toMatchObject({
};
expect(effectiveAgentConfig(env, { agent: { command: "pi", dir: join(tempDir, "config-agent") } })).toMatchObject({
command: "acme-agent",
dir: join(tempDir, "web-env-agent"),
});
expect(agentDirEnvSource(env)).toBe("pi-web");
});
it("keeps legacy Pi env directory overrides scoped to the canonical Pi command", () => {
const legacyDir = join(tempDir, "pi-env-agent");
expect(effectiveAgentConfig({ PI_CODING_AGENT_DIR: legacyDir }, { agent: { dir: join(tempDir, "config-agent") } })).toMatchObject({ dir: legacyDir });
const alternateDir = join(tempDir, "alternate-agent");
const env = { PI_CODING_AGENT_DIR: legacyDir };
expect(effectiveAgentConfig(env, { agent: { dir: join(tempDir, "config-agent") } })).toMatchObject({ dir: legacyDir });
expect(effectiveAgentConfig(env, { agent: { command: "acme-agent", dir: alternateDir } })).toMatchObject({ command: "acme-agent", dir: alternateDir });
expect(agentDirEnvSource(env)).toBe("pi-compatibility");
expect(hasAgentDirEnvOverride(env, "pi")).toBe(true);
expect(hasAgentDirEnvOverride(env, "acme-agent")).toBe(false);
for (const command of ["acme-agent", join(tempDir, "bin", "pi")]) {
expect(() => effectiveAgentConfig({ PI_CODING_AGENT_DIR: legacyDir }, { agent: { command } }))
expect(() => effectiveAgentConfig(env, { agent: { command } }))
.toThrow(`PI WEB config agent.dir or PI_WEB_AGENT_DIR is required when agent.command is ${JSON.stringify(command)}`);
}
});
+15 -14
View File
@@ -1,9 +1,12 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { basename, dirname, isAbsolute, join, normalize, resolve } from "node:path";
import type { PiWebConfigValues } from "./shared/apiTypes.js";
import type { PiWebAgentDirEnvSource, PiWebConfigValues } from "./shared/apiTypes.js";
import { isPiCompanionCommand, usesPiCodingAgentStateCompatibility } from "./shared/activeAgentProfile.js";
import { isPiWebPluginId, piWebPluginIdPattern } from "./shared/pluginIds.js";
export { isPiCompanionCommand };
export type PiWebConfig = PiWebConfigValues;
export interface LoadedPiWebConfig {
@@ -61,7 +64,7 @@ export interface EffectivePiWebAgentConfig {
export function effectiveAgentConfig(env: NodeJS.ProcessEnv = process.env, config: Pick<PiWebConfig, "agent"> = {}): EffectivePiWebAgentConfig {
const command = parseAgentCommand(envValue(env, PI_WEB_AGENT_COMMAND_ENV) ?? config.agent?.command ?? DEFAULT_AGENT_COMMAND, "agent.command", "environment", "current");
const configuredDir = envValue(env, PI_WEB_AGENT_DIR_ENV) ?? (usesDefaultPiStatePolicy(command) ? envValue(env, PI_CODING_AGENT_DIR_ENV) : undefined) ?? config.agent?.dir ?? defaultAgentDirForCommand(command, env);
const configuredDir = envValue(env, PI_WEB_AGENT_DIR_ENV) ?? (usesPiCodingAgentStateCompatibility(command) ? envValue(env, PI_CODING_AGENT_DIR_ENV) : undefined) ?? config.agent?.dir ?? defaultAgentDirForCommand(command, env);
return {
command,
dir: resolveAgentDirPath(configuredDir, env, "agent.dir", "environment"),
@@ -72,12 +75,19 @@ 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,
...(usesDefaultPiStatePolicy(command) ? [PI_CODING_AGENT_SESSION_DIR_ENV] : []),
...(usesPiCodingAgentStateCompatibility(command) ? [PI_CODING_AGENT_SESSION_DIR_ENV] : []),
]);
}
export function agentDirEnvSource(env: NodeJS.ProcessEnv): PiWebAgentDirEnvSource | undefined {
if (isEnvSet(env[PI_WEB_AGENT_DIR_ENV])) return "pi-web";
if (isEnvSet(env[PI_CODING_AGENT_DIR_ENV])) return "pi-compatibility";
return undefined;
}
export function hasAgentDirEnvOverride(env: NodeJS.ProcessEnv, command = DEFAULT_AGENT_COMMAND): boolean {
return isEnvSet(env[PI_WEB_AGENT_DIR_ENV]) || (usesDefaultPiStatePolicy(command) && isEnvSet(env[PI_CODING_AGENT_DIR_ENV]));
const source = agentDirEnvSource(env);
return source === "pi-web" || (source === "pi-compatibility" && usesPiCodingAgentStateCompatibility(command));
}
export function hasAgentSessionDirEnvOverride(env: NodeJS.ProcessEnv, command = DEFAULT_AGENT_COMMAND): boolean {
@@ -391,19 +401,10 @@ function expandHomePath(value: string, env: NodeJS.ProcessEnv): string {
}
function defaultAgentDirForCommand(command: string, env: NodeJS.ProcessEnv): string {
if (usesDefaultPiStatePolicy(command)) return expandHomePath("~/.pi/agent", env);
if (usesPiCodingAgentStateCompatibility(command)) return expandHomePath("~/.pi/agent", env);
throw new Error(`PI WEB config agent.dir or ${PI_WEB_AGENT_DIR_ENV} is required when agent.command is ${JSON.stringify(command)}`);
}
function usesDefaultPiStatePolicy(command: string): boolean {
return !command.includes("/") && !command.includes("\\") && isPiCompanionCommand(command);
}
export function isPiCompanionCommand(command: string): boolean {
const name = command.split(/[\\/]/u).at(-1)?.toLowerCase() ?? command.toLowerCase();
return name.replace(/(?:\.[cm]?js|\.exe|\.cmd)$/iu, "") === DEFAULT_AGENT_COMMAND;
}
function envValue(env: NodeJS.ProcessEnv, key: string): string | undefined {
const value = env[key];
return value !== undefined && value !== "" ? value : undefined;
+125 -6
View File
@@ -61,18 +61,39 @@ describe("buildApp machine routes", () => {
packageName: "@jmfederico/pi-web",
generatedAt: "2026-05-25T00:00:00.000Z",
components: {
web: { component: "web", label: "Remote Web", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage, "future.capability"] },
sessiond: { component: "sessiond", label: "Remote Sessiond", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] },
web: { component: "web", label: "Remote Web", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.agentProfileConfig, "future.capability"] },
sessiond: {
component: "sessiond",
label: "Remote Sessiond",
runtimeVersion: "1.0.0",
available: true,
capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived],
activeAgentProfile: {
schemaVersion: 1,
revision: `sha256:${"a".repeat(64)}`,
command: "remote-agent",
dir: "/srv/remote-agent",
sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR"],
},
},
},
capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage, "future.capability"],
capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.agentProfileConfig, "future.capability"],
},
}));
appTestContext.remoteClient = fakeRemoteClient({ requestJson });
const runtime = await appTestContext.app.inject({ method: "GET", url: `/api/machines/${remote.id}/runtime` });
const refreshedRuntime = await appTestContext.app.inject({ method: "GET", url: `/api/machines/${remote.id}/runtime?refresh=1` });
expect(runtime.statusCode).toBe(200);
expect(runtime.json()).toMatchObject({ machineId: remote.id, ok: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage] });
expect(refreshedRuntime.statusCode).toBe(200);
expect(runtime.json()).toMatchObject({
machineId: remote.id,
ok: true,
capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.agentProfileConfig],
components: { sessiond: { activeAgentProfile: { command: "remote-agent", dir: "/srv/remote-agent" } } },
});
expect(requestJson).toHaveBeenCalledTimes(2);
expect(requestJson).toHaveBeenCalledWith("GET", "/api/pi-web/runtime", undefined, { timeoutMs: 3000 });
});
@@ -101,9 +122,11 @@ describe("buildApp machine routes", () => {
it("merges remote selected-machine config updates into the target machine config", async () => {
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
let persistedConfig = fullPiWebConfig();
const requestJson = vi.fn<MachineClient["requestJson"]>((method, _path, body) => {
if (method === "GET") return Promise.resolve({ statusCode: 200, headers: { "content-type": "application/json" }, body: piWebConfigResponse(fullPiWebConfig()) });
return Promise.resolve({ statusCode: 200, headers: { "content-type": "application/json" }, body: piWebConfigResponse(configFromMachineConfigWriteBody(body)) });
if (method === "GET") return Promise.resolve({ statusCode: 200, headers: { "content-type": "application/json" }, body: piWebConfigResponse(persistedConfig) });
persistedConfig = configFromMachineConfigWriteBody(body);
return Promise.resolve({ statusCode: 200, headers: { "content-type": "application/json" }, body: piWebConfigResponse(persistedConfig) });
});
appTestContext.remoteClient = fakeRemoteClient({ requestJson });
@@ -136,6 +159,102 @@ describe("buildApp machine routes", () => {
});
});
it("rejects a false-success agent profile write from an older remote machine", async () => {
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const legacyConfig = fullPiWebConfig();
delete legacyConfig.agent;
const requestJson = vi.fn<MachineClient["requestJson"]>(() => Promise.resolve({
statusCode: 200,
headers: { "content-type": "application/json" },
body: piWebConfigResponse(legacyConfig),
}));
appTestContext.remoteClient = fakeRemoteClient({ requestJson });
const response = await appTestContext.app.inject({
method: "PUT",
url: `/api/machines/${remote.id}/config`,
payload: { config: { agent: { command: "remote-agent", dir: "/srv/remote-agent" } } },
});
expect(response.statusCode).toBe(409);
expect(response.json()).toMatchObject({
error: "Remote machine did not persist the requested agent profile",
machineId: remote.id,
});
expect(requestJson).toHaveBeenNthCalledWith(1, "GET", "/api/config");
expect(requestJson).toHaveBeenNthCalledWith(2, "PUT", "/api/config", {
config: { ...legacyConfig, agent: { command: "remote-agent", dir: "/srv/remote-agent" } },
});
});
it("verifies an explicit remote profile reset instead of treating an empty profile as no patch", async () => {
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const requestJson = vi.fn<MachineClient["requestJson"]>((method) => {
const config = fullPiWebConfig();
if (method === "PUT") delete config.agent;
return Promise.resolve({ statusCode: 200, headers: { "content-type": "application/json" }, body: piWebConfigResponse(config) });
});
appTestContext.remoteClient = fakeRemoteClient({ requestJson });
const response = await appTestContext.app.inject({
method: "PUT",
url: `/api/machines/${remote.id}/config`,
payload: { config: { agent: {} } },
});
expect(response.statusCode).toBe(409);
expect(response.json()).toMatchObject({ error: "Remote machine did not persist the requested agent profile" });
expect(requestJson).toHaveBeenNthCalledWith(2, "PUT", "/api/config", {
config: { ...fullPiWebConfig(), agent: {} },
});
});
it("keeps non-profile selected-machine saves compatible with older remote machines", async () => {
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const legacyConfig = fullPiWebConfig();
delete legacyConfig.agent;
const requestJson = vi.fn<MachineClient["requestJson"]>((method, _path, body) => {
const config = method === "PUT" ? configFromMachineConfigWriteBody(body) : legacyConfig;
return Promise.resolve({ statusCode: 200, headers: { "content-type": "application/json" }, body: piWebConfigResponse(config) });
});
appTestContext.remoteClient = fakeRemoteClient({ requestJson });
const response = await appTestContext.app.inject({
method: "PUT",
url: `/api/machines/${remote.id}/config`,
payload: { config: { spawnSessions: true } },
});
expect(response.statusCode).toBe(200);
expect(response.json<PiWebConfigResponse>().config.spawnSessions).toBe(true);
});
it("preserves foreign-platform agent paths while the target verifies persistence", async () => {
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const windowsAgent = { command: "C:\\tools\\pi.exe", dir: "C:\\agent-profiles\\work" };
const requestJson = vi.fn<MachineClient["requestJson"]>((method, _path, body) => {
const config = method === "PUT" ? configFromMachineConfigWriteBody(body) : fullPiWebConfig();
return Promise.resolve({ statusCode: 200, headers: { "content-type": "application/json" }, body: piWebConfigResponse(config) });
});
appTestContext.remoteClient = fakeRemoteClient({ requestJson });
const response = await appTestContext.app.inject({
method: "PUT",
url: `/api/machines/${remote.id}/config`,
payload: { config: { agent: windowsAgent } },
});
expect(response.statusCode).toBe(200);
expect(response.json<PiWebConfigResponse>().config.agent).toEqual(windowsAgent);
expect(requestJson).toHaveBeenNthCalledWith(2, "PUT", "/api/config", {
config: { ...fullPiWebConfig(), agent: windowsAgent },
});
});
it("rejects unsafe remote selected-machine config keys before proxying", async () => {
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
+17
View File
@@ -202,6 +202,23 @@ describe("config routes", () => {
expect(parsed.envOverrides).toMatchObject({ agentCommand: false, agentDir: false, agentSessionDir: false });
});
it("retains the agent directory environment source across federation responses", () => {
const parsed = parsePiWebConfigResponseBody({
...responseFor({}, false),
envOverrides: {
...responseFor({}, false).envOverrides,
agentDir: true,
agentDirSource: "pi-compatibility",
},
});
expect(parsed.envOverrides).toMatchObject({ agentDir: true, agentDirSource: "pi-compatibility" });
expect(() => parsePiWebConfigResponseBody({
...responseFor({}, false),
envOverrides: { ...responseFor({}, false).envOverrides, agentDirSource: "future-source" },
})).toThrow("valid agent directory source");
});
it("rejects unsafe local selected-machine config keys before writing", async () => {
savedConfig = fullConfig();
+12 -2
View File
@@ -1,6 +1,6 @@
import type { FastifyInstance } from "fastify";
import { hasAgentDirEnvOverride, hasAgentSessionDirEnvOverride, loadPiWebConfig, parseAgentConfig, parseUploadsConfig, resolveEffectivePiWebConfig, savePiWebConfig, type AgentPathHost, type LoadOptions, type PiWebConfig } from "../config.js";
import type { PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js";
import { agentDirEnvSource, hasAgentDirEnvOverride, hasAgentSessionDirEnvOverride, loadPiWebConfig, parseAgentConfig, parseUploadsConfig, resolveEffectivePiWebConfig, savePiWebConfig, type AgentPathHost, type LoadOptions, type PiWebConfig } from "../config.js";
import type { PiWebAgentDirEnvSource, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js";
import { isPiWebPluginId } from "../shared/pluginIds.js";
export interface PiWebConfigService {
@@ -243,6 +243,7 @@ function parsePiWebConfigEnvOverridesResponse(value: unknown, source: string): P
subsessions: requireResponseBoolean(record, "subsessions", source),
agentCommand: optionalResponseBoolean(record, "agentCommand", source) ?? false,
agentDir: optionalResponseBoolean(record, "agentDir", source) ?? false,
...optionalAgentDirSource(record, source),
agentSessionDir: optionalResponseBoolean(record, "agentSessionDir", source) ?? false,
};
}
@@ -271,8 +272,16 @@ function optionalResponseBoolean(record: Record<string, unknown>, key: string, s
return value;
}
function optionalAgentDirSource(record: Record<string, unknown>, source: string): { agentDirSource?: PiWebAgentDirEnvSource } {
const value = record["agentDirSource"];
if (value === undefined) return {};
if (value !== "pi-web" && value !== "pi-compatibility") throw new Error(`${source} field must be a valid agent directory source: agentDirSource`);
return { agentDirSource: value };
}
function piWebConfigEnvOverrides(env: NodeJS.ProcessEnv, config: PiWebConfig = {}): PiWebConfigEnvOverrides {
const command = config.agent?.command;
const dirEnvSource = agentDirEnvSource(env);
return {
host: isEnvSet(env["PI_WEB_HOST"]),
port: isEnvSet(env["PI_WEB_PORT"]) || isEnvSet(env["PORT"]),
@@ -281,6 +290,7 @@ function piWebConfigEnvOverrides(env: NodeJS.ProcessEnv, config: PiWebConfig = {
subsessions: isEnvSet(env["PI_WEB_SUBSESSIONS"]),
agentCommand: isEnvSet(env["PI_WEB_AGENT_COMMAND"]),
agentDir: hasAgentDirEnvOverride(env, command),
...(dirEnvSource === undefined ? {} : { agentDirSource: dirEnvSource }),
agentSessionDir: hasAgentSessionDirEnvOverride(env, command),
};
}
+17 -3
View File
@@ -1,5 +1,6 @@
import type { FastifyInstance, FastifyReply } from "fastify";
import type { WebSocket } from "ws";
import type { PiWebAgentConfig } from "../../shared/apiTypes.js";
import { FEDERATED_HTTP_ROUTES, FEDERATED_WEBSOCKET_ROUTES, type FederatedHttpRouteSpec } from "../../shared/federatedRoutes.js";
import { mergeSelectedMachineConfig, parsePiWebConfigResponseBody, parseSelectedMachineConfigRequest, selectedMachineConfigResponse } from "../configRoutes.js";
import { bridgeSockets } from "../webSocketBridge.js";
@@ -75,7 +76,8 @@ async function proxySelectedMachineConfigRequest(client: MachineClient, machineI
const current = parsePiWebConfigResponseBody(currentResponse.body, "Remote machine config response");
const merged = mergeSelectedMachineConfig(current.config, patch);
return sendSelectedMachineConfigResponse(reply, await client.requestJson("PUT", remotePath, { config: merged }), machineId);
const updateResponse = await client.requestJson("PUT", remotePath, { config: merged });
return sendSelectedMachineConfigResponse(reply, updateResponse, machineId, patch.agent);
}
return reply.code(405).send({ error: "Method not allowed" });
@@ -85,11 +87,23 @@ function configPayload(body: unknown): unknown {
return isRecord(body) ? body["config"] : undefined;
}
function sendSelectedMachineConfigResponse(reply: FastifyReply, upstream: MachineJsonResponse, machineId: string): FastifyReply {
function sendSelectedMachineConfigResponse(reply: FastifyReply, upstream: MachineJsonResponse, machineId: string, expectedAgentProfile?: PiWebAgentConfig): FastifyReply {
if (!isSuccessfulStatus(upstream.statusCode)) return sendUpstreamJsonResponse(reply, upstream, machineId);
const response = parsePiWebConfigResponseBody(upstream.body, "Remote machine config response");
if (expectedAgentProfile !== undefined && !sameAgentProfile(response.config.agent, expectedAgentProfile)) {
return reply.code(409).send({
error: "Remote machine did not persist the requested agent profile",
machineId,
detail: "Update and restart PI WEB on the remote machine before changing its agent profile.",
});
}
reply.code(upstream.statusCode);
applySafeHeaders(reply, upstream.headers);
return reply.send(selectedMachineConfigResponse(parsePiWebConfigResponseBody(upstream.body, "Remote machine config response")));
return reply.send(selectedMachineConfigResponse(response));
}
function sameAgentProfile(actual: PiWebAgentConfig | undefined, expected: PiWebAgentConfig): boolean {
return actual !== undefined && actual.command === expected.command && actual.dir === expected.dir;
}
function sendUpstreamJsonResponse(reply: FastifyReply, upstream: MachineJsonResponse, machineId: string): FastifyReply {
+2 -2
View File
@@ -18,8 +18,8 @@ export function registerMachineRoutes(app: FastifyInstance, machines = new Machi
return health;
});
app.get<{ Params: { machineId: string } }>("/api/machines/:machineId/runtime", async (request, reply) => {
const runtime = await machines.runtime(request.params.machineId);
app.get<{ Params: { machineId: string }; Querystring: { refresh?: string } }>("/api/machines/:machineId/runtime", async (request, reply) => {
const runtime = await machines.runtime(request.params.machineId, request.query.refresh === "1");
if (runtime === undefined) return reply.code(404).send({ error: "Machine not found" });
return runtime;
});
+4 -1
View File
@@ -171,6 +171,7 @@ describe("MachineService", () => {
const first = await remoteService.runtime(machine.id);
const second = await remoteService.runtime(machine.id);
const forced = await remoteService.runtime(machine.id, true);
expect(first).toEqual({
machineId: machine.id,
@@ -182,7 +183,8 @@ describe("MachineService", () => {
capabilities: body.capabilities,
});
expect(second).toEqual(first);
expect(requestJson).toHaveBeenCalledTimes(1);
expect(forced).toEqual(first);
expect(requestJson).toHaveBeenCalledTimes(2);
expect(requestJson).toHaveBeenCalledWith("GET", "/api/pi-web/runtime", undefined, { timeoutMs: 3000 });
expect(factoryMachines).toEqual([
expect.objectContaining({
@@ -192,6 +194,7 @@ describe("MachineService", () => {
token: "secret",
headers: { "X-Pi-Web-Test": "yes" },
}),
expect.objectContaining({ id: machine.id }),
]);
});
+2 -2
View File
@@ -93,10 +93,10 @@ export class MachineService {
return health;
}
async runtime(id: string): Promise<MachineRuntime | undefined> {
async runtime(id: string, refresh = false): Promise<MachineRuntime | undefined> {
const cached = this.runtimeCache.get(id);
const now = this.now().getTime();
if (cached !== undefined && cached.expiresAt > now) return cached.runtime;
if (!refresh && cached !== undefined && cached.expiresAt > now) return cached.runtime;
const runtime = id === "local" ? await this.localRuntime() : await this.remoteRuntime(id);
if (runtime === undefined) return undefined;
+3 -2
View File
@@ -109,10 +109,11 @@ describe("PI WEB status", () => {
const runtime = await getPiWebRuntime(daemon);
expect(runtime.components.web.capabilities).toEqual(expect.arrayContaining([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings]));
expect(runtime.components.web.capabilities).toEqual(expect.arrayContaining([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, PI_WEB_CAPABILITIES.agentProfileConfig]));
expect(runtime.components.sessiond.capabilities).not.toContain(PI_WEB_CAPABILITIES.piPackagesManage);
expect(runtime.components.sessiond.capabilities).not.toContain(PI_WEB_CAPABILITIES.selectedMachineSettings);
expect(runtime.capabilities).toEqual(expect.arrayContaining([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings]));
expect(runtime.components.sessiond.capabilities).not.toContain(PI_WEB_CAPABILITIES.agentProfileConfig);
expect(runtime.capabilities).toEqual(expect.arrayContaining([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, PI_WEB_CAPABILITIES.agentProfileConfig]));
});
it("carries the daemon-owned active agent profile through the web runtime response", async () => {
+9
View File
@@ -2,6 +2,15 @@ import type { ActiveAgentProfileDescriptor } from "./apiTypes.js";
export const ACTIVE_AGENT_PROFILE_SCHEMA_VERSION = 1 as const;
export function isPiCompanionCommand(command: string): boolean {
const name = command.split(/[\\/]/u).at(-1)?.toLowerCase() ?? command.toLowerCase();
return name.replace(/(?:\.[cm]?js|\.exe|\.cmd)$/iu, "") === "pi";
}
export function usesPiCodingAgentStateCompatibility(command: string): boolean {
return !command.includes("/") && !command.includes("\\") && isPiCompanionCommand(command);
}
const ACTIVE_AGENT_PROFILE_FIELDS = new Set([
"schemaVersion",
"revision",
+5
View File
@@ -11,6 +11,7 @@ export const PI_WEB_CAPABILITIES = {
workspaceFileSuggestions: "workspace.fileSuggestions",
piPackagesManage: "piPackages.manage",
selectedMachineSettings: "settings.selectedMachine",
agentProfileConfig: "settings.agentProfile",
} as const;
export type PiWebCapability = typeof PI_WEB_CAPABILITIES[keyof typeof PI_WEB_CAPABILITIES];
@@ -149,6 +150,8 @@ export interface PiPackageMutationResponse extends PiPackagesResponse {
removed?: boolean;
}
export type PiWebAgentDirEnvSource = "pi-web" | "pi-compatibility";
export interface PiWebConfigEnvOverrides {
host: boolean;
port: boolean;
@@ -157,6 +160,8 @@ export interface PiWebConfigEnvOverrides {
subsessions: boolean;
agentCommand: boolean;
agentDir: boolean;
/** The configured directory environment source, even when Pi compatibility is inactive for the desired command. */
agentDirSource?: PiWebAgentDirEnvSource;
agentSessionDir: boolean;
}
+4 -2
View File
@@ -5,13 +5,15 @@ describe("PI WEB capabilities", () => {
it("advertises web-only capabilities without requiring session daemon support", () => {
expect(WEB_RUNTIME_CAPABILITIES).toContain(PI_WEB_CAPABILITIES.piPackagesManage);
expect(WEB_RUNTIME_CAPABILITIES).toContain(PI_WEB_CAPABILITIES.selectedMachineSettings);
expect(WEB_RUNTIME_CAPABILITIES).toContain(PI_WEB_CAPABILITIES.agentProfileConfig);
expect(SESSIOND_RUNTIME_CAPABILITIES).not.toContain(PI_WEB_CAPABILITIES.piPackagesManage);
expect(SESSIOND_RUNTIME_CAPABILITIES).not.toContain(PI_WEB_CAPABILITIES.selectedMachineSettings);
expect(SESSIOND_RUNTIME_CAPABILITIES).not.toContain(PI_WEB_CAPABILITIES.agentProfileConfig);
expect(effectivePiWebCapabilities({
web: { available: true, capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings] },
web: { available: true, capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, PI_WEB_CAPABILITIES.agentProfileConfig] },
sessiond: { available: false, capabilities: [] },
})).toEqual([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings]);
})).toEqual([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, PI_WEB_CAPABILITIES.agentProfileConfig]);
});
it("requires web and session daemon support for authoritative session persistence", () => {
+2
View File
@@ -16,6 +16,7 @@ export const WEB_RUNTIME_CAPABILITIES = [
PI_WEB_CAPABILITIES.workspaceFileSuggestions,
PI_WEB_CAPABILITIES.piPackagesManage,
PI_WEB_CAPABILITIES.selectedMachineSettings,
PI_WEB_CAPABILITIES.agentProfileConfig,
] as const satisfies readonly PiWebCapability[];
export const SESSIOND_RUNTIME_CAPABILITIES = [
@@ -37,6 +38,7 @@ const EFFECTIVE_CAPABILITY_REQUIREMENTS = {
[PI_WEB_CAPABILITIES.workspaceFileSuggestions]: ["web"],
[PI_WEB_CAPABILITIES.piPackagesManage]: ["web"],
[PI_WEB_CAPABILITIES.selectedMachineSettings]: ["web"],
[PI_WEB_CAPABILITIES.agentProfileConfig]: ["web"],
} as const satisfies Record<PiWebCapability, readonly PiWebServiceComponent[]>;
export function isPiWebCapability(value: unknown): value is PiWebCapability {
+4 -4
View File
@@ -8,16 +8,16 @@ describe("PI WEB status parsing", () => {
packageName: "@jmfederico/pi-web",
generatedAt: "now",
components: {
web: { component: "web", label: "Web/UI", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, "future.capability"] },
web: { component: "web", label: "Web/UI", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, PI_WEB_CAPABILITIES.agentProfileConfig, "future.capability"] },
sessiond: { component: "sessiond", label: "Session daemon", runtimeVersion: "1.0.0", available: true, capabilities: ["future.sessiondCapability"] },
},
capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, "future.capability"],
capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, PI_WEB_CAPABILITIES.agentProfileConfig, "future.capability"],
})).toMatchObject({
components: {
web: { capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings] },
web: { capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, PI_WEB_CAPABILITIES.agentProfileConfig] },
sessiond: { capabilities: [] },
},
capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings],
capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, PI_WEB_CAPABILITIES.agentProfileConfig],
});
});