diff --git a/.github/workflows/omp-runner.yml b/.github/workflows/omp-runner.yml new file mode 100644 index 000000000..6446d1dad --- /dev/null +++ b/.github/workflows/omp-runner.yml @@ -0,0 +1,91 @@ +name: omp runner binaries + +on: + push: + branches: + - main + pull_request: + paths: + - "runner/**" + - ".github/workflows/omp-runner.yml" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + test: + name: Typecheck and test + runs-on: ubuntu-latest + defaults: + run: + working-directory: runner + steps: + - name: Checkout repository + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.4.4 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Typecheck + run: bunx tsc -p . + + - name: Test + run: bun test + + build: + name: Build (${{ matrix.target }}) + # Bun cross-compiles every target from Linux, as omp's own release does. + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - { target: linux-x64, os: linux, cpu: x64, binary: ompweb-omp-linux-x64 } + - { target: linux-arm64, os: linux, cpu: arm64, binary: ompweb-omp-linux-arm64 } + - { target: darwin-x64, os: darwin, cpu: x64, binary: ompweb-omp-darwin-x64 } + - { target: darwin-arm64, os: darwin, cpu: arm64, binary: ompweb-omp-darwin-arm64 } + - { target: windows-x64, os: win32, cpu: x64, binary: ompweb-omp-windows-x64.exe } + defaults: + run: + working-directory: runner + + steps: + - name: Checkout repository + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.4.4 + + # The host's own addon package is not needed; the target's is what gets embedded. + - name: Install dependencies + run: bun install --frozen-lockfile --os=${{ matrix.os }} --cpu=${{ matrix.cpu }} + + - name: Build binary + run: bun scripts/build-binary.ts ${{ matrix.target }} + + - name: Smoke test + if: matrix.target == 'linux-x64' + run: | + set -euo pipefail + runtime_dir="$(mktemp -d)" + HOME="$runtime_dir/home" XDG_DATA_HOME="$runtime_dir/xdg" release/${{ matrix.binary }} --version + HOME="$runtime_dir/home" XDG_DATA_HOME="$runtime_dir/xdg" release/${{ matrix.binary }} --smoke-test + + - name: Upload binary + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ matrix.binary }} + path: runner/release/${{ matrix.binary }} + if-no-files-found: error diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 837387778..b43e6bd10 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -33,10 +33,10 @@ jobs: - run: npm ci - run: npm test - run: npm run build - - run: npm run smoke - + # Builds the omp runners too, which the source smoke spawns from runner/release/. - name: Build the release binaries run: sh scripts/build-release-binaries.sh + - run: npm run smoke - run: npm run smoke -- --binary release/ompweb-linux-x64/ompweb - name: Create draft GitHub Release @@ -55,6 +55,26 @@ jobs: TAG: ${{ github.ref_name }} run: gh release upload "$TAG" release/ompweb-*.tar.gz release/ompweb-*.zip --repo "$GITHUB_REPOSITORY" --clobber + # npm installs get the runner the esbuild way: one prebuilt package per platform, which npm installs only on + # the matching os/cpu, pinned to this exact version. No install script, no Bun or compile on the user's machine. + - name: Publish the omp runner packages and pin them + run: | + VERSION="$(node -p 'require("./package.json").version')" + for target in linux-x64 linux-arm64 darwin-x64 darwin-arm64 windows-x64; do + os="${target%-*}"; cpu="${target#*-}"; exe="" + if [ "$os" = windows ]; then os=win32; exe=.exe; fi + name="@kahme247/ompweb-omp-$target" + dir="runner-packages/$target" + mkdir -p "$dir" + cp "runner/release/ompweb-omp-$target$exe" "$dir/ompweb-omp$exe" + cp LICENSE "$dir/" + node -e 'const [name, version, os, cpu, bin] = process.argv.slice(1); process.stdout.write(JSON.stringify({ name, version, description: "The omp runner ompweb spawns, for one platform", license: "MIT", repository: { type: "git", url: "git+https://github.com/kahme247/ompweb.git" }, os: [os], cpu: [cpu], files: [bin, "LICENSE"] }, null, 2))' "$name" "$VERSION" "$os" "$cpu" "ompweb-omp$exe" > "$dir/package.json" + if ! npm view "$name@$VERSION" version --registry https://registry.npmjs.org/ >/dev/null 2>&1; then + (cd "$dir" && npm publish --provenance --access public) + fi + npm pkg set "optionalDependencies.$name=$VERSION" + done + - name: Publish to npm run: | PACKAGE_NAME="$(node -p 'require("./package.json").name')" diff --git a/.github/workflows/verify-pr.yml b/.github/workflows/verify-pr.yml index fc5e7f02c..c643cd041 100644 --- a/.github/workflows/verify-pr.yml +++ b/.github/workflows/verify-pr.yml @@ -43,8 +43,11 @@ jobs: node-version-file: .nvmrc cache: npm + # The runner's own deps too: lib/hosts/omp-socket.test.mjs checks the host against runner/src/channel.ts. - name: Install dependencies - run: npm ci + run: | + npm ci + npx bun install --cwd runner --frozen-lockfile - name: TypeScript typecheck run: npm run typecheck @@ -58,6 +61,9 @@ jobs: - name: Verify production build run: npm run build + - name: Build the omp runner + run: npx bun runner/scripts/build-binary.ts + - name: Smoke test run: npm run smoke @@ -68,25 +74,30 @@ jobs: run: | if [ "$RUNNER_OS" = Windows ]; then npx bun scripts/build-binary.ts --target bun-windows-x64-baseline - npm run smoke -- --binary release/ompweb-windows-x64/ompweb.exe + bin=release/ompweb-windows-x64/ompweb.exe else npx bun scripts/build-binary.ts --target bun-linux-x64-baseline - npm run smoke -- --binary release/ompweb-linux-x64/ompweb + bin=release/ompweb-linux-x64/ompweb fi + npm run smoke -- --binary "$bin" + # What the host updater checks on a new release before it switches (lib/hosts/update-worker.ts): the marker + # it finds the command by, then the command itself against the bundled ompweb-omp. + grep -qa ompweb-preflight-scratch- "$bin" + timeout 120 "$bin" preflight - name: Packed install smoke test shell: bash run: | npm pack --ignore-scripts npm install -g --prefix "$RUNNER_TEMP/ompweb-prefix" ./kahme247-ompweb-*.tgz - npm run smoke -- --package "$(npm root -g --prefix "$RUNNER_TEMP/ompweb-prefix")/@kahme247/ompweb" + npm run smoke -- --omp "$(ls runner/release/ompweb-omp-*)" --package "$(npm root -g --prefix "$RUNNER_TEMP/ompweb-prefix")/@kahme247/ompweb" # Bun's postinstall does not run here: the launcher must fall back to @oven/bun-- (spec R1). - name: Packed install smoke test (install scripts disabled) shell: bash run: | npm install -g --ignore-scripts --prefix "$RUNNER_TEMP/ompweb-prefix-no-scripts" ./kahme247-ompweb-*.tgz - npm run smoke -- --package "$(npm root -g --prefix "$RUNNER_TEMP/ompweb-prefix-no-scripts")/@kahme247/ompweb" + npm run smoke -- --omp "$(ls runner/release/ompweb-omp-*)" --package "$(npm root -g --prefix "$RUNNER_TEMP/ompweb-prefix-no-scripts")/@kahme247/ompweb" - name: Parse Windows PowerShell scripts (Windows only) if: runner.os == 'Windows' diff --git a/AGENTS.md b/AGENTS.md index 7593b18c1..fbd3d6e02 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,8 @@ npm run build:binary [-- --target bun--] # build, then compile relea npm run typecheck && npm run lint ``` -The dev server needs the `omp` binary installed (on `PATH`, or set `OMP_WEB_OMP_BIN`). +The dev server needs ompweb's omp build (`runner/`: `cd runner && bun install`, then point +`OMP_WEB_OMP_BIN` at `runner/release/ompweb-omp--` or a wrapper running `bun runner/src/cli.ts`). All live-agent features go through it; session browsing works without it. --- @@ -20,8 +21,8 @@ All live-agent features go through it; session browsing works without it. ## Architecture omp-web never imports `@oh-my-pi/*` or `@earendil-works/*` packages, even -though the server now runs on Bun: live work goes through the installed `omp` -child. See `DESIGN.md` for the full porting contract. +though the server now runs on Bun: live work goes through the `ompweb-omp` +child (`runner/`, ompweb's own omp build). See `DESIGN.md` for the full porting contract. ``` Browser Bun server (server/app.ts) omp child process @@ -32,8 +33,8 @@ Browser Bun server (server/app.ts) omp child process │ (all tabs) POST /api/events ─▶ subscribe running/session/file │ │ │ │ ├─ send message ─────────▶ POST /api/agent/[id] │ - │ │ startRpcSession() ── spawn ─────▶│ omp --mode rpc-ui - │ │ sendCommand({type:"prompt"}) ───▶│ (NDJSON stdio) + │ │ startRpcSession() ── spawn ─────▶│ ompweb-omp --headless + │ │ sendCommand({type:"prompt"}) ───▶│ (channel, NDJSON stdio) │ │ │ ├─ session subscription ─▶ event-stream-hub onEvent() │ │ │ onFrame() ◀── event frames ──────│ @@ -52,7 +53,7 @@ its own process (no launcher), `ompweb host` and `ompweb hash-password`. files via `lib/session-reader.ts` — no child process involved. **Sending a message**: `startRpcSession()` in `lib/rpc-manager.ts` asks the session's `Host` (`lib/hosts/host.ts`; `localHost` in `lib/hosts/local-host.ts`) -to spawn `omp --mode rpc-ui` (one process per active session) and runs +to spawn `ompweb-omp --headless` (one process per active session) and runs `lib/omp/rpc-process.ts` over the returned transport. Every machine-local action of a session wrapper (spawn, session-file checks, list invalidation) goes through that host. @@ -61,9 +62,9 @@ Shared foundations in `lib/omp/`: - `paths.ts` — Node port of omp's directory resolution (`~/.omp/agent`, XDG, session dir slugs) and `sessionLocalRoot`, omp's `local://` root rule. -- `omp-cli.ts` — locate/probe the installed `omp` binary (`resolveOmpBin`, +- `omp-cli.ts` — locate/probe the `ompweb-omp` binary (`resolveOmpBin`, `getOmpVersion`). -- `rpc-process.ts` — process + NDJSON protocol layer (`RpcProcess`). +- `rpc-process.ts` — process + NDJSON channel layer (`RpcProcess`; `rpc-*` names are historical, the wire is the `ompweb-omp` channel of `docs/specs/2026-11-12-omp-runner-design.md`). - `rpc-transport.ts` — `RpcTransport`: the byte pipe under `RpcProcess` (`spawnLocalRpcTransport` for a local child; `RemoteRpcTransport` in `lib/hosts/hub.ts` for a remote one). @@ -99,7 +100,7 @@ app/api/ sessions/[id]/export/route.ts GET exported HTML for a session sessions/[id]/local-file/route.ts GET ?uri=local:// → `{ path, sessionId }`: the absolute path of a file in the session's own local:// directory, resolved on the owning host's daemon, and the canonical session that owns it (a live alias resolves to its current id; the tab must store that, since the alias goes with its wrapper). The open_file host tool then reads it through /api/files with that `sessionId`; 400 `invalid_local_uri` / `not_a_file`, 404 `session_not_found` / `file_not_found`, 403 on a symlink out, 501 `workspace_unsupported` for a host daemon that predates `resolveLocalFile` agent/new/route.ts POST { cwd, message, toolNames?, provider?, modelId? } - agent/[id]/route.ts GET state | POST any RPC command + agent/[id]/route.ts GET state | POST any channel command events/route.ts GET multiplexed SSE | POST subscribe/unsubscribe channels events/link/route.ts GET WebSocket upgrade: the browser link (lib/browser-link.ts), bound to the request's web session; gated like every API route, 426 without an upgrade transport-metrics/route.ts GET { now } (the SSE clock probe) | POST { samples } → /omp-web/transport-metrics.jsonl @@ -113,7 +114,7 @@ app/api/ notifications/presence/route.ts POST { clientId, deviceId, visible, sessionId, seq } notifications/test/route.ts POST { deviceId } send a test push (502 on push-service refusal) notifications/history/route.ts GET entries | PATCH { ids } mark read | DELETE ?id= remove one, ?all=1 clear - auth/** provider list, login/logout, API keys (via RPC) + auth/** provider list, login/logout, API keys (via channel commands) 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 @@ -307,6 +308,7 @@ The table is static imports, not a runtime glob, so `bun build --compile` (`scri - The binary needs `process.env.NODE_ENV` defined as `"production"` at build time (`scripts/build-binary.ts`): a plain `bun build --compile` inlines `"development"`, and the server then never writes the daemon's link. `npm run smoke -- --binary` catches that (no session lists). - `Bun.serve` turns on `SO_REUSEPORT` when `development` is false, so a second omp-web would share the port instead of failing; `startServer` passes `reusePort: false`, and `startProduction` (`server/app.ts`) reports an `EADDRINUSE` in one line. - The web-server in-app update worker runs on Node (`OMP_WEB_NODE_BIN`, the launcher's `process.execPath`), not Bun. The compiled web server updates by download (`lib/self-update.ts`). Host-daemon updates use their own helper in `lib/hosts/update-worker.ts`, including for binary hosts; never reuse the web-server worker to restart a host. +- Both updaters run the new release's `ompweb preflight` (`lib/hosts/preflight.ts`) in the live environment before they stop anything, and abort on failure: it starts the session omp the release resolves (the web worker sets `OMP_WEB_PACKAGE_DIR` to the scratch-installed candidate) and requires its hello on `CHANNEL_PROTO`. A candidate with a runner but no preflight command is refused; a compiled release is recognized by `PREFLIGHT_MARKER`, which only `preflight.ts` may embed. A failed or aborted web update writes `complete.json` so the armed launcher disarms. - The daemon-owned watcher (`lib/hosts/workspace-watch.ts`) re-stats ~50 ms after every event to catch burst saves (Bun may drop native events within ~2 ms); main only routes its sequenced watch events. - `bun:sqlite` `.get()` returns `null` for no row (not `undefined`). - `bun:sqlite` `close()` keeps the database file open while prepared statements are alive (until GC), so Windows cannot remove or replace it: the stores close with `close(true)`, which finalizes them (`closeNotificationStore`, `closeUsageDatabase`). @@ -419,7 +421,7 @@ The table is static imports, not a runtime glob, so `bun build --compile` (`scri busy key; control lines skip both. The `hello` is the first text frame of a socket. Each `hello.channels` entry carries `pending`: the raw stdout lines of that child's - still-open `host_tool_call`s, `host_uri_request`s and blocking `extension_ui_request`s + still-open `host_tool_call`s, `host_uri_request`s and blocking `ui_request`s (`PendingRequests`), or null past `LINK_LIMITS.pendingBytes`. Each side keeps unacked lines, resends after a reconnect and drops any `seq` it already applied. The hub decodes a whole frame before applying any of it (a bad line closes 1007). @@ -640,14 +642,13 @@ The table is static imports, not a runtime glob, so `bun build --compile` (`scri after it saves; neither may await between reading the pools and saving. - **Protocol 10.** `open.launch.configVersion` is the master's version (64 hex digits, `CONFIG_VERSION_RE`). The hub sets it for a cluster member's process start (not for a join of a - running channel), after reading `pools.json` a second time so a host that left meanwhile gets none; a member - below v10 is refused 426 `host_upgrade_required`. The daemon strips `shareCredentials`, + running channel), after reading `pools.json` a second time so a host that left meanwhile gets none. The daemon strips `shareCredentials`, `credentialsServer` and `configVersion` from every wire launch and sets them only for the opening link (`forStart`); a launch with a version needs `credentialsServer` and applies it first (`createClusterConfig.ensure`) or the start is refused 409 `cluster_config_failed`. `config_state { state, owned }` is an unsequenced daemon report (like `capacity`, never replayed), at link up, on each change and after each `host.json` reload; one that finds the send window full is not dropped: the latest goes at - the link's next flush (main's ack, or the poll of a full socket buffer). The hub ignores it below v10. The + the link's next flush (main's ack, or the poll of a full socket buffer). The `clusterConfig` op is `{ action: "apply", version }` or `{ action: "restore" }` and answers `{ state, owned }`. **The version is never persisted in the resume manifest:** `ChannelTable.running()` drops it and `host-running.json` has no field for it, so a recovered session starts on the version already applied. @@ -679,10 +680,11 @@ The table is static imports, not a runtime glob, so `bun build --compile` (`scri `startPooledSession` says why: "still receiving the configuration" or "Update ompweb on the pool's workers". A start pinned to one member applies first and is refused with the reason; a plugin difference does not refuse it. - - **Upgrade.** Every cluster member needs a daemon at protocol 10 (`CLUSTER_CONFIG_SINCE_VERSION`; also the - join rule `CLUSTER_SINCE_VERSION`). Until then starts there are refused and placement skips it. + - **Upgrade.** Every linked daemon speaks protocol 10 or newer (`MIN_LINK_PROTOCOL_VERSION`; an older one is refused + at the link), so every member takes the configuration; joining also needs the `gitOrigins` op and an omp that + shares credentials (`clusterJoinBlock`). - **Settings.** Rows carry `clusterConfig { …state, owned, current }` (`listHosts`); the member line shows - current, updating (none while `masterConfigError` is set), failed (reason), held by another server, needs update (below v10), and the MCP commands + current, updating (none while `masterConfigError` is set), failed (reason), held by another server, needs update (`clusterJoinBlock` says "upgrade"), and the MCP commands missing there. For a cluster session on another member (`isClusterTarget` with a member host) the MCP, Skills, Plugins and Agents panels use no cwd and no session (the user-level routes above); MCP alone keeps the session for `?attach=1`, read-only (`McpConfig liveOnly`). @@ -925,7 +927,7 @@ The table is static imports, not a runtime glob, so `bun build --compile` (`scri - **Dev mode does not take the daemon.** With `NODE_ENV=development` `ensureLocalHostLink` is skipped, so the daemon stays linked to production and the dev server shows "This machine" offline. The local row of `GET /api/hosts` carries `daemonManaged: false` there, which hides the install banner. - **`local` is a registry record like any host**, with three exceptions: it is listed first, `PATCH` takes `label` only (`allowAgentStart` is fixed), and it cannot be revoked. Renaming "This machine" is a `PATCH /api/hosts {id: "local", label}`; an empty label resets it (`registry.ts` stores `""`; `customLocalLabel()` / `DEFAULT_LOCAL_LABEL` map it). `getHost("local")` works without the record: it is the daemon link, offline while none is linked, and "needs upgrade" on a protocol mismatch (`hostVersion` in the row is the daemon's). - **Joining on hello** (`reattach`/`adopt` in `hub.ts`): v7 servers call `open` to join each listed channel, restore pending requests and the current whole streaming message, then request state/subagents/side-question snapshots before releasing held frames. They also join `channels_changed` announcements with no tab open, preserving background badges/notifications. The daemon has already negotiated and set process-global subscriptions; servers do not change those settings. Legacy hosts retain the previous adoption handshake, including startup settings. -- **The daemon replays open blocking requests** (`pending-requests.ts`): `host_tool_call`, `host_uri_request` and blocking `extension_ui_request` lines, until answered or cancelled, capped at `LINK_LIMITS.pendingBytes` per channel. An adopted wrapper feeds them through `handleFrame` (`replaying`; agent tools are not re-run), so dialogs reappear and browser tool calls wait for the next tab (`hostToolsUnknown`, `parkHostTool`). A request opened while the old server stopped is also resent unacked: `RpcProcess.release` skips held requests the replay already delivered, so it is handled once. The hello also carries `autoApproveExtension`, which an adopted run has no spawn result for. +- **The daemon replays open blocking requests** (`pending-requests.ts`): `host_tool_call`, `host_uri_request` and blocking `ui_request` lines, until answered or cancelled, capped at `LINK_LIMITS.pendingBytes` per channel. An adopted wrapper feeds them through `handleFrame` (`replaying`; agent tools are not re-run), so dialogs reappear and browser tool calls wait for the next tab (`hostToolsUnknown`, `parkHostTool`). A request opened while the old server stopped is also resent unacked: `RpcProcess.release` skips held requests the replay already delivered, so it is handled once. The hello also carries `autoApproveExtension`, which an adopted run has no spawn result for. - **Legacy orphan cleanup:** only v4–v6 channels use `killOrphan` when adoption is impossible. v7 adoption failure/disposal closes the server's membership; the host decides abandonment and continues any peer's run. Explicit delete/archive is fenced on the host against a concurrent open, even before this server has a wrapper. - **Shutdown freezes the links and never kills daemon children.** The `cleanup` in `rpc-manager.ts` runs `markShuttingDown()` and `freezeHostLinks()`: link sockets are closed 1001 and from then on a link neither sends nor applies anything (no kill, no rejection of open host tool calls as tabs close); new upgrades get 503 "omp-web is shutting down". A real SIGTERM exits in the same tick (`process.exit` in `instrumentation.node.ts`), before Bun writes the close frame, so daemons usually see 1006 or ECONNREFUSED rather than 1001 and redial the same way. The daemon keeps the output unacked and redials for the next server. An adoption that fails after the freeze leaves its child to the next server instead of killing it. - **Install banner** (`components/LocalDaemonBanner.tsx`). In production, when the local row stays offline with `hostVersion === null` (no daemon has linked since this server started) for 90 s of a page view (well past the daemon's redial wait, at most 3 s on loopback, so a restarting daemon does not trip it), it shows the platform's install command and, for a foreground `ompweb` (terminal or npx, no service), `ompweb host` to keep running in another terminal; Settings → Hosts shows both for an offline local row. Dismissal is kept in `localStorage`. Main never installs services or starts the daemon. @@ -1104,7 +1106,7 @@ end to end: ### Event protocol differences vs pi omp emits no `prompt_done` / `prompt_error` / `compaction_start` / `compaction_end` events. Completion is `agent_end` (`isTerminal !== false`), -errors surface as failed RPC responses plus `notice` events. +errors surface as failed command results (`res`) plus `notice` events. New frame types (`turn_start/end`, `notice`, `todo_reminder`, ...) must be handled or safely ignored. @@ -1142,7 +1144,7 @@ during the wait. (`lib/composer-prefs.ts`, `omp-web:submit-during-run`); only an explicitly stored choice overrides it, and an absent key yields `queue`. - omp-web's own `!!` shell command (`bashRunning`, set for the whole `bash` - RPC) is the one state omp refuses a prompt in. The wrapper degrades instead + command) is the one state omp refuses a prompt in. The wrapper degrades instead of throwing (`queueWhileShellRunning` in `lib/rpc-manager.ts`): the frame is forwarded as omp's `follow_up` — never aborted, never reimplemented here — and the reply reports it (`{queued:true, queue:"followUp", @@ -1457,7 +1459,7 @@ during the wait. non-collapsible default (`collapsible` prop) for SSR tests. ### Side questions (`/btw`, `lib/btw.ts`, `hooks/useBtw.ts`, `components/BtwPanel.tsx`) -- `btw` / `btw_cancel` / `get_btw_history` are passthrough RPC commands; +- `btw` / `btw_cancel` / `get_btw_history` are passthrough channel commands; answers stream as `btw_delta` (text appended to the latest turn) and `btw_record` (full snapshot per lifecycle change) frames. omp persists the history per session (and re-reads it from disk when idle), so the TUI and @@ -1509,7 +1511,7 @@ during the wait. - **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. + the live 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 (`/.jsonl`) and the parent file's @@ -1517,7 +1519,7 @@ during the wait. 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 + `get_subagent_messages`, which is registry-gated and refuses files it doesn't know). The dialog reads only the final output — `.md` via `?mode=completion` (bounded tail read that also works for transcripts beyond the 16MB paging cap) with a live `get_subagents` snapshot fallback @@ -1698,7 +1700,7 @@ during the wait. - 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 + the channel 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`. - `GET /api/mcp?sessionId=…&attach=1` returns the live status of a session this server already has running (a remote one included), and otherwise answers `liveError: "The session is not running"` instead of starting one (a cluster session on another member is read where it runs; `McpConfig liveOnly` shows it read-only). @@ -1719,7 +1721,7 @@ during the wait. - `/api/omp-update` remains an explicit server-side maintenance API for existing callers. The settings UI and AppShell no longer use it as a global update control or send a server-wide session restart after a host update. ### Auth and model config -- Auth flows go through RPC commands (`get_login_providers`, `login`, `get_logout_accounts`, `logout`) against the omp child process; credentials live in omp's `agent.db` (SQLite) which omp-web never touches directly. `/api/auth/accounts/[provider]` lists stored accounts and removes one by `credentialId`; omp without those commands reports `supported: false` and the UI falls back to the read-only `omp usage` account list. +- Auth flows go through `ompweb-omp` commands (`get_login_providers`, `login`, `get_logout_accounts`, `logout`) against the omp child process; credentials live in omp's `agent.db` (SQLite) which omp-web never touches directly. `/api/auth/accounts/[provider]` lists stored accounts and removes one by `credentialId`; omp without those commands reports `supported: false` and the UI falls back to the read-only `omp usage` account list. - Linked hosts with `shareCredentials` read this machine's credentials through `lib/omp/auth-broker.ts`, the `omp auth-broker serve` child (the token comes from `omp auth-broker token` and stays in the server process; a refused token stops the child and the next request starts a new one). The broker reloads the store on every snapshot, so a login or logout in Settings reaches every omp process a sharing host starts afterwards; a running process picks it up the next time it is used (omp stops listening for updates after 20 seconds without activity and resumes on its next use) and keeps the access tokens and API keys it has already loaded until it ends. - 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. @@ -1756,7 +1758,7 @@ during the wait. the `.sidebar-shell` container query (the keyboard shortcuts still work). ### Composer word prediction (`hooks/useWordPrediction.ts`, `components/GhostMirror.tsx`) -- Ghost text comes from omp's `predict_word` RPC (engine = omp's +- Ghost text comes from omp's `predict_word` command (engine = omp's `spelling.autocomplete` setting; omp applies the prose gates). Tab or → accepts; accept/typed-past outcomes go back as `predict_word_feedback`. - Keystroke predictions never spawn or replace an omp child: the agent route diff --git a/CHANGELOG.md b/CHANGELOG.md index 271ecb895..eb161f6eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to **omp-web** (`@kahme247/ompweb`) are documented in this f ### Added +- **Upgrades check the session omp before they switch.** `ompweb preflight` resolves omp exactly as the host does for a new session (`OMP_WEB_OMP_BIN` or the bundled `ompweb-omp`), starts it headless with `--no-session`, and waits up to 30 s for its hello: it prints `preflight ok: `, or exits 1 naming the binary and why (not found, not executable, exited with its code, or no hello with omp's last stderr). The host updater and the web app's in-app update run the new release's preflight in the live environment before they stop anything, and abort with the current install still running when it fails. A release without the command is checked only if it ships `ompweb-omp`; then the update fails. An npm-installed `ompweb host` now finds the bundled `ompweb-omp` from its own package rather than from the directory it was started in. - **Rename the cluster.** Settings → Hosts shows the cluster's name with a **Rename cluster** button; the sidebar group follows. The cluster is still created as "Cluster" when the first host joins, so rename it afterwards. `PATCH /api/hosts { poolId, label }` renames a pool without replacing the pool registry. - **Cluster members use this machine's omp configuration.** **Before upgrading:** every cluster member needs an ompweb host daemon at link protocol 10. Until a member's daemon is updated, sessions cannot start there (the start is refused and names the update), cluster placement skips it, a host below protocol 10 cannot join, and Settings → Hosts says it needs an update. Once its daemon is updated, every host already in the first pool receives this machine's configuration, secrets included (API keys, MCP environments and headers, tokens), automatically and without a confirmation having been shown, and its own omp configuration is replaced until it leaves the cluster; it is kept in `/omp-web/own-config/` on the member and put back on leaving. To avoid that for a host, remove it from the first pool (`PUT /api/hosts`, or editing `~/.omp/agent/omp-web/pools.json`) before updating its daemon. Update the web server first, then the members. @@ -50,6 +51,7 @@ All notable changes to **omp-web** (`@kahme247/ompweb`) are documented in this f ### Fixes & Improvements - **Sorting, pinning and reordering work in the cluster's sidebar group.** The group listed the cluster's shared projects first whatever the sort, and Move up/down, drag and pins only worked among projects of the same machine, so a pinned or alphabetically earlier project of a member could not move above a shared one. The group is now one list: the chosen sort, pins and manual order apply across shared projects and every member's own projects. +- **omp's questions and forms are easier to read and answer.** Every choice, in a question, a form, a pick list or an approval, is now a row of the same size, and a checked row is highlighted, including the options the agent pre-checked in a form. Text fields have visible borders and show the keyboard focus ring again, a field left invalid after Submit is outlined in red, a list's **Done selecting** row is marked as an action, and form sections and field labels are spaced apart. Rows and fields follow the touch-target setting, and native controls in the warm dark theme are dark. - **Git commands stay on the folder's own repository.** A host daemon started from a Git hook or with `GIT_DIR` set in its environment no longer runs Git status, file search, worktree, clone or pool preparation against that other repository. The Git panel of a bare repository now reports "not a Git repository" instead of failing. On Windows pool workers, a long creation ID no longer makes preparation fail on Git's path-length limit, and the reported CPU busy time no longer counts interrupt time twice. - **Multiple-choice questions show which options are already checked.** When omp asks one checkbox question at a time, each option now shows ☑ or ☐, and screen readers announce "checked" or "not checked". Before, every option looked unchecked, so tapping an option the agent had pre-checked unchecked it instead. In the composer, tapping a checkbox option now toggles it at once instead of waiting for **Next**, so choosing **Done selecting** next can no longer drop it. This needs an omp that sends checkbox state on `select` requests (can1357/oh-my-pi#18929); older omp versions show the options as before. - **Reuse unchanged chat Markdown during streaming.** Markdown bodies skip rendering when their props are unchanged, while relevant tool-result changes still refresh their message. Back/forward session navigation requests the selected session instead of downloading the full session list. diff --git a/DESIGN.md b/DESIGN.md index 6c5a71620..c74b9335f 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -16,8 +16,8 @@ do not assume that Pi-specific implementation changes can be merged unchanged. ## Product principles 1. **OMP remains authoritative.** Sessions, credentials, providers, and agent - behavior belong to the installed `omp` CLI. ompweb must not invent a - parallel data format or credential store. + behavior belong to omp (run as ompweb's own build of it, `runner/`). ompweb + must not invent a parallel data format or credential store. 2. **Local-first by default.** The server binds to `127.0.0.1`; remote access is an explicit user choice and must be protected by a trusted network boundary and HTTPS. @@ -55,29 +55,32 @@ Browser ompweb (Bun server, started by the Node launcher) ├─ reads native OMP session files and selected configuration ├─ serves allow-listed project files - └─ starts one `omp --mode rpc-ui` child per active session - │ NDJSON over stdio + └─ starts one `ompweb-omp --headless` child per active session + │ ompweb channel, NDJSON over stdio ▼ - installed OMP CLI and its existing ~/.omp/agent state + ompweb's own omp build (runner/) and the existing ~/.omp/agent state ``` -### Why the CLI boundary is locked +### The omp boundary OMP SDK packages are Bun-only TypeScript and import Bun APIs. Importing `@oh-my-pi/*` or `@earendil-works/*` into the ompweb server, even though it runs on Bun, would tie it to omp's internals and make the application unreliable. -Therefore, production code must not add those runtime dependencies. +Therefore, the ompweb server and daemon must not add those runtime dependencies. -Live work goes through the user’s installed `omp --mode rpc-ui` process. This -keeps the agent version, providers, extensions, and session behavior aligned -with the CLI the user already trusts. The RPC layer negotiates v2 when the CLI -advertises it, reassembles bounded chunked frames, and remains compatible with -v1-capable installations. +Live work goes through ompweb's own omp build, `runner/`: upstream omp's CLI +pinned to an exact release, with one change, its session factory connects every +session to `ompweb host` over ompweb's channel. The host receives the same +`AgentSession` events and calls the same session methods omp's TUI does, so +nothing depends on omp's RPC mode. A session started from the runner's TUI +connects to the daemon's Unix socket and shows up live in the web; dialogs open +on both sides and the first answer wins. The design and the wire are in +`docs/specs/2026-11-12-omp-runner-design.md`. ### The Host seam Every machine-local action of a session goes through one interface, -`Host` (`lib/hosts/host.ts`): spawning `omp --mode rpc-ui`, checking that a +`Host` (`lib/hosts/host.ts`): spawning `ompweb-omp --headless`, checking that a session file exists, invalidating cached session lists, and the file-reading operations behind the session and project routes. The web server has no direct path to any machine, its own included: it reaches every host @@ -86,9 +89,9 @@ through the link described below. The implementation on the web server is the machine whose omp it owns. ``` -Browser ─▶ ompweb ◀── link (loopback) ── ompweb host ── spawn omp --mode rpc-ui (stdio) +Browser ─▶ ompweb ◀── link (loopback) ── ompweb host ── spawn ompweb-omp --headless (stdio) ◀── link (WebSocket) ── ompweb host on another machine - └─ spawn omp --mode rpc-ui + └─ spawn ompweb-omp --headless ``` "This machine" is the host with the fixed id `local`, linked to the daemon @@ -98,8 +101,8 @@ a run: the restarted server adopts the live children from the daemon's hello, with their open blocking requests replayed. A direct spawn path would tie every run to the web server's lifetime, which is why none exists. -The CLI boundary above holds on every machine: a remote machine runs its own -installed `omp`, its own `~/.omp/agent` and its own credentials, unless sharing is +The omp boundary above holds on every machine: a remote machine runs its own +runner build, its own `~/.omp/agent` and its own credentials, unless sharing is turned on for that host (Settings → Hosts): then its sessions use the server's provider logins through `omp auth-broker serve`, and the host never gets a refresh token. ompweb only routes. `RpcProcess` takes its byte pipe @@ -127,9 +130,9 @@ the daemon requires `https://` except for loopback or an explicit - `~/.omp/agent` is the source of truth for sessions, configuration, models, skills, plugins, and blobs. - `agent.db` contains authentication data. ompweb never reads or writes it; - authentication actions go through the OMP RPC process. + authentication actions go through the `ompweb-omp` process. - A live OMP process owns writes to its session file. ompweb routes supported - live actions through RPC and never races a live file rewrite. + live actions through the runner's channel and never races a live file rewrite. ### Direct file access @@ -211,7 +214,7 @@ version change invalidates offsets rather than trusting an unchecked prefix. Failed reads never advance the confirmed cursor. File-only catch-up also refreshes model and thinking metadata without replacing -newer or pending RPC choices. Replacement observer connections restore browser +newer or pending channel choices. Replacement observer connections restore browser registrations and the subagent roster only after successfully connecting. Deploy the frontend and API support together; no native OMP upgrade is required. @@ -244,7 +247,9 @@ Deploy the frontend and API support together; no native OMP upgrade is required. rather than secrets (`OMP_WEB_PACKAGE_DIR`, `OMP_WEB_OMP_BIN`, ...) are kept. - API requests are origin-checked. Do not add browser-to-host execution paths that bypass this boundary. -- OMP RPC host tools are intentionally not registered. A browser request must +- Host tools (`host_tool_call`) come from a fixed set: the browser's own tools + (open a URL or file, notify) and the server's session tools in `lib/agent-tools.ts`; + none runs a host command. A browser request must not become arbitrary host command execution through an extension callback. - File APIs are not a general filesystem browser. They are restricted to selected workspaces, valid Git worktrees, session-referenced directories, and @@ -257,8 +262,8 @@ Deploy the frontend and API support together; no native OMP upgrade is required. - The session sidebar is the durable navigation model: projects, sessions, branches, worktrees, and files must agree about the selected workspace. -- Streaming state is explicit. The UI reconciles Server-Sent Events with RPC - state so a background tab cannot remain falsely “running”. +- Streaming state is explicit. The UI reconciles Server-Sent Events with the + session's state so a background tab cannot remain falsely “running”. - Desktop and mobile share the same core workflow. Mobile controls keep usable touch targets and a visible loading state rather than a blank shell. - Accessibility and motion preferences are first-class. Components use the diff --git a/README.md b/README.md index b7236c674..611b08c32 100644 --- a/README.md +++ b/README.md @@ -26,14 +26,12 @@ A clean, modern web UI for the [oh-my-pi (omp)](https://github.com/can1357/oh-my ## Requirements -- [omp](https://github.com/can1357/oh-my-pi) installed and available on your `PATH` (or specified via `OMP_WEB_OMP_BIN`) +- Nothing else for omp: ompweb ships its own [omp](https://github.com/can1357/oh-my-pi) build, `ompweb-omp` (beside the single binary, or as the `@kahme247/ompweb-omp--` optional dependency of the npm package). It reads your `~/.omp` settings, auth and sessions. `OMP_WEB_OMP_BIN` points ompweb at another one - Node.js `>= 24`. The server runs on [Bun](https://bun.sh), which installs automatically as a dependency of the npm package (a Bun on your `PATH` is used if the bundled one is missing) -The **Steer** action on a queued follow-up requires an omp runtime with the `promote_queued_message` RPC command (not available in omp 18.1.16). Older runtimes report an error and leave the message queued as a follow-up; ompweb does not send a duplicate steering message. +The **Steer**, **Delete** and **Edit** actions on a queued message are commands of the `ompweb-omp` channel (`promote_queued_message`, `remove_queued_message`). Deletion is confirmed by omp before the chip disappears; editing recalls text only after cancellation succeeds. A message that is no longer pending leaves the queue unchanged and displays a notice; ompweb does not send a duplicate steering message. -Queue **Delete** and **Edit** additionally require `remove_queued_message`. Deletion is confirmed by OMP before the chip disappears; editing recalls text only after cancellation succeeds. Unsupported runtimes or messages that are no longer pending leave the queue unchanged and display a notice. - -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. +The queue panel shows omp's own queue (`queuedMessages` in `get_state` and `queue_update` events), 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. ## App installation behind authentication @@ -55,20 +53,6 @@ support. Local Chromium verification covered cookie-gated metadata with HTTP icon URLs blocked (zero installability errors). The owner also confirmed that installation on a physical phone works as expected behind Cloudflare Access. -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. - ## Notifications Turn on notifications in **Settings → Notifications**. Each browser keeps its own @@ -92,8 +76,8 @@ session you are viewing. Clicking a notification opens its session. - Desktop browsers show notifications through the operating system's notification center. Push delivery needs the browser process running. -Completion notifications use omp's `prompt_result` and `session_settled` RPC -frames (omp 18.3.1 or later). +Completion notifications use the `ompweb-omp` channel's `prompt_result` and +`session_settled` frames. ## Quick Start @@ -125,6 +109,7 @@ Open [http://127.0.0.1:30177](http://127.0.0.1:30177) in your browser. ompweb --port 8080 # Custom port ompweb --hostname 0.0.0.0 # Listen on network ompweb hash-password # Print the value for OMP_WEB_PASSWORD_HASH +ompweb preflight # Start the session omp once (as a new session would) and wait for its hello; exit 1 says why it failed ompweb --no-open # Don't auto-open the browser ompweb --install-tray # Install Windows System Tray service & Desktop shortcuts ompweb --uninstall-tray # Uninstall Windows System Tray service & shortcuts @@ -454,7 +439,7 @@ Each [release](https://github.com/kahme247/ompweb/releases/latest) also ships ompweb as one compiled program with the files it serves beside it: `ompweb-linux-x64.tar.gz`, `ompweb-linux-arm64.tar.gz`, `ompweb-darwin-x64.tar.gz`, `ompweb-darwin-arm64.tar.gz` and `ompweb-windows-x64.zip`. It needs no Node, npm -or Bun; only omp must be installed. The x64 builds use Bun's baseline runtime, so +or Bun, and no omp: its own omp build, `ompweb-omp`, is beside it. The x64 builds use Bun's baseline runtime, so they also run on CPUs without AVX2. Each archive carries the licenses of what it bundles in `licenses/`. Every commit on `main` that passes CI is also published as a [prerelease](https://github.com/kahme247/ompweb/releases) named after the time it was @@ -466,7 +451,7 @@ tar -xzf ompweb-linux-x64.tar.gz -C ~/.local/share # unpacks ompweb-linux-x64/ ~/.local/share/ompweb-linux-x64/ompweb host # the local daemon, in a second terminal ``` -- **Keep `ompweb` in its directory.** It serves `dist/`, `public/` and the other +- **Keep `ompweb` in its directory.** It runs `ompweb-omp` and serves `dist/`, `public/` and the other files from the directory it is in (a symlink to it on your `PATH` works), or from `OMP_WEB_PACKAGE_DIR` when that is set. - **macOS** quarantines downloads: run `xattr -dr com.apple.quarantine ompweb-darwin-arm64` @@ -480,8 +465,7 @@ tar -xzf ompweb-linux-x64.tar.gz -C ~/.local/share # unpacks ompweb-linux-x64/ `ompweb host`, replace the directory with the new release, and start both again; **Settings → System** links the release page when a new version is out. Your sessions and settings live in `~/.omp`, not in that directory. -- **As systemd user services**, write the two units yourself (adjust the paths; - add omp's directory to `PATH`, or set `OMP_WEB_OMP_BIN`): +- **As systemd user services**, write the two units yourself (adjust the paths): ```ini # ~/.config/systemd/user/ompweb.service @@ -826,7 +810,7 @@ update, stop the service, replace the release directory, and start it again. Aft revoking a host, stop and remove its service: it otherwise keeps retrying. **Linux (systemd user service).** Create `~/.config/systemd/user/ompweb-host.service` -(adjust the release path, and add omp's directory to `PATH` or set `OMP_WEB_OMP_BIN`): +(adjust the release path): ```ini [Unit] diff --git a/app/api/auth/accounts/[provider]/route.ts b/app/api/auth/accounts/[provider]/route.ts index 54dbb6553..ba9a21738 100644 --- a/app/api/auth/accounts/[provider]/route.ts +++ b/app/api/auth/accounts/[provider]/route.ts @@ -9,9 +9,6 @@ interface OmpProviderAccount { type: "api_key" | "oauth"; } -// omp releases older than the get_logout_accounts/logout RPC answer "Unknown command". -const UNSUPPORTED = "Unknown command"; - function isAccount(value: unknown): value is OmpProviderAccount { if (typeof value !== "object" || value === null) return false; const a = value as Record; @@ -35,9 +32,7 @@ export async function GET(_req: Request, { params }: { params: Promise<{ provide accounts: list.map(({ credentialId, label, detail, type }) => ({ credentialId, label, detail, type })), }); } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (message.includes(UNSUPPORTED)) return Response.json({ supported: false, accounts: [] }); - return Response.json({ error: message }, { status: 500 }); + return Response.json({ error: error instanceof Error ? error.message : String(error) }, { status: 500 }); } } @@ -54,16 +49,6 @@ export async function DELETE(req: Request, { params }: { params: Promise<{ provi invalidateModelsCache(); return Response.json({ ok: true }); } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (message.includes(UNSUPPORTED)) { - return Response.json( - { - error: "omp-web cannot disconnect accounts with this omp version. Update omp, or run `omp` in a terminal and use /logout.", - code: "logout_unsupported", - }, - { status: 501 }, - ); - } - return Response.json({ error: message }, { status: 500 }); + return Response.json({ error: error instanceof Error ? error.message : String(error) }, { status: 500 }); } } diff --git a/app/api/auth/login/[provider]/route.ts b/app/api/auth/login/[provider]/route.ts index 69dcdcdfd..920ce2651 100644 --- a/app/api/auth/login/[provider]/route.ts +++ b/app/api/auth/login/[provider]/route.ts @@ -5,12 +5,13 @@ import { RpcProcess, type RpcFrame } from "@/lib/omp/rpc-process"; import { disposeUtilityRpc } from "@/lib/omp/rpc-utility"; /** - * Interactive login over a dedicated `omp --mode rpc-ui` process. omp drives - * the flow with extension_ui_request frames: `open_url` carries the OAuth URL, - * `input` asks for the pasted code/redirect URL, `notify` reports progress. - * The SSE stream keeps pi-web's event names (auth, prompt_request, progress, - * success, error, cancelled) so the client flow is unchanged; the POST handler - * feeds the user's pasted value back as an extension_ui_response frame. + * Interactive login over a dedicated headless runner. The runner drives the + * flow with login frames (runner/src/commands/agents.ts): `login_open_url` + * carries the OAuth URL, `login_input` asks for the pasted code/redirect URL, + * `login_progress` reports progress. The SSE stream keeps pi-web's event names + * (auth, prompt_request, progress, success, error, cancelled) so the client + * flow is unchanged; the POST handler feeds the user's pasted value back as a + * `login_input_response` frame. */ const LOGIN_EXTRA_ARGS = ["--no-session", "--no-extensions", "--no-skills", "--no-lsp"]; @@ -94,41 +95,36 @@ export async function GET( } }, HEARTBEAT_MS); - // The user may paste the code before omp's input request arrives (the - // auth event shows the paste box immediately) — buffer one value. - let pendingInputId: string | null = null; + // The user may paste the code before the runner's input request arrives + // (the auth event shows the paste box immediately) — buffer one value. + let pendingInputId: number | null = null; let bufferedValue: string | null = null; let proc: RpcProcess | null = null; const handleFrame = (frame: RpcFrame) => { - if (frame.type !== "extension_ui_request") return; - const method = frame.method; - if (method === "open_url") { + if (frame.type === "login_open_url") { send({ type: "auth", url: String(frame.url ?? ""), instructions: typeof frame.instructions === "string" ? frame.instructions : null, token, }); - } else if (method === "input") { - const id = String(frame.id); + } else if (frame.type === "login_input" && typeof frame.id === "number") { if (bufferedValue !== null) { const value = bufferedValue; bufferedValue = null; - proc?.sendFrame({ type: "extension_ui_response", id, value }); + proc?.sendFrame({ type: "login_input_response", id: frame.id, value }); } else { - pendingInputId = id; + pendingInputId = frame.id; send({ type: "prompt_request", - message: typeof frame.title === "string" ? frame.title : "Enter the authorization code", + message: typeof frame.prompt === "string" ? frame.prompt : "Enter the authorization code", placeholder: typeof frame.placeholder === "string" ? frame.placeholder : null, token, }); } - } else if (method === "notify") { + } else if (frame.type === "login_progress") { if (typeof frame.message === "string") send({ type: "progress", message: frame.message }); - } else if (method === "cancel") { - if (pendingInputId !== null && frame.targetId === pendingInputId) pendingInputId = null; } }; @@ -149,7 +145,7 @@ export async function GET( if (pendingInputId !== null) { const id = pendingInputId; pendingInputId = null; - child.sendFrame({ type: "extension_ui_response", id, value }); + child.sendFrame({ type: "login_input_response", id, value }); } else { bufferedValue = value; } @@ -164,8 +160,7 @@ export async function GET( req.signal.addEventListener("abort", cleanup); try { - const ready = await child.waitReady(READY_TIMEOUT_MS); - await child.negotiateProtocol(ready); + await child.waitReady(READY_TIMEOUT_MS); await child.sendCommand({ type: "login", providerId: provider }, LOGIN_TIMEOUT_MS); enableProvider(provider); invalidateModelsCache(); diff --git a/app/api/hosts/[id]/cluster/route.ts b/app/api/hosts/[id]/cluster/route.ts index c70cc97ec..fb7efe06c 100644 --- a/app/api/hosts/[id]/cluster/route.ts +++ b/app/api/hosts/[id]/cluster/route.ts @@ -3,8 +3,8 @@ import { parseJsonWithinLimit, RequestBodyTooLargeError } from "@/lib/bounded-fo import { comparableProjectPath } from "@/lib/comparable-path"; import { CLUSTER_ID, CLUSTER_LABEL, CLUSTER_PROJECTS_DIR, ClusterError, clusterJoinBlock, isClusterMember, joinCluster, leaveCluster, MIN_SHARED_CREDENTIALS_OMP, type HostProjectFacts } from "@/lib/hosts/cluster"; import type { Host } from "@/lib/hosts/host"; -import { clusterConfigOp, getHost, getHostLinkProtocol, getHostOmpVersion, listHosts, masterConfigVersion, notifyHostsChange } from "@/lib/hosts/hub"; -import { CLUSTER_CONFIG_SINCE_VERSION, HostOpError } from "@/lib/hosts/link-protocol"; +import { clusterConfigOp, getHost, getHostOmpVersion, listHosts, masterConfigVersion, notifyHostsChange } from "@/lib/hosts/hub"; +import { HostOpError } from "@/lib/hosts/link-protocol"; import { loadPools, poolProjectBindings, savePools, validatePools, type HostPool, type PoolMember, type PoolProject } from "@/lib/hosts/pools"; import { LOCAL_HOST_ID, loadHosts } from "@/lib/hosts/registry"; import type { OpResult } from "@/lib/hosts/session-ops"; @@ -48,7 +48,7 @@ function valueOf(result: OpResult): T { } function requireReady(host: Host): void { - const block = clusterJoinBlock({ status: host.status(), protocol: getHostLinkProtocol(host.id), ompVersion: getHostOmpVersion(host.id), supports: (operation) => host.supportsWorkspaceOp(operation) }); + const block = clusterJoinBlock({ status: host.status(), ompVersion: getHostOmpVersion(host.id), supports: (operation) => host.supportsWorkspaceOp(operation) }); if (block === null) return; if (block === "omp-old") throw new HostOpError(409, "host_omp_too_old", `Update omp on ${host.label} to ${MIN_SHARED_CREDENTIALS_OMP} or newer first`); if (block !== "upgrade") throw new HostOpError(409, "host_offline", `${host.label} is offline`); @@ -164,18 +164,15 @@ export async function DELETE(request: Request, { params }: Context) { if (loadHosts().find((record) => record.id === id)?.shareCredentials !== true) abortBrokerRequests(id); notifyHostsChange(); // After the save, so no apply follows it (spec K6). A host that cannot take it now (offline or timed out) gets it - // from the sync once it reports holding this server's configuration. A daemon below protocol 10 never applied one; - // one that held it before a downgrade reports it once it is updated, and the sync restores it then. + // from the sync once it reports holding this server's configuration. let restoreError: string | undefined; - if ((getHostLinkProtocol(id) ?? CLUSTER_CONFIG_SINCE_VERSION) >= CLUSTER_CONFIG_SINCE_VERSION) { - try { - const answer = await clusterConfigOp(id, { action: "restore" }); - // Still holding a version that is not this server's: one this server cannot put back (another server's, or this - // server's recorded at an address the host still links to). - if (answer.state.version !== null && !answer.owned) restoreError = `${getHost(id)?.label ?? id} still runs a cluster configuration this server cannot put back`; - } catch (error) { - restoreError = error instanceof Error ? error.message : String(error); - } + try { + const answer = await clusterConfigOp(id, { action: "restore" }); + // Still holding a version that is not this server's: one this server cannot put back (another server's, or this + // server's recorded at an address the host still links to). + if (answer.state.version !== null && !answer.owned) restoreError = `${getHost(id)?.label ?? id} still runs a cluster configuration this server cannot put back`; + } catch (error) { + restoreError = error instanceof Error ? error.message : String(error); } return Response.json({ pools, ...(restoreError === undefined ? {} : { restoreError }) }, { headers: noStore }); } catch (error) { diff --git a/app/globals.css b/app/globals.css index d6cc805b4..8aa26b1a8 100644 --- a/app/globals.css +++ b/app/globals.css @@ -325,6 +325,7 @@ html.omp .omp-wordmark { html.dark:not([data-theme]):not(.omp), html[data-theme="dark"], html.theme-dark { + color-scheme: dark; --bg: #1B1916; --bg-panel: #231F1B; --bg-hover: #2B2721; @@ -4829,6 +4830,108 @@ html[data-touch-targets="accessible"] .extension-dialog-footer > button { } } +/* omp's ask dialogs: one choice-row look for select, approve, ask and the + agent's own forms. A chosen or pre-checked row is tinted; Recommended stays + a text tag so it never reads as a selection. */ +.extension-dialog-header { padding: 12px 16px; border-bottom: 1px solid var(--border); } +.extension-dialog-body { padding: 16px; } +@media (max-width: 640px) { + .extension-dialog-header { padding: 10px 12px; } + .extension-dialog-body { padding: 12px; } +} +.extension-choice { + display: flex; + align-items: flex-start; + gap: 10px; + box-sizing: border-box; + width: 100%; + min-width: 0; + min-height: 36px; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg-panel); + color: var(--text); + font: inherit; + font-size: 13px; + line-height: 20px; + text-align: left; + cursor: pointer; +} +.extension-choice > input { flex-shrink: 0; width: 16px; height: 16px; margin: 2px 0 0; accent-color: var(--accent-strong); cursor: pointer; } +.extension-choice-mark { flex-shrink: 0; width: 16px; text-align: center; color: var(--text-dim); } +.extension-choice[data-checked] .extension-choice-mark { color: var(--accent); } +@media (hover: hover) { + .extension-choice:not(:disabled, [data-checked], :has(input:checked)):hover { + background: var(--bg-hover); + border-color: color-mix(in srgb, var(--text-muted) 35%, var(--border)); + } +} +.extension-choice:is([data-checked], :has(input:checked)) { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 10%, var(--bg-panel)); +} +/* The Other row's text box has no frame of its own, so the row shows its focus. */ +.extension-choice:has(textarea:focus-visible) { outline: 2px solid var(--accent); outline-offset: 2px; } +html.omp .extension-choice:has(textarea:focus-visible) { outline-color: var(--accent-2); } +.extension-choice:disabled { cursor: default; opacity: 0.6; } +/* omp's trailing "Done selecting" row is an action, not one more answer. */ +.extension-choice-action { justify-content: center; border-style: dashed; background: transparent; color: var(--text-muted); font-weight: 600; } +.extension-choice textarea::placeholder, +.extension-field::placeholder { color: var(--text-dim); opacity: 1; } +.extension-field { + box-sizing: border-box; + min-width: 0; + min-height: 36px; + padding: 7px 12px; + /* At least 3:1 against the fill on the darkest themes (omp, warm dark). */ + border: 1px solid color-mix(in srgb, var(--text-muted) 55%, var(--border)); + border-radius: 8px; + background: var(--bg-panel); + color: var(--text); + font: inherit; + font-size: 13px; + line-height: 20px; +} +.extension-field:focus-visible { border-radius: 8px; outline-offset: 1px; } +.extension-dialog-form { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 6px; + color: var(--text); + font-size: 13px; + line-height: 20px; +} +.extension-dialog-form p { margin: 0 0 4px; color: var(--text-muted); } +.extension-dialog-form fieldset { display: grid; grid-template-columns: minmax(0, 1fr); gap: 6px; min-width: 0; margin: 0; padding: 0; border: 0; } +.extension-dialog-form legend { margin-bottom: 8px; padding: 0; font-weight: 600; } +/* A field's own label opens a new block: more room above it than below it. */ +.extension-dialog-form label:not(.extension-choice, .extension-choice > label) { margin-top: 8px; color: var(--text-muted); font-size: 12px; font-weight: 600; } +.extension-dialog-form fieldset + fieldset, +.extension-dialog-form .extension-field + .extension-choice { margin-top: 10px; } +.extension-choice > label { flex: 1; min-width: 0; cursor: pointer; } +.extension-dialog-form :user-invalid { border-color: var(--status-error); } +.extension-dialog-button { + min-height: 32px; + padding: 6px 14px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg); + color: var(--text-muted); + font: inherit; + font-size: 13px; + font-weight: 500; + cursor: pointer; +} +.extension-dialog-button:hover:not(:disabled) { background: var(--bg-hover); color: var(--text); } +.extension-dialog-button-primary { border-color: var(--accent-strong); background: var(--accent-strong); color: var(--on-accent); } +.extension-dialog-button-primary:hover:not(:disabled) { background: var(--accent-strong); color: var(--on-accent); filter: brightness(1.12); } +.extension-dialog-button-primary:disabled { background: var(--bg-subtle); color: var(--text-dim); cursor: not-allowed; opacity: 0.65; } +html[data-touch-targets="accessible"] :is(.extension-choice, .extension-field) { min-height: 44px; } +@media (max-width: 640px) and (pointer: coarse) and (hover: none) { + html:not([data-touch-targets="compact"]):not([data-touch-targets="accessible"]) :is(.extension-choice, .extension-field) { min-height: 40px; } +} + /* Pagination restores history without replaying entrance motion. */ .chat-message-card { animation: none; } .chat-message[data-live="true"] { animation: chat-message-in var(--dur-med) var(--ease-out-warm) both; } diff --git a/bin/omp-web-host.js b/bin/omp-web-host.js index a32f6c5fc..528ccd365 100755 --- a/bin/omp-web-host.js +++ b/bin/omp-web-host.js @@ -8,6 +8,8 @@ // eslint-disable-next-line @typescript-eslint/no-require-imports require("./node-version").exitIfUnsupportedNode(); +// The daemon finds the runner package from here (lib/omp/omp-cli.ts), not from whatever cwd it was started in. +process.env.OMP_WEB_PACKAGE_DIR ??= module.require("node:path").join(__dirname, ".."); // eslint-disable-next-line @typescript-eslint/no-require-imports const jiti = require("jiti").createJiti(__filename); jiti.import("../lib/hosts/host-cli.ts").then( diff --git a/bin/omp-web-host.test.mjs b/bin/omp-web-host.test.mjs index a42c2301a..875efb20f 100644 --- a/bin/omp-web-host.test.mjs +++ b/bin/omp-web-host.test.mjs @@ -145,7 +145,7 @@ test("status/drain/resume consume the real CLI daemon endpoint and SIGTERM remov const frame = JSON.parse(line); if (frame.type === "hello") { hellos += 1; - ws.send(JSON.stringify({ type: "welcome", v: 10, hostId: "cli-fixture" })); + ws.send(JSON.stringify({ type: "welcome", v: frame.v, hostId: "cli-fixture" })); } else if (typeof frame.seq === "number") ws.send(JSON.stringify({ type: "ack", ack: frame.seq })); } }, diff --git a/bin/omp-web-update-worker.js b/bin/omp-web-update-worker.js index f02e577c7..6b0925114 100644 --- a/bin/omp-web-update-worker.js +++ b/bin/omp-web-update-worker.js @@ -65,6 +65,11 @@ function updateStatus(patch) { fs.renameSync(tmp, statusFile()); } function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } +// The launcher (bin/omp-web.js watchUpdateCompletion) was armed at commit: an attempt that ends without a restart must +// disarm it, or it takes the running server's next exit for this update's restart and waits forever. +function disarmLauncher() { + try { atomicWrite(markerFile("complete.json"), { protocol: 1, attemptId, state: "failed", completedAt: new Date().toISOString() }); } catch {} +} function isLaunchdActive() { try { const plist = path.join(os.homedir(), "Library", "LaunchAgents", "com.kahme247.ompweb.plist"); @@ -129,6 +134,38 @@ async function stopOriginalProcesses() { return { launchd, tray }; } +// The candidate's `ompweb preflight` (lib/hosts/preflight.ts), installed into a scratch prefix and run in this +// environment before anything stops: a session omp that cannot start fails the update, not the running server. +function preflightCandidate() { + const prefix = fs.mkdtempSync(path.join(os.tmpdir(), "ompweb-candidate-")); + try { + fs.writeFileSync(path.join(prefix, "package.json"), '{"private":true}'); + const pkg = `@kahme247/ompweb@${target}`; + // shell: true (Windows) joins arguments unquoted. + const where = process.platform === "win32" ? `"${prefix}"` : prefix; + const args = manager === "bun" ? ["add", "--cwd", where, "--production", "--exact", pkg] : ["install", "--prefix", where, "--omit=dev", "--no-audit", "--no-fund", pkg]; + const install = cp.spawnSync(manager === "bun" ? (managerPath || "bun") : (managerPath || "npm"), args, { timeout: 5 * 60 * 1000, windowsHide: true, env: process.env, shell: process.platform === "win32" }); + if (install.status !== 0) { + const detail = install.stderr ? install.stderr.toString().trim().slice(-300) : String(install.error || ""); + throw new Error(`candidate install failed (exit ${install.status})${detail ? `: ${detail}` : ""}`); + } + const release = path.join(prefix, "node_modules", "@kahme247", "ompweb"); + // Detected statically: an older ompweb takes `preflight` for a positional and starts a web server. + if (!fs.existsSync(path.join(release, "lib", "hosts", "preflight.ts"))) { + const optional = Object.keys((readJson(path.join(release, "package.json")) || {}).optionalDependencies || {}); + if (optional.some((name) => name.startsWith("@kahme247/ompweb-omp-"))) throw new Error("the new release has no preflight command to check its omp with"); + console.warn(`ompweb ${target} has no preflight command; its omp was not checked`); + return; + } + // OMP_WEB_PACKAGE_DIR: the launcher set it to the running install, whose omp this must not check. + const result = cp.spawnSync(process.execPath, [path.join(release, "bin", "omp-web.js"), "preflight"], { cwd: prefix, timeout: 60 * 1000, windowsHide: true, env: { ...process.env, OMP_WEB_PACKAGE_DIR: release }, encoding: "utf8" }); + if (result.status !== 0) throw new Error(`the new release cannot start a session: ${((result.stderr || "").trim() || String(result.error || `exit ${result.status}`)).slice(-200)}`); + } finally { + // Best effort: a locked file (Windows) must not fail an update the check passed. + try { fs.rmSync(prefix, { recursive: true, force: true, maxRetries: 3 }); } catch {} + } +} + async function runManagerGate() { updateStatus({ stage: "installing" }); if (kind === "omp") { @@ -219,6 +256,7 @@ async function main() { // Check abort if (fs.existsSync(abortFile())) { updateStatus({ state: "failed", error: (readJson(abortFile()) || {}).reason || "aborted", finishedAt: new Date().toISOString(), cleanupReady: true }); + disarmLauncher(); process.exit(0); } // Check go marker exists (commit signal) @@ -234,7 +272,10 @@ async function main() { let svcInfo = { launchd: false, tray: false }; try { - if (kind !== "omp") svcInfo = await stopOriginalProcesses(); + if (kind !== "omp") { + preflightCandidate(); + svcInfo = await stopOriginalProcesses(); + } await runManagerGate(); try { const pkgPath = path.join(packageDir, "package.json"); @@ -257,10 +298,12 @@ async function main() { try { systemctl("start"); } catch { msg = `${systemdUnit} is still stopped: ${msg}`; } } updateStatus({ state: "failed", error: msg.slice(0, 240), finishedAt: new Date().toISOString(), cleanupReady: true }); + disarmLauncher(); } } main().catch((e) => { try { updateStatus({ state: "failed", error: String(e).slice(0, 240), finishedAt: new Date().toISOString(), cleanupReady: true }); } catch {} + disarmLauncher(); process.exit(1); }); diff --git a/bin/omp-web-update-worker.test.mjs b/bin/omp-web-update-worker.test.mjs index 843bf4739..e97ba5736 100644 --- a/bin/omp-web-update-worker.test.mjs +++ b/bin/omp-web-update-worker.test.mjs @@ -1,45 +1,79 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { delimiter, join } from "node:path"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { delimiter, dirname, join } from "node:path"; import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; import test from "node:test"; const WORKER = fileURLToPath(new URL("./omp-web-update-worker.js", import.meta.url)); const ATTEMPT = "11111111-2222-3333-4444-555555555555"; +const REPO = dirname(dirname(WORKER)); +const PLATFORM = `${process.platform === "win32" ? "windows" : process.platform}-${process.arch}`; +// A session omp that says hello as the runner does. +const RUNNER = `#!/bin/sh\nif [ "$1" = "--version" ]; then echo 18.99.0; exit 0; fi\necho '{"t":"hello","proto":1}'\nexec cat >/dev/null\n`; -// Runs the worker for a systemd unit with stub `systemctl` and `npm` on PATH. -function runWorker(t, { npmExit = 0, startExit = 0, activeExit = 0 } = {}) { +/** The new release as npm installs it. "old": no preflight command, no runner; "old-runner": no preflight command but + * a runner dependency; "ok": this checkout's real preflight with a runner dependency that says hello; "no-runner": the + * same without the runner installed. */ +function writeCandidate(dir, candidate) { + mkdirSync(dir); + if (candidate === "old") return; + writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "@kahme247/ompweb", version: "0.6.0", optionalDependencies: { [`@kahme247/ompweb-omp-${PLATFORM}`]: "0.6.0" } })); + if (candidate === "old-runner") return; + symlinkSync(join(REPO, "bin"), join(dir, "bin")); + symlinkSync(join(REPO, "lib"), join(dir, "lib")); + if (candidate === "no-runner") return; + const runner = join(dir, "node_modules", "@kahme247", `ompweb-omp-${PLATFORM}`); + mkdirSync(runner, { recursive: true }); + writeFileSync(join(runner, "package.json"), "{}"); + writeFileSync(join(runner, "ompweb-omp"), RUNNER, { mode: 0o755 }); +} + +// Runs the worker for a systemd unit with stub `systemctl` and `npm` on PATH, under the launcher's OMP_WEB_PACKAGE_DIR: +// the running install, whose own runner says hello. `candidate`: see writeCandidate. +function runWorker(t, { npmExit = 0, startExit = 0, activeExit = 0, candidate = "old" } = {}) { const dir = mkdtempSync(join(tmpdir(), "update-worker-test-")); t.after(() => rmSync(dir, { recursive: true, force: true })); const log = join(dir, "calls.log"); writeFileSync(join(dir, "systemctl"), `#!/bin/sh\necho "systemctl $*" >> "${log}"\n[ "$2" = start ] && exit ${startExit}\n[ "$2" = is-active ] && { echo failed; exit ${activeExit}; }\nexit 0\n`, { mode: 0o755 }); - writeFileSync(join(dir, "npm"), `#!/bin/sh\necho "npm $*" >> "${log}"\nexit ${npmExit}\n`, { mode: 0o755 }); + const installed = join(dir, "running"); + mkdirSync(join(installed, "runner", "release"), { recursive: true }); + writeFileSync(join(installed, "runner", "release", `ompweb-omp-${PLATFORM}`), RUNNER, { mode: 0o755 }); + const template = join(dir, "candidate"); + writeCandidate(template, candidate); + // The scratch install (`npm install --prefix …`) succeeds; the global one exits npmExit. + writeFileSync(join(dir, "npm"), `#!/bin/sh\necho "npm $*" >> "${log}"\nif [ "$2" = --prefix ]; then mkdir -p "$3/node_modules/@kahme247" && ln -s "${template}" "$3/node_modules/@kahme247/ompweb"; exit 0; fi\nexit ${npmExit}\n`, { mode: 0o755 }); writeFileSync(join(dir, "status.json"), JSON.stringify({ attemptId: ATTEMPT, state: "running" }), { mode: 0o600 }); writeFileSync(join(dir, `${ATTEMPT}.go`), "{}", { mode: 0o600 }); + const env = { ...process.env, PATH: `${dir}${delimiter}${process.env.PATH}`, OMP_WEB_PACKAGE_DIR: installed, PI_CODING_AGENT_DIR: join(dir, "agent") }; + delete env.OMP_WEB_OMP_BIN; const result = spawnSync("node", [ WORKER, "--attempt", ATTEMPT, "--root", dir, - "--package-dir", dir, + "--package-dir", installed, "--manager", "npm", "--manager-path", "npm", "--target", "0.6.0", "--kind", "app", "--systemd-unit", "ompweb.service", "--systemd-user", "1", - ], { env: { ...process.env, PATH: `${dir}${delimiter}${process.env.PATH}` }, timeout: 20000 }); + ], { env, timeout: 60000 }); assert.equal(result.status, 0, String(result.stderr)); + let complete = null; + try { complete = JSON.parse(readFileSync(join(dir, `${ATTEMPT}.complete.json`), "utf8")); } catch {} return { calls: readFileSync(log, "utf8").trim().split("\n"), status: JSON.parse(readFileSync(join(dir, "status.json"), "utf8")), + complete, }; } test("a systemd update stops the unit, installs, starts it and checks it stayed up", { skip: process.platform === "win32" }, (t) => { const { calls, status } = runWorker(t); - assert.deepEqual(calls, [ + assert.match(calls[0], /^npm install --prefix \S+ --omit=dev --no-audit --no-fund @kahme247\/ompweb@0\.6\.0$/); + assert.deepEqual(calls.slice(1), [ "systemctl --user stop ompweb.service", "npm install -g @kahme247/ompweb@0.6.0", "systemctl --user start ompweb.service", @@ -66,3 +100,27 @@ test("a new release that does not stay up fails the update", { skip: process.pla assert.equal(status.state, "failed"); assert.match(status.error, /systemctl is-active ompweb\.service failed: failed/); }); + +test("a candidate whose own omp says hello is installed", { skip: process.platform === "win32" }, (t) => { + const { calls, status } = runWorker(t, { candidate: "ok" }); + assert.equal(calls[1], "systemctl --user stop ompweb.service"); + assert.equal(status.state, "succeeded"); +}); + +test("a candidate missing its omp fails its preflight even though the running install has one, and never stops the running server", { skip: process.platform === "win32" }, (t) => { + const { calls, status, complete } = runWorker(t, { candidate: "no-runner" }); + assert.ok(!calls.includes("systemctl --user stop ompweb.service"), calls.join("\n")); + assert.ok(!calls.some((call) => call.startsWith("npm install -g")), calls.join("\n")); + assert.equal(status.state, "failed"); + assert.equal(status.error, "the new release cannot start a session: preflight failed: ompweb-omp: not found"); + // What bin/omp-web.js watchUpdateCompletion accepts to disarm the launcher armed at commit. + assert.equal(complete?.protocol, 1); + assert.equal(complete?.attemptId, ATTEMPT); +}); + +test("a candidate with an omp but no preflight command is refused before anything stops", { skip: process.platform === "win32" }, (t) => { + const { calls, status } = runWorker(t, { candidate: "old-runner" }); + assert.ok(!calls.includes("systemctl --user stop ompweb.service"), calls.join("\n")); + assert.equal(status.state, "failed"); + assert.equal(status.error, "the new release has no preflight command to check its omp with"); +}); diff --git a/bin/omp-web.js b/bin/omp-web.js index 73b1c8b4f..f0a5e47b8 100755 --- a/bin/omp-web.js +++ b/bin/omp-web.js @@ -32,6 +32,18 @@ if (process.argv[2] === "hash-password" || process.argv[2] === "hash_password") } function startLauncher() { +// `ompweb preflight` (lib/hosts/preflight.ts): what an updater runs on a candidate release; it prints "preflight ok: ". +if (process.argv[2] === "preflight") { + process.env.OMP_WEB_PACKAGE_DIR ??= module.require("node:path").join(__dirname, ".."); + module.require("jiti").createJiti(__filename).import("../lib/hosts/preflight.ts").then( + ({ runPreflight }) => runPreflight().then((code) => process.exit(code)), + (error) => { + console.error(`preflight failed: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + }, + ); + return; +} // `ompweb host` runs in this process: a supervisor's SIGTERM must reach the daemon, not a wrapper. if (process.argv[2] === "host") { process.argv.splice(2, 1); diff --git a/components/ChatWindow.tsx b/components/ChatWindow.tsx index 660a64817..82ff71e8e 100644 --- a/components/ChatWindow.tsx +++ b/components/ChatWindow.tsx @@ -3,7 +3,7 @@ import { sendAgentCommand } from "@/lib/agent-client"; import { registerAbortHandler } from "@/hooks/useKeyboardShortcuts"; import { Fragment, memo, useCallback, useEffect, useMemo, useRef, useState, type ReactNode, type Ref } from "react"; import { ArrowDown, ChevronDown, ChevronUp, Paperclip, Square } from "lucide-react"; -import type { AgentMessage, AssistantMessage, ExtensionUiRequest, SessionInfo, SessionTreeNode, ToolResultMessage } from "@/lib/types"; +import type { AgentMessage, AssistantMessage, SessionInfo, SessionTreeNode, ToolResultMessage, UiCustomFrame } from "@/lib/types"; import { translate, useI18n } from "@/lib/i18n"; import { collectToolResults, isGroupAnchor, planTranscriptRows, type TranscriptRow } from "@/lib/chat-transcript-plan"; import { isReplyBlock } from "@/lib/message-display"; @@ -16,7 +16,6 @@ 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"; @@ -455,7 +454,6 @@ export function ChatWindow({ session, newSessionCwd, newSessionHostId, newSessio 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, @@ -1000,7 +998,7 @@ export function ChatWindow({ session, newSessionCwd, newSessionHostId, newSessio return null; }, [bashRunning, pendingBash, isCompacting, agentRunning, streamState.streamingMessage, pendingToolHeaders.length, agentPhase, activeSubagentCount, currentTodoPhase, t, tn]); - const askOpen = extensionDialog?.method === "ask"; + const askOpen = extensionDialog?.method === "askDialog"; const hiddenComposerRef = useRef(null); useEffect(() => { // A hidden composer that keeps focus keeps the phone keyboard up: the @@ -1218,7 +1216,6 @@ export function ChatWindow({ session, newSessionCwd, newSessionHostId, newSessio {newSessionWorkspace} {hostBanner} - setSkillStartupDiagnostics(false)} /> {/* ChatInput insets itself by CHAT_COLUMN_PADDING; cancel this column's padding so the composer matches its in-session width. */} @@ -1432,7 +1429,6 @@ export function ChatWindow({ session, newSessionCwd, newSessionHostId, newSessio /> )} - setSkillStartupDiagnostics(false)} /> ; - function renderAnsiLine(line: string, keyPrefix: string): ReactNode[] { return parseAnsiLine(line).map((segment, index) => ( Object.keys(segment.style).length > 0 @@ -1649,8 +1643,8 @@ function ExtensionCustomPanel({ request, onInput, }: { - request: ExtensionCustomRequest; - onInput: (request: ExtensionCustomRequest, data: string) => void; + request: UiCustomFrame; + onInput: (request: UiCustomFrame, data: string) => void; }) { const { t } = useI18n(); const inputRef = useRef(null); diff --git a/components/ExtensionDialog.ask.test.mjs b/components/ExtensionDialog.ask.test.mjs index 93ddb4471..3e9186c2a 100644 --- a/components/ExtensionDialog.ask.test.mjs +++ b/components/ExtensionDialog.ask.test.mjs @@ -25,11 +25,23 @@ const extras = { recommended: 2, }; +/** omp's ExtensionAskDialogResultItem for `question`. */ +function result(question, selectedOptions, customInput) { + return { + id: question.id, + question: question.question, + options: question.options.map((option) => option.label), + multi: question.multi ?? false, + selectedOptions, + ...(customInput === undefined ? {} : { customInput }), + }; +} + function renderAsk(questions) { const responses = []; render(React.createElement(ExtensionDialog, { - request: { type: "extension_ui_request", id: "ask-1", method: "ask", questions }, - onRespond: (_request, response) => responses.push(response), + request: { type: "ui_request", id: 1, method: "askDialog", args: [questions] }, + onRespond: (_request, value) => responses.push(value), attached: true, })); return responses; @@ -77,10 +89,11 @@ test("submit sends answers in question order: single choice, multi choices in op await user.click(screen.getByRole("radio", { name: "S" })); await user.click(screen.getByRole("button", { name: "Submit" })); assert.deepEqual(responses, [{ - answers: [ - { id: "color", selectedOptions: ["Green"] }, - { id: "extras", selectedOptions: ["Cheese", "Egg"], customInput: "pickles" }, - { id: "size", selectedOptions: ["S"] }, + kind: "submit", + results: [ + { id: "color", question: "Pick a color", options: ["Red", "Green", "Blue"], multi: false, selectedOptions: ["Green"] }, + { id: "extras", question: "Extras?", options: ["Cheese", "Ham", "Egg"], multi: true, selectedOptions: ["Cheese", "Egg"], customInput: "pickles" }, + { id: "size", question: "Pick a size", options: ["S", "M"], multi: false, selectedOptions: ["S"] }, ], }]); }); @@ -93,7 +106,7 @@ test("typing in Other chooses it and replaces the radio choice", async () => { assert.equal(other.checked, true); assert.equal(screen.getByRole("radio", { name: /Green/ }).checked, false); await user.click(screen.getByRole("button", { name: "Submit" })); - assert.deepEqual(responses.pop(), { answers: [{ id: "color", selectedOptions: [], customInput: "Teal" }] }); + assert.deepEqual(responses.pop(), { kind: "submit", results: [result(color, [], "Teal")] }); }); test("the Other text stays when another option is chosen and counts again when Other is", async () => { @@ -106,12 +119,12 @@ test("the Other text stays when another option is chosen and counts again when O assert.equal(field.value, "Teal", "the typed text outlives the choice"); assert.equal(other.checked, false); await user.click(screen.getByRole("button", { name: "Submit" })); - assert.deepEqual(responses.pop(), { answers: [{ id: "color", selectedOptions: ["Red"] }] }, "an unchosen Other is not sent"); + assert.deepEqual(responses.pop(), { kind: "submit", results: [result(color, ["Red"])] }, "an unchosen Other is not sent"); await user.click(other); assert.equal(document.activeElement, field, "choosing Other puts the cursor in its text"); await user.click(screen.getByRole("button", { name: "Submit" })); - assert.deepEqual(responses.pop(), { answers: [{ id: "color", selectedOptions: [], customInput: "Teal" }] }); + assert.deepEqual(responses.pop(), { kind: "submit", results: [result(color, [], "Teal")] }); }); test("going back into the kept Other text chooses Other again", async () => { @@ -124,7 +137,7 @@ test("going back into the kept Other text chooses Other again", async () => { assert.equal(screen.getByRole("radio", { name: "Other: type your own answer" }).checked, true); assert.equal(screen.getByRole("radio", { name: "Red" }).checked, false); await user.click(screen.getByRole("button", { name: "Submit" })); - assert.deepEqual(responses, [{ answers: [{ id: "color", selectedOptions: [], customInput: "Teal" }] }]); + assert.deepEqual(responses, [{ kind: "submit", results: [result(color, [], "Teal")] }]); }); test("a chosen but blank Other blocks single-select Submit, and clearing the text unchooses it", async () => { @@ -152,12 +165,12 @@ test("multi-select Other is a checkbox alongside the options, and unchecking kee assert.equal(other.checked, false); assert.equal(field.value, "Pickles"); await user.click(screen.getByRole("button", { name: "Submit" })); - assert.deepEqual(responses, [{ answers: [{ id: "extras", selectedOptions: ["Cheese"] }] }]); + assert.deepEqual(responses, [{ kind: "submit", results: [result(extras, ["Cheese"])] }]); }); -test("Cancel answers cancelled", async () => { +test("Cancel answers with no value", async () => { const user = userEvent.setup(); const responses = renderAsk([color]); await user.click(screen.getByRole("button", { name: "Cancel" })); - assert.deepEqual(responses, [{ cancelled: true }]); + assert.deepEqual(responses, [undefined]); }); diff --git a/components/ExtensionDialog.form.test.mjs b/components/ExtensionDialog.form.test.mjs new file mode 100644 index 000000000..841c2e5a3 --- /dev/null +++ b/components/ExtensionDialog.form.test.mjs @@ -0,0 +1,115 @@ +import "../tests/setup-dom.mjs"; +import assert from "node:assert/strict"; +import test, { afterEach } from "node:test"; +import React from "react"; +const { cleanup, render, screen } = await import("@testing-library/react/pure.js"); +const { default: userEvent } = await import("@testing-library/user-event"); + +const { ExtensionDialog } = await import("./ExtensionDialog.tsx"); + +afterEach(cleanup); + +function renderForm(html) { + const responses = []; + render(React.createElement(ExtensionDialog, { + request: { type: "ui_request", id: 1, method: "formDialog", args: [html] }, + onRespond: (_request, value) => responses.push(value), + attached: true, + })); + return responses; +} + +const html = `
+

