Files
Khaled Ahmed ae6ca84f26 feat(chat): stream live tool output while tools run
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.
2026-09-12 06:09:59 +03:00

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 installed omp binary (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 globalThis registry.
  • globalThis survives 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 .jsonl file. Shown as a child in the sidebar tree via parentSession header 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:

  • useAgentSession keeps a liveToolResults map keyed by toolCallId (seeded on tool_execution_start with partial: true, refreshed on tool_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 (ChatWindow merges them), so a reload never shows a stale snapshot.
  • ToolCallBlock renders a partial result 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. AppShell must pass toolCallsDefaultCollapsed into ChatWindow; without it the setting is inert (the chat then always collapses).
  • tool_execution_update is coalesced per tool call at display rate in lib/message-update-coalescer.ts (chatty commands emit ~10-100+ frames/s). message_end drops the pending message_update (the committed message supersedes it) but must NOT drop buffered tool updates.
  • Live entries are cleared on agent_start, terminal agent_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 by subscribeRunningSessions() in lib/rpc-manager.ts, so running badges update without polling.
  • useAgentSession still treats per-session SSE as primary for chat events, but while a run is active it periodically calls GET /api/agent/[id] and also reconciles on visibilitychange/online. This fixes missed agent_end events 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. ComposerPanels renders 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 while started, check/alert/ban for terminal states) fed by the same subagent_lifecycle/subagent_progress SSE frames; clicking a chip opens the transcript dialog. TodoList keeps a non-collapsible default (collapsible prop) for SSR tests.

Subagent integration (lib/subagent-types.ts, lib/subagent-history.ts)

  • Live detail: subagent_progress frames carry the full AgentProgress object — lib/subagent-types.ts parses it defensively into SubagentInfo.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_event frames also feed a bounded per-subagent activity buffer shown in the transcript dialog.
  • Roster hydration: get_subagents snapshots (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 keep progress[]/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 (mirroring get_subagent_messages, which is RPC-registry-gated and refuses files it doesn't know). The dialog reads only the final output — <id>.md via ?mode=completion (bounded tail read that also works for transcripts beyond the 16MB paging cap) with a live get_subagents snapshot 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.tsx TaskResultPanel): the session reader allowlists a SIZE-BOUNDED subset of task toolResult details (telemetry only — no output/stderr, long text truncated to 240 chars, lib/session-reader.ts keepTaskToolResultDetails), and expanded task tool 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.task progress), and the ⤴ async marker (live detached flag or history details.async presence). Shared formatters live in lib/subagent-format.ts.

Worktrees and project grouping

  • lib/worktree.ts resolves linked worktree top-levels back to the main repo projectRoot; listAllSessions() attaches that to each SessionInfo so all worktrees for one repo are grouped together in the sidebar.
  • Worktree operations are served by /api/worktrees and guarded by the same allowed-root rules as /api/files.
  • New worktrees are created under <repoRoot>-worktrees/<sanitized-branch>. Existing branches are reused; otherwise git worktree add -b creates the branch.
  • Removing a dirty worktree returns 409 with { dirty: true } so the UI can ask before retrying with force.
  • 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: POST resolves worktrees to their main repo via resolveProject, and resolveProject returns the symlink-free on-disk form for plain directories so registered and session-discovered paths compare equal on Windows casing.
  • GET /api/projects re-authorizes registered roots with allowFileRoot() — 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 in localStorage (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/files is intentionally not a general filesystem browser. Allowed roots come from session cwds, their resolved project roots, ~/omp-cwd-*, and roots explicitly added with allowFileRoot().
  • /api/cwd/validate, /api/default-cwd, and /api/worktrees call allowFileRoot() 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 in lib/session-reader.ts plus an mtime-keyed directory walk in lib/omp/session-files.ts (listSessionFiles).
  • The walk cache keys on the sessions root mtime. On Windows/NTFS a new .jsonl inside an existing project subdirectory does NOT bump the root mtime, so the walk stays stale indefinitely.
  • invalidateSessionListCache() (fired on agent_end, session_info_update, compaction, renames) must therefore ALSO clear the walk cache via invalidateSessionFileListCache() — never add a session-mutation path that forgets this. Regression test: session-reader.test.mjs.

Chat scroll-follow

  • useAgentSession follows the conversation: the effect depends on both messages (boundaries) and streamState (every token batch) and throttles to one requestAnimationFrame while a run is active (followScrollFrameRef).
  • A manual scroll-up sets completionScrollAllowedRef = false and disables following until the next prompt; scrollUserMsgToTop handles the pending-scroll after sending.
  • Programmatic smooth scrolling must respect prefers-reduced-motion (usePrefersReducedMotion in hooks/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.json at the git top level (falls back to cwd for non-git dirs). Server definitions support stdio, http, and sse; exactly one of command/url is required and validated before any write.
  • Writes are atomic (temp file + rename), preserve unrelated top-level keys (disabledServers, $schema, ...), and support rename via previousName.
  • The MCP settings live in their own Settings tab (SettingsTabs id "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/plugins shells out to the user's omp plugin CLI (list/install/uninstall/enable/disable/upgrade, --json where available) — never the Bun-only SDK.
  • /api/skills uses lib/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 / .github compat dirs and managed skills.
  • Skill toggling edits only the disable-model-invocation frontmatter key on the target SKILL.md; keep that surgical so user formatting survives.
  • /api/skills/install shells through npx skills add ... --agent universal, which installs into the ecosystem-standard .agents/skills dirs 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-update queries the npm registry for @kahme247/ompweb updates, detects the install manager (bun vs npm via detectInstallMethod), and returns updateAvailable plus the exact terminal command (e.g. npm install -g @kahme247/ompweb or bun add -g @kahme247/ompweb).
  • POST /api/omp-update (action: "check") runs omp update --check and returns updateAvailable plus updateCommand: "omp update".
  • POST /api/omp-update (action: "restart") restarts active OMP sessions after a manual CLI update.
  • Notifications in AppShell and settings cards in SettingsConfig present 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's agent.db (SQLite) which omp-web never touches directly.
  • The Models panel reads and writes models.yml in the omp agent directory (~/.omp/agent/models.yml, .yaml fallback).
  • API-key status endpoints must never return the raw key.

Completion sound

  • hooks/useAudio.ts stores the toggle in localStorage and reuses one AudioContext.
  • Browser autoplay policy means sound must be unlocked from a user gesture; ChatInput calls the unlock hook from interactive controls, and ChatWindow plays the tone from onAgentEnd.

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/blobs and 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.