Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ All notable changes to **omp-web** (`@kahme247/ompweb`) are documented in this f
### Added

- Choose **Classifier** or **Vendor default** in Settings → AI Model Defaults → **Auto Thinking Source** when Reasoning is `auto`. The choice is saved in omp's native config for newly started or restarted agents. Vendor mode prefers the publisher's effort, then omp's per-model default, then normal Auto fallback. Requires an omp release containing [can1357/oh-my-pi#14114](https://github.com/can1357/oh-my-pi/pull/14114).
- Show conflicting skill names and deduplicated installations when an OMP session starts, with resolution details, a **×** button that dismisses the notice for the session until the diagnostic report changes, and a persisted **Turn off** action that disables startup notices. Inspect a running session manually in **Settings → Extensions & Tools → Skills**, even when notices are off; inspection never resumes a stopped session. Requires an OMP runtime with the skill-diagnostics RPC commands; older runtimes report live diagnostics as unavailable.
- Ask a side question with `/btw <question>`: omp answers from the current conversation in a panel above the composer, even while the agent is working, and the question never enters the chat. Cancel a running answer, copy it, or ask follow-ups in the same topic. `/btw` on its own lists the session's past side questions, including ones asked in the terminal, each expandable to all its turns with **Copy** and **Follow up**. Side questions are text-only: with an attachment in the composer, `/btw` is refused and the draft is kept. An answer that omp lost (for example after its process restarted) shows as **Interrupted** instead of spinning. Requires an omp with the `btw` RPC commands; older omp shows "/btw requires a newer omp" instead of sending the text to the model.
- Show omp's Claude usage-limit badge in the composer toolbar, matching the terminal status line during the wrap-up allowance or once `/slow` low priority takes over. Reset times use the browser's locale and timezone. Requires an omp version whose `get_state` reports structured `usageLimit` state.
- Add a **Slow** toggle next to **Fast** in the composer toolbar, the equivalent of omp's `/slow`. On Claude models it turns low priority on or off in your omp settings, so it applies to every session and terminal; on OpenAI and Google models it switches this session to the provider's flex tier. Once the session's omp process runs, the toggle follows omp's `get_state` (`slowModeSupported`); before that, including in a new session, it follows the selected model's provider (`anthropic`, `openai`, `openai-codex`, `google`, `google-vertex`) and, on Claude, the `providers.anthropic.slowMode` setting. Clicking it there starts the session's omp process.
Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,20 @@ Queue **Delete** and **Edit** additionally require `remove_queued_message`. Dele

The queue panel shows omp's own queue (`queuedMessages` in `get_state` and `queue_update` events, omp 18.4.4 or later), so every device viewing a session sees the same queued messages. **Stop** moves the text of messages still waiting in the queue back into the composer instead of letting the agent run them; a steer the model already picked up through live steering still runs. Older omp runtimes show no queue panel, and Stop cannot take queued messages back.

Skill startup notices require an OMP runtime with `get_skill_diagnostics`,
`set_skill_startup_diagnostics`, and `skill_diagnostics_update`. Conflicts and
redundant installations appear above the composer when its OMP session starts;
an empty new-chat page does not start OMP just for diagnostics. **Details** shows
the resolved default, variants, identical copies, backing paths, sources, and
selection reason. The **×** button dismisses the notice for that session until
the diagnostic report changes. **Turn off** and **Settings →
Interface & Behavior → Skill startup notices** use OMP's persisted
`skills.showStartupDiagnostics` preference, not a separate browser setting.
**Settings → Extensions & Tools → Skills → View skill diagnostics** remains
available for a selected running session when notices are off. Inspection does
not resume stopped sessions. Missing support or no running session is unavailable,
not a clean result.

## Quick Start

**Run directly without installing:**
Expand Down
16 changes: 15 additions & 1 deletion app/api/agent/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ const NO_SPAWN_REPLIES: Record<string, unknown> = {
btw_cancel: { cancelled: false },
};

// Live diagnostics of a skill-resolving omp. A saved or exited session has none,
// and resuming a full child (MCP, LSP) just to answer a read-only inspection or
// flip one setting is not what the click asked for: with no live wrapper these
// answer 409 instead of starting omp, and a live idle child is never replaced
// because the advisor flag differs.
const LIVE_SESSION_ONLY = new Set(["get_skill_diagnostics", "set_skill_startup_diagnostics"]);

