diff --git a/.github/workflows/omp-runner.yml b/.github/workflows/omp-runner.yml new file mode 100644 index 000000000..56c17e663 --- /dev/null +++ b/.github/workflows/omp-runner.yml @@ -0,0 +1,97 @@ +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: Fetch the pinned omp packages + run: bun scripts/use-omp-build.ts + + - 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 + + - name: Fetch the pinned omp packages + run: bun scripts/use-omp-build.ts + + # 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/prerelease.yml b/.github/workflows/prerelease.yml index 063d7c6ff..c7fe959b9 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -40,6 +40,24 @@ jobs: - run: npm ci - run: npm run build + # The omp the runners compile against: the fork's newest integration build, not runner/omp-build.json's pin + # (not committed back). No such release or a runner that fails tsc against it fails the release. + - name: Find the newest omp integration build + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + sha=$(gh api repos/ompweb-dev/oh-my-pi/commits/integration-release --jq .sha) + tag=$(gh api --paginate 'repos/ompweb-dev/oh-my-pi/releases?per_page=100' --jq '.[].tag_name' \ + | grep -E "^v[0-9]+\.[0-9]+\.[0-9]+-int\.[0-9]{12}\.g${sha:0:7}\$" | head -n 1 || true) + if [ -z "$tag" ]; then + echo "::error::ompweb-dev/oh-my-pi has no integration release for integration-release ($sha)" + exit 1 + fi + echo "OMP_TAG=$tag" >> "$GITHUB_ENV" + # Without GH_TOKEN: bun install runs the omp packages' scripts. + - name: Pin the runner to it + run: npx bun runner/scripts/use-omp-build.ts "$OMP_TAG" && (cd runner && npx bun x tsc -p .) - name: Build the release binaries run: sh scripts/build-release-binaries.sh - run: npm run smoke -- --binary release/ompweb-linux-x64/ompweb @@ -55,5 +73,5 @@ jobs: # A draft first, so a failed upload never leaves a published prerelease missing archives. gh release create "$tag" release/ompweb-*.tar.gz release/ompweb-*.zip \ --repo "$GITHUB_REPOSITORY" --target "$SHA" --draft --prerelease --title "$title" \ - --notes "Binaries built from $GITHUB_REPOSITORY@$SHA ($subject). Not a release: no npm package, no update notice." + --notes "Binaries built from $GITHUB_REPOSITORY@$SHA ($subject), omp: ompweb-dev/oh-my-pi $OMP_TAG. Not a release: no npm package, no update notice." gh release edit "$tag" --repo "$GITHUB_REPOSITORY" --draft=false diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 837387778..f2fb6856a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -31,13 +31,34 @@ jobs: - run: npm install --global npm@^11.5.1 - run: npm ci + # The omp the runners compile against: the fork's newest integration build, not runner/omp-build.json's pin + # (not committed back). No such release or a runner that fails tsc against it fails the release. Pinned before + # the tests: they import the runner (lib/hosts/omp-socket.test.mjs), and must run against the omp that ships. + - name: Find the newest omp integration build + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + sha=$(gh api repos/ompweb-dev/oh-my-pi/commits/integration-release --jq .sha) + tag=$(gh api --paginate 'repos/ompweb-dev/oh-my-pi/releases?per_page=100' --jq '.[].tag_name' \ + | grep -E "^v[0-9]+\.[0-9]+\.[0-9]+-int\.[0-9]{12}\.g${sha:0:7}\$" | head -n 1 || true) + if [ -z "$tag" ]; then + echo "::error::ompweb-dev/oh-my-pi has no integration release for integration-release ($sha)" + exit 1 + fi + echo "OMP_TAG=$tag" >> "$GITHUB_ENV" + # Without GH_TOKEN: bun install runs the omp packages' scripts. + - name: Pin the runner to it + run: npx bun runner/scripts/use-omp-build.ts "$OMP_TAG" && (cd runner && npx bun x tsc -p .) - run: npm test - run: npm run build - - run: npm run smoke - + # Builds the omp runners too, which the source smoke spawns from runner/release/. The archives leave them out: + # every install fetches its runner from this release (lib/omp/runner-fetch.ts), attached below. - name: Build the release binaries - run: sh scripts/build-release-binaries.sh - - run: npm run smoke -- --binary release/ompweb-linux-x64/ompweb + run: sh scripts/build-release-binaries.sh --no-runner + - run: npm run smoke + # --omp: the draft release cannot serve the runner to the binary yet. + - run: npm run smoke -- --binary release/ompweb-linux-x64/ompweb --omp runner/release/ompweb-omp-linux-x64 - name: Create draft GitHub Release env: @@ -46,14 +67,28 @@ jobs: run: | if ! gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then node bin/generate-release-notes.js --tag "$TAG" --output release-notes.md + printf '\nomp: ompweb-dev/oh-my-pi %s\n' "$OMP_TAG" >> release-notes.md gh release create "$TAG" --repo "$GITHUB_REPOSITORY" --verify-tag --title "$TAG" --notes-file release-notes.md --draft fi - - name: Attach the binaries to the draft release + # The runners, by the names and checksum listing lib/omp/runner-fetch.ts fetches (OMP_RUNNER_ASSET, + # OMP_RUNNER_CHECKSUMS), for binary and npm installs alike. + - name: Attach the binaries and the omp runners to the draft release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + runners=(ompweb-omp-linux-x64 ompweb-omp-linux-arm64 ompweb-omp-darwin-x64 ompweb-omp-darwin-arm64 ompweb-omp-windows-x64.exe) + (cd runner/release && sha256sum "${runners[@]}" > ompweb-omp.sha256) + gh release upload "$TAG" release/ompweb-*.tar.gz release/ompweb-*.zip "${runners[@]/#/runner/release/}" runner/release/ompweb-omp.sha256 --repo "$GITHUB_REPOSITORY" --clobber + + # Before npm: an npm install fetches its runner from this release, which a draft does not serve. + - name: Publish GitHub Release env: GH_TOKEN: ${{ github.token }} TAG: ${{ github.ref_name }} - run: gh release upload "$TAG" release/ompweb-*.tar.gz release/ompweb-*.zip --repo "$GITHUB_REPOSITORY" --clobber + run: gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --draft=false - name: Publish to npm run: | @@ -65,12 +100,6 @@ jobs: npm publish --provenance --access public fi - - name: Publish GitHub Release - env: - GH_TOKEN: ${{ github.token }} - TAG: ${{ github.ref_name }} - run: gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --draft=false - notify-discord: needs: publish runs-on: ubuntu-latest diff --git a/.github/workflows/verify-pr.yml b/.github/workflows/verify-pr.yml index fc5e7f02c..05fbb310a 100644 --- a/.github/workflows/verify-pr.yml +++ b/.github/workflows/verify-pr.yml @@ -43,8 +43,12 @@ 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 runner/scripts/use-omp-build.ts + npx bun install --cwd runner --frozen-lockfile - name: TypeScript typecheck run: npm run typecheck @@ -58,6 +62,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 +75,34 @@ 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 + archive=release/ompweb-windows-x64.zip 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 + archive=release/ompweb-linux-x64.tar.gz fi + # The archive a host downloads must pass the updater's own size limits and checks. + npx bun scripts/check-host-release-archive.ts "$archive" + 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..fe89e94fa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,11 @@ 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/`). Fetch the pinned omp packages first, then install +(`npm test` needs this too): +`bun runner/scripts/use-omp-build.ts && cd runner && bun install --frozen-lockfile`; then build +`runner/release/ompweb-omp--` (`bun runner/scripts/build-binary.ts`, this machine's target), which a source checkout +finds by itself, or point `OMP_WEB_OMP_BIN` at it or at a wrapper running `bun runner/src/cli.ts`. All live-agent features go through it; session browsing works without it. --- @@ -20,8 +24,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 +36,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 +56,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 +65,14 @@ 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`, - `getOmpVersion`). -- `rpc-process.ts` — process + NDJSON protocol layer (`RpcProcess`). +- `omp-cli.ts` — locate/probe the `ompweb-omp` binary (`resolveOmpBin`, + `getOmpVersion`): `OMP_WEB_OMP_BIN`, else a source checkout's `runner/release/`, else the fetched copy + (`fetchedOmpBin`: beside a compiled binary, otherwise `/omp-web/omp//`). +- `runner-fetch.ts` — `fetchOmpRunner`/`ensureOmpRunner`/`ompweb fetch-omp`: downloads this version's + `ompweb-omp-` from its own GitHub release (repo from `package.json` `repository`, `lib/release-repo.ts`), + checks it against the release's `ompweb-omp.sha256` and writes it atomically. The web server and the daemon run it in + the background at start when the runner is missing. There are no runner npm packages. +- `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 +108,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 +122,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 @@ -146,7 +155,7 @@ lib/ plugins-cli.ts `runOmpCli`, `listPlugins` (`omp plugin list --json` as `PluginEntry[]`), `parseJsonLoose`, `ANSI_RE`; loaded by the daemon too (relative imports only); used by `app/api/plugins/route.ts`, `cluster-config-server.ts` and `daemon-cluster-config.ts` hosts/ hosts and the local daemon (see "Remote hosts"); none of the daemon-loaded files may import `@/` (the daemon runs on Node through `jiti`) host.ts `Host` interface, `HostStatus`, `isLinkUp` - local-host.ts `localHost`, the daemon's side: spawn + file checks on the daemon's machine; `resolveSpawnCwdResult`; `SESSION_EDIT_EXTENSION_PATH` (from the module's own location: the daemon runs in HOME). Never imported by main, nor is `daemon.ts` (`no-direct-local.test.mjs`) + local-host.ts `localHost`, the daemon's side: spawn + file checks on the daemon's machine; `resolveSpawnCwdResult`. Never imported by main, nor is `daemon.ts` (`no-direct-local.test.mjs`) local-bootstrap.ts web server side of the local link: `localLinkUrl`, `ensureLocalHostLink` (the `local` record + `host.json`) host-login.ts `host.json` (`loginHost`, `readHostLogin`) and `isLoopbackHost`, shared by the daemon and local-bootstrap session-ops.ts file-reading half of the session/project routes, run on the owning host's daemon (this machine's too) @@ -277,7 +286,7 @@ scripts/ smoke.ts `npm run smoke`: starts the built server (or `--binary `) and `ompweb host` with a throwaway HOME; checks the SPA, an asset, the API, a session listed through the daemon and SSE transport-metrics.ts `bun scripts/transport-metrics.ts [--since 2h|] [--until ] [--client ] [--session ] [--file ]`: per-transport summary of the browser transport samples and per host and direction of the host link samples (`--session`: link samples of one session; default: the last 24 h of the agent dir's file) link-hol-probe.ts `bun scripts/link-hol-probe.ts --host --model `: head-of-line load on one host link (large prompts down, large session reads up, get_state beside them) for the link samples; usage at its top - build-binary.ts `npm run build:binary`: compiles server/binary.ts (NODE_ENV define, no .env/bunfig autoload) into release/ompweb--/ beside dist/, public/, the KaTeX files, the session-edit extension and the licenses; archives it + build-binary.ts `npm run build:binary`: compiles server/binary.ts (NODE_ENV define, no .env/bunfig autoload) into release/ompweb--/ beside dist/, public/, the KaTeX files, the omp runner (`ompweb-omp`, built first by `runner/scripts/build-binary.ts`; not with `--no-runner`, which releases use: they attach the runners as separate assets) and the licenses; archives it build-release-binaries.sh the five release targets (one list), run by publish.yml and prerelease.yml (a prerelease per verified `main` commit, titled by build time; one concurrency group per commit, so no commit's run is dropped) licenses.ts the release's `licenses/`: license files of every npm package the server and client bundles hold (Bun.build metafiles; build-client.ts writes build/client-packages.json) and `bun-LICENSE-.md`, the vendored license of the embedded Bun runtime. Bumping Bun: vendor its LICENSE.md for the new version, or build-binary.ts stops @@ -307,6 +316,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 fetch-omp` (its runner, from its own release) and then its `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`). @@ -325,7 +335,8 @@ The table is static imports, not a runtime glob, so `bun build --compile` (`scri Wrappers report tab/command interest and protected preparation through transport capabilities; disposal closes membership, while explicit delete/reload can kill. Reconciliation failure never kills a process another server uses. -- For legacy hosts, concurrent `startRpcSession()` calls share one start promise. +- Every link is host-owned since link v11, so the backstops below run only for a transport without + `hostOwnedLifecycle` (test fakes): concurrent `startRpcSession()` calls share one start promise. Two cleanup backstops remain on the server, both unref'd: `IDLE_DESTROY_MS` (10 min) and `DISCONNECT_DESTROY_MS` (`OMP_WEB_DISCONNECT_DESTROY_MS`, 2 min, `0` disables). The shorter one only @@ -341,7 +352,7 @@ The table is static imports, not a runtime glob, so `bun build --compile` (`scri parameters in `omp-web/host-running.json`; the daemon resumes them once after its restart. Web-server restarts just rejoin. The first v6 → v7 daemon upgrade requires a quiescent host: the old daemon has no recovery manifest. -- **Legacy hosts only:** the per-web-server recovery mechanism below remains. +- The per-web-server recovery mechanism below runs only for a transport without `hostOwnedLifecycle`. - Off by default (`autoResumeSessions` in `omp-web-settings.json`). When on, `notifyRunningChange()` keeps `omp-web-interrupted-sessions-.json` in the agent dir listing sessions that are mid-run; startup @@ -371,8 +382,7 @@ The table is static imports, not a runtime glob, so `bun build --compile` (`scri The daemon owns lifecycle, streaming snapshots and first-answer dialog settlement. Web callbacks choose the last-active eligible server and are not blindly retried. `channels_changed` discovers background runs; `sessions_changed` invalidates lists; - one link is the automatic notification publisher. v4–v6 links remain exclusive; - cross-version attempts to share a session fail with `session_running_elsewhere`. + one link is the automatic notification publisher. Cross-server fences live in the daemon: a history edit takes the acknowledged `editLease` op before preflight, and a `peer_message_command` marker makes another server's identical echo ambiguous (`watchReplacement`). An open's preparation lease passes only to the @@ -419,7 +429,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). @@ -462,14 +472,13 @@ The table is static imports, not a runtime glob, so `bun build --compile` (`scri v4 (the WebSocket link) dropped v3 before any release. v5 (pieces) keeps v4; v6 (uploads: hub → daemon binary frames, `upload_ack`, `uploadStart`/ `uploadAbort`/`uploadAttach`) keeps v4 and v5. v7 adds multi-server joins, - host-owned lifecycle, callback routing and channel/list discovery: - `MIN_LINK_PROTOCOL_VERSION` is 4 and `LINK_PROTOCOL_VERSION` 10 (literal - one-liners: the deploy script parses them with `sed`); a v4 daemon gets no - `part`, whole lines and no unacked window (`link.sender.parts`); a v4/v5 - daemon gets 426 for uploads. v8 (host pool) adds `capacity` reports, `creationId` + host-owned lifecycle, callback routing and channel/list discovery. + v8 (host pool) adds `capacity` reports, `creationId` and `preparePoolProject`; v9 adds `open.launch.shareCredentials` (see "Shared credentials" below); v10 adds `open.launch.configVersion`, the `config_state` - report and the `clusterConfig` op (see "Cluster configuration" below). Main keeps at least N-1, and + report and the `clusterConfig` op (see "Cluster configuration" below). v11 (the omp runner) makes channel + lines the runner's `{t}` frames instead of omp RPC frames and drops v4–v10: `MIN_LINK_PROTOCOL_VERSION` and + `LINK_PROTOCOL_VERSION` are both 11 (literal one-liners: the deploy script parses them with `sed`). Main keeps at least N-1, and dropping one is an explicit decision (a hello outside the range gets close 4426 with `{"serverVersion":N}`, a pre-WebSocket daemon's POST gets 426). Once the WebSocket experiments end, the first version @@ -640,14 +649,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 +687,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 11 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`). @@ -924,9 +933,9 @@ The table is static imports, not a runtime glob, so `bun build --compile` (`scri - **Local link bootstrap** (`lib/hosts/local-bootstrap.ts`, `instrumentation.node.ts`): async `ensureLocalHostLink()` atomically upserts this server's `{url, token}` in `host.json.links` under the same lock as token selection, preserving the primary pair and other links. Files stay 0600. The directory watch reconciles added/removed links without restarting the daemon; malformed configuration is not overwritten. `ompweb host login --add --url ` adds a remote server (token from stdin); `host logout --url ` removes its link. A remote primary remains protected from local bootstrap replacement. - **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. -- **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. +- **Joining on hello** (`reattach`/`adopt` in `hub.ts`): 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. +- **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. +- **Adoption failure** or 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. - **No direct local path.** `local-host.ts` is the daemon's; `lib/hosts/no-direct-local.test.mjs` fails when anything outside its allow-list spawns omp or imports it. The utility omp (`rpc-utility.ts`, login) is the one deliberate exception: it is not a session. @@ -934,7 +943,8 @@ The table is static imports, not a runtime glob, so `bun build --compile` (`scri ### Session moves (omp >= 18.5 ownership) and title generation - Only the first omp process to write a session file owns it; a non-owner moves to a sibling file with a new id on its first write and emits a - `notice` with `source: "session-persistence"`. `handleFrame` answers it with + `notice` with `source: "session-persistence"` (the runner sends it before the + new `session` frame, which the daemon's `ChannelTable` reads the same way). `handleFrame` answers it with `followSessionMove()`: the pending flag makes the next `applyIdentity` treat the id change as the same conversation (stream/run state kept, **old id kept as a registry alias**, new id registered via `onIdentityChange({keepOldId})`). @@ -958,7 +968,7 @@ executor-style promises instead. Server code may use them freely. ### Editing and branching — don't confuse them - **Fork** ("Fork a new session from this point" button, `messageView.newSessionTitle`, on user and assistant messages; only offered while the session is idle — ChatWindow gates it on `!sessionBusy && !isNew`): creates a new independent `.jsonl` file via omp's `branch` RPC. Shown as a child in the sidebar tree via `parentSession` header field. `branch` only takes a user entry and keeps the history *before* it, so `lib/chat-fork.ts` maps rows: a user prompt forks at itself and its returned text prefills the fork's composer (edit-and-resend, text only — attached images are not restored); an assistant reply forks at the next user prompt so the reply is kept; the newest reply falls back to its own prompt with the prefill. Rows that would edit the very first prompt (an empty fork) offer no fork. The button only opens a `ConfirmDialog` (`ForkSessionButton`); `onFork` runs on confirm, so a stray tap while scrolling never forks. -- **Edit** (`edit_message`, user-message button): moves the live conversation's history to before that user entry; it is not a read-only historical view or an explicit Fork. `lib/omp/session-edit-extension.mjs`, explicitly loaded on spawn/restart, calls omp's public `ctx.navigateTree(entryId, { summarize: false })` and appends a non-message `omp-web-history-navigation` entry. Node never imports the Bun-only SDK or rewrites the JSONL. +- **Edit** (`edit_message`, user-message button): moves the live conversation's history to before that user entry; it is not a read-only historical view or an explicit Fork. The runner's `edit_message` command (`runner/src/commands/session.ts`) calls omp's `navigateTree(entryId, { summarize: false })` and appends a non-message `omp-web-history-navigation` entry. The web server never imports the Bun-only SDK or rewrites the JSONL. - The button only opens a `ConfirmDialog` in `UserMessageView`; `onEdit` runs on confirm, so a stray tap never rewinds the live session. - Require authoritative `get_state.isSettled === true` and reject pending background work; runtimes without settlement reporting fail explicitly. Preserve run/edit fencing, including rename and automatic-title fallbacks, and do not report success after an observed edit-time native persistence error. - `SessionEditResult` remains `{ sessionId: string, cancelled: boolean, leafId: string | null }`. The returned ID identifies the confirmed post-edit conversation. Single-owner Edit keeps ID/file; a non-owner's native copy-on-write relocation is expected. Server persistence notices feed `applyIdentity` and registry aliases; the HTTP result drives client identity adoption (there is no public identity-change frame). Reload the confirmed target before text prefill, reject unrelated switches, and preserve the original file and old branches. @@ -1104,7 +1114,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 +1152,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", @@ -1222,6 +1232,10 @@ during the wait. Startup recovery fails a same-PID `delivering` row (a reused PID from the last run) unless this process holds that claim in memory: an add accepted before a delayed startup retry may already be in flight. + A steer waits (`held`) while an ask (`askDialog`/`formDialog`) is open, since + omp's ask tool aborts on a user steer; the wrapper's `onAskClosed` (answered, + cancelled, expired or child gone) pumps it. The composer is hidden meanwhile, + and a `send_to_session` steer goes as a follow-up (its result says so). Host offline → `waiting_host`; uncertain replies/child exits while delivering → `failed` with `mayHaveBeenSent`, never independently resent. Anything that stops an entry before its native write (an error before the claim, omp exiting @@ -1457,7 +1471,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 +1523,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 +1531,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 +1712,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). @@ -1713,13 +1727,13 @@ during the wait. ### Updates: web server versus host software - **Settings → System** owns the web server's settings and application update. `GET /api/app-update` and its prepare/commit flow remain web-server-only; a compiled web server still links to the release download page. -- **Settings → Hosts** owns each machine's ompweb daemon and OMP runtime, including **This machine**. `GET /api/hosts/[id]/updates` checks on that daemon; `POST {target, version}` starts its update. Offline and older daemons return explicit failures, never fall back to checking or updating the web server. -- The hub supplies its supported protocol range to `checkUpdates`/`updateSoftware` host ops. Both the hub and the daemon reject an unknown or incompatible candidate protocol before installing ompweb. The update stays visible with a blocked reason. OMP runtime updates do not change the link protocol. -- A daemon update is refused while it owns session processes (subagents included); the daemon also refuses new spawns while an update is running. Check completion through the durable operation state after reconnect, not the POST's acceptance alone. An OMP update leaves already-running sessions on their loaded runtime. -- `/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. +- **Settings → Hosts** owns each machine's ompweb daemon, including **This machine**, and shows its omp version ("Updated with ompweb": each release ships its `ompweb-omp`, so there is no separate omp update and daemons refuse `target: "omp"`). `GET /api/hosts/[id]/updates` checks on that daemon; `POST {target, version}` starts its update. Offline and older daemons return explicit failures, never fall back to checking or updating the web server. +- The hub supplies its supported protocol range to `checkUpdates`/`updateSoftware` host ops. Both the hub and the daemon reject an unknown or incompatible candidate protocol before installing ompweb. The update stays visible with a blocked reason. +- A daemon update is refused while it owns session processes (subagents included); the daemon also refuses new spawns while an update is running. Check completion through the durable operation state after reconnect, not the POST's acceptance alone. +- `/api/omp-update` keeps only `restart`, 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 +1770,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 @@ -1953,6 +1967,9 @@ during the wait. activity is wrapped in `subagent_*` frames and never notifies. "Completed" fires on `session_settled` (not `prompt_result`), so background work started by the prompt is done too; a failed or aborted last prompt suppresses it. + On a shared daemon channel the other servers get that prompt's outcome as + `peer_prompt_outcome` (no command id), which only feeds their detector, so the + elected notifier hears a failure whichever server prompted. Auto-approved tool confirmations return before the hook and never notify. omp sends `retry_fallback_applied` before the `auto_retry_start` of the same failure, so the detector holds a reason-less fallback until that frame. diff --git a/CHANGELOG.md b/CHANGELOG.md index 62801f331..c2fc2456a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,17 @@ All notable changes to **omp-web** (`@kahme247/ompweb`) are documented in this f ## [Unreleased] +### Breaking changes + +- **ompweb ships its own omp, `ompweb-omp`; omp no longer needs installing.** Every session runs on ompweb's own omp build: omp's CLI from an `ompweb-dev/oh-my-pi` integration release (an upstream omp release plus the PRs it carries), with ompweb's channel instead of omp's RPC mode. It reads the same `~/.omp` settings, logins and sessions. Each release attaches `ompweb-omp-` for linux-x64, linux-arm64, darwin-x64, darwin-arm64 and windows-x64, with their SHA-256 sums; every install, npm and npx included, downloads the one of its exact version on its first start, checks it and keeps it (beside the single binary, otherwise in `/omp-web/omp//`), and `ompweb fetch-omp` does so on demand. Sessions start once it is there. On any other platform ompweb cannot start sessions. The release archives do not contain it; prerelease archives do. omp updates with ompweb: both updaters fetch the new version's runner before its preflight. `OMP_WEB_OMP_BIN` now points at another `ompweb-omp` build, for example a copy of the release asset on a machine that cannot reach GitHub: a stock `omp` does not speak the channel and cannot run ompweb's sessions. On Linux and macOS, `ompweb-omp` run in a terminal works like omp's TUI, and its session also shows up live in the web. +- **Link protocol 11 only.** Web servers and host daemons on this release speak link protocol 11 and refuse every older peer, prereleases on protocol 10 included: update the web server first, then every host. Until a host is updated, Settings → Hosts says it needs an update and no session starts there. +- **Every host needs one manual update.** A host daemon on an older release speaks link protocol 10 or below, so a web server on this release refuses its link, and Settings → Hosts cannot update it (an older web server cannot either: its hosts refuse a candidate on a newer protocol). Update each host by hand once, after the web server: for npm, restart its `ompweb host` or service (`npx @kahme247/ompweb@latest` picks up the new version; a global install needs `npm install -g @kahme247/ompweb` first); for a single binary, stop `ompweb host`, replace its directory with the new release and start it again. Either downloads its `ompweb-omp` on that start. Later updates from Settings → Hosts work, and fetch the new version's runner before its preflight. +- **The service installers no longer pin a stock omp.** `ompweb systemd install` and `ompweb-launchd install` used to write the `omp` found on `PATH` into the service environment as `OMP_WEB_OMP_BIN`, which would now make every session fail. They write `OMP_WEB_OMP_BIN` only when you set it yourself. Reinstall the services after updating if your service environment (`~/.omp/agent/web-service.env`, the unit or the plist) still names a stock `omp`. +- **Skill startup notices and "View skill diagnostics" are gone.** They needed omp commands that only an unmerged omp PR had; the notices above the composer, the Settings → Interface & Behavior switch and the Skills panel's **View skill diagnostics** are removed. + ### 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 this version's downloaded `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. Before the preflight, both run the new release's `ompweb fetch-omp`, so its runner is in place first. A release without the preflight command is checked only if it ships `ompweb-omp`; then the update fails. - **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. @@ -43,12 +52,13 @@ All notable changes to **omp-web** (`@kahme247/ompweb`) are documented in this f - **Show tool calls outside Process details.** A new setting keeps each tool call's row in the transcript instead of folding it; whether the row opens still follows "Keep tool calls collapsed". - **Never fold tool activity.** A new setting renders every turn flat, with no Process details rows at all. Long turns are paged, and jumping to a search match reveals its message even outside the current page. - **Connection diagnostics.** Turn on **Settings → Interface & Behavior → Show connection diagnostics** to get a **Connection** tab in the right panel, on desktop and mobile. It shows the transport actually in use (WebSocket or server-sent events, including fallback), connection status, reconnects, and latency statistics over the last 30 minutes: mean, p50, p95, p99.9 and max. Successful commands and sampled live events are grouped by transport. One-way times are clock-corrected estimates; web app ↔ omp includes processing, with `get_state` shown separately as a delivery proxy. p99.9 needs at least 1,000 samples. -- **Updates belong to each host.** Settings → Hosts now shows the ompweb daemon and OMP runtime versions, available updates, and update actions for every machine, including This machine. Checks and installations run on that host. An incompatible or unverifiable daemon update stays visible but blocked, with a reason to update the web server first; OMP updates are independent of the link protocol. A daemon update is refused while session processes or subagents run on that host (close them, then start it again), and the host refuses new sessions while it installs. The host update retains a backup and reports progress after reconnect. **System & Updates** is now **System**, containing only the web server's controls; it no longer updates or restarts a host's OMP sessions globally. +- **Updates belong to each host.** Settings → Hosts now shows the ompweb daemon and OMP runtime versions, available updates, and update actions for every machine, including This machine. Checks and installations run on that host. An incompatible or unverifiable daemon update stays visible but blocked, with a reason to update the web server first. A daemon update is refused while session processes or subagents run on that host (close them, then start it again), and the host refuses new sessions while it installs. The host update retains a backup and reports progress after reconnect. **System & Updates** is now **System**, containing only the web server's controls; it no longer updates or restarts a host's OMP sessions globally. - **Messages wait safely until omp has them.** In an existing chat, the composer clears once IndexedDB has stored the message, and a dimmed bubble names the delivery leg: **Sending**, **Waiting for **, **Delivering**, or **Not sent** with the reason. Browser-held messages survive reloads and closed tabs; server-held messages survive web-server restarts and host outages, and appear on every device viewing the chat. Chromium's Background Sync can deliver after the last tab closes; other browsers retry when an ompweb page reopens. Duplicate submissions share one message id. If a lost host reply leaves delivery uncertain, the message is marked **Not sent** and never retried automatically. A prompt that omp stops (Stop, or a failure) before it reaches the transcript is marked **Not sent** at once, so the messages behind it go on without waiting. Held messages offer **Delete** until dispatched. Follow-ups and steers become omp's own queue chips when accepted. New chats keep their existing first-message path; a browser unable to store the message keeps the composer draft instead. ### Fixes & Improvements +- **omp updates with ompweb: the omp row no longer runs `omp update`.** Each ompweb release ships its own omp runner, so Settings → Hosts shows the host's omp version with "Updated with ompweb" and no update button. Hosts refuse an omp update request, and `/api/omp-update` keeps only `restart`. - **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. - **Cluster members install plugins from the source this machine installed them from.** A plugin installed here from Git (`github:…`, `git+…`) or a tarball URL is installed the same way on each member instead of as an unrelated npm package of the same name and version, and a member that already has that npm package replaces it. A plugin linked or installed from a local folder here is not installed on members; Settings → Hosts shows the member as failed and names the plugin to install there by hand. A source that installs a different version than this machine runs (an unpinned Git branch, for example) also shows the member as failed, with both versions. Credentials in a source never appear in the host daemon log. - **Resuming an idle session no longer fails while omp is still starting.** omp can take more than five seconds to answer its first commands after a cold start (model refresh, plugin updates, hooks). The startup handshake now allows 30 seconds, so a message sent to an idle session is no longer marked **Not sent** with `RPC command get_state timed out after 5000ms`. Status polls of a running session keep their five-second limit. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7ca0dbb2c..9a9e4f8ba 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,13 +5,17 @@ Thanks for your interest in contributing! ## Setup - Node.js 24 or newer (the server runs on [Bun](https://bun.sh), which `npm install` provides) -- The [omp](https://github.com/can1357/oh-my-pi) binary on your `PATH` (or set `OMP_WEB_OMP_BIN`) ```bash npm install +npx bun runner/scripts/use-omp-build.ts && (cd runner && npx bun install --frozen-lockfile) # ompweb's omp build; npm test needs it too npm run dev # http://127.0.0.1:30178, Bun hot reload ``` +For live sessions, build the runner for this machine with `npx bun runner/scripts/build-binary.ts`: ompweb finds +`runner/release/ompweb-omp--` by itself (`OMP_WEB_OMP_BIN` overrides it). A stock `omp` does not +speak ompweb's channel. + The repository tracks a [graphify](https://github.com/Graphify-Labs/graphify) code graph in `graphify-out/graph.json`. To keep it current, install the graphify release that built it (`uv tool install graphifyy==0.9.99`; releases build different graphs @@ -62,9 +66,9 @@ The jsdom dependency stays on 29.x for now; moving to a newer major is a separat icons come from `lucide-react`. - **i18n**: every user-facing string needs entries in all four dictionaries: `lib/i18n/locales/{en,zh-CN,zh-TW,ja}.json`. -- **Architecture**: omp-web never imports `@oh-my-pi/*` or `@earendil-works/*` - packages (Bun-only). Live agent features go through the `omp` child process - via RPC; see `DESIGN.md` and `AGENTS.md` for the full contract. +- **Architecture**: the ompweb server and daemon never import `@oh-my-pi/*` or `@earendil-works/*` + packages (Bun-only). Live agent features go through the `ompweb-omp` child process + (`runner/`) over ompweb's channel; see `DESIGN.md` and `AGENTS.md` for the full contract. ## Pull requests diff --git a/DESIGN.md b/DESIGN.md index 6c5a71620..c2d1e12b7 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,39 @@ 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. - -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. +Therefore, the ompweb server and daemon must not add those runtime dependencies. + +Live work goes through ompweb's own omp build, `runner/`, compiled against the +fork's integration build: an `ompweb-dev/oh-my-pi` release that is an upstream omp +release plus the PRs the fork carries (among them `runSessionHost`, which the +headless mode needs), pinned to one release in `runner/omp-build.json` and +re-pinned to the newest one by each ompweb release. The runner replaces omp's +launch command: 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. Each ompweb release attaches its runners (`ompweb-omp-`), +and every install downloads the one of its own version +(`lib/omp/runner-fetch.ts`), so omp updates with ompweb. The design and the wire are in +`docs/specs/2026-11-12-omp-runner-design.md`; the fork build in +`docs/specs/2026-11-12-omp-build-in-ompweb-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 +96,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 +108,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 +137,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 +221,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 +254,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 +269,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.ja.md b/README.ja.md index 96432729e..0b2593ba1 100644 --- a/README.ja.md +++ b/README.ja.md @@ -26,14 +26,12 @@ ## 必要条件 -- [omp](https://github.com/can1357/oh-my-pi) がインストールされ、`PATH` に含まれていること(または `OMP_WEB_OMP_BIN` で指定) +- omp を別途インストールする必要はありません。ompweb は独自の [omp](https://github.com/can1357/oh-my-pi) ビルド `ompweb-omp` を使い、`~/.omp` の設定、認証、セッションをそのまま使います。どのインストール(npm、npx、単一バイナリ)も、初回起動時に同じバージョンの `ompweb-omp` をそのバージョンの [GitHub リリース](https://github.com/kahme247/ompweb/releases)からダウンロードし、SHA-256 を確認して保存します(単一バイナリでは同じディレクトリ、それ以外は `~/.omp/agent/omp-web/omp//`)。`ompweb fetch-omp` でも取得できます。対応プラットフォームは linux-x64、linux-arm64、darwin-x64、darwin-arm64、windows-x64 です。それ以外(musl の Linux、Arm 版 Windows、FreeBSD)ではセッションを開始できません。GitHub に接続できないマシンでは、`OMP_WEB_OMP_BIN` にそのリリースの `ompweb-omp--` のコピーを指定してください - Node.js `>= 24`。サーバーは [Bun](https://bun.sh) で動作し、npm パッケージの依存関係として自動的にインストールされます(同梱の Bun が見つからない場合は `PATH` 上の Bun を使用します) -キュー内のフォローアップに対する **Steer** 操作には、`promote_queued_message` RPC コマンドをサポートする omp ランタイムが必要です(omp 18.1.16 では未対応)。古いランタイムではエラーを表示し、メッセージはフォローアップとしてキューに残ります。ompweb が重複したステアリングメッセージを送信することはありません。 +キュー内のメッセージに対する **Steer**、**Delete**、**Edit** 操作は `ompweb-omp` チャネルのコマンド(`promote_queued_message`、`remove_queued_message`)です。omp がキャンセルを確認してから表示を削除し、編集時はキャンセル成功後にテキストを入力欄へ戻します。すでにキューに存在しないメッセージでは、キューを変更せず通知を表示します。ompweb が重複したステアリングメッセージを送信することはありません。 -キューの **Delete** と **Edit** には、さらに `remove_queued_message` が必要です。OMP がキャンセルを確認してから表示を削除し、編集時はキャンセル成功後にテキストを入力欄へ戻します。未対応のランタイムや、すでにキューに存在しないメッセージでは、キューを変更せず通知を表示します。 - -キューパネルは omp 自身のキュー(`get_state` の `queuedMessages` と `queue_update` イベント、omp 18.4.4 以降)を表示するため、同じセッションを開いているすべての端末に同じキューが表示されます。**Stop** は、まだキューで待機中のメッセージをエージェントに実行させず、そのテキストを入力欄に戻します。ただし、ライブステアリングでモデルがすでに受け取ったステアは実行されます。それより古い omp ではキューパネルは表示されず、Stop でキュー内のメッセージを取り戻すこともできません。 +キューパネルは omp 自身のキュー(`get_state` の `queuedMessages` と `queue_update` イベント)を表示するため、同じセッションを開いているすべての端末に同じキューが表示されます。**Stop** は、まだキューで待機中のメッセージをエージェントに実行させず、そのテキストを入力欄に戻します。ただし、ライブステアリングでモデルがすでに受け取ったステアは実行されます。 ## クイックスタート @@ -60,6 +58,8 @@ ompweb ompweb --port 8080 # ポート番号指定 ompweb --hostname 0.0.0.0 # ネットワーク公開 ompweb hash-password # OMP_WEB_PASSWORD_HASH 用の値を出力 +ompweb fetch-omp # このバージョンの ompweb-omp を GitHub リリースからダウンロード(SHA-256 確認、未取得の場合のみ) +ompweb preflight # 新しいセッションと同じように omp を一度起動して hello を待つ。失敗時は理由を表示して終了コード 1 ompweb --no-open # ブラウザ自動起動を無効化 ``` @@ -129,7 +129,7 @@ Linux トレイ、Windows サービス)はインストール時に `OMP_WEB_PA | `OMP_WEB_NO_OPEN` | `1` でブラウザ自動起動を無効化 | `0` | | `OMP_WEB_DISABLE_AUTOUPDATE` | `1` で更新チェックとアプリ内更新を無効化(変更後は再起動) | `0` | | `OMP_WEB_NAME` | ブラウザのタブとインストールしたアプリに表示する名前。`url`・`host`・`domain`(大文字小文字を区別しない)を指定すると、ブラウザが接続したホスト名(ポートなし)を使用します(localhost と IP アドレスは `omp web` のまま)。それ以外の値はそのまま使用します(変更後は再起動) | `omp web` | -| `OMP_WEB_OMP_BIN` | `omp` の絶対パス(PATH 未登録時) | _自動検出_ | +| `OMP_WEB_OMP_BIN` | ダウンロード版の代わりに使う `ompweb-omp` ビルドのパス(GitHub に接続できないマシンではリリースの `ompweb-omp--` のコピー、ソースチェックアウトでは `runner/release/ompweb-omp--`)。通常の `omp` は ompweb のチャネルを話さないため使えません。サービスのインストーラーは、これを設定したときだけサービス環境に書き込みます | _ダウンロードした `ompweb-omp`_ | | `PI_CODING_AGENT_DIR` | カスタム omp エージェントディレクトリ | `~/.omp/agent` | | `OMP_WEB_STT_ENDPOINT` | OpenAI 互換の音声認識エンドポイント URL。未設定の場合は `OMP_WEB_STT_MODEL` を omp 自身の認証情報で使って文字起こしします | _なし_ | | `OMP_WEB_STT_KEY` | STT エンドポイント用の API キー | _なし_ | @@ -143,10 +143,12 @@ Linux トレイ、Windows サービス)はインストール時に `OMP_WEB_PA git clone https://github.com/kahme247/ompweb.git cd ompweb npm install +npx bun runner/scripts/use-omp-build.ts && (cd runner && npx bun install --frozen-lockfile) +npx bun runner/scripts/build-binary.ts # このマシン用の ompweb の omp ビルド: runner/release/ompweb-omp-- npm run dev ``` -ローカル開発サーバーは [http://127.0.0.1:30178](http://127.0.0.1:30178) で起動します。 +ローカル開発サーバーは [http://127.0.0.1:30178](http://127.0.0.1:30178) で起動します。`runner/release/` のランナーは自動的に見つかります。`npm test` と `npm run build:binary` にもランナーのセットアップが必要です。 `npm run dev` は Bun のホットリロード付きでサーバーを起動します。`npm run build` はブラウザアプリを `dist/` にバンドルし、`npm start` はそれをポート 30177 で配信します(`npm run smoke` で結果を確認できます)。`npm run dev:lan` は開発サーバーをすべてのインターフェースにバインドします。 diff --git a/README.md b/README.md index b7236c674..7d5ed32ed 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 runs its own [omp](https://github.com/can1357/oh-my-pi) build, `ompweb-omp`, which reads your `~/.omp` settings, auth and sessions. Every install (npm, npx or the single binary) downloads the `ompweb-omp` of its exact version from that version's [GitHub release](https://github.com/kahme247/ompweb/releases) the first time it starts, checks its SHA-256 and keeps it (beside the single binary, otherwise in `~/.omp/agent/omp-web/omp//`); `ompweb fetch-omp` does the same on demand. Sessions start once it is there. It exists for linux-x64, linux-arm64, darwin-x64, darwin-arm64 and windows-x64; on any other platform (Linux on musl, Windows on Arm, FreeBSD) ompweb cannot start sessions. A machine that cannot reach GitHub needs `OMP_WEB_OMP_BIN` pointing at a copy of that release's `ompweb-omp--` - 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,8 @@ 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 fetch-omp # Download this version's ompweb-omp from its GitHub release (SHA-256-checked) if it is missing +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 +440,8 @@ 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: on its first start it downloads its own omp build, `ompweb-omp`, from the same release +(SHA-256-checked) and keeps it beside itself (prerelease archives already carry 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 +453,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 the `ompweb-omp` beside it 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 +467,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 @@ -669,9 +655,9 @@ host (KDE Plasma, and most Wayland/X11 desktops). ## Run sessions on another machine One ompweb can start and watch omp sessions on other machines. Each machine keeps -its own `omp` install, `~/.omp/agent` directory, sessions, and credentials (unless you +its own ompweb (with its `ompweb-omp`), `~/.omp/agent` directory, sessions, and credentials (unless you [share the server's provider logins](#use-the-servers-provider-logins-on-a-host)); -ompweb only routes. The remote machine needs `omp` and either Node 24 (for `npx`) or +ompweb only routes. The remote machine needs either Node 24 (for `npx`) or the [single binary](#single-binary), and must be able to reach the ompweb server over HTTP(S). It dials out, so it can sit behind NAT. @@ -695,15 +681,14 @@ HTTP(S). It dials out, so it can sit behind NAT. The host shows **online** in Settings → Hosts, the sidebar gets a group for it, and the new-session screen gets a host picker. -**Settings → Hosts** also shows each machine's ompweb daemon and OMP runtime -versions, available updates and update actions, including **This machine**. +**Settings → Hosts** also shows each machine's ompweb daemon and omp versions, +available updates and update actions, including **This machine**. Checks and installations run on the selected host, not on the web server. A host update whose link protocol is incompatible with the current web app stays visible but is blocked with a reason; update the web server first. An unverifiable target -protocol is blocked too. OMP updates are independent of the link protocol. +protocol is blocked too. omp is part of each ompweb release and updates with it. Before updating a daemon, close its session processes: the updater refuses to -interrupt sessions or subagents. Updating OMP leaves existing processes on their -loaded version until they restart. **Settings → System** keeps the web server's +interrupt sessions or subagents. **Settings → System** keeps the web server's own application update and service settings. Hosts need a daemon build that supports these update operations; older builds show an unsupported message and need one manual update first. If the daemon @@ -826,7 +811,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] @@ -952,7 +937,7 @@ Stop it with `Stop-ScheduledTask ompweb-host`, then end the running host with | `OMP_WEB_NO_OPEN` | Set to `1` to prevent auto-opening browser | `0` | | `OMP_WEB_DISABLE_AUTOUPDATE` | Set to `1` to disable update checks and in-app updates; restart after changing | `0` | | `OMP_WEB_NAME` | Name shown in browser tabs and installed-app names. `url`, `host` or `domain` (any case) uses the hostname the browser connected to, without port; localhost and IP addresses keep `omp web`. Any other value is used as-is. Restart after changing | `omp web` | -| `OMP_WEB_OMP_BIN` | Path to `omp` binary if not on `PATH` | _auto-detected_ | +| `OMP_WEB_OMP_BIN` | Path to an `ompweb-omp` build to use instead of the downloaded one: a copy of the release's `ompweb-omp--` on a machine that cannot reach GitHub, or `runner/release/ompweb-omp--` from a source checkout. A stock `omp` does not work: it does not speak ompweb's channel. The service installers write it into the service environment only when you set it | _the downloaded `ompweb-omp`_ | | `PI_CODING_AGENT_DIR` | Custom omp agent directory | `~/.omp/agent` | | `OMP_WEB_STT_ENDPOINT` | OpenAI-compatible transcription endpoint URL. Leave unset to transcribe with `OMP_WEB_STT_MODEL` through omp's own credentials | _None_ | | `OMP_WEB_STT_KEY` | Optional API key for the STT endpoint | _None_ | @@ -1075,10 +1060,13 @@ addresses you list in `set_real_ip_from`: git clone https://github.com/kahme247/ompweb.git cd ompweb npm install +npx bun runner/scripts/use-omp-build.ts && (cd runner && npx bun install --frozen-lockfile) +npx bun runner/scripts/build-binary.ts # ompweb's omp build for this machine: runner/release/ompweb-omp-- npm run dev ``` -The dev server runs at [http://127.0.0.1:30178](http://127.0.0.1:30178). +The dev server runs at [http://127.0.0.1:30178](http://127.0.0.1:30178). It finds the runner in +`runner/release/` by itself; `npm test` and `npm run build:binary` need the runner set up too. A dev server does not take over the local daemon: only the production web server writes the daemon's `host.json`, so "This machine" shows offline in dev diff --git a/README.zh-CN.md b/README.zh-CN.md index ace6bcf43..469c13da5 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -26,14 +26,12 @@ ## 环境要求 -- 已安装 [omp](https://github.com/can1357/oh-my-pi) 且在 `PATH` 中(或通过 `OMP_WEB_OMP_BIN` 指定路径) +- 无需另行安装 omp:ompweb 使用自己的 [omp](https://github.com/can1357/oh-my-pi) 构建 `ompweb-omp`,直接使用 `~/.omp` 中的设置、认证和会话。每种安装方式(npm、npx 或单一可执行文件)都会在首次启动时从该版本的 [GitHub 发布](https://github.com/kahme247/ompweb/releases)下载同一版本的 `ompweb-omp`,校验 SHA-256 后保存(单一可执行文件放在同一目录,其他方式放在 `~/.omp/agent/omp-web/omp//`);也可用 `ompweb fetch-omp` 手动获取。支持的平台为 linux-x64、linux-arm64、darwin-x64、darwin-arm64 和 windows-x64;在其他平台(musl 的 Linux、Arm 版 Windows、FreeBSD)上无法启动会话。无法访问 GitHub 的机器请将 `OMP_WEB_OMP_BIN` 指向该发布的 `ompweb-omp--` 副本 - Node.js `>= 24`。服务器运行在 [Bun](https://bun.sh) 上,Bun 会作为 npm 包的依赖自动安装(找不到随包安装的 Bun 时使用 `PATH` 中的 Bun) -将排队的后续消息转为 **Steer** 需要 omp 运行时支持 `promote_queued_message` RPC 命令(omp 18.1.16 尚不支持)。旧版运行时会返回错误,消息仍作为后续消息排队;ompweb 不会重复发送一条引导消息。 +排队消息的 **Steer**、**Delete** 和 **Edit** 操作是 `ompweb-omp` 通道的命令(`promote_queued_message`、`remove_queued_message`)。只有 omp 确认取消后才会移除消息;编辑时也必须先取消成功,再将文本恢复到输入框。消息已不在队列中时,界面保留队列并显示提示;ompweb 不会重复发送一条引导消息。 -队列中的 **Delete** 和 **Edit** 还需要支持 `remove_queued_message`。只有 OMP 确认取消后才会移除消息;编辑时也必须先取消成功,再将文本恢复到输入框。运行时不支持该命令或消息已不在队列中时,界面保留队列并显示提示。 - -队列面板显示 omp 自身的队列(`get_state` 中的 `queuedMessages` 和 `queue_update` 事件,需 omp 18.4.4 或更高版本),因此查看同一会话的所有设备都会看到相同的排队消息。**Stop** 会将仍在队列中等待的消息文本放回输入框,而不是让智能体运行它们;但模型已通过实时引导接收的引导消息仍会运行。更早版本的 omp 不显示队列面板,Stop 也无法取回排队消息。 +队列面板显示 omp 自身的队列(`get_state` 中的 `queuedMessages` 和 `queue_update` 事件),因此查看同一会话的所有设备都会看到相同的排队消息。**Stop** 会将仍在队列中等待的消息文本放回输入框,而不是让智能体运行它们;但模型已通过实时引导接收的引导消息仍会运行。 ## 快速开始 @@ -60,6 +58,8 @@ ompweb ompweb --port 8080 # 自定义端口 ompweb --hostname 0.0.0.0 # 监听网络地址 ompweb hash-password # 输出 OMP_WEB_PASSWORD_HASH 的值 +ompweb fetch-omp # 从 GitHub 发布下载本版本的 ompweb-omp(校验 SHA-256,仅在缺失时) +ompweb preflight # 像新会话一样启动一次 omp 并等待其 hello;失败时说明原因并以 1 退出 ompweb --no-open # 不自动打开浏览器 ``` @@ -122,7 +122,7 @@ Windows 服务)仍在安装时接受 `OMP_WEB_PASSWORD`,但在写入配置 | `OMP_WEB_NO_OPEN` | 设为 `1` 时禁止自动打开浏览器 | `0` | | `OMP_WEB_DISABLE_AUTOUPDATE` | 设为 `1` 时禁用更新检查和应用内更新;修改后需重启 | `0` | | `OMP_WEB_NAME` | 浏览器标签页和已安装应用中显示的名称。设为 `url`、`host` 或 `domain`(不区分大小写)时使用浏览器所连接的主机名(不含端口),localhost 和 IP 地址仍显示 `omp web`;其他值按原样使用。修改后需重启 | `omp web` | -| `OMP_WEB_OMP_BIN` | `omp` 二进制路径(未在 PATH 时使用) | _自动检测_ | +| `OMP_WEB_OMP_BIN` | 用于替代下载版本的 `ompweb-omp` 构建路径(无法访问 GitHub 的机器上为发布中 `ompweb-omp--` 的副本,源码检出中为 `runner/release/ompweb-omp--`)。普通的 `omp` 不支持 ompweb 的通道,无法使用。服务安装程序仅在你设置了它时才写入服务环境 | _下载的 `ompweb-omp`_ | | `PI_CODING_AGENT_DIR` | 自定义 omp agent 目录 | `~/.omp/agent` | | `OMP_WEB_STT_ENDPOINT` | OpenAI 兼容的语音转文字接口 URL。不设置时使用 `OMP_WEB_STT_MODEL` 通过 omp 自身的凭据转录 | _无_ | | `OMP_WEB_STT_KEY` | STT 接口对应的 API Key | _无_ | @@ -136,10 +136,12 @@ Windows 服务)仍在安装时接受 `OMP_WEB_PASSWORD`,但在写入配置 git clone https://github.com/kahme247/ompweb.git cd ompweb npm install +npx bun runner/scripts/use-omp-build.ts && (cd runner && npx bun install --frozen-lockfile) +npx bun runner/scripts/build-binary.ts # 为本机构建 ompweb 的 omp:runner/release/ompweb-omp-- npm run dev ``` -本地开发服务器运行在 [http://127.0.0.1:30178](http://127.0.0.1:30178)。 +本地开发服务器运行在 [http://127.0.0.1:30178](http://127.0.0.1:30178)。它会自动找到 `runner/release/` 中的 runner;`npm test` 和 `npm run build:binary` 也需要先准备好 runner。 `npm run dev` 以 Bun 热重载方式运行服务器。`npm run build` 将浏览器端应用打包到 `dist/`,`npm start` 在 30177 端口提供该产物(可用 `npm run smoke` 检查结果)。`npm run dev:lan` 会将开发服务器绑定到所有网络接口。 diff --git a/README.zh-TW.md b/README.zh-TW.md index 433e4f309..ccb282cb8 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -26,14 +26,12 @@ ## 系統需求 -- 已安裝 [omp](https://github.com/can1357/oh-my-pi) 並設定於系統 `PATH` 中(或透過 `OMP_WEB_OMP_BIN` 指定路徑) +- 無需另行安裝 omp:ompweb 使用自己的 [omp](https://github.com/can1357/oh-my-pi) 建置版本 `ompweb-omp`,直接使用 `~/.omp` 中的設定、認證與對話。每種安裝方式(npm、npx 或單一二進位檔)都會在首次啟動時從該版本的 [GitHub 發行版本](https://github.com/kahme247/ompweb/releases)下載同一版本的 `ompweb-omp`,驗證 SHA-256 後保存(單一二進位檔放在同一資料夾,其他方式放在 `~/.omp/agent/omp-web/omp//`);也可用 `ompweb fetch-omp` 手動取得。支援的平台為 linux-x64、linux-arm64、darwin-x64、darwin-arm64 與 windows-x64;在其他平台(musl 的 Linux、Arm 版 Windows、FreeBSD)上無法啟動對話。無法連線至 GitHub 的機器請將 `OMP_WEB_OMP_BIN` 指向該發行版本 `ompweb-omp--` 的副本 - Node.js `>= 24`。伺服器運作於 [Bun](https://bun.sh) 上,Bun 會作為 npm 套件的相依套件自動安裝(找不到隨套件安裝的 Bun 時會使用 `PATH` 中的 Bun) -針對佇列中待傳送後續訊息執行的**引導(Steer)**操作,需搭配支援 `promote_queued_message` RPC 指令的 omp 執行階段(omp 18.1.16 尚未支援)。較舊版本會回報錯誤並保留訊息在佇列中作為普通後續訊息;ompweb 不會重複傳送引導訊息。 +佇列中訊息的**引導(Steer)**、**刪除(Delete)**與**編輯(Edit)**操作皆為 `ompweb-omp` 通道的指令(`promote_queued_message`、`remove_queued_message`)。刪除操作會先經 omp 確認後,標籤才會自畫面消失;編輯操作則在成功取消佇列後,才會將文字取回輸入框。若該訊息已不再處於等待狀態,佇列將維持原狀並顯示提示;ompweb 不會重複傳送引導訊息。 -佇列的**刪除(Delete)**與**編輯(Edit)**額外需要 `remove_queued_message` 支援。刪除操作會先經 OMP 確認後,標籤才會自畫面消失;編輯操作則在成功取消佇列後,才會將文字取回輸入框。若執行階段不支援或該訊息已不再處於等待狀態,佇列將維持原狀並顯示提示。 - -佇列面板會顯示 omp 自身的佇列狀態(需 omp 18.4.4 或更新版本,支援 `get_state` 中的 `queuedMessages` 與 `queue_update` 事件),因此所有檢視該對話的裝置皆能看到一致的排隊訊息。點選**停止(Stop)**會將仍在佇列中等待的訊息文字退回輸入框,而非任由代理繼續執行;但若模型已經透過即時引導接收了該操作,該次引導仍會繼續執行。較舊的 omp 執行階段不會顯示佇列面板,且停止時無法取回排隊中的訊息。 +佇列面板會顯示 omp 自身的佇列狀態(`get_state` 中的 `queuedMessages` 與 `queue_update` 事件),因此所有檢視該對話的裝置皆能看到一致的排隊訊息。點選**停止(Stop)**會將仍在佇列中等待的訊息文字退回輸入框,而非任由代理繼續執行;但若模型已經透過即時引導接收了該操作,該次引導仍會繼續執行。 ## 在密碼保護下安裝應用程式(PWA) @@ -43,8 +41,6 @@ 請保持 Cloudflare Access 與應用程式驗證處於啟用狀態;無需為 manifest 設定公開存取例外或 Access 略過規則。此機制並未加入離線支援。本機 Chromium 驗證已涵蓋受 Cookie 保護的中繼資料並封鎖 HTTP 圖示網址的情境(未產生任何安裝性錯誤)。專案維護者亦確認在 Cloudflare Access 保護下,實體手機安裝皆可正常運作。 -技能啟動提示需搭配支援 `get_skill_diagnostics`、`set_skill_startup_diagnostics` 與 `skill_diagnostics_update` 的 OMP 執行階段。衝突與多餘重複安裝的項目會在該 OMP 對話啟動時顯示於訊息輸入框上方;開啟空白的新對話頁面不會單純為了診斷而啟動 OMP。點選**詳細資訊**可檢視解析出的預設項目、不同版本、完全相同的副本、實際檔案路徑、來源以及選取原因。點選 **×** 按鈕可在該對話中隱藏該提示,直到診斷報告有所變更為止。點選**關閉**以及前往**設定 → 介面與行為 → 技能啟動提示**會直接變更 OMP 持久化的 `skills.showStartupDiagnostics` 偏好設定,而非瀏覽器獨立設定。即使提示已關閉,在已選取的執行中對話內仍可透過**設定 → 擴充功能與工具 → 技能 → 檢視技能診斷資訊**進行查閱。檢視診斷不會重新接續已停止的對話。若缺乏執行階段支援或沒有執行中的對話,將顯示為無法使用,而非無問題的正常結果。 - ## 通知功能 在**設定 → 通知**中開啟通知。每個瀏覽器皆各自保留其獨立設定:包含哪些事件需發出通知(任務完成、等待輸入、執行失敗、模型已自動切換),以及當你在另一個分頁使用 omp-web 時的通知方式(應用程式內浮動提示,或一律顯示系統通知)。目前正在檢視中的對話不會顯示通知。點選通知可直接開啟該對話。 @@ -54,7 +50,7 @@ - 若在具備安全環境但未支援推播的環境下,只要 omp-web 分頁保持開啟,系統通知依然有效。若在非 localhost 位址透過純 HTTP 連線,瀏覽器會禁止所有系統通知:此時僅會保留在操作 omp-web 時顯示的應用程式內浮動提示。 - 桌面瀏覽器會透過作業系統的通知中心顯示通知。若要接收背景推播,需保持瀏覽器處理程序執行中。 -完成通知採用 omp 的 `prompt_result` 與 `session_settled` RPC 訊框(需 omp 18.3.1 或更新版本)。 +完成通知採用 `ompweb-omp` 通道的 `prompt_result` 與 `session_settled` 訊框。 ## 快速開始 @@ -86,6 +82,8 @@ ompweb ompweb --port 8080 # 自訂連接埠 ompweb --hostname 0.0.0.0 # 監聽網路介面 ompweb hash-password # 輸出供 OMP_WEB_PASSWORD_HASH 使用的雜湊值 +ompweb fetch-omp # 從 GitHub 發行版本下載此版本的 ompweb-omp(驗證 SHA-256,僅在缺少時) +ompweb preflight # 如同新對話般啟動一次 omp 並等待其 hello;失敗時說明原因並以 1 結束 ompweb --no-open # 不自動開啟瀏覽器 ompweb --install-tray # 安裝 Windows 系統匣服務與桌面捷徑 ompweb --uninstall-tray # 解除安裝 Windows 系統匣服務與捷徑 @@ -140,7 +138,7 @@ ompweb 透過一個輕量的守護行程 `ompweb host` 來執行這台機器的 每個[發行版本](https://github.com/kahme247/ompweb/releases/latest)亦提供編譯完成的單一執行檔,隨附其所提供服務的相關檔案: `ompweb-linux-x64.tar.gz`、`ompweb-linux-arm64.tar.gz`、`ompweb-darwin-x64.tar.gz`、 `ompweb-darwin-arm64.tar.gz` 以及 `ompweb-windows-x64.zip`。無需安裝 Node、npm -或 Bun;僅需安裝 omp。x64 建置版本採用 Bun 的基準執行階段,因此亦可在不具備 AVX2 指令集的 CPU 上運作。每個封存檔皆於 `licenses/` 包含其打包內容的授權條款。`main` 分支上通過 CI 的每次提交亦會發布為以建置時間命名的[預先發行版(prerelease)](https://github.com/kahme247/ompweb/releases)(例如 `2026-11-01T10:30:00Z`),同樣包含上述五種封存檔。 +或 Bun,也無需安裝 omp:首次啟動時,它會從同一個發行版本下載其 omp 建置版本 `ompweb-omp`(驗證 SHA-256),並保存在同一資料夾中(預先發行版的封存檔已內含)。x64 建置版本採用 Bun 的基準執行階段,因此亦可在不具備 AVX2 指令集的 CPU 上運作。每個封存檔皆於 `licenses/` 包含其打包內容的授權條款。`main` 分支上通過 CI 的每次提交亦會發布為以建置時間命名的[預先發行版(prerelease)](https://github.com/kahme247/ompweb/releases)(例如 `2026-11-01T10:30:00Z`),同樣包含上述五種封存檔。 ```bash tar -xzf ompweb-linux-x64.tar.gz -C ~/.local/share # 解壓縮出 ompweb-linux-x64/ @@ -148,11 +146,11 @@ tar -xzf ompweb-linux-x64.tar.gz -C ~/.local/share # 解壓縮出 ompweb-linux ~/.local/share/ompweb-linux-x64/ompweb host # 本機守護行程,於第二個終端機執行 ``` -- **將 `ompweb` 保留在其資料夾內。** 它會從所在目錄提供 `dist/`、`public/` 與其他檔案(在系統 `PATH` 中建立指向它的符號連結可正常運作),若設定了 `OMP_WEB_PACKAGE_DIR` 則從該路徑提供。 +- **將 `ompweb` 保留在其資料夾內。** 它會執行同一資料夾中的 `ompweb-omp`,並從所在目錄提供 `dist/`、`public/` 與其他檔案(在系統 `PATH` 中建立指向它的符號連結可正常運作),若設定了 `OMP_WEB_PACKAGE_DIR` 則從該路徑提供。 - **macOS** 會對下載檔案加上隔離屬性(quarantine):首次啟動前請先執行一次 `xattr -dr com.apple.quarantine ompweb-darwin-arm64`。 - **macOS 15 或更新版本** 在應用程式存取區域網路前會先詢問權限。若 Mac 上的 `ompweb host` 對區網上的伺服器回報 `ECONNREFUSED`,但 `curl` 卻能正常連線,請前往「系統設定 → 隱私權與安全性 → 區域網路」允許你的終端機(或 `ompweb`)。透過 Tailscale 等 VPN 連線的伺服器,或從 SSH 工作階段啟動的 host 則不受影響。 - **不提供系統匣、服務安裝程式或應用程式內更新。** 若要更新,請停止 `ompweb` 與 `ompweb host`,以新發行版本替換該目錄,然後再次啟動兩者;當有新版本釋出時,**設定 → 系統** 會提供發行頁面連結。你的對話與設定皆儲存於 `~/.omp`,不在該目錄中。 -- **作為 systemd 使用者服務**,請自行編寫這兩個單元(請調整路徑;將 omp 所在目錄加入 `PATH`,或設定 `OMP_WEB_OMP_BIN`): +- **作為 systemd 使用者服務**,請自行編寫這兩個單元(請調整路徑): ```ini # ~/.config/systemd/user/ompweb.service @@ -301,7 +299,7 @@ npx --yes --package=@kahme247/ompweb@latest ompweb-tray --uninstall # 移除 ## 在其他機器上執行對話 -單一 ompweb 可以啟動並監看其他機器上的 omp 對話。每台機器保留其各自的 `omp` 安裝、`~/.omp/agent` 目錄、對話與認證資訊;ompweb 僅負責路由。遠端機器需要安裝 `omp` 以及 Node 24(用於 `npx`)或[單一二進位檔](#單一二進位檔),並且必須能透過 HTTP(S) 連線至 ompweb 伺服器。遠端機器向外連線,因此可以位於 NAT 後方。 +單一 ompweb 可以啟動並監看其他機器上的 omp 對話。每台機器保留其各自的 ompweb(及其 `ompweb-omp`)、`~/.omp/agent` 目錄、對話與認證資訊;ompweb 僅負責路由。遠端機器需要 Node 24(用於 `npx`)或[單一二進位檔](#單一二進位檔),並且必須能透過 HTTP(S) 連線至 ompweb 伺服器。遠端機器向外連線,因此可以位於 NAT 後方。 ### 透明主機集區 @@ -329,7 +327,7 @@ npx --yes --package=@kahme247/ompweb@latest ompweb-tray --uninstall # 移除 主機會在「設定 → 主機」中顯示為**線上**,側邊欄會為其建立群組,而在新對話畫面中也會出現主機選取器。 -**設定 → 主機**也會顯示每台機器的 ompweb 守護行程與 OMP 執行階段版本、可用更新與更新操作(包含**本機**)。檢查與安裝會在所選主機上執行,而非在網頁伺服器上執行。若某個主機更新的連線協定與目前的網頁應用程式不相容,該更新仍會顯示,但會被封鎖並說明原因;請先更新網頁伺服器。無法驗證目標連線協定的更新同樣會被封鎖。OMP 更新則與連線協定無關。在更新守護行程之前,請先關閉其對話行程:更新程式拒絕中斷執行中的對話或子代理。更新 OMP 後,現有行程在重新啟動前仍會維持已載入的舊版本。**設定 → 系統**則保留網頁伺服器自身的應用程式更新與服務設定。主機需要支援這些更新操作的守護行程版本;較舊的版本會顯示不支援訊息,需先手動更新一次。若守護行程與網頁伺服器共用同一個安裝目錄,請先更新伺服器,再使用主機的「僅重新啟動」更新。不支援的服務封裝程式與偵測到的外部重新啟動指令碼會顯示需手動更新的原因。更新前景執行的守護行程前,請先停止外部的自動重新啟動迴圈。 +**設定 → 主機**也會顯示每台機器的 ompweb 守護行程與 omp 版本、可用更新與更新操作(包含**本機**)。檢查與安裝會在所選主機上執行,而非在網頁伺服器上執行。若某個主機更新的連線協定與目前的網頁應用程式不相容,該更新仍會顯示,但會被封鎖並說明原因;請先更新網頁伺服器。無法驗證目標連線協定的更新同樣會被封鎖。omp 隨附於每個 ompweb 發行版本,並隨之一起更新。在更新守護行程之前,請先關閉其對話行程:更新程式拒絕中斷執行中的對話或子代理。**設定 → 系統**則保留網頁伺服器自身的應用程式更新與服務設定。主機需要支援這些更新操作的守護行程版本;較舊的版本會顯示不支援訊息,需先手動更新一次。若守護行程與網頁伺服器共用同一個安裝目錄,請先更新伺服器,再使用主機的「僅重新啟動」更新。不支援的服務封裝程式與偵測到的外部重新啟動指令碼會顯示需手動更新的原因。更新前景執行的守護行程前,請先停止外部的自動重新啟動迴圈。 守護行程更新是由主機上獨立的更新程式行程執行。它會停止守護行程、替換為新發行版並再次啟動;若失敗則會還原先前的發行版。若該更新程式在停止守護行程後本身遭到強制終止(例如重新開機或記憶體不足 OOM 終止),將不會有任何程序重新啟動主機,主機便會維持離線。在主機上手動復原方式:在包含安裝目錄的資料夾中,`.ompweb-backup-` 即為先前的發行版。若該目錄存在,請將目前的安裝目錄移開,並將備份重新命名回原本的安裝目錄名稱,然後再次啟動 `ompweb host`(或其服務)。「設定 → 主機」會將該次更新回報為已中斷。 @@ -361,7 +359,7 @@ npx @kahme247/ompweb@latest host --allow-http 以下方式皆以主機模式執行[單一二進位檔](#單一二進位檔)。在 Linux 上,服務可在登出與重新開機後持續維持連線;Windows 排程工作會在使用者登入後啟動,因此重新開機後必須先登入;在 macOS 上,則透過指令碼在終端機中保持執行(原因詳見下方)。請先解壓縮適用於該機器的發行版,接著依照步驟 2 執行一次 `ompweb host login --url …` 以儲存連線資訊(將 `npx @kahme247/ompweb@latest` 替換為二進位檔路徑)。每種設定都會在 `ompweb host` 失敗 10 秒後重新啟動,但結束代碼為 `0` 時不會重新啟動:那代表有使用相同權杖的較新連線取代了它,若重新啟動會導致每 10 秒互相搶奪連線。若伺服器網址為純 `http://`,請在 `host` 後方加上 `--allow-http`;若儲存連線資訊時有設定 `PI_CODING_AGENT_DIR`,請在服務中也一併設定。獨立主機可同時連結至多台伺服器,並在伺服器支援連線協定 v7 時共用即時對話:請從每台額外的伺服器取得權杖,並執行 `ompweb host login --add --url `。請勿在同一台機器上同時執行本機 ompweb 網頁伺服器,因為它會為自己的守護行程寫入同一個連線設定檔。若要更新,請先停止服務、替換發行版目錄,然後重新啟動服務。撤銷主機後,請停止並移除其服務,否則它會持續重試。 -**Linux(systemd 使用者服務)。** 建立 `~/.config/systemd/user/ompweb-host.service`(請調整發行版路徑,並將 omp 所在目錄加入 `PATH` 或設定 `OMP_WEB_OMP_BIN`): +**Linux(systemd 使用者服務)。** 建立 `~/.config/systemd/user/ompweb-host.service`(請調整發行版路徑): ```ini [Unit] @@ -472,7 +470,7 @@ Start-ScheduledTask ompweb-host | `OMP_WEB_NO_OPEN` | 設為 `1` 可防止自動開啟瀏覽器 | `0` | | `OMP_WEB_DISABLE_AUTOUPDATE` | 設為 `1` 可停用更新檢查與應用程式內更新;變更後需重新啟動 | `0` | | `OMP_WEB_NAME` | 顯示於瀏覽器分頁與已安裝應用程式的名稱。若設為 `url`、`host` 或 `domain`(不分大小寫),則採用瀏覽器連線的主機名稱(不含連接埠);localhost 與 IP 位址仍會保持 `omp web`。其他任何值將原樣採用。變更後需重新啟動 | `omp web` | -| `OMP_WEB_OMP_BIN` | 當 `omp` 二進位檔未在 `PATH` 中時指定的路徑 | _自動偵測_ | +| `OMP_WEB_OMP_BIN` | 用於取代下載版本的 `ompweb-omp` 建置版本路徑(無法連線至 GitHub 的機器上為發行版本中 `ompweb-omp--` 的副本,原始碼檢出中為 `runner/release/ompweb-omp--`)。一般的 `omp` 不支援 ompweb 的通道,無法使用。服務安裝程式僅在你設定它時才會寫入服務環境 | _下載的 `ompweb-omp`_ | | `PI_CODING_AGENT_DIR` | 自訂 omp 代理目錄 | `~/.omp/agent` | | `OMP_WEB_STT_ENDPOINT` | 相容於 OpenAI 的語音轉文字(STT)端點網址。若保留未設定,則會透過 omp 自身的憑證搭配 `OMP_WEB_STT_MODEL` 進行轉錄 | _無_ | | `OMP_WEB_STT_KEY` | STT 端點的選用 API 金鑰 | _無_ | @@ -556,10 +554,12 @@ proxy_set_header X-Omp-Web-Auth $ompweb_auth; git clone https://github.com/kahme247/ompweb.git cd ompweb npm install +npx bun runner/scripts/use-omp-build.ts && (cd runner && npx bun install --frozen-lockfile) +npx bun runner/scripts/build-binary.ts # 為本機建置 ompweb 的 omp:runner/release/ompweb-omp-- npm run dev ``` -開發伺服器將執行於 [http://127.0.0.1:30178](http://127.0.0.1:30178)。 +開發伺服器將執行於 [http://127.0.0.1:30178](http://127.0.0.1:30178)。它會自動找到 `runner/release/` 中的 runner;`npm test` 與 `npm run build:binary` 也需要先準備好 runner。 開發伺服器不會接管本機守護行程:只有正式環境的網頁伺服器會寫入守護行程的 `host.json`,因此「本機」在開發環境中會顯示為離線,而守護行程會保持與您已安裝的 ompweb 連結。 `npm run dev` 以 Bun 熱重載(hot reload)方式執行伺服器。`npm run build` 將瀏覽器端應用打包至 `dist/`,`npm start` 則在 30177 連接埠提供該產物(可用 `npm run smoke` 檢查結果)。`npm run dev:lan` 會將開發伺服器繫結至所有網路介面。`npm run build:binary` 會將適用於本機的[單一二進位檔](#單一二進位檔)建置到 `release/`(可使用 `-- --target bun-linux-arm64` 等參數為其他平台建置;`npm run smoke -- --binary release/ompweb-linux-x64/ompweb` 進行檢查)。 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/api/hosts/[id]/updates/route.ts b/app/api/hosts/[id]/updates/route.ts index 74f9c9d3b..edc74dfa6 100644 --- a/app/api/hosts/[id]/updates/route.ts +++ b/app/api/hosts/[id]/updates/route.ts @@ -28,7 +28,7 @@ export async function POST(request: Request, { params }: Context) { if (request.headers.get("content-type")?.split(";", 1)[0].trim().toLowerCase() !== "application/json") return fail("Content-Type must be application/json", "unsupported_media_type", 415); try { const body = await parseJsonWithinLimit(request, 4096); - if (!isRecord(body) || Object.keys(body).length !== 2 || !(body.target === "ompweb" || body.target === "omp") + if (!isRecord(body) || Object.keys(body).length !== 2 || body.target !== "ompweb" || typeof body.version !== "string" || body.version.length > 100 || !/^\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?$/.test(body.version)) { return fail("target and a release version are required", "invalid_host_update", 400); } diff --git a/app/api/omp-update/route.ts b/app/api/omp-update/route.ts index 6d2c41812..055518e8c 100644 --- a/app/api/omp-update/route.ts +++ b/app/api/omp-update/route.ts @@ -1,27 +1,8 @@ import { isApiRequestOriginAllowed, shouldCheckApiRequestOrigin } from "@/lib/request-security"; import { parseJsonWithinLimit, RequestBodyTooLargeError } from "@/lib/bounded-form-data"; -import { checkOmpUpdate } from "@/lib/omp/updates"; import { restartAllRpcSessions } from "@/lib/rpc-manager"; -import { - acknowledgeSelfUpdate, - commitSelfUpdate, - getSelfUpdateStatus, - getSelfUpdateSupport, - markSelfUpdateStopping, - prepareSelfUpdate, - SelfUpdateError, - validateCommitSelfUpdate, -} from "@/lib/self-update"; - -const OMP_KIND = "omp" as const; - -function errorResponse(error: unknown): Response { - if (error instanceof SelfUpdateError) { - return Response.json({ error: error.message, code: error.code }, { status: error.httpStatus }); - } - return Response.json({ error: "The OMP update could not be started", code: "update_failed" }, { status: 500 }); -} +/** Restarts every running omp session. omp itself updates with ompweb: its release ships the runner. */ export async function POST(request: Request) { if (shouldCheckApiRequestOrigin(request) && !isApiRequestOriginAllowed(request)) { return Response.json({ error: "Cross-origin API requests are not allowed", code: "cross_origin_forbidden" }, { status: 403 }); @@ -31,39 +12,14 @@ export async function POST(request: Request) { } try { const body = await parseJsonWithinLimit | null>(request, 4_096); - if (!body || typeof body !== "object" || Array.isArray(body)) throw new SelfUpdateError("invalid_action", "action must be check, restart, update, status, or acknowledge"); - const keys = Object.keys(body); - if (body.action === "check") { - // selfUpdateSupported: whether "Update now" can run here (never in the compiled binary, lib/self-update.ts). - return Response.json({ ...await checkOmpUpdate(body.force === true), selfUpdateSupported: getSelfUpdateSupport().supported }); - } - if (body.action === "restart" && keys.length === 1) { - const sessionsRestarted = await restartAllRpcSessions(); - return Response.json({ success: true, sessionsRestarted }); - } - if (body.action === "update" && keys.length === 1) { - const result = await prepareSelfUpdate(OMP_KIND); - return Response.json(result, { status: 202 }); - } - if (body.action === "commit" && keys.length === 2 && typeof body.attemptId === "string") { - const commitState = validateCommitSelfUpdate(body.attemptId, OMP_KIND); - if (commitState !== "replay") { - if (commitState === "ready") markSelfUpdateStopping(body.attemptId, OMP_KIND); - await commitSelfUpdate(body.attemptId, OMP_KIND); - } - return Response.json({ accepted: true, attemptId: body.attemptId }, { status: 202 }); - } - if (body.action === "status" && keys.length === 1) { - const selfUpdateStatus = getSelfUpdateStatus(OMP_KIND); - return Response.json(selfUpdateStatus ?? null, { headers: { "Cache-Control": "no-store" } }); - } - if (body.action === "acknowledge" && keys.length === 2 && typeof body.attemptId === "string") { - return Response.json(acknowledgeSelfUpdate(body.attemptId, OMP_KIND)); + if (!body || typeof body !== "object" || Array.isArray(body) || body.action !== "restart" || Object.keys(body).length !== 1) { + return Response.json({ error: "action must be restart", code: "invalid_action" }, { status: 400 }); } - throw new SelfUpdateError("invalid_action", "action must be check, restart, update, status, or acknowledge"); + const sessionsRestarted = await restartAllRpcSessions(); + return Response.json({ success: true, sessionsRestarted }); } catch (error) { if (error instanceof RequestBodyTooLargeError) return Response.json({ error: error.message, code: "body_too_large" }, { status: 413 }); - if (error instanceof SyntaxError) return errorResponse(new SelfUpdateError("invalid_json", "Request body must be valid JSON")); - return errorResponse(error); + if (error instanceof SyntaxError) return Response.json({ error: "Request body must be valid JSON", code: "invalid_json" }, { status: 400 }); + return Response.json({ error: "The OMP sessions could not be restarted", code: "restart_failed" }, { status: 500 }); } } diff --git a/app/api/plugins/route.ts b/app/api/plugins/route.ts index c3a1ed1eb..2e76178d6 100644 --- a/app/api/plugins/route.ts +++ b/app/api/plugins/route.ts @@ -2,7 +2,7 @@ import { execFile } from "child_process"; import { existsSync, promises as fs } from "fs"; import { basename, extname, join } from "path"; import { ANSI_RE, parseJsonLoose } from "@/lib/omp/plugins-cli"; -import { resolveOmpBin } from "@/lib/omp/omp-cli"; +import { OMP_NOT_FOUND, resolveOmpBin } from "@/lib/omp/omp-cli"; import { ensureAgentDir } from "@/lib/omp/paths"; import { createChildProcessEnvironment } from "@/lib/project-command-env"; import { getAllowedFileRoots, isExistingFilePathAllowed } from "@/lib/file-access"; @@ -70,7 +70,7 @@ function runOmp( ): Promise<{ stdout: string; stderr: string }> { const bin = resolveOmpBin(); if (!bin) { - return Promise.reject(new Error("omp binary not found. Install oh-my-pi or set OMP_WEB_OMP_BIN.")); + return Promise.reject(new Error(OMP_NOT_FOUND)); } return new Promise((resolve, reject) => { execFile( @@ -256,7 +256,7 @@ function readScope(scope: unknown): PluginScope { * known cause worth a stable code for client-side localization. */ function pluginErrorResponse(error: unknown): Response { const message = error instanceof Error ? error.message : String(error); - if (message.includes("omp binary not found")) { + if (message.includes(OMP_NOT_FOUND)) { return Response.json({ error: message, code: "omp_not_found" }, { status: 500 }); } return Response.json({ error: message }, { status: 500 }); diff --git a/bin/omp-web-host.js b/bin/omp-web-host.js index a32f6c5fc..25c7f8e58 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 its runner 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..799369b00 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 })); } }, @@ -164,7 +164,7 @@ test("status/drain/resume consume the real CLI daemon endpoint and SIGTERM remov status = JSON.parse((await hostCommand(f.env, "status")).stdout); } assert.equal(status.pid, daemon.child.pid, "the launcher must not leave a wrapper PID"); - assert.equal(status.protocol, 10); + assert.equal(status.protocol, (await import("../lib/hosts/link-protocol.ts")).LINK_PROTOCOL_VERSION); assert.equal(status.drained, false); assert.equal(hellos, 1); assert.equal(JSON.stringify(status).includes("private-cli-fixture-token"), false); diff --git a/bin/omp-web-launchd.js b/bin/omp-web-launchd.js index 84e79c818..79cecd8ed 100644 --- a/bin/omp-web-launchd.js +++ b/bin/omp-web-launchd.js @@ -107,15 +107,14 @@ function install(pkgArg) { const siblingNpx = path.join(path.dirname(nodeBin), "npx"); const npxBin = isExecutableFile(siblingNpx) ? siblingNpx : (which("npx") ?? fail("npx not found on PATH")); - const ompBin = process.env.OMP_WEB_OMP_BIN ?? which("omp"); - if (process.env.OMP_WEB_OMP_BIN) { + // Only an explicit override: ompweb runs its own omp (ompweb-omp). A stock omp on PATH cannot run its sessions. + const ompBin = process.env.OMP_WEB_OMP_BIN || null; + if (ompBin) { try { - fs.accessSync(process.env.OMP_WEB_OMP_BIN, fs.constants.X_OK); + fs.accessSync(ompBin, fs.constants.X_OK); } catch { - fail(`OMP_WEB_OMP_BIN=${process.env.OMP_WEB_OMP_BIN} is not executable`); + fail(`OMP_WEB_OMP_BIN=${ompBin} is not executable`); } - } else if (!ompBin) { - console.warn("warning: omp binary not found; live-agent features will be unavailable (set OMP_WEB_OMP_BIN)"); } const pkg = pkgArg ?? process.env.OMP_WEB_PKG ?? "@kahme247/ompweb@latest"; diff --git a/bin/omp-web-systemd.js b/bin/omp-web-systemd.js index 371111f0f..dd9ccd58c 100644 --- a/bin/omp-web-systemd.js +++ b/bin/omp-web-systemd.js @@ -315,12 +315,9 @@ function install(options = {}) { fail(error instanceof Error ? error.message : String(error)); } - const ompBin = process.env.OMP_WEB_OMP_BIN ?? which("omp"); - if (process.env.OMP_WEB_OMP_BIN && !isExecutableFile(process.env.OMP_WEB_OMP_BIN)) { - fail(`OMP_WEB_OMP_BIN=${process.env.OMP_WEB_OMP_BIN} is not executable`); - } else if (!ompBin) { - console.warn("warning: omp binary not found; live-agent features will be unavailable (set OMP_WEB_OMP_BIN)"); - } + // Only an explicit override: ompweb runs its own omp (ompweb-omp). A stock omp on PATH cannot run its sessions. + const ompBin = process.env.OMP_WEB_OMP_BIN || null; + if (ompBin && !isExecutableFile(ompBin)) fail(`OMP_WEB_OMP_BIN=${ompBin} is not executable`); // Note: the missing-interpreter check lives in runCli (before the Linux // gate) so it fails loudly on every platform; install() only reuses the // resolved ompBin for the unit + env file. @@ -534,13 +531,12 @@ async function runCli(argv = process.argv.slice(2)) { // Validate the install inputs before the platform gate so misconfigured // launchers fail loudly on any platform (and stay testable on Windows). - // Missing `omp` only warns (live-agent features retry on demand), but a - // launcher script whose interpreter is nowhere on PATH would install a - // unit that can only fail later with exit 127 (issue #116) — fail now. + // An OMP_WEB_OMP_BIN launcher script whose interpreter is nowhere on PATH + // would install a unit that can only fail later with exit 127 (issue #116) — fail now. if (command === "install") { - const ompBin = process.env.OMP_WEB_OMP_BIN ?? which("omp"); - if (process.env.OMP_WEB_OMP_BIN && !isExecutableFile(process.env.OMP_WEB_OMP_BIN)) { - fail(`OMP_WEB_OMP_BIN=${process.env.OMP_WEB_OMP_BIN} is not executable`); + const ompBin = process.env.OMP_WEB_OMP_BIN || null; + if (ompBin && !isExecutableFile(ompBin)) { + fail(`OMP_WEB_OMP_BIN=${ompBin} is not executable`); } else if (ompBin && readLauncherInterpreter(ompBin) && !launcherInterpreterDir(ompBin)) { fail( `omp at ${ompBin} is a launcher script whose interpreter ` + diff --git a/bin/omp-web-systemd.test.mjs b/bin/omp-web-systemd.test.mjs index 223bf2be4..ceb2ae8b1 100644 --- a/bin/omp-web-systemd.test.mjs +++ b/bin/omp-web-systemd.test.mjs @@ -181,6 +181,8 @@ test("install creates the env file and unit with LAN settings", { skip: process. mkdirSync(binDir, { recursive: true }); writeFileSync(fakeOmpweb, "#!/bin/sh\nexit 0\n", { mode: 0o755 }); writeFileSync(fakeSystemctl, "#!/bin/sh\ncase \"$*\" in *is-active*) exit 3 ;; *) exit 0 ;; esac\n", { mode: 0o755 }); + // A stock omp on PATH: pinning the services to it would replace ompweb's own omp, which its sessions need. + writeFileSync(path.join(binDir, "omp"), "#!/bin/sh\nexit 0\n", { mode: 0o755 }); const childEnv = { ...process.env, @@ -223,6 +225,7 @@ test("install creates the env file and unit with LAN settings", { skip: process. OMP_WEB_TRUSTED_HEADER_SHA256: "a".repeat(64), }); assert.match(readFileSync(unitPath, "utf8"), /EnvironmentFile=.*web-service\.env/); + assert.doesNotMatch(readFileSync(unitPath, "utf8"), /OMP_WEB_OMP_BIN/); const hostUnit = readFileSync(path.join(path.dirname(unitPath), "ompweb-host.service"), "utf8"); assert.match(hostUnit, /^ExecStart=.*\/ompweb host$/m); assert.match(hostUnit, /EnvironmentFile=.*web-service\.env/); diff --git a/bin/omp-web-update-worker.js b/bin/omp-web-update-worker.js index f02e577c7..a1382b6c1 100644 --- a/bin/omp-web-update-worker.js +++ b/bin/omp-web-update-worker.js @@ -27,7 +27,6 @@ const target = arg("--target"); const from = arg("--from"); const launcherPid = Number(arg("--launcher-pid")); const serverPid = Number(arg("--server-pid")); -const kind = arg("--kind") || "app"; // Set when the server runs as this systemd unit; systemd stops and starts it. const systemdUnit = arg("--systemd-unit"); const systemctlScope = arg("--systemd-user") === "1" ? ["--user"] : []; @@ -65,6 +64,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,21 +133,47 @@ async function stopOriginalProcesses() { return { launchd, tray }; } +// The candidate's `ompweb fetch-omp` (lib/omp/runner-fetch.ts: its version's omp runner, into the per-version cache the +// installed release then uses) and `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"))) { + 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 candidate = (command, timeout) => { + const result = cp.spawnSync(process.execPath, [path.join(release, "bin", "omp-web.js"), command], { cwd: prefix, timeout, windowsHide: true, env: { ...process.env, OMP_WEB_PACKAGE_DIR: release }, encoding: "utf8" }); + return result.status === 0 ? null : ((result.stderr || "").trim() || String(result.error || `exit ${result.status}`)).slice(-300); + }; + // 15 min, as the host updater allows the download. + const fetchFailure = fs.existsSync(path.join(release, "lib", "omp", "runner-fetch.ts")) ? candidate("fetch-omp", 15 * 60 * 1000) : null; + if (fetchFailure) throw new Error(`the new release could not fetch its omp: ${fetchFailure}`); + // 120 s: the preflight itself can take 30 s for the hello, 7 s to stop omp and 30 s for the version probe. + const preflightFailure = candidate("preflight", 120 * 1000); + if (preflightFailure) throw new Error(`the new release cannot start a session: ${preflightFailure}`); + } 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") { - // Run omp update - const bin = process.env.OMP_WEB_OMP_BIN || "omp"; - // Try resolve via packageDir/../? - await new Promise((resolve) => { - const child = cp.spawn(bin, ["update"], { timeout: 5 * 60 * 1000, windowsHide: true, env: { ...process.env, FORCE_COLOR: "0", NO_COLOR: "1" } }); - child.on("close", () => resolve()); - child.on("error", () => resolve()); - setTimeout(() => { try { child.kill("SIGKILL"); } catch {} resolve(); }, 5 * 60 * 1000 + 1000); - }); - return; - } - // App update via npm/bun const cmd = manager === "bun" ? (managerPath || "bun") : (managerPath || "npm"); const args = manager === "bun" ? ["add", "-g", `@kahme247/ompweb@${target}`] : ["install", "-g", `@kahme247/ompweb@${target}`]; if (Array.isArray(managerPrefix) && managerPrefix.length) args.unshift(...managerPrefix); @@ -219,6 +249,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 +265,8 @@ async function main() { let svcInfo = { launchd: false, tray: false }; try { - if (kind !== "omp") svcInfo = await stopOriginalProcesses(); + preflightCandidate(); + svcInfo = await stopOriginalProcesses(); await runManagerGate(); try { const pkgPath = path.join(packageDir, "package.json"); @@ -245,7 +277,7 @@ async function main() { } } } catch {} - if (kind !== "omp") await restartServices(svcInfo); + await restartServices(svcInfo); await sleep(500); updateStatus({ state: "succeeded", stage: "finalizing", finishedAt: new Date().toISOString(), cleanupReady: true }); // write complete marker @@ -257,10 +289,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..815069f64 100644 --- a/bin/omp-web-update-worker.test.mjs +++ b/bin/omp-web-update-worker.test.mjs @@ -1,45 +1,84 @@ 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; "ok": this checkout's real fetch-omp and preflight, + * its runner already fetched; "stub" / "fetch-fails": a `bin/omp-web.js` that logs `candidate ` and passes, + * or fails `fetch-omp` as an offline fetch does. */ +function writeCandidate(dir, candidate, log) { + mkdirSync(dir); + if (candidate === "old") return; + writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "@kahme247/ompweb", version: "0.6.0" })); + if (candidate === "ok") { + symlinkSync(join(REPO, "bin"), join(dir, "bin")); + symlinkSync(join(REPO, "lib"), join(dir, "lib")); + return; + } + for (const file of ["bin/omp-web.js", "lib/hosts/preflight.ts", "lib/omp/runner-fetch.ts"]) mkdirSync(dirname(join(dir, file)), { recursive: true }); + for (const file of ["lib/hosts/preflight.ts", "lib/omp/runner-fetch.ts"]) writeFileSync(join(dir, file), ""); + const fail = candidate === "fetch-fails" ? `if (command === "fetch-omp") { console.error("fetch-omp failed: Could not fetch ompweb's omp from https://github.com/x: fetch failed"); process.exit(1); }\n` : ""; + writeFileSync(join(dir, "bin", "omp-web.js"), `const command = process.argv[2];\nrequire("node:fs").appendFileSync(${JSON.stringify(log)}, "candidate " + command + "\\n");\n${fail}`); +} + +// 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, log); + // Where the candidate's fetch-omp keeps this version's runner (lib/omp/omp-cli.ts fetchedOmpBin): already there. + if (candidate === "ok") { + mkdirSync(join(dir, "agent", "omp-web", "omp", "0.6.0"), { recursive: true }); + writeFileSync(join(dir, "agent", "omp-web", "omp", "0.6.0", "ompweb-omp"), RUNNER, { mode: 0o755 }); + } + // 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 +105,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("the candidate fetches its omp before its preflight, both before anything stops", { skip: process.platform === "win32" }, (t) => { + const { calls, status } = runWorker(t, { candidate: "stub" }); + assert.deepEqual(calls.slice(1, 4), ["candidate fetch-omp", "candidate preflight", "systemctl --user stop ompweb.service"]); + assert.equal(status.state, "succeeded"); +}); + +test("a candidate that cannot fetch its omp never stops the running server", { skip: process.platform === "win32" }, (t) => { + const { calls, status, complete } = runWorker(t, { candidate: "fetch-fails" }); + 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.ok(!calls.includes("candidate preflight"), calls.join("\n")); + assert.equal(status.state, "failed"); + assert.equal(status.error, "the new release could not fetch its omp: fetch-omp failed: Could not fetch ompweb's omp from https://github.com/x: fetch failed"); + // What bin/omp-web.js watchUpdateCompletion accepts to disarm the launcher armed at commit. + assert.equal(complete?.protocol, 1); + assert.equal(complete?.attemptId, ATTEMPT); +}); diff --git a/bin/omp-web.js b/bin/omp-web.js index 73b1c8b4f..657b0367a 100755 --- a/bin/omp-web.js +++ b/bin/omp-web.js @@ -32,6 +32,30 @@ 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 fetch-omp` (lib/omp/runner-fetch.ts): fetch this version's omp runner if missing; updaters run it before preflight. +if (process.argv[2] === "fetch-omp") { + process.env.OMP_WEB_PACKAGE_DIR ??= module.require("node:path").join(__dirname, ".."); + module.require("jiti").createJiti(__filename).import("../lib/omp/runner-fetch.ts").then( + ({ runFetchOmp }) => runFetchOmp().then((code) => process.exit(code)), + (error) => { + console.error(`fetch-omp 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..151225dc7 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,10 @@ 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"; + // omp's ask tool uses formDialog whenever the UI has one; askDialog is its per-question fallback. + // Like omp's TUI, which swaps its editor for the ask, nothing is sent while either is open: + // a steer would interrupt the ask ("Ask interrupted by message"). + const askOpen = extensionDialog?.method === "askDialog" || extensionDialog?.method === "formDialog"; const hiddenComposerRef = useRef(null); useEffect(() => { // A hidden composer that keeps focus keeps the phone keyboard up: the @@ -1218,7 +1219,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 +1432,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 +1646,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..72d27b5ba 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 }; @@ -298,7 +298,7 @@ test("the keyboard cap applies to every attached method with a text field", () = const restore = stubVisualViewport(viewportHeight); const restoreMedia = stubMedia({ coarse: true }); try { - renderAttached({ type: "extension_ui_request", id: "input-1", method: "input", title: "Name?" }); + renderAttached({ type: "ui_request", id: 2, method: "input", args: ["Name?"] }); fireEvent.focus(screen.getByRole("textbox", { name: "Name?" })); assert.equal(screen.getByRole("dialog").style.maxHeight, `${viewportHeight - 12}px`); } finally { @@ -319,7 +319,7 @@ test("on touch, focusing an option or button keeps the panel's height", () => { assert.equal(panel.style.maxHeight, "min(420px, 60dvh)"); } view.rerender(React.createElement(ExtensionDialog, { - request: { type: "extension_ui_request", id: "select-1", method: "select", title: "Which?", options: ["First", "Second"] }, + request: { type: "ui_request", id: 3, method: "select", args: ["Which?", ["First", "Second"]] }, onRespond: () => {}, attached: true, })); @@ -339,7 +339,10 @@ test("without a visible-area API the panel keeps its CSS cap", () => { fireEvent.change(other, { target: { value: "Teal" } }); assert.equal(panel.style.maxHeight, "min(420px, 60dvh)"); fireEvent.click(screen.getByRole("button", { name: "Submit" })); - assert.deepEqual(responses, [{ answers: [{ id: "color", selectedOptions: [], customInput: "Teal" }] }]); + assert.deepEqual(responses, [{ + kind: "submit", + results: [{ id: "color", question: "Pick a color", options: OPTIONS.map((option) => option.label), multi: false, selectedOptions: [], customInput: "Teal" }], + }]); }); /** A press as the browser sends it: pointerdown, then the compat mousedown. diff --git a/components/ExtensionDialog.prompt-style.test.mjs b/components/ExtensionDialog.prompt-style.test.mjs index 7a768077a..76f580629 100644 --- a/components/ExtensionDialog.prompt-style.test.mjs +++ b/components/ExtensionDialog.prompt-style.test.mjs @@ -5,9 +5,9 @@ import { renderToStaticMarkup } from "react-dom/server"; const { ExtensionDialog } = await import("./ExtensionDialog.tsx"); -function renderEditorTextarea(extra) { +function renderEditorTextarea(editorOptions) { const html = renderToStaticMarkup(React.createElement(ExtensionDialog, { - request: { type: "extension_ui_request", id: "r1", method: "editor", title: "Answer", ...extra }, + request: { type: "ui_request", id: 1, method: "editor", args: ["Answer", null, null, editorOptions] }, onRespond: () => {}, attached: true, })); @@ -23,14 +23,14 @@ test("promptStyle editor (ask 'Other' answer) uses the chat font, not monospace" }); test("plain editor requests keep the monospace code font", () => { - const textarea = renderEditorTextarea({}); + const textarea = renderEditorTextarea(undefined); assert.match(textarea, /font-family:var\(--font-mono\)/); assert.match(textarea, /font-size:13px/); }); test("multi-line titles keep their line breaks", () => { const html = renderToStaticMarkup(React.createElement(ExtensionDialog, { - request: { type: "extension_ui_request", id: "r2", method: "input", title: "Question\n\nDetails" }, + request: { type: "ui_request", id: 2, method: "input", args: ["Question\n\nDetails"] }, onRespond: () => {}, })); assert.match(html, /
Question\n\nDetails<\/div>/); diff --git a/components/ExtensionDialog.test.mjs b/components/ExtensionDialog.test.mjs index fc9c2ae2b..a716d9ea1 100644 --- a/components/ExtensionDialog.test.mjs +++ b/components/ExtensionDialog.test.mjs @@ -8,14 +8,9 @@ const { ExtensionDialog } = await import("./ExtensionDialog.tsx"); afterEach(cleanup); test("Ask editor preserves an answer on request replay and initializes the next request", async () => { - const request = { - type: "extension_ui_request", - id: "answer-1", - method: "editor", - title: "How should this work?", - }; + const request = { type: "ui_request", id: 1, method: "editor", args: ["How should this work?"] }; const responses = []; - const onRespond = (current, response) => responses.push({ id: current.id, ...response }); + const onRespond = (current, value) => responses.push({ id: current.id, value }); const dialog = (current) => React.createElement(ExtensionDialog, { request: current, onRespond, attached: true }); const answer = "Keep my answer\nincluding this second line."; const view = render(dialog(request)); @@ -26,19 +21,19 @@ test("Ask editor preserves an answer on request replay and initializes the next view.rerender(dialog(JSON.parse(JSON.stringify(request)))); assert.equal(screen.getByRole("textbox").value, answer); fireEvent.keyDown(screen.getByRole("textbox"), { key: "Enter", ctrlKey: true }); - assert.deepEqual(responses, [{ id: "answer-1", value: answer }]); + assert.deepEqual(responses, [{ id: 1, value: answer }]); - view.rerender(dialog({ ...request, id: "answer-2" })); + view.rerender(dialog({ ...request, id: 2 })); assert.equal(screen.getByRole("textbox").value, ""); - const nextRequest = { ...request, id: "answer-3", prefill: "Suggested answer" }; + const nextRequest = { ...request, id: 3, args: ["How should this work?", "Suggested answer"] }; view.rerender(dialog(nextRequest)); assert.equal(screen.getByRole("textbox").value, "Suggested answer"); fireEvent.change(screen.getByRole("textbox"), { target: { value: "Edited suggestion" } }); view.rerender(dialog(JSON.parse(JSON.stringify(nextRequest)))); assert.equal(screen.getByRole("textbox").value, "Edited suggestion"); - view.rerender(dialog({ ...request, id: "answer-4" })); + view.rerender(dialog({ ...request, id: 4 })); assert.equal(screen.getByRole("textbox").value, ""); } finally { view.unmount(); @@ -46,43 +41,81 @@ test("Ask editor preserves an answer on request replay and initializes the next }); test("question selection survives request replay but is cleared for the next question", async () => { - const request = { - type: "extension_ui_request", - id: "choice-1", - method: "select", - title: "Which approach?", - options: ["First", "Second"], - }; + const request = { type: "ui_request", id: 1, method: "select", args: ["Which approach?", ["First", { label: "Second", description: "the other one" }]] }; const responses = []; - const onRespond = (current, response) => responses.push({ id: current.id, ...response }); + const onRespond = (current, value) => responses.push({ id: current.id, value }); const dialog = (current) => React.createElement(ExtensionDialog, { request: current, onRespond, attached: true }); const view = render(dialog(request)); try { - fireEvent.click(screen.getByRole("button", { name: "Second" })); + fireEvent.click(screen.getByRole("button", { name: /^Second/ })); view.rerender(dialog(JSON.parse(JSON.stringify(request)))); - assert.equal(screen.getByRole("button", { name: "Second" }).getAttribute("aria-pressed"), "true"); + assert.equal(screen.getByRole("button", { name: /^Second/ }).getAttribute("aria-pressed"), "true"); assert.equal(screen.getByRole("button", { name: "Next" }).disabled, false); fireEvent.click(screen.getByRole("button", { name: "Next" })); - assert.deepEqual(responses, [{ id: "choice-1", value: "Second" }]); + // omp's select returns the chosen label, even for an option with a description. + assert.deepEqual(responses, [{ id: 1, value: "Second" }]); - view.rerender(dialog({ ...request, id: "choice-2" })); - assert.equal(screen.getByRole("button", { name: "Second" }).getAttribute("aria-pressed"), "false"); + view.rerender(dialog({ ...request, id: 2 })); + assert.equal(screen.getByRole("button", { name: /^Second/ }).getAttribute("aria-pressed"), "false"); assert.equal(screen.getByRole("button", { name: "Next" }).disabled, true); } finally { view.unmount(); } }); +test("confirm answers true, and cancel answers with no value", async () => { + const request = { type: "ui_request", id: "omp-web:abc", method: "confirm", args: ["Delete it?", "This cannot be undone."] }; + const responses = []; + const onRespond = (current, value) => responses.push({ id: current.id, value }); + const view = render(React.createElement(ExtensionDialog, { request, onRespond, attached: true })); + try { + screen.getByText("This cannot be undone."); + fireEvent.click(screen.getByRole("button", { name: "Confirm" })); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + assert.deepEqual(responses, [{ id: "omp-web:abc", value: true }, { id: "omp-web:abc", value: undefined }]); + } finally { + view.unmount(); + } +}); + +test("approve offers omp's choices and answers with the chosen ExtensionApprovalAnswer", async () => { + const request = { + type: "ui_request", + id: 7, + method: "approve", + args: [{ + full: "Run bash?\nrm -rf build", + compact: "Run bash? rm -rf build", + session: "Approve for this session", + grants: [{ scope: "folder", label: "Approve Directory: ~/src", alwaysLabel: "Always Approve Directory: ~/src" }], + }], + }; + const responses = []; + const onRespond = (_request, value) => responses.push(value); + const view = render(React.createElement(ExtensionDialog, { request, onRespond, attached: true })); + try { + screen.getByText("Run bash?"); + screen.getByText("rm -rf build"); + for (const name of ["Allow", "Approve for this session", "Approve Directory: ~/src", "Always Approve Directory: ~/src", "Deny"]) { + fireEvent.click(screen.getByRole("button", { name })); + } + assert.deepEqual(responses, ["once", "session", { grant: "folder", always: false }, { grant: "folder", always: true }, "deny"]); + assert.equal(screen.queryByRole("button", { name: "Submit" }), null); + } finally { + view.unmount(); + } +}); + test("omp's checkbox select shows which options are already checked", async () => { const request = { - type: "extension_ui_request", - id: "jobs-1", + type: "ui_request", + id: 1, method: "select", - title: "(2 selected) Choose enabled jobs", - options: ["pytest", "go-quality", "lint", "Other (type your own)", "✓ Done selecting"], - selectionMarker: "checkbox", - checkedIndices: [0, 1], - markableCount: 3, + args: [ + "(2 selected) Choose enabled jobs", + ["pytest", "go-quality", "lint", "Other (type your own)", "✓ Done selecting"], + { selectionMarker: "checkbox", checkedIndices: [0, 1], markableCount: 3 }, + ], }; const view = render(React.createElement(ExtensionDialog, { request, onRespond: () => {} })); try { @@ -98,21 +131,13 @@ test("omp's checkbox select shows which options are already checked", async () = }); test("attached checkbox select sends each toggle at once, so Done cannot replace it", async () => { - const request = { - type: "extension_ui_request", - id: "jobs-2", - method: "select", - title: "Choose enabled jobs", - options: ["pytest", "lint", "✓ Done selecting"], - selectionMarker: "checkbox", - checkedIndices: [], - markableCount: 2, - }; + const options = { selectionMarker: "checkbox", checkedIndices: [], markableCount: 2 }; + const request = { type: "ui_request", id: 2, method: "select", args: ["Choose enabled jobs", ["pytest", "lint", "✓ Done selecting"], options] }; const responses = []; const dialog = (current) => React.createElement(ExtensionDialog, { request: current, - onRespond: (answered, response) => { - responses.push({ id: answered.id, ...response }); + onRespond: (answered, value) => { + responses.push({ id: answered.id, value }); return Promise.resolve(true); }, attached: true, @@ -120,18 +145,18 @@ test("attached checkbox select sends each toggle at once, so Done cannot replace const view = render(dialog(request)); try { fireEvent.click(screen.getByRole("button", { name: "lint, not checked" })); - assert.deepEqual(responses, [{ id: "jobs-2", value: "lint" }]); + assert.deepEqual(responses, [{ id: 2, value: "lint" }]); // omp takes one answer per request: the options are disabled until the next request arrives. assert.equal(screen.getByRole("button", { name: "pytest, not checked" }).disabled, true); fireEvent.click(screen.getByRole("button", { name: "pytest, not checked" })); assert.equal(responses.length, 1); // The next request carries the toggle; the action row waits for Next. - view.rerender(dialog({ ...request, id: "jobs-3", checkedIndices: [1] })); + view.rerender(dialog({ ...request, id: 3, args: [request.args[0], request.args[1], { ...options, checkedIndices: [1] }] })); fireEvent.click(screen.getByRole("button", { name: "✓ Done selecting" })); assert.equal(responses.length, 1); fireEvent.click(screen.getByRole("button", { name: "Next" })); - assert.deepEqual(responses[1], { id: "jobs-3", value: "✓ Done selecting" }); + assert.deepEqual(responses[1], { id: 3, value: "✓ Done selecting" }); } finally { view.unmount(); } @@ -139,21 +164,17 @@ test("attached checkbox select sends each toggle at once, so Done cannot replace test("a checkbox toggle that fails to send can be tried again", async () => { const request = { - type: "extension_ui_request", - id: "jobs-4", + type: "ui_request", + id: 4, method: "select", - title: "Choose enabled jobs", - options: ["pytest", "✓ Done selecting"], - selectionMarker: "checkbox", - checkedIndices: [], - markableCount: 1, + args: ["Choose enabled jobs", ["pytest", "✓ Done selecting"], { selectionMarker: "checkbox", checkedIndices: [], markableCount: 1 }], }; const responses = []; const results = [false, true]; const view = render(React.createElement(ExtensionDialog, { request, - onRespond: (answered, response) => { - responses.push({ id: answered.id, ...response }); + onRespond: (answered, value) => { + responses.push({ id: answered.id, value }); return Promise.resolve(results.shift()); }, attached: true, @@ -162,7 +183,7 @@ test("a checkbox toggle that fails to send can be tried again", async () => { fireEvent.click(screen.getByRole("button", { name: "pytest, not checked" })); await waitFor(() => assert.equal(screen.getByRole("button", { name: "pytest, not checked" }).disabled, false)); fireEvent.click(screen.getByRole("button", { name: "pytest, not checked" })); - assert.deepEqual(responses, [{ id: "jobs-4", value: "pytest" }, { id: "jobs-4", value: "pytest" }]); + assert.deepEqual(responses, [{ id: 4, value: "pytest" }, { id: 4, value: "pytest" }]); assert.equal(screen.getByRole("button", { name: "pytest, not checked" }).disabled, true); } finally { view.unmount(); @@ -170,16 +191,8 @@ test("a checkbox toggle that fails to send can be tried again", async () => { }); test("ExtensionDialog masks sensitive input and toggles visibility", async () => { - const request = { - type: "extension_ui_request", - id: "secret-1", - method: "input", - title: "Enter API Key", - placeholder: "sk-...", - }; - const responses = []; - const onRespond = (current, response) => responses.push({ id: current.id, ...response }); - const dialog = (current) => React.createElement(ExtensionDialog, { request: current, onRespond, attached: true }); + const request = { type: "ui_request", id: 1, method: "input", args: ["Enter API Key", "sk-..."] }; + const dialog = (current) => React.createElement(ExtensionDialog, { request: current, onRespond: () => {}, attached: true }); const view = render(dialog(request)); try { const input = view.getByPlaceholderText("sk-..."); @@ -195,30 +208,19 @@ test("ExtensionDialog masks sensitive input and toggles visibility", async () => assert.equal(input.type, "password"); // explicit secret: false does not mask even if title contains sensitive words - view.rerender(dialog({ ...request, id: "secret-2", secret: false })); + view.rerender(dialog({ ...request, id: 2, args: ["Enter API Key", "sk-...", { secret: false }] })); const plainInput = view.getByPlaceholderText("sk-..."); assert.equal(plainInput.type, "text"); assert.equal(screen.queryByRole("button", { name: "Show password" }), null); // explicit secret: true masks regardless of title - view.rerender(dialog({ - type: "extension_ui_request", - id: "secret-3", - method: "input", - title: "Random prompt", - secret: true, - })); + view.rerender(dialog({ type: "ui_request", id: 3, method: "input", args: ["Random prompt", null, { secret: true }] })); const maskedInput = view.container.querySelector("input"); assert.ok(maskedInput); assert.equal(maskedInput.type, "password"); // plural prompt test: "Enter your credentials" - view.rerender(dialog({ - type: "extension_ui_request", - id: "secret-4", - method: "input", - title: "Enter your credentials", - })); + view.rerender(dialog({ type: "ui_request", id: 4, method: "input", args: ["Enter your credentials"] })); const pluralInput = view.container.querySelector("input"); assert.ok(pluralInput); assert.equal(pluralInput.type, "password"); diff --git a/components/ExtensionDialog.tsx b/components/ExtensionDialog.tsx index 61145756a..a219de43d 100644 --- a/components/ExtensionDialog.tsx +++ b/components/ExtensionDialog.tsx @@ -1,25 +1,14 @@ "use client"; -import { useEffect, useRef, useState, useSyncExternalStore, type RefObject } from "react"; +import { createElement, Fragment, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactNode, type RefObject } from "react"; import { Eye, EyeOff } from "lucide-react"; -import type { ExtensionUiRequest } from "@/lib/types"; -import type { RpcAskDialogAnswer } from "@/lib/pi-types"; +import type { HtmlFormData } from "@/lib/pi-types"; +import type { UiApprovalAnswer, UiDialogRequest, UiDialogValue } from "@/lib/types"; import { useI18n } from "@/lib/i18n"; import { useModalDialog } from "@/hooks/useModalDialog"; import { useTouchTargets } from "@/hooks/useTouchTargets"; import { matchMobileViewport } from "@/hooks/useIsMobile"; -export type ExtensionDialogRequest = Extract< - ExtensionUiRequest, - { method: "select" | "confirm" | "input" | "editor" | "ask" } ->; - -export type ExtensionDialogResponse = - | { value: string } - | { confirmed: boolean } - | { cancelled: true } - | { answers: RpcAskDialogAnswer[] }; - // The Other text lives as long as the question: choosing a regular option only // unchooses it, so the text is still in its row when Other is chosen again. type AskDraft = { selected: string[]; other: string; otherChosen: boolean }; @@ -211,17 +200,236 @@ export function isSensitivePrompt(title?: string, placeholder?: string): boolean } /** Single-select questions start on their recommended option. */ -function initialAskDrafts(request: ExtensionDialogRequest): AskDraft[] { - if (request.method !== "ask") return []; - return request.questions.map((question) => { +function initialAskDrafts(request: UiDialogRequest): AskDraft[] { + if (request.method !== "askDialog") return []; + return request.args[0].map((question) => { const recommended = question.multi || question.recommended === undefined ? undefined : question.options[question.recommended]; return { selected: recommended ? [recommended.label] : [], other: "", otherChosen: false }; }); } +function dialogTitle(request: UiDialogRequest, askTitle: string): string { + switch (request.method) { + case "askDialog": + case "formDialog": + return askTitle; + case "approve": + return request.args[0].full.split("\n", 1)[0]; + default: + return request.args[0]; + } +} + +/** omp's approval choices, in its order: this call, the session, each grant, then no. */ +function approvalChoices(request: Extract, allow: string, deny: string): { label: string; value: UiApprovalAnswer }[] { + const [prompt] = request.args; + return [ + { label: allow, value: "once" }, + ...(prompt.session ? [{ label: prompt.session, value: "session" as const }] : []), + ...(prompt.grants ?? []).flatMap((grant) => [ + { label: grant.label, value: { grant: grant.scope, always: false } }, + { label: grant.alwaysLabel, value: { grant: grant.scope, always: true } }, + ]), + { label: deny, value: "deny" }, + ]; +} + +// The agent's form markup becomes React elements: these tags render as themselves with only the +// attributes copied below; others contribute just their text, and dropped ones nothing at all. +const FORM_TAGS: Record = { + fieldset: true, legend: true, p: true, label: true, div: true, span: true, section: true, strong: true, em: true, b: true, i: true, + code: true, small: true, br: true, hr: true, ul: true, ol: true, li: true, h1: true, h2: true, h3: true, h4: true, h5: true, h6: true, + input: true, select: true, option: true, optgroup: true, textarea: true, +}; +const DROPPED_TAGS: Record = { + script: true, style: true, template: true, noscript: true, button: true, iframe: true, object: true, embed: true, img: true, svg: true, math: true, +}; +const INPUT_TYPES: Record = { + radio: true, checkbox: true, text: true, number: true, range: true, email: true, url: true, tel: true, search: true, date: true, time: true, hidden: true, +}; +const FIELD_STYLE = { padding: "7px 10px", borderRadius: 6, border: "1px solid var(--border)", background: "var(--bg-panel)", color: "var(--text)", fontSize: 12.5, fontFamily: "inherit" } as const; + +/** A radio/checkbox group that offers a free-text "Other" answer, as omp's html-form adds one. */ +interface OtherGroup { type: string; name: string; required: boolean } +interface FormCtx { + prefix: string; + otherLabel: string; + /** Option element after which a group's Other box is rendered. */ + others: Map; + /** Options of those groups: their `required` moves to the Other box (see syncOtherValidity). */ + grouped: Set; + /** Bare radio/checkbox inputs mapped to their `