feat: add OMP runtime support

This commit is contained in:
Jeff Scott Ward
2026-06-26 12:49:58 -04:00
parent 4605a4f1d8
commit 84a485d62e
26 changed files with 639 additions and 83 deletions
+6 -6
View File
@@ -7,15 +7,15 @@ describe("API parsers", () => {
expect(parsePiWebConfigResponse({
path: "/tmp/config.json",
exists: true,
config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "manual/uploads" }, maxUploadBytes: 1234 },
effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: ".pi-web/uploads" } },
envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "manual/uploads" }, maxUploadBytes: 1234, agent: { command: "omp", dir: "~/.omp/agent" } },
effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: ".pi-web/uploads" }, agent: { command: "omp", dir: "/Users/dev/.omp/agent" } },
envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: true, agentSessionDir: false },
})).toEqual({
path: "/tmp/config.json",
exists: true,
config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "manual/uploads" }, maxUploadBytes: 1234 },
effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: ".pi-web/uploads" } },
envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "manual/uploads" }, maxUploadBytes: 1234, agent: { command: "omp", dir: "~/.omp/agent" } },
effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: ".pi-web/uploads" }, agent: { command: "omp", dir: "/Users/dev/.omp/agent" } },
envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: true, agentSessionDir: false },
});
});
+20 -1
View File
@@ -531,11 +531,21 @@ function parsePiWebConfigValues(value: unknown): PiWebConfigValues {
...optionalField("pathAccess", optionalPathAccess(record["pathAccess"])),
...optionalField("uploads", optionalUploads(record["uploads"])),
...optionalField("maxUploadBytes", optionalNumber(record, "maxUploadBytes")),
...optionalField("agent", optionalAgent(record["agent"])),
...optionalField("spawnSessions", optionalBoolean(record, "spawnSessions")),
...optionalField("subsessions", optionalBoolean(record, "subsessions")),
};
}
function optionalAgent(value: unknown): PiWebConfigValues["agent"] | undefined {
if (value === undefined) return undefined;
if (!isRecord(value) || Array.isArray(value)) throw new Error("Invalid PI WEB agent field");
return {
...optionalField("command", optionalString(value, "command")),
...optionalField("dir", optionalString(value, "dir")),
};
}
function optionalAllowedHosts(value: unknown): PiWebConfigValues["allowedHosts"] | undefined {
if (value === undefined) return undefined;
if (value === true) return true;
@@ -598,7 +608,16 @@ function optionalPlugins(value: unknown): PiWebPluginConfigMap | undefined {
function parsePiWebConfigEnvOverrides(value: unknown): PiWebConfigEnvOverrides {
const record = requireRecord(value);
return { host: requireBoolean(record, "host"), port: requireBoolean(record, "port"), allowedHosts: requireBoolean(record, "allowedHosts"), spawnSessions: requireBoolean(record, "spawnSessions"), subsessions: requireBoolean(record, "subsessions") };
return {
host: requireBoolean(record, "host"),
port: requireBoolean(record, "port"),
allowedHosts: requireBoolean(record, "allowedHosts"),
spawnSessions: requireBoolean(record, "spawnSessions"),
subsessions: requireBoolean(record, "subsessions"),
agentCommand: optionalBoolean(record, "agentCommand") ?? false,
agentDir: optionalBoolean(record, "agentDir") ?? false,
agentSessionDir: optionalBoolean(record, "agentSessionDir") ?? false,
};
}
export function parsePiWebPluginsResponse(value: unknown): PiWebPluginsResponse {
+2 -2
View File
@@ -54,13 +54,13 @@ export class AuthDialog extends LitElement {
case "method": return html`
<div class="options">
<button @click=${() => { this.onChooseMethod?.("oauth"); }}><span>Use a subscription</span><small>ChatGPT Plus/Pro, Claude Pro/Max, or GitHub Copilot</small></button>
<button @click=${() => { this.onChooseMethod?.("api_key"); }}><span>Use an API key</span><small>Store an API key in pi auth.json</small></button>
<button @click=${() => { this.onChooseMethod?.("api_key"); }}><span>Use an API key</span><small>Store an API key in the configured agent auth.json</small></button>
</div>
`;
case "providers": return html`<div class="options">${state.providers.length === 0 ? html`<div class="empty">No providers available.</div>` : state.providers.map((provider) => this.renderProviderButton(provider))}</div>`;
case "apiKey": return html`
<div class="form">
<p>Enter the API key for <strong>${state.provider.name}</strong>. It will be stored by pi in <code>auth.json</code>.</p>
<p>Enter the API key for <strong>${state.provider.name}</strong>. It will be stored in the configured agent <code>auth.json</code>.</p>
<input type="password" autocomplete="off" placeholder="API key" .value=${state.value} @input=${(event: Event) => { if (event.target instanceof HTMLInputElement) this.onApiKeyInput?.(event.target.value); }}>
${state.error !== undefined && state.error !== "" ? html`<div class="error-text">${state.error}</div>` : null}
<div class="actions"><button @click=${() => { this.cancel(); }}>Cancel</button><button class="primary" ?disabled=${state.saving === true} @click=${() => { this.onSaveApiKey?.(); }}>${state.saving === true ? "Saving…" : "Save API key"}</button></div>
@@ -21,6 +21,9 @@ export class SettingsSessiondPanel extends LitElement {
const subsessionsOverridden = config?.envOverrides.subsessions === true;
// Beta, off by default; also requires spawn to be enabled.
const effectiveSubsessions = config?.effectiveConfig.subsessions === true && effectiveSpawn;
const agentCommandOverridden = config?.envOverrides.agentCommand === true;
const agentDirOverridden = config?.envOverrides.agentDir === true;
const effectiveAgent = config?.effectiveConfig.agent;
return html`
<div class="section-heading">
<div>
@@ -36,6 +39,40 @@ export class SettingsSessiondPanel extends LitElement {
<span>Config file</span>
<code>${config?.path ?? "Unknown"}</code>
</div>
<div 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>Use <code>omp</code> to make doctor/update checks target Oh My Pi. The embedded session runtime remains PI WEB's SDK path, so this does not dynamically load a different agent implementation.</small>
</div>
<div 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 ~/.omp/agent"
?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. For OMP, set this to <code>~/.omp/agent</code>, then restart the session daemon.</small>
</div>
<div class="field">
<span class="field-heading">
<span>Allow agents to start sessions</span>
@@ -72,6 +109,8 @@ export class SettingsSessiondPanel extends LitElement {
<section class="effective-card" aria-label="Effective configuration summary">
<h3>Effective 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 default</span>`}</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>
@@ -86,6 +125,28 @@ export class SettingsSessiondPanel extends LitElement {
return null;
}
private async saveAgentField(field: "command" | "dir", event: Event): Promise<void> {
if (!(event.target instanceof HTMLInputElement)) return;
const value = event.target.value.trim();
const baseConfig = this.configResponse?.config ?? {};
const nextConfig: PiWebConfigValues = { ...baseConfig };
const nextAgent: NonNullable<PiWebConfigValues["agent"]> = { ...(baseConfig.agent ?? {}) };
if (field === "command") {
if (value === "") delete nextAgent.command;
else nextAgent.command = value;
} else if (value === "") {
delete nextAgent.dir;
} else {
nextAgent.dir = value;
}
if (nextAgent.command === undefined && nextAgent.dir === undefined) {
delete nextConfig.agent;
} else {
nextConfig.agent = nextAgent;
}
await this.onSave?.(nextConfig);
}
private async toggleSpawnSessions(event: Event): Promise<void> {
const enabled = event.target instanceof HTMLInputElement && event.target.checked;
const baseConfig = this.configResponse?.config ?? {};
@@ -124,6 +185,8 @@ export class SettingsSessiondPanel extends LitElement {
.field-heading { display: flex; align-items: center; gap: 8px; }
.toggle { display: flex; align-items: center; gap: 9px; cursor: pointer; }
.toggle input { width: 16px; height: 16px; }
.text-input { width: 100%; box-sizing: border-box; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-bg); color: var(--pi-text); padding: 8px 9px; font: 13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
.text-input:disabled { opacity: .65; cursor: not-allowed; }
.toggle input:disabled { cursor: not-allowed; }
.override-badge { border: 1px solid var(--pi-warning-border); border-radius: 999px; color: var(--pi-warning); background: var(--pi-warning-surface); padding: 2px 7px; font-size: 11px; font-weight: 600; text-transform: none; }
.beta-badge { border: 1px solid var(--pi-border); border-radius: 999px; color: var(--pi-muted); background: var(--pi-bg); padding: 2px 7px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; }
@@ -20,7 +20,7 @@ describe("settings config drafts", () => {
allowedHostsMode: "list",
allowedHostsText: "example.local, 192.168.1.20\n",
allowedPathsText: "/tmp\n~/SDKs\n",
}, { shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false } }, pathAccess: { allowedPaths: ["/old"] }, uploads: { defaultFolder: "manual/uploads" }, maxUploadBytes: 1234 })).toEqual({
}, { shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false } }, pathAccess: { allowedPaths: ["/old"] }, uploads: { defaultFolder: "manual/uploads" }, maxUploadBytes: 1234, agent: { command: "omp", dir: "~/.omp/agent" } })).toEqual({
host: "127.0.0.1",
port: 9000,
allowedHosts: ["example.local", "192.168.1.20"],
@@ -29,6 +29,7 @@ describe("settings config drafts", () => {
pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] },
uploads: { defaultFolder: "manual/uploads" },
maxUploadBytes: 1234,
agent: { command: "omp", dir: "~/.omp/agent" },
});
});
@@ -30,6 +30,7 @@ export function configFromDraft(draft: ConfigDraft, baseConfig: PiWebConfigValue
...(baseConfig.maxUploadBytes === undefined ? {} : { maxUploadBytes: baseConfig.maxUploadBytes }),
...(baseConfig.spawnSessions === undefined ? {} : { spawnSessions: baseConfig.spawnSessions }),
...(baseConfig.subsessions === undefined ? {} : { subsessions: baseConfig.subsessions }),
...(baseConfig.agent === undefined ? {} : { agent: baseConfig.agent }),
};
const host = draft.host.trim();
const port = draft.port.trim();