/** omp-web's own failures carry a stable code the client can localize; omp's
* errors stay opaque English text. */
function commandErrorResponse(error: unknown) {
Expand Down Expand Up @@ -60,14 +67,21 @@ export async function POST(
// running and pick the flag up at the next natural respawn.
const existing = getRpcSession(id);
const noSpawn = Object.hasOwn(NO_SPAWN_REPLIES, body.type);
const liveOnly = LIVE_SESSION_ONLY.has(body.type);
if (existing?.isAlive()) {
if (noSpawn || existing.advisorSpawned === advisor || existing.isRunning()) {
if (noSpawn || liveOnly || existing.advisorSpawned === advisor || existing.isRunning()) {
const result = await existing.send(body);
return NextResponse.json({ success: true, data: result });
}
await existing.destroyAndWait();
}
if (noSpawn) return NextResponse.json({ success: true, data: NO_SPAWN_REPLIES[body.type] });
if (liveOnly) {
return NextResponse.json(
{ error: "Skill diagnostics require a running session", code: "skill_diagnostics_unavailable" },
{ status: 409 },
);
}

const resolved = await resolveSessionPathOr404(id);
if ("response" in resolved) return resolved.response;
Expand Down
4 changes: 4 additions & 0 deletions components/ChatWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { ExtensionDialog } from "./ExtensionDialog";
import { SubagentTranscriptDialog } from "./SubagentTranscriptDialog";
import { ChatMinimap, useMessageRefs } from "./ChatMinimap";
import { ComposerPanels } from "./ComposerPanels";
import { SkillDiagnosticsNotice } from "./SkillDiagnostics";
import { BtwHistoryDialog, type BtwPanelProps } from "./BtwPanel";
import OmpWebLogo from "./OmpWebLogo";
import { CHAT_COLUMN_MAX_WIDTH, MINIMAP_WIDTH } from "@/lib/chat-layout";
Expand Down Expand Up @@ -532,6 +533,7 @@ export function ChatWindow({ session, newSessionCwd, newSessionWorkspace, toolCa
isCompacting, compactResult, tokensPerSecond, displayModel: displayModelValue, sessionStats,
slashCommands, slashCommandsLoading, queuedMessages, advisorActive, advisorEnabled, handleAdvisorChange,
notices, dismissNotice, extensionDialog, extensionCustomUi, extensionStatuses, extensionWidgets, respondToExtensionUi, sendExtensionCustomInput,
skillDiagnostics, setSkillStartupDiagnostics,
isAutoModelSelection,
agentPhase, activeGoal, activePlan,
liveToolResults,
Expand Down Expand Up @@ -1232,6 +1234,7 @@ export function ChatWindow({ session, newSessionCwd, newSessionWorkspace, toolCa
</div>
{newSessionWorkspace}
<NoticeShelf notices={notices} onDismiss={dismissNotice} align="right" />
<SkillDiagnosticsNotice snapshot={skillDiagnostics} onDisable={() => setSkillStartupDiagnostics(false)} />
<ComposerPanels todoPhases={[]} subagents={[]} onSelectSubagent={setSelectedSubagent} btw={btwPanel} />
{/* ChatInput insets itself by CHAT_COLUMN_PADDING; cancel this column's
padding so the composer matches its in-session width. */}
Expand Down Expand Up @@ -1447,6 +1450,7 @@ export function ChatWindow({ session, newSessionCwd, newSessionWorkspace, toolCa
/>
</div>
)}
<SkillDiagnosticsNotice snapshot={skillDiagnostics} onDisable={() => setSkillStartupDiagnostics(false)} />
<ComposerPanels
todoPhases={todoPhases}
subagents={subagents}
Expand Down
12 changes: 9 additions & 3 deletions components/SettingsConfig.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ type NativeSettings = {
autolearn?: { enabled?: boolean; autoContinue?: boolean; minToolCalls?: number };
mnemopi?: { scoping?: "global" | "per-project" | "per-project-tagged"; autoRecall?: boolean; autoRetain?: boolean; noEmbeddings?: boolean };
mcp?: { enableProjectConfig?: boolean; renderMarkdownResults?: boolean; notifications?: boolean; notificationDebounceMs?: number };
skills?: { showStartupDiagnostics?: boolean };
retry?: { enabled?: boolean; maxRetries?: number; modelFallback?: boolean };
};

Expand Down Expand Up @@ -141,6 +142,7 @@ type SettingIndexEntry = {

const SETTING_INDEX: SettingIndexEntry[] = [
// Interface & Behavior
{ id: "skill-startup-notices", tab: "general", sectionKey: "settingsConfig.interfaceBehavior", labelKey: "settingsConfig.skillStartupNotices", descKey: "settingsConfig.skillStartupNoticesDesc", fallbackSection: "Interface & Behavior", fallbackLabel: "Skill startup notices", fallbackDesc: "Show conflicts and redundant skill copies when an OMP session starts.", scope: "Native OMP" },
{ id: "completion-sound", tab: "general", sectionKey: "settingsConfig.interfaceBehavior", labelKey: "settingsConfig.completionSound", descKey: "settingsConfig.completionSoundDesc", fallbackSection: "Interface & Behavior", fallbackLabel: "Completion sound", fallbackDesc: "Play a tone when the agent completes a run.", scope: "UI" },
{ id: "keep-tool-calls-collapsed", tab: "general", sectionKey: "settingsConfig.interfaceBehavior", labelKey: "settingsConfig.keepToolCallsCollapsed", descKey: "settingsConfig.keepToolCallsCollapsedDesc", fallbackSection: "Interface & Behavior", fallbackLabel: "Keep tool calls collapsed", fallbackDesc: "Show only compact headers while tools execute.", scope: "UI" },
{ id: "open-url-automatically", tab: "general", sectionKey: "settingsConfig.interfaceBehavior", labelKey: "settingsConfig.openUrlAutomatically", descKey: "settingsConfig.openUrlAutomaticallyDesc", fallbackSection: "Interface & Behavior", fallbackLabel: "Open agent links without asking", fallbackDesc: "Links the agent opens from the session you are viewing open in a new tab right away. Links from other sessions always ask first. Your browser may still block pop-ups.", scope: "UI" },
Expand Down Expand Up @@ -884,7 +886,7 @@ export function SettingsConfig({ activeTab, toolCallsDefaultCollapsed, onToolCal
}, [ompUpdating, t, checkForUpdate, restartSessions]);


const currentTab = getNormalizedActive(activeTab);
const currentTab = activeTab === "skills" ? activeTab : getNormalizedActive(activeTab);
const nativeSettingsRequired = currentTab === "general" || currentTab === "safety" || currentTab === "models" || currentTab === "intelligence" || currentTab === "mcp";
useEffect(() => {
if (currentTab === "system") {
Expand Down Expand Up @@ -1140,6 +1142,9 @@ export function SettingsConfig({ activeTab, toolCallsDefaultCollapsed, onToolCal
<NativeSetting searchId="provider-usage" label={t("settingsConfig.providerUsage")} description={t("settingsConfig.providerUsageDesc")} scope="UI">
<ToggleSwitch checked={providerUsageVisible} onChange={onProviderUsageVisibleChange} />
</NativeSetting>
<NativeSetting searchId="skill-startup-notices" label={t("settingsConfig.skillStartupNotices")} description={t("settingsConfig.skillStartupNoticesDesc")} scope="Native OMP">
<ToggleSwitch checked={nativeSettings?.skills?.showStartupDiagnostics !== false} disabled={nativeSettingsLoading} onChange={(enabled) => patchSection("skills", { showStartupDiagnostics: enabled })} />
Comment thread
andrebrait marked this conversation as resolved.
</NativeSetting>
<NativeSetting searchId="chat-font-size" label={t("settingsConfig.chatFontSize")} description={t("settingsConfig.chatFontSizeDesc")} scope="UI">
<select
style={nativeSelectStyle}
Expand Down Expand Up @@ -1488,6 +1493,7 @@ export function SettingsConfig({ activeTab, toolCallsDefaultCollapsed, onToolCal
<div style={{ marginBottom: 4 }}>
<h2 className="display-serif" style={{ fontSize: 22, fontWeight: 600, margin: 0, color: "var(--text)", letterSpacing: "-0.01em" }}>{t("settingsConfig.extensionsTools")}</h2>
<p className="settings-content-subtitle" style={{ margin: "4px 0 16px", fontSize: 13, color: "var(--text-muted)", lineHeight: 1.45 }}>{t("settingsConfig.extensionsToolsDesc")}</p>
{cwd && <button type="button" className="settings-back ui-focus-ring" onClick={() => handleSelectTab("skills")}>{t("skillsConfig.title")}</button>}
</div>
{cwd && (
<div style={{ display: "flex", flexDirection: "column", gap: 10, width: "100%" }}>
Expand Down Expand Up @@ -1518,8 +1524,8 @@ export function SettingsConfig({ activeTab, toolCallsDefaultCollapsed, onToolCal

{/* SKILLS SUB-PANEL CONTRACT MATCH */}
{cwd && currentTab === "skills" && (
<div role="tabpanel" id="settings-panel-skills" aria-labelledby="settings-tab-skills" className="settings-panel-inner" style={{ display: currentTab === "skills" ? "flex" : "none", width: "100%", maxWidth: 940, minHeight: isMobile ? undefined : 600, flexDirection: "column", padding: isMobile ? "16px 14px 32px" : "32px 24px 64px" }}>
<SkillsConfig embedded cwd={cwd} onClose={onClose} />
<div role="tabpanel" id="settings-panel-skills" aria-labelledby="settings-tab-mcp" className="settings-panel-inner" style={{ display: currentTab === "skills" ? "flex" : "none", width: "100%", maxWidth: 940, minHeight: isMobile ? undefined : 600, flexDirection: "column", padding: isMobile ? "16px 14px 32px" : "32px 24px 64px" }}>
<SkillsConfig embedded cwd={cwd} sessionId={sessionId} onClose={onClose} />
</div>
)}

Expand Down
70 changes: 70 additions & 0 deletions components/SkillDiagnostics.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import "../tests/setup-dom.mjs";
import assert from "node:assert/strict";
import test, { afterEach } from "node:test";
import React from "react";
import { cleanup, render, screen, waitFor } from "@testing-library/react/pure.js";
import userEvent from "@testing-library/user-event";
import { createJiti } from "jiti";

const jiti = createJiti(import.meta.url, { jsx: { runtime: "automatic" }, tsconfigPaths: true });
const { SkillDiagnosticsDialog, SkillDiagnosticsNotice } = await jiti.import("./SkillDiagnostics.tsx");
afterEach(cleanup);

const snapshot = {
cwd: "/project",
showStartupDiagnostics: true,
diagnostics: [{
name: "review", reason: "source-order",
skills: [
{ name: "review", filePath: "/first/review/SKILL.md", source: "native:user" },
{ name: "second/review", filePath: "/second/review/SKILL.md", source: "agents:project" },
], duplicates: [],
}],
};

test("diagnostic display neutralizes path-spoofing controls without discarding ordinary Unicode", () => {
const hostile = {
cwd: "/project\u202E",
showStartupDiagnostics: true,
diagnostics: [{
name: "調査\u202E", reason: "source-order",
skills: [{ name: "調査\u202E", filePath: "/技能/\u202Eevil\u0007/SKILL.md", source: "custom:user\u2066", pluginName: "補助\u2069" }],
duplicates: [{
skill: { name: "調査\u202E", filePath: "/mirror\u0085/SKILL.md", source: "custom:user" },
retained: { name: "調査\u202E", filePath: "/技能/\u202Eevil\u0007/SKILL.md", source: "custom:user" },
}],
}],
};
render(React.createElement(SkillDiagnosticsDialog, { open: true, onOpenChange: () => {}, snapshot: hostile }));
const text = screen.getByRole("dialog", { name: "Skill diagnostics" }).textContent;
assert.doesNotMatch(text, /[\p{Cc}\u202a-\u202e\u2066-\u2069]/u);
assert.ok(text.includes("/技能/evil/SKILL.md"));
assert.ok(text.includes("補助"));
assert.ok(text.includes("調査"));
});

test("losing a diagnostic snapshot closes details instead of reopening them on recovery", async () => {
const user = userEvent.setup();
const props = { snapshot, onDisable: async () => snapshot };
const view = render(React.createElement(SkillDiagnosticsNotice, props));
await user.click(screen.getByRole("button", { name: "Details" }));
assert.ok(screen.getByRole("dialog", { name: "Skill diagnostics" }));
view.rerender(React.createElement(SkillDiagnosticsNotice, { ...props, snapshot: null }));
view.rerender(React.createElement(SkillDiagnosticsNotice, props));
await waitFor(() => assert.equal(screen.queryByRole("dialog", { name: "Skill diagnostics" }) === null, true));
assert.ok(screen.getByRole("button", { name: "Details" }));
});

test("dismissing the notice hides that report until the diagnostics change", async () => {
const user = userEvent.setup();
const props = { snapshot, onDisable: async () => snapshot };
const view = render(React.createElement(SkillDiagnosticsNotice, props));
await user.click(screen.getByRole("button", { name: "Dismiss skill notice" }));
assert.equal(screen.queryByRole("status"), null);
view.rerender(React.createElement(SkillDiagnosticsNotice, { ...props, snapshot: structuredClone(snapshot) }));
assert.equal(screen.queryByRole("status"), null);
const changed = structuredClone(snapshot);
changed.diagnostics[0].name = "audit";
view.rerender(React.createElement(SkillDiagnosticsNotice, { ...props, snapshot: changed }));
assert.ok(screen.getByRole("status"));
});
Loading