Choose how to deploy.

+
Database + + +
+
Extras + + + +
+ + + + +
`; + +test("a form submits its pre-checked and prefilled defaults untouched; checkbox groups give arrays", async () => { + const user = userEvent.setup(); + const responses = renderForm(html); + assert.ok(screen.getByText(/Choose how to deploy/), "paragraph text is shown"); + assert.ok(screen.getByText("Database"), "legend text is shown"); + assert.equal(screen.getByRole("radio", { name: /Postgres/ }).checked, true); + await user.click(screen.getByRole("button", { name: "Submit" })); + assert.deepEqual(responses, [{ data: { db: "pg", extras: ["cache", "metrics"], region: "eu", replicas: 3, notes: "keep it" } }]); +}); + +test("agent markup never becomes live HTML: scripts, handlers and buttons are dropped", () => { + renderForm(html); + const dialog = screen.getByRole("dialog"); + assert.equal(dialog.querySelector("script, img, [onerror], [onclick]"), null); + assert.equal(screen.queryByRole("button", { name: "Do it" }), null); + assert.equal(window.pwned, undefined); +}); + +test("changed answers are submitted, an unchecked radio group is omitted", async () => { + const user = userEvent.setup(); + const responses = renderForm(`
+ + + + +
`); + await user.click(screen.getByRole("checkbox", { name: /A$/ })); + await user.click(screen.getByRole("checkbox", { name: /B$/ })); + await user.click(screen.getByRole("checkbox", { name: /Agree/ })); + await user.click(screen.getByRole("button", { name: "Submit" })); + assert.deepEqual(responses, [{ data: { extras: ["b"], agree: "on" } }]); +}); + +test("a radio group's Other text is its value, and picking an option afterwards sets it aside", async () => { + const user = userEvent.setup(); + const responses = renderForm(`
`); + const other = screen.getByRole("textbox", { name: /Other/ }); + await user.click(screen.getByRole("radio", { name: /Red/ })); + await user.type(other, " Green "); + assert.equal(screen.getByRole("radio", { name: /Red/ }).checked, false, "typing drops the picked option"); + await user.click(screen.getByRole("button", { name: "Submit" })); + assert.deepEqual(responses, [{ data: { c: "Green" } }]); + await user.click(screen.getByRole("radio", { name: /Blue/ })); + await user.click(screen.getByRole("button", { name: "Submit" })); + assert.deepEqual(responses[1], { data: { c: "b" } }); +}); + +test("a checkbox group's Other text is appended to its array; blank adds nothing", async () => { + const user = userEvent.setup(); + const responses = renderForm(`
`); + await user.click(screen.getByRole("button", { name: "Submit" })); + await user.type(screen.getByRole("textbox", { name: /Other/ }), "mine"); + await user.click(screen.getByRole("button", { name: "Submit" })); + assert.deepEqual(responses, [{ data: { x: ["a"] } }, { data: { x: ["a", "mine"] } }]); +}); + +test("no Other box when an option is already labelled Other or the group has one option", () => { + renderForm(`
`); + assert.equal(screen.queryByRole("textbox"), null); +}); + +test("a required group is satisfied by its Other text", async () => { + const user = userEvent.setup(); + const responses = renderForm(`
`); + await user.click(screen.getByRole("button", { name: "Submit" })); + assert.deepEqual(responses, [], "nothing answered yet"); + await user.type(screen.getByRole("textbox", { name: /Other/ }), "Green"); + await user.click(screen.getByRole("button", { name: "Submit" })); + assert.deepEqual(responses, [{ data: { c: "Green" } }]); +}); + +test("controls named like HTMLFormElement members still submit", async () => { + const user = userEvent.setup(); + const responses = renderForm(`
`); + await user.click(screen.getByRole("button", { name: "Submit" })); + assert.deepEqual(responses, [{ data: { elements: "a", reportValidity: "b", querySelectorAll: "c", submit: "d", checkValidity: "e" } }]); +}); diff --git a/components/ExtensionDialog.mobile.test.mjs b/components/ExtensionDialog.mobile.test.mjs index d143202ae..94c65451c 100644 --- a/components/ExtensionDialog.mobile.test.mjs +++ b/components/ExtensionDialog.mobile.test.mjs @@ -16,7 +16,7 @@ const OPTIONS = [ ]; function askRequest(questions = [{ id: "color", header: "Color", question: "Pick a color", options: OPTIONS, recommended: 1 }]) { - return { type: "extension_ui_request", id: "ask-1", method: "ask", questions }; + return { type: "ui_request", id: 1, method: "askDialog", args: [questions] }; } /** Renders the composer-attached panel — the mobile path these fixes cover. */ @@ -24,7 +24,7 @@ function renderAttached(request = askRequest(), attached = true) { const responses = []; const view = render(React.createElement(ExtensionDialog, { request, - onRespond: (_request, response) => responses.push(response), + onRespond: (_request, value) => responses.push(value), attached, })); return { responses, view }; @@ -109,7 +109,7 @@ test("both ask grids pin their column so a wide question cannot stretch the pane const grids = html.match(/grid-template-columns:minmax\(0, 1fr\)/g) ?? []; assert.equal(grids.length, 2, "the questions grid and each question's fieldset"); assert.equal((html.match(/
]*min-width:0/g) ?? []).length, 1); - assert.match(html, /
/); + assert.match(html, /
/); }); test("the option row, its text and its preview cannot push past the panel edge", () => { @@ -117,7 +117,8 @@ test("the option row, its text and its preview cannot push past the panel edge", // The label is a flex row inside the fieldset's grid: it needs a zero // minimum and wrapping text of its own, otherwise a long option label or // preview keeps the track at min-content and overflows. - assert.match(html, /