Seed a live tool-result snapshot on tool_execution_start, refresh it on tool_execution_update (coalesced per tool call at display rate), and release it on tool end or the committed toolResult. Rows render a running indicator with streamed output instead of a dead no-result row, and honor the Keep tool calls collapsed setting via toolCallsDefaultCollapsed.
24 KiB
omp-web - Development Notes
Quick Start
npm run dev # port 30178
Typecheck: node_modules/.bin/tsc --noEmit
Lint: npm run lint
Never run next build during dev — pollutes .next/ and breaks npm run dev.
The dev server needs the omp binary installed (on PATH, or set OMP_WEB_OMP_BIN).
All live-agent features go through it; session browsing works without it.
Architecture
omp-web never imports @oh-my-pi/* or @earendil-works/* packages (they are
Bun-only and cannot run inside Node/Next). See DESIGN.md for the full porting
contract.
Browser Next.js Server omp child process
│ │ │
├─ GET /api/sessions ────▶ reads ~/.omp/agent/sessions/ │
├─ GET /api/sessions/[id] reads .jsonl file directly │
├─ GET /api/agent/running/events ───▶ running id SSE │
│ │ │
├─ send message ─────────▶ POST /api/agent/[id] │
│ │ startRpcSession() ── spawn ─────▶│ omp --mode rpc-ui
│ │ sendCommand({type:"prompt"}) ───▶│ (NDJSON stdio)
│ │ │
├─ SSE connect ──────────▶ GET /api/agent/[id]/events │
│ │ onFrame() ◀── event frames ──────│
│◀── data: {...} ─────────│ │
Session browsing (read-only): pure-Node parsing of omp session .jsonl
files via lib/session-reader.ts — no child process involved.
Sending a message: startRpcSession() in lib/rpc-manager.ts spawns
omp --mode rpc-ui (one process per active session) through
lib/omp/rpc-process.ts.
Shared foundations in lib/omp/:
paths.ts— Node port of omp's directory resolution (~/.omp/agent, XDG, session dir slugs).omp-cli.ts— locate/probe the installedompbinary (resolveOmpBin,getOmpVersion).rpc-process.ts— process + NDJSON protocol layer (RpcProcess).
File Map
app/api/
sessions/route.ts GET list all sessions
sessions/[id]/route.ts GET/PATCH/DELETE session
sessions/[id]/context/route.ts GET ?leafId= — context for a specific leaf
sessions/[id]/export/route.ts GET exported HTML for a session
agent/new/route.ts POST { cwd, message, toolNames?, provider?, modelId? }
agent/[id]/route.ts GET state | POST any RPC command
agent/[id]/events/route.ts GET SSE stream
agent/running/events/route.ts GET SSE stream of currently-running session ids
auth/** provider list, login/logout, API keys (via RPC)
cwd/validate/route.ts POST validate/select a cwd
default-cwd/route.ts POST create ~/omp-cwd-YYYYMMDD
files/[...path]/route.ts GET file contents for viewer
home/route.ts GET user home directory
models/route.ts GET { models, modelList, defaultModel }
models-config/route.ts GET/PUT — read/write ~/.omp/agent/models.yml
models-config/test/route.ts POST test a configured model/provider
omp-settings/route.ts GET/PUT native config.yml settings (allow-listed)
mcp/route.ts GET/POST/PUT/DELETE project MCP servers
plugins/route.ts GET/POST plugin management (shells out to `omp plugin`)
projects/route.ts GET registered+discovered projects | POST add | DELETE hide
skills/route.ts GET/PATCH loaded skills and disable-model-invocation
skills/install/route.ts POST install skills through npx skills add
skills/search/route.ts GET/POST skills.sh search
worktrees/route.ts GET/POST/DELETE git worktrees
lib/
omp/ shared omp foundations (paths, CLI probe, RpcProcess)
agent-client.ts typed fetch helper for /api/agent commands
draft-store.ts local draft persistence helpers
file-access.ts allowed file roots for /api/files and worktrees
file-paths.ts client/server path encoding helpers
markdown.ts shared markdown helpers
npx.ts npx runner used by skill install
pi-types.ts local structural types for agent/RPC objects
project-ordering.ts pure project sort/group/activity helpers (client + tests)
project-registry.ts on-disk managed-project registry (~/.omp/agent/projects.json)
rpc-manager.ts session registry + startRpcSession over RpcProcess
session-reader.ts session .jsonl parsing + path cache + buildSessionContext
skills-service.ts pure-Node skill discovery mirroring omp's providers
tool-presets.ts PRESET_NONE/DEFAULT/FULL + getToolNamesForPreset()
types.ts shared TypeScript types
normalize.ts normalizeToolCalls() — field name mismatch between file format and our types
worktree.ts project/worktree resolution and git worktree operations
components/
AppShell.tsx layout + URL state + tab management
SessionSidebar.tsx session tree + FileExplorer
ChatWindow.tsx chat composition + completion sound wrapper
ChatInput.tsx input bar + model/thinking/tools/compact controls
ComposerPanels.tsx composer-attached todo + subagent panels (collapsible, live states)
TodoList.tsx todo phase grid with preview/show-all (used by ComposerPanels)
SubagentTranscriptDialog.tsx task + final output summary dialog (wide, screen-adaptive)
MessageView.tsx renders one message (user/assistant/toolCall/toolResult)
CommandPalette.tsx ⌘K/Ctrl+K palette (cmdk): session switch, new session, theme
ImageLightbox.tsx click-to-preview lightbox for chat images (ClickableImage)
BranchNavigator.tsx in-session branch switcher
ChatMinimap.tsx scroll minimap alongside the message list
MarkdownBody.tsx markdown renderer
ModelsConfig.tsx modal for models/auth configuration
McpConfig.tsx project MCP server editor (Settings → MCP tab)
PluginsConfig.tsx modal for installed plugins
SkillsConfig.tsx modal for loaded/search/installable skills
FileExplorer.tsx file tree inside sidebar
FileViewer.tsx file content in a tab
TabBar.tsx tab bar (Chat + open file tabs)
ui/ shared primitives: Dialog/Tooltip/Collapsible, fields, toast
hooks/
useAgentSession.ts messages + streaming + SSE + fork/navigate/reconciliation logic
useAudio.ts completion sound + browser AudioContext unlock
useDragDrop.ts shared drag/drop state
useIsMobile.ts responsive breakpoint hook
usePrefersReducedMotion.ts OS reduce-motion preference (SMIL-safe)
useTheme.ts theme state (localStorage key "omp-theme")
Key Design Decisions & Traps
RPC session lifecycle (lib/rpc-manager.ts)
- One wrapper per session id, keyed in a
globalThisregistry. globalThissurvives Next.js hot-reload; plain module-level Map does not.- Idle sessions are disposed after a timeout; concurrent
startRpcSession()calls must share a single start promise.
Two kinds of branching — don't confuse them
- Fork (Fork button on user message): creates a new independent
.jsonlfile. Shown as a child in the sidebar tree viaparentSessionheader field. - In-session branch (Continue button / BranchNavigator): navigates the entry tree within the same file. Multiple entries share the same
parentId. Switching between them calls/api/sessions/[id]/context?leafId=.
ToolCall field normalization
Sessions store toolCall blocks as {type:"toolCall", id, name, arguments} but ToolCallContent uses {toolCallId, toolName, input}. normalizeToolCalls() in lib/normalize.ts handles this — called in both session-reader.ts (file load) and streaming event handling.
Live tool execution (tool_execution_start/update/end)
omp announces a tool the moment it starts, streams the tool's output while it
runs, and only commits the toolResult message at the end. The UI must not
wait for that commit:
useAgentSessionkeeps aliveToolResultsmap keyed bytoolCallId(seeded ontool_execution_startwithpartial: true, refreshed ontool_execution_update— omp sends the FULL accumulated partial result per chunk, latest wins — and released on_end/the committed toolResult). Committed results always win over live entries (ChatWindowmerges them), so a reload never shows a stale snapshot.ToolCallBlockrenders apartialresult as running (spinner, and "Running tool…" instead of the "(no output)" marker when nothing has been printed yet), and opens the row while it runs when the "Keep tool calls collapsed" setting is off — that is what that setting means.AppShellmust passtoolCallsDefaultCollapsedintoChatWindow; without it the setting is inert (the chat then always collapses).tool_execution_updateis coalesced per tool call at display rate inlib/message-update-coalescer.ts(chatty commands emit ~10-100+ frames/s).message_enddrops the pendingmessage_update(the committed message supersedes it) but must NOT drop buffered tool updates.- Live entries are cleared on
agent_start, terminalagent_end, prompt send/settlement failure — a tool must never leak into the next run.
Event protocol differences vs pi
omp emits no prompt_done / prompt_error / queue_update /
compaction_start / compaction_end events. Completion is agent_end
(isTerminal !== false), errors surface as failed RPC responses plus notice
events, and the queue length comes from get_state.queuedMessageCount.
New frame types (turn_start/end, notice, todo_reminder, ...) must be
handled or safely ignored.
Running state SSE + reconciliation
- The sidebar listens to
/api/agent/running/events, backed bysubscribeRunningSessions()inlib/rpc-manager.ts, so running badges update without polling. useAgentSessionstill treats per-session SSE as primary for chat events, but while a run is active it periodically callsGET /api/agent/[id]and also reconciles onvisibilitychange/online. This fixes missedagent_endevents from background tabs or half-open connections.- Prompt runs use a monotonic run id; late SSE or slow reconciliation responses from an old run must be ignored so they cannot resurrect stale streaming bubbles.
Composer-attached panels (components/ComposerPanels.tsx)
- The live todo plan (
TodoList) and the subagent roster live pinned above the chat input, not inside the scrollable message list.ComposerPanelsrenders both, each independently collapsible via its header row (chevron); panels start collapsed (headers always show live progress / running-summary). Subagent chips carry live state (pulsing dot whilestarted, check/alert/ban for terminal states) fed by the samesubagent_lifecycle/subagent_progressSSE frames; clicking a chip opens the transcript dialog.TodoListkeeps a non-collapsible default (collapsibleprop) for SSR tests.
Subagent integration (lib/subagent-types.ts, lib/subagent-history.ts)
- Live detail:
subagent_progressframes carry the fullAgentProgressobject —lib/subagent-types.tsparses it defensively intoSubagentInfo.progress(current tool/intent, tokens, cost, context gauge, resolved model, retry state, detached flag, agentSource). The composer chips surface the current activity + telemetry line; retry (⟳ retrying N/M) takes precedence over the tool line.subagent_eventframes also feed a bounded per-subagent activity buffer shown in the transcript dialog. - Roster hydration:
get_subagentssnapshots (which carry progress) rehydrate the roster after SSE reconnect (refreshSubagentRoster, wired into mount, send, and the reconcile poll). Terminal subagents vanish from the RPC registry — history fills that gap. - On-disk history (
lib/subagent-history.ts,/api/sessions/[id]/subagents*): omp persists each subagent's transcript to the parent session's sibling artifacts dir (<session-dir>/<subagent-id>.jsonl) and the parent file's task toolResults keepprogress[]/results[]snapshots. omp-web recovers the roster from disk (extractSubagentHistory, result fields win over the mid-run snapshot), so past/finished runs show in the composer panel after a reload. The transcript route pages the sibling file byte-wise (mirroringget_subagent_messages, which is RPC-registry-gated and refuses files it doesn't know). The dialog reads only the final output —<id>.mdvia?mode=completion(bounded tail read that also works for transcripts beyond the 16MB paging cap) with a liveget_subagentssnapshot fallback for header enrichment; it never pages the raw transcript. Subagent ids are[A-Za-z0-9_-]{1,80}— the route validates before joining to confine reads to the sibling dir. - In-message task summary (
components/MessageView.tsxTaskResultPanel): the session reader allowlists a SIZE-BOUNDED subset oftasktoolResult details (telemetry only — nooutput/stderr, long text truncated to 240 chars,lib/session-reader.tskeepTaskToolResultDetails), and expandedtasktool calls render a per-subagent summary (status, agent, task, tokens/cost/duration/model, async marker) above the raw result text. - Chip extras: agent-source labels (
user/project), nested-subagent count (inflightTaskDetails/extractedToolData.taskprogress), and the⤴async marker (livedetachedflag or historydetails.asyncpresence). Shared formatters live inlib/subagent-format.ts.
Worktrees and project grouping
lib/worktree.tsresolves linked worktree top-levels back to the main repoprojectRoot;listAllSessions()attaches that to eachSessionInfoso all worktrees for one repo are grouped together in the sidebar.- Worktree operations are served by
/api/worktreesand guarded by the same allowed-root rules as/api/files. - New worktrees are created under
<repoRoot>-worktrees/<sanitized-branch>. Existing branches are reused; otherwisegit worktree add -bcreates the branch. - Removing a dirty worktree returns
409with{ dirty: true }so the UI can ask before retrying withforce. - Sessions whose cwd points at a removed worktree are inferred back into the main project instead of becoming a phantom project row.
Managed projects sidebar (lib/project-registry.ts, /api/projects)
- The sidebar lists managed projects: explicitly added directories (registered in
~/.omp/agent/projects.json, written atomically as temp-file + rename) plus session-discovered ones — hidden entries excluded. Removing a project only marks it hidden (reversible via re-adding); hidden entries suppress session re-discovery. - Registry paths are canonical
projectRoots:POSTresolves worktrees to their main repo viaresolveProject, andresolveProjectreturns the symlink-free on-disk form for plain directories so registered and session-discovered paths compare equal on Windows casing. GET /api/projectsre-authorizes registered roots withallowFileRoot()— the in-memory browse allowlist does not survive restarts, and empty managed projects derive no root from sessions.- The client sorts the merged list by most-recently-added (registration
order), then by path for session-discovered projects
(
lib/project-ordering.ts); the order deliberately does NOT depend on session activity, so project rows never jump around while sessions refresh. Expanded project paths live inlocalStorage(omp-web:expanded-projects), defaulting to only the active/restored project expanded, and stale keys are pruned against the current project list (only after the first project fetch — an empty still-loading list must never wipe storage). - Each project's session tree is capped at 5 roots with a show-more toggle; project rows are cards matching the session items' height/margins/accent treatment, and the active project's worktree selector renders directly below its row.
File access allow-list
/api/filesis intentionally not a general filesystem browser. Allowed roots come from session cwds, their resolved project roots,~/omp-cwd-*, and roots explicitly added withallowFileRoot()./api/cwd/validate,/api/default-cwd, and/api/worktreescallallowFileRoot()when they make a new location browsable.
Session list caching — new sessions must appear immediately
listAllSessions()(sidebar, command palette) is cached twice: a 30s TTL list cache inlib/session-reader.tsplus an mtime-keyed directory walk inlib/omp/session-files.ts(listSessionFiles).- The walk cache keys on the sessions root mtime. On Windows/NTFS a new
.jsonlinside an existing project subdirectory does NOT bump the root mtime, so the walk stays stale indefinitely. invalidateSessionListCache()(fired onagent_end,session_info_update, compaction, renames) must therefore ALSO clear the walk cache viainvalidateSessionFileListCache()— never add a session-mutation path that forgets this. Regression test:session-reader.test.mjs.
Chat scroll-follow
useAgentSessionfollows the conversation: the effect depends on bothmessages(boundaries) andstreamState(every token batch) and throttles to onerequestAnimationFramewhile a run is active (followScrollFrameRef).- A manual scroll-up sets
completionScrollAllowedRef = falseand disables following until the next prompt;scrollUserMsgToTophandles the pending-scroll after sending. - Programmatic smooth scrolling must respect
prefers-reduced-motion(usePrefersReducedMotioninhooks/usePrefersReducedMotion.ts— also the only way to stop SVG SMIL animations, which CSS cannot).
MCP configuration (lib/omp/mcp-config.ts, /api/mcp, components/McpConfig.tsx)
- Project MCP config resolution order:
.omp/mcp.json,.omp/.mcp.json,mcp.json,.mcp.jsonat the git top level (falls back to cwd for non-git dirs). Server definitions supportstdio,http, andsse; exactly one ofcommand/urlis required and validated before any write. - Writes are atomic (temp file + rename), preserve unrelated top-level keys
(
disabledServers,$schema, ...), and support rename viapreviousName. - The MCP settings live in their own Settings tab (
SettingsTabsid"mcp", workspace-gated). Server list rows show a config-derived status dot (valid+enabled / disabled / invalid) — no live-connectivity probe exists in the RPC protocol, so failures surface as toasts (toast.error) from the editor actions, not inline text. - The endpoint is guarded by the same allowed-root rules as
/api/files.
Plugins and skills
/api/pluginsshells out to the user'somp pluginCLI (list/install/uninstall/enable/disable/upgrade,--jsonwhere available) — never the Bun-only SDK./api/skillsuseslib/skills-service.ts, a pure-Node scanner mirroring omp's discovery order: project.omp/skills(walk-up),~/.omp/agent/skills, then the.claude/.agent(s)/.codex/.githubcompat dirs and managed skills.- Skill toggling edits only the
disable-model-invocationfrontmatter key on the targetSKILL.md; keep that surgical so user formatting survives. /api/skills/installshells throughnpx skills add ... --agent universal, which installs into the ecosystem-standard.agents/skillsdirs omp reads; project installs run with the selected cwd.
Update notifications (/api/omp-update, /api/app-update)
- Automatic in-app self-updating has been removed in favor of explicit user notifications and manual terminal commands.
GET /api/app-updatequeries the npm registry for@kahme247/ompwebupdates, detects the install manager (bunvsnpmviadetectInstallMethod), and returnsupdateAvailableplus the exact terminal command (e.g.npm install -g @kahme247/ompweborbun add -g @kahme247/ompweb).POST /api/omp-update(action: "check") runsomp update --checkand returnsupdateAvailableplusupdateCommand: "omp update".POST /api/omp-update(action: "restart") restarts active OMP sessions after a manual CLI update.- Notifications in
AppShelland settings cards inSettingsConfigpresent the update notification alongside copyable terminal update commands.
Auth and model config
- Auth flows go through RPC commands (
get_login_providers,login) against the omp child process; credentials live in omp'sagent.db(SQLite) which omp-web never touches directly. - The Models panel reads and writes
models.ymlin the omp agent directory (~/.omp/agent/models.yml,.yamlfallback). - API-key status endpoints must never return the raw key.
Completion sound
hooks/useAudio.tsstores the toggle inlocalStorageand reuses oneAudioContext.- Browser autoplay policy means sound must be unlocked from a user gesture;
ChatInputcalls the unlock hook from interactive controls, andChatWindowplays the tone fromonAgentEnd.
omp Session File Format (v3)
Location: ~/.omp/agent/sessions/<encoded-cwd>/<timestamp>_<uuid>.jsonl
{"type":"title","v":1,"title":"...","source":"...","updatedAt":"...","pad":" ..."} ← fixed 256-byte slot
{"type":"session","version":3,"id":"<uuid>","timestamp":"...","cwd":"/path","parentSession":"/abs/path/to/parent.jsonl"}
{"type":"model_change","id":"<8hex>","parentId":null,"provider":"...","modelId":"...","timestamp":"..."}
{"type":"message","id":"<8hex>","parentId":"<8hex>","message":{"role":"user","content":"..."}}
{"type":"message","id":"<8hex>","parentId":"<8hex>","message":{"role":"assistant","content":[...],...}}
{"type":"message","id":"<8hex>","parentId":"<8hex>","message":{"role":"toolResult","toolCallId":"...","content":[...]}}
{"type":"compaction","id":"<8hex>","parentId":"<8hex>","summary":"...","firstKeptEntryId":"<8hex>","tokensBefore":N}
- Line 1 is a fixed-width 256-byte padded title slot, rewritable in place.
Old pi files may lack it — the
{"type":"session"}header is then line 1. - Entries form a tree via
(id, parentId). Additional entry types (title_change,session_init,mode_change,ttsr_injection, ...) must be tolerated by readers. - Large payloads (images) are externalized to the content-addressed blob store
at
~/.omp/agent/blobsand referenced from entries.
entryIds[] in SessionContext is a parallel array to messages[] — maps each displayed message back to its .jsonl entry id, used for fork and navigate_tree calls.
Design Tokens & UI Kit (app/globals.css, components/ui/)
Warm-paper (light) / warm-ember (dark) palettes; every text/background pair is
WCAG AA-verified (measured ratios noted in globals.css comments). Components
must consume these variables — no hardcoded colors.
color: --bg --bg-panel --bg-hover --bg-selected --border --bg-subtle
--text --text-muted --text-dim
--accent --accent-strong --accent-hover (links / filled buttons / hover)
--user-bg --tool-bg
type: --font-serif (display headings, class .display-serif) --font-mono
shape: --radius-control (8) --radius-card (12) --radius-modal (16)
depth: --shadow-card --shadow-pop --shadow-modal
motion: --dur-fast (150ms) --dur-med (220ms) --dur-slow (320ms) --ease-out-warm
components/ui/ holds the shared primitives (built on @base-ui/react):
primitives.tsx (Dialog/Tooltip/Collapsible), field.tsx (form fields +
ConfirmDialog), toast.tsx (toast.success/error/info, mounted in AppShell).
Icons come from lucide-react — do not add new inline SVGs. The command
palette (components/CommandPalette.tsx, ⌘K/Ctrl+K) is built on cmdk.
This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in node_modules/next/dist/docs/ (resolved from this file's directory; in monorepos the next package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by next dev — verify at node_modules/next/dist/server/lib/generate-agent-files.js. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.