diff --git a/.cache/3d37c554f4f09215e1f38a0e99af42d5681e0211f2416e8e740a8893b1eee23f.json b/.cache/3d37c554f4f09215e1f38a0e99af42d5681e0211f2416e8e740a8893b1eee23f.json new file mode 100644 index 0000000..a4cd8fc --- /dev/null +++ b/.cache/3d37c554f4f09215e1f38a0e99af42d5681e0211f2416e8e740a8893b1eee23f.json @@ -0,0 +1,6 @@ +{ + "url": "https://remcodex.com/", + "markdown": "# RemCodex - Remote Codex Control from Anywhere\nRemote Codex control\nRun Codex on one machine and control the same session from another device. RemCodex is a local-first remote control layer for Codex that gives you a browser and phone view for monitoring progress, approving commands, interrupting work, and resuming remotely.\nIt runs on your machine, not in a cloud IDE or remote desktop. You keep the existing Codex workflow, then open the same live session from your browser or your phone.\n$ `npx remcodex`\nMIT licensed npm package live Local-first\nRuns locally Your code stays on your machine\nBrowser and phone Same session across devices\nRemote actions Monitor, approve, interrupt, resume\nDefault port 18840, with auto fallback\n![RemCodex browser and phone views for remote Codex control](https://remcodex.com/assets/hero-cover.png)\nQuick start\n## How to Control Codex from Another Device\nStart RemCodex on the machine where Codex already runs, open the browser UI locally, then keep the same session available from another device. If you want the full flow, [see how remote Codex control works](https://remcodex.com/how-it-works).\n```\nnpx remcodex\n```\nRemCodex starts locally on `http://127.0.0.1:18840` by default and can also be opened with your host IP when you want remote Codex access from a phone or another browser on the same network.\n1. Run `npx remcodex` next to the Codex session you want to control.\n2. Open the browser UI and choose the session you want to monitor or resume.\n3. Use the same session from your phone or another device when you leave your desk.\nLive demos\n## Use Codex on Your Phone Without Leaving the Same Session\nWatch the desktop and phone walkthroughs side by side. They show the same remote Codex control loop: monitor progress, review events, and stay connected from the browser even when you are away from the machine.\nDesktop walkthrough\n### See remote Codex control in the browser\nFollow the browser workflow for monitoring, approvals, and same-session handoff.\n[RemCodex desktop walkthrough](https://www.youtube-nocookie.com/embed/FNeAIqYps7o?rel=0)\nPhone walkthrough\n### Control Codex from your phone\nSee how phone access stays inside the same session instead of becoming a separate workflow.\n[RemCodex mobile walkthrough](https://www.youtube-nocookie.com/embed/-VuxsfbBmZA?rel=0)\nWhy it exists\n## Why Remote Codex Control Matters\n### The gap\nCodex already works well on a local machine. The friction shows up when a long session keeps running after you leave your desk and still needs supervision, approvals, or an occasional interrupt from another device.\n### What RemCodex adds\nRemCodex adds browser control for Codex without turning the product into a remote desktop, cloud IDE, or generic AI dashboard. It keeps the Codex process local and adds a clean control surface on top.\nGuides\n## What RemCodex Is\nRemCodex is a local-first remote control layer for Codex. These pages cover remote Codex setup, browser control, phone access, and the product boundaries that keep it different from remote desktop tools.\nWorkflow\n## Monitor, Approve, Interrupt, and Resume Remotely\nRemCodex is built for the real moments around a running Codex session: keep watching, respond to approvals, interrupt when the session goes off course, and resume the same run from another device.\n### Monitor a running Codex session\nView commands, edits, approvals, and assistant updates in one execution timeline.\n### Approve Codex commands remotely\nHandle file-system approvals from the browser or your phone without dropping into a raw terminal.\n### Interrupt and resume the same session\nPause or continue the live run instead of creating a second disconnected workflow on another device.\n### Keep remote Codex access local-first\nOpen your own machine from another browser while the code and execution stay where Codex already runs.\nA practical remote Codex loop\n### Control Codex from anywhere without leaving the same session behind.\n1 **Run Codex locally**\nKeep files, processes, and context on your own machine.\n2 **Open the browser UI**\nWatch the live session from your main machine first, then hand it off when needed.\n3 **Approve on your phone**\nReview the pending action remotely and keep the same session moving.\n4 **Interrupt or resume**\nStay in control when you need to pause, recover, or continue the live run.\nScreenshots\n## See the Remote Codex Control Surface\nThese product views show the current browser surface for remote Codex control: desktop monitoring, phone access, approvals, and readable session history.\n![RemCodex desktop browser view for remote Codex control](https://remcodex.com/assets/hero-desktop.png)\n### Desktop workspace\nRun, review, and reconnect to the same Codex session in one browser control surface.\n![RemCodex phone view for Codex remote access](https://remcodex.com/assets/mobile-session.png)\n### Phone view\nUse Codex on your phone when the same session needs attention away from your desk.\n![Browser approval flow for remote Codex commands](https://remcodex.com/assets/approval-flow.png)\n### Approval flow\nApprove sensitive actions remotely instead of juggling raw terminal prompts.\n![RemCodex session history view for Codex remote monitoring](https://remcodex.com/assets/imported-session.png)\n### Session history\nKeep Codex rollouts readable and available from the same browser workspace.\nInstall details\n## Local-First Remote Control for Codex\nThe fastest path is still the npm package. RemCodex starts locally on port `18840` by default, keeps your code on your own machine, and opens a browser control layer for the same Codex session.\n```\nnpx remcodex\n```\nOr install it globally if you plan to keep using remote Codex control every day.\n```\nnpm install -g remcodex\nremcodex\n```\n- Node.js installed\n- Codex CLI already working locally\n- LAN reachability if you want Codex on phone or another device\nNeed more context first? [Read the remote Codex docs](https://remcodex.com/docs) or [check the FAQ about remote Codex access](https://remcodex.com/faq).\nStart now\n## Control the Same Codex Session from Anywhere\nStart locally with `npx remcodex`, then open the same live session from your browser or phone when you need remote Codex control.", + "timestamp": 1783042317681, + "title": "RemCodex - Remote Codex Control from Anywhere" +} \ No newline at end of file diff --git a/README.md b/README.md index 023b3af..e921891 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ RemCodex fixes that. - Human-in-the-loop command approval - Multi-device access to the same live session - Resume after refresh, sleep, or reconnect +- Voice-note prompt capture with local transcription - Browser-based UI — **no extra client required** - Works with Codex CLI @@ -96,6 +97,28 @@ http://:18840 > Runs entirely on your local machine. No cloud, no data upload. +### RemCodex directory + +RemCodex can also serve a directory page that links to multiple RemCodex +instances. Configure the instances as a JSON array at startup: + +```bash +export REMCODEX_DIRECTORY_INSTANCES='[ + {"name":"Home","url":"https://remcodex.example","description":"Primary workspace"}, + {"name":"Lab","url":"https://lab-remcodex.example"} +]' +npx remcodex +``` + +Then open `/directory` on the RemCodex host. Each configured instance is +validated and displayed as an openable card. + +Nix users can run the packaged server directly: + +```bash +nix run .# +``` + --- ## 🖥 Screenshots @@ -128,10 +151,28 @@ It is built for real workflows: - mobile check-ins - approval prompts - imported rollout history +- discoverable Codex app-server sessions - timeline-style execution flow Instead of raw terminal logs, you get a structured, visual timeline you can follow and control. +### Interactive Codex app-servers + +When configured by Panoply, a bare interactive `codex` command starts a Unix-socket +app-server and records its endpoint under `$XDG_RUNTIME_DIR/codex-app-servers`. +RemCodex discovers those endpoints, imports their thread history, and can send +follow-up turns through the same app-server. Remote follow-up turns preserve the +unattended dangerous-bypass policy and auto-approve tools from discovered MCP +servers. Explicit Codex subcommands continue to pass directly to the underlying +CLI. + +The workspace sidebar's `+ New session` control also remembers a default +profile. Pick `Custom` to keep the current project-selection flow, or choose one +of the defined profiles to reuse it for future UI-started sessions. +When a profile provides a starting prompt, RemCodex shows it above the composer +as a temporary banner and prepends it to the first message you send in that +session. + --- ## 🧩 Current product shape @@ -140,6 +181,7 @@ Instead of raw terminal logs, you get a structured, visual timeline you can foll - Left sidebar for session navigation - Right-side execution timeline - Fixed input composer +- Optional voice-note capture button under send - Semantic timeline rendering for: - user messages - assistant output @@ -202,6 +244,150 @@ Default port: **18840** PORT=18841 npx remcodex ``` +If RemCodex is reached through a hostname or reverse proxy, set +`REMCODEX_HOSTNAME` to the public hostname or full HTTP(S) origin. Session +links returned by the API and MCP tools will use that value; when it is not +set, they remain relative links: + +```bash +REMCODEX_HOSTNAME=https://remcodex.example.com npx remcodex +``` + +The MCP endpoint lives at `POST /mcp`. If `REMCODEX_MCP_API_TOKEN` is set at +startup, the server will require the matching bearer token on every MCP +request. If it is not set, MCP stays disabled and the rest of RemCodex still +starts normally: + +```bash +REMCODEX_MCP_API_TOKEN=secret npx remcodex +``` + +Clients must send: + +```bash +Authorization: Bearer secret +``` + +To register RemCodex itself as a Codex MCP server, add this to +`~/.codex/config.toml` and adjust the port or token path if needed: + +```toml +[mcp_servers.remcodex] +url = "http://127.0.0.1:18840/mcp" +default_tools_approval_mode = "approve" + +[mcp_servers.remcodex.http_headers] +Authorization = "Bearer $(cat /path/to/remcodex-token)" +``` + +If you do not set `REMCODEX_MCP_API_TOKEN` when starting RemCodex, leave the +`http_headers` table out. + +### Interactive TUI wrapper + +`remcodex-tui` is a Python wrapper around the Codex CLI. With no arguments it +starts a local Unix-socket Codex app-server, registers it for RemCodex +discovery, and launches the interactive TUI through `codex --remote`. Explicit +Codex arguments pass through unchanged, so commands such as +`remcodex-tui exec --help` retain normal Codex behavior. + +The wrapper honors `CODEX_COMMAND`, `XDG_RUNTIME_DIR`, and +`CODEX_APP_SERVER_REGISTRY_DIR`. + +The MCP `create-session` tool accepts either a `projectId` or a +`workingDirectory`. Directory requests resolve an existing project and reuse +the latest non-terminal session for that directory; if the directory is not +registered yet, RemCodex registers it as a project before creating the session. +Providing `parentSessionId` always creates a new child session instead of +reusing an existing one. + +The read-only `list-sessions-by-directory` tool returns all sessions recorded +for a working directory, including completed and failed sessions. + +The `resume-session` tool selects an existing session for continued work. It +can optionally associate the resumed session with a parent session and changes +terminal session status back to `idle` so the next message can continue it. +Parenting follows the directory tree: a parent session's project directory +must contain the child session's project directory. + +RemCodex can seed agent profiles from a static TOML config at startup. By +default it reads `~/.remcodex/config.toml`, and you can override that with +`REMCODEX_CONFIG_PATH`. + +The sample file at `docs/remcodex-profiles.example.toml` shows the supported +shape: + +```toml +[[profiles]] +name = "remcodex-demo" +starting_prompt = "Use RemCodex to inspect the current session, keep the workspace state in view, and prefer small safe changes when testing UI flows." +default_directory = "/home/evak/programming/by_category/agentic/remcodex" +``` + +The profile API still exists for runtime additions, but startup config is the +preferred way to define the profiles that appear in the new-session dropdown. +The profile `starting_prompt` becomes a one-time prefix for the first message in +the session, and the UI banner disappears after that message is sent. + +The flake default package wraps `remcodex` with: + +- Node.js 20 +- `whisper-ctranslate2` +- `ffmpeg` +- a bundled tiny English Whisper model exposed through `REMCODEX_STT_MODEL_PATH` + +Examples: + +```bash +nix build .# +./result/bin/remcodex doctor +./result/bin/remcodex stt-self-test + +nix develop +remcodex start +``` + +For the Web UI reply-drop investigation harness, the flake also exposes a Linux check that: + +- boots RemCodex in a local OCI container +- swaps real Codex calls for a deterministic mock `app-server` +- streams assistant replies through cumulative `item/updated` events +- drives repeated chat submissions through Playwright until a reply goes missing or the loop completes + +Run it with: + +```bash +nix build .#checks.x86_64-linux.remcodex-debug-output +``` + +The package also includes a smoke test for the MCP auth gate: + +```bash +npm run smoke:mcp-auth +``` + +Voice-note transcription is optional and stays local. RemCodex will look for a whisper CLI on the server machine in this order: + +- `whisper` +- `whisper.cpp` +- `whisper-ctranslate2` + +You can override that detection with: + +```bash +REMCODEX_STT_BINARY=/path/to/whisper \ +REMCODEX_STT_MODEL_PATH=/path/to/model \ +npx remcodex +``` + +When voice transcription is available, the composer shows a microphone button below send. Click once to record, click again to stop, transcribe, and submit the transcript as a normal user prompt in the timeline. + +To validate the local transcription wiring without opening the browser: + +```bash +remcodex stt-self-test +``` + --- ## 📦 Install FAQ diff --git a/bin/remcodex-tui b/bin/remcodex-tui new file mode 100755 index 0000000..6f224df --- /dev/null +++ b/bin/remcodex-tui @@ -0,0 +1,2 @@ +#!/bin/sh +exec python3 "$(dirname "$0")/../scripts/remcodex-tui.py" "$@" diff --git a/docs/remcodex-mcp-config.toml b/docs/remcodex-mcp-config.toml new file mode 100644 index 0000000..0280569 --- /dev/null +++ b/docs/remcodex-mcp-config.toml @@ -0,0 +1,9 @@ +# Example Codex MCP configuration for RemCodex. +# Copy this into ~/.codex/config.toml and adjust the token path if needed. + +[mcp_servers.remcodex] +url = "http://127.0.0.1:18840/mcp" +default_tools_approval_mode = "approve" + +[mcp_servers.remcodex.http_headers] +Authorization = "Bearer $(cat /path/to/remcodex-token)" diff --git a/docs/remcodex-profiles.example.toml b/docs/remcodex-profiles.example.toml new file mode 100644 index 0000000..348ee7d --- /dev/null +++ b/docs/remcodex-profiles.example.toml @@ -0,0 +1,16 @@ +# Copy this to ~/.remcodex/config.toml or point REMCODEX_CONFIG_PATH at it. + +[[profiles]] +name = "orchestrator" +starting_prompt = "Manage delegated Codex work by reusing an existing session in the requested directory or starting a fresh session there when none exists." +default_directory = "/home/evak/programming" + +[[profiles]] +name = "remcodex-demo" +starting_prompt = "Use RemCodex to inspect the current session, keep the workspace state in view, and prefer small safe changes when testing UI flows." +default_directory = "/home/evak/programming/by_category/agentic/remcodex" + +[[profiles]] +name = "remcodex-review" +starting_prompt = "Review the current RemCodex worktree, summarize risks, and check for regressions before making changes." +default_directory = "/home/evak/programming/by_category/agentic/remcodex" diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..2468070 --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1735563628, + "narHash": "sha256-OnSAY7XDSx7CtDoqNh8jwVwh4xNL/2HaJxGjryLWzX8=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "b134951a4c9f3c995fd7be05f3243f8ecd65d798", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-24.05", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..e611732 --- /dev/null +++ b/flake.nix @@ -0,0 +1,218 @@ +{ + description = "RemCodex with local Whisper transcription support"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.05"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { inherit system; }; + lib = pkgs.lib; + nodejs = pkgs.nodejs_20; + mockCodexCommand = pkgs.writeShellScriptBin "mock-codex" '' + exec ${nodejs}/bin/node ${./tests/mock-codex-app-server.js} "$@" + ''; + playwrightRunner = pkgs.writeShellScriptBin "run-remcodex-debug-output-playwright" '' + export NODE_PATH=${pkgs.playwright}/lib/node_modules + export CHROMIUM_BIN=${pkgs.chromium}/bin/chromium + exec ${nodejs}/bin/node ${./tests/remcodex-debug-output.playwright.js} "$@" + ''; + whisperModelTiny = pkgs.runCommand "faster-whisper-tiny-en" { } '' + mkdir -p $out + cp ${pkgs.fetchurl { + url = "https://huggingface.co/Systran/faster-whisper-tiny.en/resolve/main/model.bin"; + sha256 = "0akbjc75br4jakamgb92l17fw461bjz7i7csbjbirfaddbhglnhs"; + }} $out/model.bin + cp ${pkgs.fetchurl { + url = "https://huggingface.co/Systran/faster-whisper-tiny.en/resolve/main/config.json"; + sha256 = "1ibwd97kijpjci44r7nb9508f6jndd1625483davqj83m4hv9c8l"; + }} $out/config.json + cp ${pkgs.fetchurl { + url = "https://huggingface.co/Systran/faster-whisper-tiny.en/resolve/main/tokenizer.json"; + sha256 = "1pr25px1bnafw3j29qyqf38k5qdmpjmx2xcanghxqdll8195574j"; + }} $out/tokenizer.json + cp ${pkgs.fetchurl { + url = "https://huggingface.co/Systran/faster-whisper-tiny.en/resolve/main/vocabulary.txt"; + sha256 = "1kqml5svagpwcv5k6xf5392f4p5rszznjnxb69fmk8nk8s3mhxzz"; + }} $out/vocabulary.txt + ''; + remcodex = pkgs.buildNpmPackage rec { + pname = "remcodex"; + version = "0.1.0-beta.12"; + src = self; + + inherit nodejs; + npmDepsHash = "sha256-tc76sGP+OO0sz98A7WTs+7frXV9EDu87TWoCgfCy1os="; + + nativeBuildInputs = [ + pkgs.makeWrapper + nodejs + pkgs.pkg-config + pkgs.python3 + ]; + + npmBuildScript = "build"; + doCheck = false; + + installPhase = '' + runHook preInstall + + mkdir -p $out/lib/remcodex + cp -r dist $out/lib/remcodex/dist + cp -r node_modules $out/lib/remcodex/node_modules + cp -r web $out/lib/remcodex/web + cp package.json package-lock.json README.md LICENSE $out/lib/remcodex/ + + makeWrapper ${nodejs}/bin/node $out/bin/remcodex \ + --add-flags $out/lib/remcodex/dist/server/src/cli.js + + runHook postInstall + ''; + + meta = with lib; { + description = "Remote control for Codex from a browser or phone"; + homepage = "https://remcodex.com"; + license = licenses.mit; + platforms = platforms.unix; + mainProgram = "remcodex"; + }; + }; + remcodex-with-whisper = pkgs.symlinkJoin { + name = "remcodex-with-whisper-${remcodex.version}"; + paths = [ remcodex ]; + nativeBuildInputs = [ pkgs.makeWrapper ]; + postBuild = '' + wrapProgram $out/bin/remcodex \ + --prefix PATH : ${lib.makeBinPath [ nodejs pkgs.whisper-ctranslate2 pkgs.ffmpeg ]} \ + --set-default REMCODEX_STT_BINARY ${pkgs.whisper-ctranslate2}/bin/whisper-ctranslate2 \ + --set-default REMCODEX_STT_MODEL_PATH ${whisperModelTiny} + ''; + meta = remcodex.meta // { + description = "${remcodex.meta.description} with whisper-ctranslate2 on PATH"; + }; + }; + remcodexDebugImage = pkgs.dockerTools.buildLayeredImage { + name = "remcodex-debug-output"; + tag = "latest"; + contents = [ + remcodex + mockCodexCommand + pkgs.coreutils + pkgs.bash + ]; + config = { + Entrypoint = [ "${remcodex}/bin/remcodex" "start" "--no-open" ]; + Env = [ + "PORT=18840" + "CODEX_COMMAND=/bin/mock-codex" + "CODEX_MODE=app-server" + "PROJECT_ROOTS=/workspace" + "DATABASE_PATH=/data/remcodex.db" + "MOCK_CODEX_STREAM_MODE=item-updated" + ]; + }; + }; + in { + packages = { + default = remcodex-with-whisper; + inherit remcodex remcodex-with-whisper whisperModelTiny remcodexDebugImage; + }; + + apps = { + default = { + type = "app"; + program = "${remcodex-with-whisper}/bin/remcodex"; + }; + remcodex = { + type = "app"; + program = "${remcodex}/bin/remcodex"; + }; + }; + + devShells.default = pkgs.mkShell { + packages = [ + nodejs + pkgs.pkg-config + pkgs.python3 + pkgs.whisper-ctranslate2 + pkgs.ffmpeg + ]; + + shellHook = '' + export REMCODEX_STT_BINARY=${pkgs.whisper-ctranslate2}/bin/whisper-ctranslate2 + export REMCODEX_STT_MODEL_PATH=${whisperModelTiny} + echo "Node $(node --version) and whisper support are available." + ''; + }; + } // lib.optionalAttrs pkgs.stdenv.isLinux { + checks.remcodex-debug-output = pkgs.testers.runNixOSTest { + name = "remcodex-debug-output"; + nodes.machine = { pkgs, ... }: { + virtualisation.podman.enable = true; + virtualisation.oci-containers.backend = "podman"; + virtualisation.oci-containers.containers.remcodex = { + autoStart = true; + image = "remcodex-debug-output:latest"; + imageFile = remcodexDebugImage; + environment = { + PORT = "18840"; + CODEX_COMMAND = "/bin/mock-codex"; + CODEX_MODE = "app-server"; + PROJECT_ROOTS = "/workspace"; + DATABASE_PATH = "/data/remcodex.db"; + MOCK_CODEX_CHUNK_DELAY_MS = "6"; + MOCK_CODEX_CHUNK_COUNT = "8"; + MOCK_CODEX_STREAM_MODE = "item-updated"; + }; + ports = [ "127.0.0.1:18840:18840" ]; + volumes = [ + "/var/lib/remcodex-test/workspace:/workspace" + "/var/lib/remcodex-test/data:/data" + ]; + }; + + systemd.tmpfiles.rules = [ + "d /var/lib/remcodex-test 0755 root root -" + "d /var/lib/remcodex-test/workspace 0755 root root -" + "d /var/lib/remcodex-test/data 0755 root root -" + "d /tmp/remcodex-debug-output 0755 root root -" + ]; + + environment.systemPackages = [ + pkgs.chromium + pkgs.nodejs_20 + pkgs.playwright + playwrightRunner + ]; + }; + + testScript = '' + machine.start() + machine.wait_for_unit("podman-remcodex.service") + machine.wait_for_open_port(18840) + machine.succeed("curl --fail --silent http://127.0.0.1:18840/health >/tmp/remcodex-health.json") + + status, output = machine.execute( + "REMCODEX_BASE_URL=http://127.0.0.1:18840 " + "REMCODEX_ARTIFACT_DIR=/tmp/remcodex-debug-output " + "REMCODEX_PROJECT_PATH=/workspace/e2e-project " + "REMCODEX_TURN_COUNT=150 " + "REMCODEX_EXPECT_STREAM_MODE=item-updated " + "run-remcodex-debug-output-playwright" + ) + if status != 0: + machine.succeed("echo '=== playwright output ===' >&2") + machine.succeed("cat /tmp/remcodex-debug-output/fatal.json >&2 || true") + machine.succeed("cat /tmp/remcodex-debug-output/summary.json >&2 || true") + machine.succeed("echo '=== podman logs ===' >&2") + machine.succeed("podman logs remcodex >&2 || journalctl -u podman-remcodex.service --no-pager >&2 || true") + raise Exception(f"Playwright reproduction failed with status {status}: {output}") + + machine.succeed("cat /tmp/remcodex-debug-output/summary.json >/tmp/remcodex-debug-output-summary.json") + ''; + }; + }); +} diff --git a/package-lock.json b/package-lock.json index abce3c6..9bae360 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,21 +10,28 @@ "hasInstallScript": true, "license": "MIT", "dependencies": { + "@modelcontextprotocol/node": "^2.0.0-beta.3", + "@modelcontextprotocol/server": "^2.0.0-beta.3", "better-sqlite3": "^11.8.1", "express": "^4.21.2", "markdown-it": "^14.1.1", "node-pty": "^1.0.0", - "ws": "^8.18.1" + "ws": "^8.18.1", + "zod": "^4.4.3" }, "bin": { "remcodex": "dist/server/src/cli.js" }, "devDependencies": { + "@modelcontextprotocol/client": "^2.0.0-beta.3", "@types/express": "^5.0.1", "@types/node": "^22.13.10", "@types/ws": "^8.5.14", "tsx": "^4.19.3", "typescript": "^5.8.2" + }, + "engines": { + "node": "20.x" } }, "node_modules/@esbuild/aix-ppc64": { @@ -469,6 +476,69 @@ "node": ">=18" } }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/client": { + "version": "2.0.0-beta.3", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0-beta.3.tgz", + "integrity": "sha512-o9z9YCGNxWNdklPdjcD0tr8pocC9OgjdArqgx3OjPMlTe0M/SSPC2olOJs5ueq77HSpz36mND0f0KteqEhKF3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "jose": "^6.1.3", + "pkce-challenge": "^5.0.0", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/node": { + "version": "2.0.0-beta.3", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/node/-/node-2.0.0-beta.3.tgz", + "integrity": "sha512-jmrJEKjPCBU+vu1YRcYaoyeRnB3VwuV2qBeEHlqgfJWN9sp6zisFqRXGZwVgRuZ2aZkEi1gO5JNSxarR8R/8Ig==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@modelcontextprotocol/server": "^2.0.0-beta.3", + "hono": "^4.11.4" + }, + "peerDependenciesMeta": { + "hono": { + "optional": true + } + } + }, + "node_modules/@modelcontextprotocol/server": { + "version": "2.0.0-beta.3", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0-beta.3.tgz", + "integrity": "sha512-EQxVIWf2XMAgjR38ZRZnX1iiOWH42tT/YJG8N4dVjAnrEGSEfPirR6FYfDC72yuNSvVYbPDTD1H3KHLLHPd18A==", + "license": "MIT", + "dependencies": { + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -781,6 +851,21 @@ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "license": "MIT" }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -979,6 +1064,29 @@ "node": ">= 0.6" } }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -1198,6 +1306,16 @@ "node": ">= 0.4" } }, + "node_modules/hono": { + "version": "4.12.29", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.29.tgz", + "integrity": "sha512-1hNiRjawYrLq/4m3DQQjPGFg0VZkk4RjQJDff/excI6Dm9BiL75qxGrd7/c6YOxPdq6AscP3LiXhQ6fKFC1Waw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -1271,6 +1389,23 @@ "node": ">= 0.10" } }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/linkify-it": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", @@ -1490,12 +1625,32 @@ "node": ">= 0.8" } }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-to-regexp": { "version": "0.1.12", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "license": "MIT" }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -1722,6 +1877,29 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -2008,6 +2186,22 @@ "node": ">= 0.8" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -2034,6 +2228,15 @@ "optional": true } } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 25caa10..9920a8b 100644 --- a/package.json +++ b/package.json @@ -4,13 +4,16 @@ "description": "Control Codex from anywhere. Even on your phone.", "license": "MIT", "bin": { - "remcodex": "dist/server/src/cli.js" + "remcodex": "dist/server/src/cli.js", + "remcodex-tui": "bin/remcodex-tui" }, "files": [ "dist", + "bin", "web", "scripts/check-node-version.js", "scripts/fix-node-pty-helper.js", + "scripts/remcodex-tui.py", "README.md" ], "scripts": { @@ -21,16 +24,21 @@ "cli": "tsx server/src/cli.ts --no-open", "preinstall": "node scripts/check-node-version.js", "smoke:tarball": "node scripts/smoke-test-tarball.js", - "smoke:start": "node scripts/smoke-start-tarball.js" + "smoke:start": "node scripts/smoke-start-tarball.js", + "smoke:mcp-auth": "node scripts/smoke-mcp-auth.js" }, "dependencies": { + "@modelcontextprotocol/node": "^2.0.0-beta.3", + "@modelcontextprotocol/server": "^2.0.0-beta.3", "better-sqlite3": "^11.8.1", "express": "^4.21.2", "markdown-it": "^14.1.1", "node-pty": "^1.0.0", - "ws": "^8.18.1" + "ws": "^8.18.1", + "zod": "^4.4.3" }, "devDependencies": { + "@modelcontextprotocol/client": "^2.0.0-beta.3", "@types/express": "^5.0.1", "@types/node": "^22.13.10", "@types/ws": "^8.5.14", diff --git a/scripts/remcodex-tui.py b/scripts/remcodex-tui.py new file mode 100755 index 0000000..2cb478c --- /dev/null +++ b/scripts/remcodex-tui.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +"""Launch Codex's interactive TUI through a local app-server.""" + +from __future__ import annotations + +import argparse +import json +import os +import shutil +import stat +import subprocess +import sys +import time +from pathlib import Path +from typing import Sequence + +BYPASS_FLAGS = ["--dangerously-bypass-approvals-and-sandbox"] + + +def resolve_codex(command: str) -> str: + resolved = shutil.which(command) + if resolved is None: + raise FileNotFoundError(f"Codex executable not found: {command}") + return resolved + + +def build_app_server_command(codex: str, endpoint: str) -> list[str]: + return [codex, *BYPASS_FLAGS, "app-server", "--listen", endpoint] + + +def build_tui_command(codex: str, endpoint: str) -> list[str]: + return [codex, *BYPASS_FLAGS, "--remote", endpoint] + + +def is_socket(path: Path) -> bool: + try: + return stat.S_ISSOCK(path.stat().st_mode) + except FileNotFoundError: + return False + + +def wait_for_socket(socket_path: Path, process: subprocess.Popen[bytes], timeout: float) -> None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if is_socket(socket_path): + return + if process.poll() is not None: + raise RuntimeError(f"Codex app-server exited before creating {socket_path}") + time.sleep(0.05) + raise TimeoutError(f"Timed out waiting for Codex app-server socket: {socket_path}") + + +def write_registry_metadata( + path: Path, + instance_id: str, + endpoint: str, + socket_path: Path, + process: subprocess.Popen[bytes], +) -> None: + metadata = { + "id": instance_id, + "endpoint": endpoint, + "wsEndpoint": f"ws+unix://{socket_path}:/", + "socketPath": str(socket_path), + "pid": process.pid, + "cwd": os.getcwd(), + "startedAt": time.strftime("%Y-%m-%dT%H:%M:%S%z"), + "source": "codex", + } + path.write_text(json.dumps(metadata) + "\n", encoding="utf-8") + + +def terminate(process: subprocess.Popen[bytes] | None) -> None: + if process is None or process.poll() is not None: + return + process.terminate() + try: + process.wait(timeout=2) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + + +def run_interactive(codex: str, registry_dir: Path, socket_timeout: float) -> int: + registry_dir.mkdir(parents=True, exist_ok=True) + registry_dir.chmod(0o700) + + instance_id = f"codex-{os.getpid()}-{time.time_ns() % 1_000_000_000}" + socket_path = registry_dir / f"{instance_id}.sock" + metadata_path = registry_dir / f"{instance_id}.json" + log_path = registry_dir / f"{instance_id}.log" + endpoint = f"unix://{socket_path}" + + app_server: subprocess.Popen[bytes] | None = None + tui: subprocess.Popen[bytes] | None = None + with log_path.open("ab") as log: + try: + app_server = subprocess.Popen( + build_app_server_command(codex, endpoint), + stdin=subprocess.DEVNULL, + stdout=log, + stderr=subprocess.STDOUT, + ) + wait_for_socket(socket_path, app_server, socket_timeout) + write_registry_metadata(metadata_path, instance_id, endpoint, socket_path, app_server) + tui = subprocess.Popen(build_tui_command(codex, endpoint)) + return tui.wait() + except KeyboardInterrupt: + return 130 + finally: + terminate(tui) + terminate(app_server) + metadata_path.unlink(missing_ok=True) + socket_path.unlink(missing_ok=True) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="remcodex-tui", + description="Run Codex interactively through a discoverable local app-server.", + ) + parser.add_argument( + "--codex-command", + default=os.environ.get("CODEX_COMMAND", "codex"), + help="Codex executable (default: CODEX_COMMAND or codex).", + ) + parser.add_argument( + "--socket-timeout", + type=float, + default=5.0, + help="Seconds to wait for the app-server socket (default: 5).", + ) + parser.add_argument( + "--registry-dir", + default=os.environ.get( + "CODEX_APP_SERVER_REGISTRY_DIR", + str(Path(os.environ.get("XDG_RUNTIME_DIR", "/tmp")) / "codex-app-servers"), + ), + help="App-server registry directory.", + ) + parser.add_argument("codex_args", nargs=argparse.REMAINDER, help="Explicit Codex arguments to pass through.") + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + args = build_parser().parse_args(argv) + try: + codex = resolve_codex(args.codex_command) + if args.codex_args: + os.execvpe(codex, [codex, *args.codex_args], os.environ.copy()) + return run_interactive(codex, Path(args.registry_dir), args.socket_timeout) + except (FileNotFoundError, RuntimeError, TimeoutError) as error: + print(f"remcodex-tui: {error}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/smoke-mcp-auth.js b/scripts/smoke-mcp-auth.js new file mode 100644 index 0000000..e2d5097 --- /dev/null +++ b/scripts/smoke-mcp-auth.js @@ -0,0 +1,233 @@ +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { execFileSync, spawn } = require("node:child_process"); +const { Client, StreamableHTTPClientTransport } = require("@modelcontextprotocol/client"); + +const repoRoot = path.resolve(__dirname, ".."); +const npmCache = path.join(os.tmpdir(), "remcodex-npm-cache"); +const smokeRoot = fs.mkdtempSync(path.join(os.tmpdir(), "remcodex-mcp-smoke-")); +const smokeHome = path.join(smokeRoot, "home"); +const smokeDb = path.join(smokeHome, ".remcodex", "smoke-mcp.db"); +const smokePort = 33118; +const authToken = "smoke-mcp-token"; +const runtimeBin = path.dirname(process.execPath); + +fs.mkdirSync(smokeHome, { recursive: true }); + +function createCleanEnv(extra = {}) { + return { + PATH: [runtimeBin, process.env.PATH].filter(Boolean).join(path.delimiter), + TERM: process.env.TERM, + SHELL: process.env.SHELL, + LANG: process.env.LANG, + HOME: smokeHome, + USERPROFILE: smokeHome, + XDG_CONFIG_HOME: path.join(smokeHome, ".config"), + XDG_DATA_HOME: path.join(smokeHome, ".local", "share"), + NPM_CONFIG_CACHE: npmCache, + npm_config_cache: npmCache, + CODEX_COMMAND: "true", + REMCODEX_MCP_API_TOKEN: authToken, + ...extra, + }; +} + +function run(command, args, cwd = repoRoot, env = createCleanEnv()) { + console.log(`$ ${command} ${args.join(" ")}`); + execFileSync(command, args, { + cwd, + stdio: "inherit", + env, + }); +} + +async function waitForHealth(url, timeoutMs) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + const response = await fetch(url); + if (response.ok) { + const body = await response.json(); + if (body && body.ok) { + return body; + } + } + } catch { + // Keep polling until the server is ready. + } + await new Promise((resolve) => setTimeout(resolve, 300)); + } + + throw new Error(`Timed out waiting for ${url}`); +} + +async function waitForProcessExit(child) { + if (child.exitCode !== null || child.signalCode !== null) { + return; + } + + await new Promise((resolve) => child.once("exit", resolve)); +} + +async function main() { + run("npm", ["run", "build"]); + + const cliPath = path.join(repoRoot, "dist", "server", "src", "cli.js"); + const disabledDb = path.join(smokeHome, ".remcodex", "disabled-mcp.db"); + const disabledChild = spawn( + process.execPath, + [cliPath, "--no-open", "--port", "33119", "--db", disabledDb], + { + cwd: repoRoot, + env: createCleanEnv({ REMCODEX_MCP_API_TOKEN: "" }), + stdio: ["ignore", "pipe", "pipe"], + }, + ); + + let disabledStdout = ""; + let disabledStderr = ""; + disabledChild.stdout.on("data", (chunk) => { + const text = chunk.toString("utf8"); + disabledStdout += text; + process.stdout.write(text); + }); + disabledChild.stderr.on("data", (chunk) => { + const text = chunk.toString("utf8"); + disabledStderr += text; + process.stderr.write(text); + }); + + try { + const disabledHealth = await waitForHealth("http://127.0.0.1:33119/health", 15000); + console.log(`Disabled-mode health check passed: ${JSON.stringify(disabledHealth)}`); + + const disabledResponse = await fetch("http://127.0.0.1:33119/mcp", { + method: "POST", + headers: { + accept: "application/json, text/event-stream", + "content-type": "application/json", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2024-11-05", + clientInfo: { + name: "remcodex-smoke", + version: "1.0.0", + }, + capabilities: {}, + }, + }), + }); + if (disabledResponse.status !== 404) { + throw new Error(`Expected MCP to be disabled without a token, got ${disabledResponse.status}`); + } + } finally { + disabledChild.kill("SIGTERM"); + await waitForProcessExit(disabledChild); + } + + console.log(`$ ${process.execPath} ${cliPath} --no-open --port ${smokePort} --db ${smokeDb}`); + + const runningChild = spawn( + process.execPath, + [cliPath, "--no-open", "--port", String(smokePort), "--db", smokeDb], + { + cwd: repoRoot, + env: createCleanEnv(), + stdio: ["ignore", "pipe", "pipe"], + }, + ); + + let stdout = ""; + let stderr = ""; + runningChild.stdout.on("data", (chunk) => { + const text = chunk.toString("utf8"); + stdout += text; + process.stdout.write(text); + }); + runningChild.stderr.on("data", (chunk) => { + const text = chunk.toString("utf8"); + stderr += text; + process.stderr.write(text); + }); + + try { + const health = await waitForHealth(`http://127.0.0.1:${smokePort}/health`, 15000); + console.log(`Health check passed: ${JSON.stringify(health)}`); + + const mcpUrl = `http://127.0.0.1:${smokePort}/mcp`; + const unauthorized = await fetch(mcpUrl, { + method: "POST", + headers: { + accept: "application/json, text/event-stream", + "content-type": "application/json", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2024-11-05", + clientInfo: { + name: "remcodex-smoke", + version: "1.0.0", + }, + capabilities: {}, + }, + }), + }); + if (unauthorized.status !== 401) { + throw new Error(`Expected 401 without auth, got ${unauthorized.status}`); + } + + const client = new Client({ name: "remcodex-smoke", version: "1.0.0" }); + const transport = new StreamableHTTPClientTransport(new URL(mcpUrl), { + requestInit: { + headers: { + authorization: `Bearer ${authToken}`, + }, + }, + }); + + try { + await client.connect(transport); + const toolsList = await client.listTools(); + const toolNames = toolsList.tools.map((tool) => tool.name); + if (!toolNames.includes("list-sessions")) { + throw new Error(`Expected MCP tools response to include list-sessions, got ${JSON.stringify(toolNames)}`); + } + } finally { + await transport.close().catch(() => null); + await client.close().catch(() => null); + } + + console.log("MCP auth smoke test passed."); + } finally { + runningChild.kill("SIGTERM"); + await new Promise((resolve) => runningChild.once("exit", resolve)); + } + + if (stderr.trim()) { + console.log(`Captured stderr:\n${stderr}`); + } + if (disabledStderr.trim()) { + console.log(`Captured disabled stderr:\n${disabledStderr}`); + } + if (disabledStdout.trim()) { + console.log(`Captured disabled stdout:\n${disabledStdout}`); + } + + console.log(`Smoke test passed: ${smokeRoot}`); + console.log(`Isolated HOME: ${smokeHome}`); + console.log(`Captured stdout:\n${stdout}`); +} + +main() + .catch((error) => { + console.error(error.stack || error.message); + process.exitCode = 1; + }); diff --git a/server/src/app.ts b/server/src/app.ts index 7f0482f..94ffcc0 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -8,27 +8,49 @@ import { createCodexOptionsRouter } from "./controllers/codex-options.controller import { createMessageRouter } from "./controllers/message.controller"; import { createProjectRouter } from "./controllers/project.controller"; import { createSessionRouter } from "./controllers/session.controller"; +import { createProfileRouter } from "./controllers/profile.controller"; +import { handleRemCodexMcpRequest } from "./mcp"; import { createDatabase } from "./db/client"; import { runMigrations } from "./db/migrations"; import { registerSessionGateway } from "./gateways/ws.gateway"; import { EventStore } from "./services/event-store"; import { CodexRolloutSyncService } from "./services/codex-rollout-sync"; +import { CodexAppServerRegistryService } from "./services/codex-app-server-registry"; import { ProjectManager } from "./services/project-manager"; +import { AgentProfileManager } from "./services/agent-profile-manager"; import { SessionManager } from "./services/session-manager"; +import { SpeechToTextService } from "./services/speech-to-text"; import { SessionTimelineService } from "./services/session-timeline-service"; import type { CodexExecutionMode } from "./services/codex-runner"; -import { resolveDefaultDatabasePath, resolvePackageRoot } from "./utils/runtime-paths"; +import { loadRemCodexConfig } from "./utils/remcodex-config"; +import { + resolveDefaultConfigPath, + resolveDefaultDatabasePath, + resolvePackageRoot, +} from "./utils/runtime-paths"; import { resolveExecutable } from "./utils/command"; import { isAppError } from "./utils/errors"; +import { + parseRemCodexDirectoryInstances, + type RemCodexDirectoryInstance, +} from "./utils/remcodex-directory"; +import { resolveRemCodexPublicBaseUrl } from "./utils/remcodex-url"; export interface RemCodexServerOptions { port?: number; databasePath?: string; + configPath?: string; projectRootsEnv?: string; repoRoot?: string; codexCommand?: string; codexMode?: CodexExecutionMode; logStartup?: boolean; + directoryInstances?: RemCodexDirectoryInstance[]; +} + +function resolveMcpApiToken(): string | null { + const token = process.env.REMCODEX_MCP_API_TOKEN?.trim() || ""; + return token.length > 0 ? token : null; } export interface StartedRemCodexServer { @@ -40,6 +62,7 @@ export interface StartedRemCodexServer { codexCommand: string; codexMode: CodexExecutionMode; projectRoots: string[]; + directoryInstances: RemCodexDirectoryInstance[]; stop: () => Promise; } @@ -54,6 +77,7 @@ interface BuiltRemCodexServer { codexMode: CodexExecutionMode; projectRoots: string[]; logStartup: boolean; + directoryInstances: RemCodexDirectoryInstance[]; } function buildRemCodexServer(options: RemCodexServerOptions = {}): BuiltRemCodexServer { @@ -63,10 +87,18 @@ function buildRemCodexServer(options: RemCodexServerOptions = {}): BuiltRemCodex options.databasePath ?? process.env.DATABASE_PATH ?? resolveDefaultDatabasePath(); + const configPath = + options.configPath ?? + process.env.REMCODEX_CONFIG_PATH ?? + resolveDefaultConfigPath(); const codexCommand = resolveExecutable(options.codexCommand ?? process.env.CODEX_COMMAND ?? "codex"); const codexMode: CodexExecutionMode = options.codexMode ?? (process.env.CODEX_MODE === "exec-json" ? "exec-json" : "app-server"); const projectRootsEnv = options.projectRootsEnv ?? process.env.PROJECT_ROOTS; + const directoryInstances = options.directoryInstances ?? parseRemCodexDirectoryInstances( + process.env.REMCODEX_DIRECTORY_INSTANCES ?? "", + ); + const publicBaseUrl = resolveRemCodexPublicBaseUrl(); mkdirSync(path.dirname(databasePath), { recursive: true }); @@ -76,7 +108,16 @@ function buildRemCodexServer(options: RemCodexServerOptions = {}): BuiltRemCodex const eventStore = new EventStore(db); const sessionTimeline = new SessionTimelineService(eventStore); const projectManager = new ProjectManager(db, projectRootsEnv, repoRoot); + const remCodexConfig = loadRemCodexConfig(configPath); + const profileManager = new AgentProfileManager(db, { + initialProfiles: remCodexConfig.profiles, + }); const codexRolloutSync = new CodexRolloutSyncService(db); + const appServerRegistry = new CodexAppServerRegistryService(codexRolloutSync); + const speechToText = new SpeechToTextService({ + preferredBinary: process.env.REMCODEX_STT_BINARY, + modelPath: process.env.REMCODEX_STT_MODEL_PATH, + }); const sessionManager = new SessionManager({ db, eventStore, @@ -84,23 +125,30 @@ function buildRemCodexServer(options: RemCodexServerOptions = {}): BuiltRemCodex codexCommand, codexMode, }); + const mcpApiToken = resolveMcpApiToken(); const app = express(); const server = http.createServer(app); app.use(express.json({ limit: "1mb" })); + app.get("/api/directory/instances", (_request, response) => { + response.json({ items: directoryInstances }); + }); + app.get("/health", (_request, response) => { response.json({ ok: true, codexMode, codexCommand, projectRoots: projectManager.listAllowedRoots(), + publicBaseUrl, now: new Date().toISOString(), }); }); app.use("/api/projects", createProjectRouter(projectManager)); + app.use("/api/profiles", createProfileRouter(profileManager)); app.use( "/api/codex", createCodexOptionsRouter({ @@ -118,13 +166,46 @@ function buildRemCodexServer(options: RemCodexServerOptions = {}): BuiltRemCodex eventStore, projectManager, codexRolloutSync, + appServerRegistry, sessionTimeline, ), ); - app.use("/api/sessions/:sessionId/messages", createMessageRouter(sessionManager)); + app.use("/api/sessions/:sessionId/messages", createMessageRouter(sessionManager, speechToText)); + if (mcpApiToken) { + app.all("/mcp", async (request, response, next) => { + try { + await handleRemCodexMcpRequest( + request, + response, + { + eventStore, + projectManager, + sessionManager, + sessionTimeline, + codexRolloutSync, + profileManager, + }, + { + apiToken: mcpApiToken, + }, + ); + } catch (error) { + next(error); + } + }); + } else { + app.all("/mcp", (_request, response) => { + response.status(404).json({ + error: "MCP is disabled. Set REMCODEX_MCP_API_TOKEN to enable it.", + }); + }); + } const webRoot = path.join(repoRoot, "web"); app.use(express.static(webRoot)); + app.get(["/directory", "/directory/"], (_request, response) => { + response.sendFile(path.join(webRoot, "directory.html")); + }); app.get("/", (_request, response) => { response.sendFile(path.join(webRoot, "index.html")); }); @@ -161,6 +242,7 @@ function buildRemCodexServer(options: RemCodexServerOptions = {}): BuiltRemCodex codexCommand, codexMode, projectRoots: projectManager.listAllowedRoots(), + directoryInstances, logStartup: options.logStartup ?? true, }; } @@ -194,6 +276,7 @@ export async function startRemCodexServer( codexMode: built.codexMode, databasePath: built.databasePath, codexCommand: built.codexCommand, + directoryInstances: built.directoryInstances.length, }), ); } @@ -207,6 +290,7 @@ export async function startRemCodexServer( codexCommand: built.codexCommand, codexMode: built.codexMode, projectRoots: built.projectRoots, + directoryInstances: built.directoryInstances, stop: () => new Promise((resolve, reject) => { built.server.close((error) => { diff --git a/server/src/cli.ts b/server/src/cli.ts index 9339cb5..b309856 100644 --- a/server/src/cli.ts +++ b/server/src/cli.ts @@ -6,8 +6,10 @@ import { homedir, networkInterfaces } from "node:os"; import path from "node:path"; import type { CodexExecutionMode } from "./services/codex-runner"; +import { SpeechToTextService } from "./services/speech-to-text"; import { resolveDefaultDatabasePath, resolvePackageRoot } from "./utils/runtime-paths"; import { resolveExecutable } from "./utils/command"; +import { resolveRemCodexPublicBaseUrl } from "./utils/remcodex-url"; interface CliFlags { port?: number; @@ -134,6 +136,7 @@ function usage() { print(" remcodex Start the local web app"); print(" remcodex start Start the local web app"); print(" remcodex doctor Check local environment"); + print(" remcodex stt-self-test Validate local speech-to-text wiring"); print(" remcodex version Show version"); print(""); print("Options:"); @@ -160,6 +163,10 @@ async function runDoctor(flags: CliFlags): Promise { const version = readPackageVersion(); const rawCodexCommand = process.env.CODEX_COMMAND ?? "codex"; const codex = commandExists(rawCodexCommand); + const speechToText = new SpeechToTextService({ + preferredBinary: process.env.REMCODEX_STT_BINARY, + modelPath: process.env.REMCODEX_STT_MODEL_PATH, + }); const packageRoot = resolvePackageRoot(); const databasePath = flags.databasePath ?? process.env.DATABASE_PATH ?? resolveDefaultDatabasePath(); const databaseDir = path.dirname(databasePath); @@ -186,6 +193,8 @@ async function runDoctor(flags: CliFlags): Promise { print(`Codex command: ${rawCodexCommand}`); print(`Codex resolved: ${codex.resolved}`); print(`Codex available: ${codex.ok ? "yes" : "no"}`); + print(`STT binary: ${speechToText.getBinaryPath() || "not found"}`); + print(`STT model: ${speechToText.getModelPath() || "not configured"}`); if (!codex.ok) { printError(""); @@ -199,6 +208,23 @@ async function runDoctor(flags: CliFlags): Promise { return 0; } +async function runSpeechToTextSelfTest(): Promise { + const speechToText = new SpeechToTextService({ + preferredBinary: process.env.REMCODEX_STT_BINARY, + modelPath: process.env.REMCODEX_STT_MODEL_PATH, + }); + const result = speechToText.selfTest(); + + print("RemCodex speech-to-text self-test"); + print(""); + print(`STT binary: ${result.binaryPath || "not found"}`); + print(`STT model: ${result.modelPath || "not configured"}`); + print(`Result: ${result.ok ? "ok" : "failed"}`); + print(result.details); + + return result.ok ? 0 : 1; +} + async function runStart(flags: CliFlags): Promise { let startRemCodexServer: typeof import("./app").startRemCodexServer; try { @@ -266,6 +292,10 @@ async function runStart(flags: CliFlags): Promise { print(`Database: ${started.databasePath}`); print(""); print(`Local: ${localUrl}`); + const publicBaseUrl = resolveRemCodexPublicBaseUrl(); + if (publicBaseUrl) { + print(`Public: ${publicBaseUrl}`); + } lanUrls.forEach((url) => { print(`LAN: ${url}`); }); @@ -317,6 +347,9 @@ async function main() { case "doctor": process.exitCode = await runDoctor(flags); return; + case "stt-self-test": + process.exitCode = await runSpeechToTextSelfTest(); + return; case "version": print(readPackageVersion()); return; diff --git a/server/src/controllers/message.controller.ts b/server/src/controllers/message.controller.ts index a8a1e4c..9d04630 100644 --- a/server/src/controllers/message.controller.ts +++ b/server/src/controllers/message.controller.ts @@ -1,11 +1,55 @@ -import { Router } from "express"; +import express, { Router } from "express"; import { SessionManager } from "../services/session-manager"; +import { SpeechToTextService } from "../services/speech-to-text"; import { normalizeCodexExecLaunchInput } from "../utils/codex-launch"; +import { AppError } from "../utils/errors"; -export function createMessageRouter(sessionManager: SessionManager): Router { +export function createMessageRouter( + sessionManager: SessionManager, + speechToText: SpeechToTextService, +): Router { const router = Router({ mergeParams: true }); + router.post( + "/voice", + express.raw({ type: ["audio/*", "application/octet-stream"], limit: "25mb" }), + (request, response, next) => { + try { + if (!speechToText.isAvailable()) { + throw new AppError( + 503, + "Voice transcription is unavailable. Install a whisper CLI or configure REMCODEX_STT_BINARY.", + ); + } + + if (!Buffer.isBuffer(request.body) || request.body.length === 0) { + throw new AppError(400, "Voice note audio is required."); + } + + const params = request.params as { sessionId: string }; + const launch = normalizeCodexExecLaunchInput({ + model: request.header("x-codex-model") ?? undefined, + reasoningEffort: request.header("x-codex-reasoning") ?? undefined, + profile: request.header("x-codex-profile") ?? undefined, + }); + const transcript = speechToText.transcribe( + request.body, + request.header("content-type"), + request.header("x-filename"), + ); + const result = sessionManager.sendMessage(params.sessionId, transcript, launch); + + response.json({ + ...result, + transcript, + }); + } catch (error) { + next(error); + } + }, + ); + router.post("/", (request, response, next) => { try { const body = request.body as { content?: string; codex?: unknown }; diff --git a/server/src/controllers/profile.controller.ts b/server/src/controllers/profile.controller.ts new file mode 100644 index 0000000..203d52c --- /dev/null +++ b/server/src/controllers/profile.controller.ts @@ -0,0 +1,40 @@ +import { Router } from "express"; + +import { AgentProfileManager } from "../services/agent-profile-manager"; + +export function createProfileRouter(profileManager: AgentProfileManager): Router { + const router = Router(); + + router.get("/", (_request, response) => { + response.json({ items: profileManager.listProfiles() }); + }); + + router.get("/:profileId", (request, response) => { + const profile = profileManager.getProfile(request.params.profileId); + if (!profile) { + response.status(404).json({ error: "Profile not found." }); + return; + } + response.json(profile); + }); + + router.post("/", (request, response, next) => { + try { + const body = request.body as { + name?: string; + startingPrompt?: string; + defaultDirectory?: string; + }; + const profile = profileManager.createProfile({ + name: body.name ?? "", + startingPrompt: body.startingPrompt ?? "", + defaultDirectory: body.defaultDirectory ?? "", + }); + response.status(201).json(profile); + } catch (error) { + next(error); + } + }); + + return router; +} diff --git a/server/src/controllers/session.controller.ts b/server/src/controllers/session.controller.ts index 57c2e12..514c7b2 100644 --- a/server/src/controllers/session.controller.ts +++ b/server/src/controllers/session.controller.ts @@ -5,29 +5,43 @@ import { CodexRolloutSyncService } from "../services/codex-rollout-sync"; import { ProjectManager } from "../services/project-manager"; import { SessionManager } from "../services/session-manager"; import { SessionTimelineService } from "../services/session-timeline-service"; +import { CodexAppServerRegistryService } from "../services/codex-app-server-registry"; import { normalizeCodexExecLaunchInput } from "../utils/codex-launch"; +import { buildRemCodexSessionUrl } from "../utils/remcodex-url"; export function createSessionRouter( sessionManager: SessionManager, eventStore: EventStore, projectManager: ProjectManager, codexRolloutSync: CodexRolloutSyncService, + appServerRegistry: CodexAppServerRegistryService, sessionTimeline: SessionTimelineService, ): Router { const router = Router(); - router.get("/", (_request, response) => { + router.get("/", async (_request, response) => { + const appServers = await appServerRegistry.sync(); + const liveAppServerIds = new Set(appServers.map((appServer) => appServer.id)); const items = sessionManager.listSessions().map((session) => ({ sessionId: session.id, title: session.title, projectId: session.project_id, + parentSessionId: session.parent_session_id, status: session.status, liveBusy: sessionManager.isLiveBusy(session.id), codexThreadId: session.codex_thread_id, + description: session.description, + tags: session.tags, + metadata: session.metadata, + startingPrompt: session.starting_prompt, sourceKind: session.source_kind, sourceRolloutPath: session.source_rollout_path, sourceThreadId: session.source_thread_id, sourceRolloutHasOpenTurn: session.source_rollout_has_open_turn === 1, + appServerId: session.app_server_id, + appServerEndpoint: session.app_server_endpoint, + appServerPid: session.app_server_pid, + appServerConnected: Boolean(session.app_server_id && liveAppServerIds.has(session.app_server_id)), pendingApproval: sessionManager.getPendingApproval(session.id), lastEventAt: session.last_event_at, lastAssistantContent: session.last_assistant_content, @@ -40,6 +54,10 @@ export function createSessionRouter( response.json({ items }); }); + router.get("/app-servers", (_request, response) => { + response.json({ items: appServerRegistry.list() }); + }); + router.post("/import-codex", (request, response, next) => { try { const body = request.body as { rolloutPath?: string }; @@ -52,15 +70,33 @@ export function createSessionRouter( router.post("/", (request, response, next) => { try { - const body = request.body as { title?: string; projectId?: string }; + const body = request.body as { + title?: string; + projectId?: string; + startingPrompt?: string; + description?: string | null; + tags?: string[]; + metadata?: Record; + parentSessionId?: string; + }; const session = sessionManager.createSession({ title: body.title, projectId: body.projectId ?? "", + startingPrompt: body.startingPrompt, + description: body.description, + tags: body.tags, + metadata: body.metadata, + parentSessionId: body.parentSessionId, }); response.status(201).json({ sessionId: session.id, status: session.status, + parentSessionId: session.parent_session_id, + startingPrompt: session.starting_prompt, + description: session.description, + tags: session.tags, + metadata: session.metadata, }); } catch (error) { next(error); @@ -81,19 +117,57 @@ export function createSessionRouter( sessionId: session.id, title: session.title, projectId: session.project_id, + parentSessionId: session.parent_session_id, projectName: project?.name ?? null, projectPath: project?.path ?? null, status: session.status, liveBusy: sessionManager.isLiveBusy(session.id), pid: session.pid, codexThreadId: session.codex_thread_id, + description: session.description, + tags: session.tags, + metadata: session.metadata, + startingPrompt: session.starting_prompt, sourceKind: session.source_kind, sourceRolloutPath: session.source_rollout_path, sourceThreadId: session.source_thread_id, sourceRolloutHasOpenTurn: session.source_rollout_has_open_turn === 1, + appServerId: session.app_server_id, + appServerEndpoint: session.app_server_endpoint, + appServerPid: session.app_server_pid, + appServerConnected: Boolean( + session.app_server_id && + appServerRegistry.list().some((appServer) => appServer.id === session.app_server_id), + ), pendingApproval: sessionManager.getPendingApproval(session.id), createdAt: session.created_at, updatedAt: session.updated_at, + sessionUrl: buildRemCodexSessionUrl(session.id), + }); + } catch (error) { + next(error); + } + }); + + router.patch("/:sessionId", (request, response, next) => { + try { + const body = request.body as { + description?: string | null; + tags?: string[]; + metadata?: Record; + }; + const session = sessionManager.updateSessionMetadata({ + sessionId: request.params.sessionId, + description: body.description, + tags: body.tags, + metadata: body.metadata, + }); + response.json({ + sessionId: session.id, + description: session.description, + tags: session.tags, + metadata: session.metadata, + updatedAt: session.updated_at, }); } catch (error) { next(error); diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index aa03504..4286079 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -60,11 +60,19 @@ export function runMigrations(db: DatabaseClient): void { db.exec(schema); ensureColumn(db, "sessions", "source_kind", "TEXT NOT NULL DEFAULT 'native'"); + ensureColumn(db, "sessions", "parent_session_id", "TEXT"); + ensureColumn(db, "sessions", "starting_prompt", "TEXT NOT NULL DEFAULT ''"); + ensureColumn(db, "sessions", "description", "TEXT"); + ensureColumn(db, "sessions", "tags_json", "TEXT NOT NULL DEFAULT '[]'"); + ensureColumn(db, "sessions", "metadata_json", "TEXT NOT NULL DEFAULT '{}'"); ensureColumn(db, "sessions", "source_rollout_path", "TEXT"); ensureColumn(db, "sessions", "source_thread_id", "TEXT"); ensureColumn(db, "sessions", "source_sync_cursor", "INTEGER"); ensureColumn(db, "sessions", "source_last_synced_at", "TEXT"); ensureColumn(db, "sessions", "source_rollout_has_open_turn", "INTEGER NOT NULL DEFAULT 0"); + ensureColumn(db, "sessions", "app_server_id", "TEXT"); + ensureColumn(db, "sessions", "app_server_endpoint", "TEXT"); + ensureColumn(db, "sessions", "app_server_pid", "INTEGER"); // Older imports mapped external open turns to status=running; that is not local runner state. db.exec( diff --git a/server/src/db/schema.sql b/server/src/db/schema.sql index 31f245a..794a420 100644 --- a/server/src/db/schema.sql +++ b/server/src/db/schema.sql @@ -5,6 +5,15 @@ CREATE TABLE IF NOT EXISTS projects ( created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) ); +CREATE TABLE IF NOT EXISTS agent_profiles ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + starting_prompt TEXT NOT NULL, + default_directory TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, title TEXT, @@ -12,15 +21,24 @@ CREATE TABLE IF NOT EXISTS sessions ( status TEXT NOT NULL, pid INTEGER, codex_thread_id TEXT, + parent_session_id TEXT, + starting_prompt TEXT NOT NULL DEFAULT '', + description TEXT, + tags_json TEXT NOT NULL DEFAULT '[]', + metadata_json TEXT NOT NULL DEFAULT '{}', source_kind TEXT NOT NULL DEFAULT 'native', source_rollout_path TEXT, source_thread_id TEXT, source_sync_cursor INTEGER, source_last_synced_at TEXT, source_rollout_has_open_turn INTEGER NOT NULL DEFAULT 0, + app_server_id TEXT, + app_server_endpoint TEXT, + app_server_pid INTEGER, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), - FOREIGN KEY (project_id) REFERENCES projects(id) + FOREIGN KEY (project_id) REFERENCES projects(id), + FOREIGN KEY (parent_session_id) REFERENCES sessions(id) ); CREATE TABLE IF NOT EXISTS session_events ( diff --git a/server/src/mcp.ts b/server/src/mcp.ts new file mode 100644 index 0000000..1f1d1fb --- /dev/null +++ b/server/src/mcp.ts @@ -0,0 +1,651 @@ +import { randomUUID } from "node:crypto"; +import path from "node:path"; + +import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node"; +import { McpServer, type CallToolResult } from "@modelcontextprotocol/server"; +import * as z from "zod/v4"; + +import type { EventStore } from "./services/event-store"; +import { ProjectManager } from "./services/project-manager"; +import { AgentProfileManager } from "./services/agent-profile-manager"; +import { SessionManager } from "./services/session-manager"; +import { SessionTimelineService } from "./services/session-timeline-service"; +import { CodexRolloutSyncService } from "./services/codex-rollout-sync"; +import { normalizeCodexExecLaunchInput } from "./utils/codex-launch"; +import { buildRemCodexSessionUrl } from "./utils/remcodex-url"; + +interface McpSessionEntry { + transport: NodeStreamableHTTPServerTransport; +} + +export interface RemCodexMcpDependencies { + eventStore: EventStore; + projectManager: ProjectManager; + sessionManager: SessionManager; + sessionTimeline: SessionTimelineService; + codexRolloutSync: CodexRolloutSyncService; + profileManager: AgentProfileManager; +} + +export interface RemCodexMcpRequestOptions { + apiToken: string; +} + +interface JsonRpcErrorBody { + jsonrpc: "2.0"; + error: { + code: number; + message: string; + }; + id: null; +} + +const sessions = new Map(); + +function mcpJsonResult(data: unknown): CallToolResult { + return { + content: [ + { + type: "text" as const, + text: JSON.stringify(data, null, 2), + }, + ], + }; +} + +function mcpErrorResult(message: string, details?: unknown): CallToolResult { + return { + isError: true, + content: [ + { + type: "text" as const, + text: details == null ? message : `${message}\n\n${JSON.stringify(details, null, 2)}`, + }, + ], + }; +} + +function serializeSession( + session: ReturnType, + projectManager: ProjectManager, + pendingApproval: unknown, +) { + if (!session) { + return null; + } + + const project = projectManager.getProject(session.project_id); + return { + sessionId: session.id, + title: session.title, + projectId: session.project_id, + parentSessionId: session.parent_session_id, + projectName: project?.name ?? null, + projectPath: project?.path ?? null, + status: session.status, + liveBusy: sessionManagerIsBusy(session.status, session.source_kind), + pid: session.pid, + codexThreadId: session.codex_thread_id, + description: session.description, + tags: session.tags, + metadata: session.metadata, + sourceKind: session.source_kind, + sourceRolloutPath: session.source_rollout_path, + sourceThreadId: session.source_thread_id, + sourceRolloutHasOpenTurn: session.source_rollout_has_open_turn === 1, + pendingApproval, + createdAt: session.created_at, + updatedAt: session.updated_at, + sessionUrl: buildRemCodexSessionUrl(session.id), + }; +} + +function serializeSessionListItem( + session: ReturnType[number], + projectManager: ProjectManager, + pendingApproval: unknown, +) { + const project = projectManager.getProject(session.project_id); + return { + sessionId: session.id, + title: session.title, + projectId: session.project_id, + parentSessionId: session.parent_session_id, + projectName: project?.name ?? null, + projectPath: project?.path ?? null, + status: session.status, + liveBusy: sessionManagerIsBusy(session.status, session.source_kind), + pid: session.pid, + codexThreadId: session.codex_thread_id, + description: session.description, + tags: session.tags, + metadata: session.metadata, + sourceKind: session.source_kind, + sourceRolloutPath: session.source_rollout_path, + sourceThreadId: session.source_thread_id, + sourceRolloutHasOpenTurn: session.source_rollout_has_open_turn === 1, + pendingApproval, + lastEventAt: session.last_event_at, + lastAssistantContent: session.last_assistant_content, + lastCommand: session.last_command, + eventCount: session.event_count, + createdAt: session.created_at, + updatedAt: session.updated_at, + sessionUrl: buildRemCodexSessionUrl(session.id), + }; +} + +function sessionManagerIsBusy(status: string, sourceKind: string | null | undefined): boolean { + if (sourceKind === "imported_rollout") { + return status !== "idle" && status !== "completed"; + } + + return ["starting", "running", "stopping"].includes(status); +} + +function jsonRpcError(code: number, message: string): JsonRpcErrorBody { + return { + jsonrpc: "2.0", + error: { code, message }, + id: null, + }; +} + +function normalizeApiToken(apiToken: string | null | undefined): string | null { + const trimmed = apiToken?.trim() ?? ""; + return trimmed.length > 0 ? trimmed : null; +} + +function parseBearerToken(value: string | undefined): string | null { + const match = value?.match(/^Bearer\s+(.+)$/i); + if (!match) { + return null; + } + + const token = match[1].trim(); + return token.length > 0 ? token : null; +} + +function authorizeMcpRequest( + request: import("express").Request, + response: import("express").Response, + apiToken: string, +): boolean { + const normalizedToken = normalizeApiToken(apiToken); + const providedToken = parseBearerToken(request.get("authorization")); + if (providedToken === normalizedToken) { + return true; + } + + response + .status(401) + .setHeader("WWW-Authenticate", 'Bearer realm="remcodex", charset="UTF-8"') + .json(jsonRpcError(-32000, "Unauthorized")); + return false; +} + +function isBootstrapRequest(body: unknown): boolean { + if (!body || typeof body !== "object" || Array.isArray(body)) { + return false; + } + + const method = String((body as { method?: unknown }).method || ""); + return method === "initialize" || method === "server/discover"; +} + +function registerToolserver( + server: McpServer, + deps: RemCodexMcpDependencies, +) { + server.registerTool( + "list-profiles", + { + description: "List RemCodex agent profiles.", + inputSchema: z.object({}), + }, + async () => mcpJsonResult(deps.profileManager.listProfiles()), + ); + + server.registerTool( + "get-profile", + { + description: "Get a RemCodex agent profile by id or name.", + inputSchema: z.object({ profileId: z.string().min(1) }), + }, + async ({ profileId }) => { + const profile = deps.profileManager.getProfile(profileId); + return profile ? mcpJsonResult(profile) : mcpErrorResult("Profile not found."); + }, + ); + + server.registerTool( + "list-projects", + { + description: "List all RemCodex projects.", + inputSchema: z.object({}), + }, + async () => + mcpJsonResult( + deps.projectManager.listProjects().map((project) => ({ + projectId: project.id, + name: project.name, + path: project.path, + createdAt: project.created_at, + })), + ), + ); + + server.registerTool( + "list-sessions", + { + description: "List RemCodex sessions.", + inputSchema: z.object({}), + }, + async () => + mcpJsonResult( + deps.sessionManager.listSessions().map((session) => + serializeSessionListItem( + session, + deps.projectManager, + deps.sessionManager.getPendingApproval(session.id), + ), + ), + ), + ); + + server.registerTool( + "list-sessions-by-directory", + { + description: "List all sessions previously started in a working directory.", + inputSchema: z.object({ + workingDirectory: z.string().min(1), + }), + }, + async ({ workingDirectory }) => + mcpJsonResult( + deps.sessionManager.listSessionsByProjectPath(workingDirectory).map((session) => + serializeSessionListItem( + session, + deps.projectManager, + deps.sessionManager.getPendingApproval(session.id), + ), + ), + ), + ); + + server.registerTool( + "get-session", + { + description: "Get a single session by id.", + inputSchema: z.object({ + sessionId: z.string().min(1), + }), + }, + async ({ sessionId }) => { + const session = deps.sessionManager.getSession(sessionId); + if (!session) { + return mcpErrorResult("Session not found."); + } + + return mcpJsonResult( + serializeSession(session, deps.projectManager, deps.sessionManager.getPendingApproval(sessionId)), + ); + }, + ); + + server.registerTool( + "resume-session", + { + description: "Resume an existing session for continued work.", + inputSchema: z.object({ + sessionId: z.string().min(1), + parentSessionId: z.string().min(1).optional(), + }), + }, + async ({ sessionId, parentSessionId }) => { + try { + const session = deps.sessionManager.resumeSession(sessionId, parentSessionId); + return mcpJsonResult({ + ...serializeSession( + session, + deps.projectManager, + deps.sessionManager.getPendingApproval(session.id), + ), + resumed: true, + }); + } catch (error) { + return mcpErrorResult("Failed to resume session.", error instanceof Error ? error.message : error); + } + }, + ); + + server.registerTool( + "create-session", + { + description: "Find or create a session for a project or working directory.", + inputSchema: z + .object({ + projectId: z.string().min(1).optional(), + workingDirectory: z.string().min(1).optional(), + title: z.string().optional(), + description: z.string().nullable().optional(), + tags: z.array(z.string()).optional(), + metadata: z.record(z.string(), z.unknown()).optional(), + parentSessionId: z.string().min(1).optional(), + }) + .refine(({ projectId, workingDirectory }) => projectId || workingDirectory, { + message: "Either projectId or workingDirectory is required.", + }), + }, + async ({ projectId, workingDirectory, title, description, tags, metadata, parentSessionId }) => { + try { + const project = projectId + ? deps.projectManager.getProject(projectId) + : deps.projectManager.getOrCreateProjectByPath( + workingDirectory ?? "", + path.basename(path.resolve(workingDirectory ?? "")), + ); + if (!project) { + return mcpErrorResult("Project not found."); + } + if ( + workingDirectory && + path.resolve(project.path) !== path.resolve(workingDirectory) + ) { + return mcpErrorResult("Project and working directory do not refer to the same path."); + } + + const reusableSession = parentSessionId + ? null + : deps.sessionManager.findSessionByProjectPath(project.path); + const session = + parentSessionId + ? deps.sessionManager.createSession({ + projectId: project.id, + title, + description, + tags, + metadata, + parentSessionId, + }) + : reusableSession ?? + deps.sessionManager.createSession({ projectId: project.id, title, description, tags, metadata }); + return mcpJsonResult( + { + ...serializeSession( + session, + deps.projectManager, + deps.sessionManager.getPendingApproval(session.id), + ), + reused: reusableSession?.id === session.id, + }, + ); + } catch (error) { + return mcpErrorResult("Failed to create session.", error instanceof Error ? error.message : error); + } + }, + ); + + server.registerTool( + "update-session-metadata", + { + description: "Update the description, tags, or metadata of an existing session.", + inputSchema: z.object({ + sessionId: z.string().min(1), + description: z.string().nullable().optional(), + tags: z.array(z.string()).optional(), + metadata: z.record(z.string(), z.unknown()).optional(), + }), + }, + async ({ sessionId, description, tags, metadata }) => { + try { + const session = deps.sessionManager.updateSessionMetadata({ + sessionId, + description, + tags, + metadata, + }); + return mcpJsonResult( + serializeSession( + session, + deps.projectManager, + deps.sessionManager.getPendingApproval(session.id), + ), + ); + } catch (error) { + return mcpErrorResult( + "Failed to update session metadata.", + error instanceof Error ? error.message : error, + ); + } + }, + ); + + server.registerTool( + "get-session-history", + { + description: "Get a timeline page for a session.", + inputSchema: z.object({ + sessionId: z.string().min(1), + after: z.number().int().nonnegative().optional(), + before: z.number().int().nonnegative().optional(), + limit: z.number().int().positive().max(1000).optional(), + }), + }, + async ({ sessionId, after, before, limit }) => { + try { + const page = deps.sessionTimeline.list(sessionId, { after, before, limit }); + return mcpJsonResult(page); + } catch (error) { + return mcpErrorResult("Failed to load session history.", error instanceof Error ? error.message : error); + } + }, + ); + + server.registerTool( + "get-session-events", + { + description: "Get raw events for a session.", + inputSchema: z.object({ + sessionId: z.string().min(1), + after: z.number().int().nonnegative().optional(), + before: z.number().int().nonnegative().optional(), + limit: z.number().int().positive().max(1000).optional(), + }), + }, + async ({ sessionId, after, before, limit }) => { + try { + const page = deps.eventStore.list(sessionId, { after, before, limit }); + return mcpJsonResult(page); + } catch (error) { + return mcpErrorResult("Failed to load session events.", error instanceof Error ? error.message : error); + } + }, + ); + + server.registerTool( + "send-message", + { + description: "Send a chat message to a session.", + inputSchema: z.object({ + sessionId: z.string().min(1), + content: z.string().min(1), + codex: z + .object({ + modelId: z.string().optional(), + reasoningId: z.string().optional(), + profile: z.string().optional(), + }) + .optional(), + }), + }, + async ({ sessionId, content, codex }) => { + try { + const result = deps.sessionManager.sendMessage( + sessionId, + content, + normalizeCodexExecLaunchInput(codex), + ); + return mcpJsonResult(result); + } catch (error) { + return mcpErrorResult("Failed to send message.", error instanceof Error ? error.message : error); + } + }, + ); + + server.registerTool( + "stop-session", + { + description: "Stop the active session runner.", + inputSchema: z.object({ + sessionId: z.string().min(1), + }), + }, + async ({ sessionId }) => { + try { + const result = deps.sessionManager.stopSession(sessionId); + return mcpJsonResult(result); + } catch (error) { + return mcpErrorResult("Failed to stop session.", error instanceof Error ? error.message : error); + } + }, + ); + + server.registerTool( + "sync-imported-session", + { + description: "Sync an imported rollout session into the database.", + inputSchema: z.object({ + sessionId: z.string().min(1), + }), + }, + async ({ sessionId }) => { + try { + const result = deps.codexRolloutSync.syncImportedSession(sessionId); + return mcpJsonResult(result); + } catch (error) { + return mcpErrorResult("Failed to sync imported session.", error instanceof Error ? error.message : error); + } + }, + ); + + server.registerTool( + "resolve-approval", + { + description: "Resolve a pending approval request for a session.", + inputSchema: z.object({ + sessionId: z.string().min(1), + requestId: z.string().min(1), + decision: z.enum(["accept", "acceptForSession", "decline"]), + }), + }, + async ({ sessionId, requestId, decision }) => { + try { + const result = deps.sessionManager.resolveApproval(sessionId, requestId, decision); + return mcpJsonResult(result); + } catch (error) { + return mcpErrorResult("Failed to resolve approval.", error instanceof Error ? error.message : error); + } + }, + ); + + server.registerTool( + "retry-approval", + { + description: "Retry a pending approval request for a session.", + inputSchema: z.object({ + sessionId: z.string().min(1), + requestId: z.string().min(1), + codex: z + .object({ + modelId: z.string().optional(), + reasoningId: z.string().optional(), + profile: z.string().optional(), + }) + .optional(), + }), + }, + async ({ sessionId, requestId, codex }) => { + try { + const result = deps.sessionManager.retryApprovalRequest( + sessionId, + requestId, + normalizeCodexExecLaunchInput(codex), + ); + return mcpJsonResult(result); + } catch (error) { + return mcpErrorResult("Failed to retry approval.", error instanceof Error ? error.message : error); + } + }, + ); +} + +function buildMcpServer(deps: RemCodexMcpDependencies) { + const server = new McpServer({ + name: "remcodex", + version: "0.1.0-beta.12", + }); + + registerToolserver(server, deps); + return server; +} + +export async function handleRemCodexMcpRequest( + request: import("express").Request, + response: import("express").Response, + deps: RemCodexMcpDependencies, + options: RemCodexMcpRequestOptions, +): Promise { + if (!authorizeMcpRequest(request, response, options.apiToken)) { + return; + } + + const sessionId = String(request.headers["mcp-session-id"] || "").trim(); + + if (request.method === "DELETE") { + if (!sessionId || !sessions.has(sessionId)) { + response.status(sessionId ? 404 : 400).json( + sessionId ? jsonRpcError(-32001, "Session not found") : jsonRpcError(-32000, "Bad Request: Session ID required"), + ); + return; + } + + const entry = sessions.get(sessionId); + sessions.delete(sessionId); + await entry?.transport.close().catch(() => null); + response.status(204).end(); + return; + } + + if (sessionId && sessions.has(sessionId)) { + await sessions.get(sessionId)?.transport.handleRequest(request, response, request.body); + return; + } + + if (!sessionId && isBootstrapRequest(request.body)) { + const transport = new NodeStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (id: string) => { + sessions.set(id, { transport }); + }, + }); + + const server = buildMcpServer(deps); + transport.onclose = () => { + if (transport.sessionId) { + sessions.delete(transport.sessionId); + } + }; + + await server.connect(transport); + await transport.handleRequest(request, response, request.body); + return; + } + + if (sessionId) { + response.status(404).json(jsonRpcError(-32001, "Session not found")); + return; + } + + response.status(400).json(jsonRpcError(-32000, "Bad Request: Session ID required")); +} diff --git a/server/src/services/agent-profile-manager.ts b/server/src/services/agent-profile-manager.ts new file mode 100644 index 0000000..ba9998c --- /dev/null +++ b/server/src/services/agent-profile-manager.ts @@ -0,0 +1,161 @@ +import { homedir } from "node:os"; + +import type { DatabaseClient } from "../db/client"; +import type { AgentProfileRecord } from "../types/models"; +import { AppError } from "../utils/errors"; +import { createId } from "../utils/ids"; + +export interface AgentProfileSeed { + name: string; + startingPrompt: string; + defaultDirectory: string; +} + +export const DEFAULT_ORCHESTRATOR_PROFILE = { + name: "orchestrator", + starting_prompt: + "Manage delegated Codex work by reusing an existing session in the requested directory or starting a fresh session there when none exists.", + default_directory: `${homedir()}/programming`, +}; + +export interface AgentProfileManagerOptions { + initialProfiles?: AgentProfileSeed[]; +} + +export class AgentProfileManager { + constructor(private readonly db: DatabaseClient, options: AgentProfileManagerOptions = {}) { + this.seedProfiles(options.initialProfiles ?? []); + this.ensureDefaultProfile(); + } + + listProfiles(): AgentProfileRecord[] { + return this.db + .prepare( + ` + SELECT id, name, starting_prompt, default_directory, created_at, updated_at + FROM agent_profiles + ORDER BY name COLLATE NOCASE + `, + ) + .all() as AgentProfileRecord[]; + } + + getProfile(idOrName: string): AgentProfileRecord | null { + return ( + (this.db + .prepare( + ` + SELECT id, name, starting_prompt, default_directory, created_at, updated_at + FROM agent_profiles + WHERE id = ? OR name = ? + `, + ) + .get(idOrName, idOrName) as AgentProfileRecord | undefined) ?? null + ); + } + + createProfile(input: { + name: string; + startingPrompt: string; + defaultDirectory: string; + }): AgentProfileRecord { + return this.persistProfile(input, "create"); + } + + private seedProfiles(profiles: AgentProfileSeed[]): void { + profiles.forEach((profile) => { + this.persistProfile(profile, "upsert"); + }); + } + + private persistProfile( + input: { + name: string; + startingPrompt: string; + defaultDirectory: string; + }, + mode: "create" | "upsert", + ): AgentProfileRecord { + const name = input.name.trim(); + const startingPrompt = input.startingPrompt.trim(); + const defaultDirectory = input.defaultDirectory.trim(); + + if (!name || !startingPrompt || !defaultDirectory) { + throw new AppError(400, "Profile name, starting prompt, and default directory are required."); + } + + const existing = this.getProfile(name); + const timestamp = new Date().toISOString(); + const profile: AgentProfileRecord = existing + ? { + ...existing, + name, + starting_prompt: startingPrompt, + default_directory: defaultDirectory, + updated_at: timestamp, + } + : { + id: createId("profile"), + name, + starting_prompt: startingPrompt, + default_directory: defaultDirectory, + created_at: timestamp, + updated_at: timestamp, + }; + + try { + if (existing) { + if (mode === "create") { + throw new AppError(409, "An agent profile with that name already exists."); + } + + this.db + .prepare( + ` + UPDATE agent_profiles + SET starting_prompt = ?, default_directory = ?, updated_at = ? + WHERE id = ? + `, + ) + .run(profile.starting_prompt, profile.default_directory, profile.updated_at, profile.id); + } else { + this.db + .prepare( + ` + INSERT INTO agent_profiles + (id, name, starting_prompt, default_directory, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + `, + ) + .run( + profile.id, + profile.name, + profile.starting_prompt, + profile.default_directory, + profile.created_at, + profile.updated_at, + ); + } + } catch (error) { + if (String(error).includes("UNIQUE constraint failed: agent_profiles.name")) { + throw new AppError(409, "An agent profile with that name already exists."); + } + throw error; + } + + return profile; + } + + private ensureDefaultProfile(): void { + const existing = this.getProfile(DEFAULT_ORCHESTRATOR_PROFILE.name); + if (existing) { + return; + } + + this.createProfile({ + name: DEFAULT_ORCHESTRATOR_PROFILE.name, + startingPrompt: DEFAULT_ORCHESTRATOR_PROFILE.starting_prompt, + defaultDirectory: DEFAULT_ORCHESTRATOR_PROFILE.default_directory, + }); + } +} diff --git a/server/src/services/codex-app-server-registry.ts b/server/src/services/codex-app-server-registry.ts new file mode 100644 index 0000000..4924835 --- /dev/null +++ b/server/src/services/codex-app-server-registry.ts @@ -0,0 +1,185 @@ +import net from "node:net"; +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import WebSocket from "ws"; + +import type { CodexRolloutSyncService } from "./codex-rollout-sync"; + +export interface CodexAppServerInstance { + id: string; + endpoint: string; + wsEndpoint: string; + socketPath: string; + pid: number | null; + cwd: string | null; + startedAt: string | null; + source: string | null; +} + +interface JsonRpcMessage { + jsonrpc: "2.0"; + id: number; + method: string; + params?: unknown; +} + +interface RegistryThread { + id?: unknown; + path?: unknown; +} + +function defaultRegistryDirectory(): string { + return process.env.CODEX_APP_SERVER_REGISTRY_DIR?.trim() || + path.join(process.env.XDG_RUNTIME_DIR?.trim() || "/tmp", "codex-app-servers"); +} + +function connectWebSocket(endpoint: string): WebSocket { + if (endpoint.startsWith("ws+unix://")) { + const value = endpoint.slice("ws+unix://".length); + const separator = value.lastIndexOf(":/"); + const socketPath = separator >= 0 ? value.slice(0, separator) : value; + return new WebSocket("ws://localhost/", { + perMessageDeflate: false, + createConnection: () => net.createConnection({ path: socketPath }), + }); + } + + return new WebSocket(endpoint, { perMessageDeflate: false }); +} + +function parseInstance(raw: unknown): CodexAppServerInstance | null { + if (!raw || typeof raw !== "object") { + return null; + } + const value = raw as Record; + const id = typeof value.id === "string" ? value.id.trim() : ""; + const endpoint = typeof value.endpoint === "string" ? value.endpoint.trim() : ""; + const socketPath = typeof value.socketPath === "string" ? value.socketPath.trim() : ""; + if (!id || !endpoint || !socketPath || !existsSync(socketPath)) { + return null; + } + + const wsEndpoint = typeof value.wsEndpoint === "string" && value.wsEndpoint.trim() + ? value.wsEndpoint.trim() + : `ws+unix://${socketPath}:/`; + return { + id, + endpoint, + wsEndpoint, + socketPath, + pid: typeof value.pid === "number" ? value.pid : null, + cwd: typeof value.cwd === "string" ? value.cwd : null, + startedAt: typeof value.startedAt === "string" ? value.startedAt : null, + source: typeof value.source === "string" ? value.source : null, + }; +} + +export class CodexAppServerRegistryService { + private readonly registryDirectory: string; + + constructor(private readonly rolloutSync: CodexRolloutSyncService) { + this.registryDirectory = defaultRegistryDirectory(); + } + + list(): CodexAppServerInstance[] { + if (!existsSync(this.registryDirectory)) { + return []; + } + + return readdirSync(this.registryDirectory) + .filter((name) => name.endsWith(".json")) + .flatMap((name) => { + try { + const parsed = parseInstance(JSON.parse(readFileSync(path.join(this.registryDirectory, name), "utf8"))); + return parsed ? [parsed] : []; + } catch { + return []; + } + }); + } + + async sync(): Promise { + const instances = this.list(); + await Promise.all(instances.map((instance) => this.syncInstance(instance))); + return instances; + } + + private async syncInstance(instance: CodexAppServerInstance): Promise { + try { + const threads = await this.listThreads(instance); + for (const thread of threads) { + const rolloutPath = typeof thread.path === "string" ? thread.path.trim() : ""; + if (!rolloutPath || !existsSync(rolloutPath)) { + continue; + } + + const imported = this.rolloutSync.importRollout(rolloutPath); + this.rolloutSync.attachAppServer(imported.sessionId, { + id: instance.id, + endpoint: instance.endpoint, + pid: instance.pid, + }); + } + } catch { + // A server can disappear while RemCodex is discovering it. The next + // refresh will retry without making the session list endpoint fail. + } + } + + private listThreads(instance: CodexAppServerInstance): Promise { + return new Promise((resolve, reject) => { + const socket = connectWebSocket(instance.wsEndpoint); + let requestId = 1; + const pending = new Map void>(); + const fail = (error: Error) => { + socket.close(); + reject(error); + }; + const request = (method: string, params?: unknown): Promise => { + const id = requestId++; + return new Promise((requestResolve) => { + pending.set(id, requestResolve); + socket.send(JSON.stringify({ jsonrpc: "2.0", id, method, params } satisfies JsonRpcMessage)); + }); + }; + + socket.once("error", (error) => fail(error instanceof Error ? error : new Error(String(error)))); + socket.once("open", async () => { + try { + await request("initialize", { + clientInfo: { name: "remcodex-discovery", version: "0.1.0" }, + capabilities: { experimentalApi: true }, + }); + socket.send(JSON.stringify({ jsonrpc: "2.0", method: "initialized" })); + const result = await request("thread/list", { cwd: instance.cwd, limit: 100 }); + socket.close(); + const value = result && typeof result === "object" ? result as Record : {}; + const threads = Array.isArray(value.data) ? value.data : Array.isArray(value.threads) ? value.threads : []; + resolve(threads.filter((thread): thread is RegistryThread => Boolean(thread && typeof thread === "object"))); + } catch (error) { + fail(error instanceof Error ? error : new Error(String(error))); + } + }); + socket.on("message", (data) => { + try { + const message = JSON.parse(data.toString()) as Record; + if (typeof message.id !== "number") { + return; + } + const resolver = pending.get(message.id); + if (!resolver) { + return; + } + pending.delete(message.id); + if (message.error) { + fail(new Error(`Codex app-server request failed: ${JSON.stringify(message.error)}`)); + return; + } + resolver(message.result); + } catch (error) { + fail(error instanceof Error ? error : new Error(String(error))); + } + }); + }); + } +} diff --git a/server/src/services/codex-remote-app-server-runner.ts b/server/src/services/codex-remote-app-server-runner.ts new file mode 100644 index 0000000..e8443d4 --- /dev/null +++ b/server/src/services/codex-remote-app-server-runner.ts @@ -0,0 +1,277 @@ +import net from "node:net"; +import WebSocket from "ws"; + +import type { CodexExecLaunchInput } from "../types/codex-launch"; +import type { CodexRunner } from "./codex-runner"; + +interface PendingRequest { + resolve: (value: unknown) => void; + reject: (error: Error) => void; +} + +interface McpServerStatusList { + data?: Array<{ name?: unknown }>; + nextCursor?: unknown; +} + +export const REMOTE_APPROVAL_POLICY = "never"; +export const REMOTE_SANDBOX_MODE = "danger-full-access"; + +export function applyMcpAutoApprovalOverrides( + config: Record, + serverNames: string[], +): void { + for (const rawName of serverNames) { + const name = rawName.trim(); + if (name) { + config[`mcp_servers.${name}.default_tools_approval_mode`] = "approve"; + } + } +} + +function connectWebSocket(endpoint: string): WebSocket { + if (endpoint.startsWith("ws+unix://") || endpoint.startsWith("unix://")) { + const prefix = endpoint.startsWith("ws+unix://") ? "ws+unix://" : "unix://"; + const value = endpoint.slice(prefix.length); + const separator = value.lastIndexOf(":/"); + const socketPath = separator >= 0 ? value.slice(0, separator) : value; + return new WebSocket("ws://localhost/", { + perMessageDeflate: false, + createConnection: () => net.createConnection({ path: socketPath }), + }); + } + + return new WebSocket(endpoint, { perMessageDeflate: false }); +} + +export class CodexRemoteAppServerRunner implements CodexRunner { + private socket: WebSocket | null = null; + private readonly jsonListeners = new Set<(event: unknown) => void>(); + private readonly textListeners = new Set<(stream: "stdout" | "stderr", text: string) => void>(); + private readonly exitListeners = new Set<(exitCode: number | null) => void>(); + private readonly pendingRequests = new Map(); + private requestId = 1; + private finalized = false; + private currentTurnId: string | null = null; + private currentThreadId: string | null = null; + + constructor( + private readonly endpoint: string, + private readonly cwd: string, + ) {} + + start(prompt: string, threadId?: string | null, launch?: CodexExecLaunchInput): number { + if (!threadId) { + throw new Error("A remote app-server session must have a Codex thread id."); + } + + const socket = connectWebSocket(this.endpoint); + this.socket = socket; + this.currentThreadId = threadId; + socket.on("open", () => { + void this.bootstrap(prompt, threadId, launch).catch((error) => { + this.emitText("stderr", `Failed to initialize remote Codex app-server: ${this.messageOf(error)}`); + this.finish(1); + }); + }); + socket.on("message", (data) => this.handleMessage(data.toString())); + socket.on("error", (error) => this.emitText("stderr", this.messageOf(error))); + socket.on("close", () => { + this.rejectPendingRequests(new Error("Remote Codex app-server closed.")); + this.socket = null; + if (!this.finalized) { + this.finalized = true; + this.exitListeners.forEach((listener) => listener(0)); + } + }); + + return 0; + } + + stop(): void { + if (!this.socket) { + return; + } + if (this.currentTurnId) { + void this.request("turn/interrupt", { + threadId: this.currentThreadId, + turnId: this.currentTurnId, + }).catch(() => undefined); + } + this.socket.close(); + } + + respond(requestId: number, result: unknown): boolean { + if (!this.socket || !Number.isFinite(requestId)) { + return false; + } + this.write({ jsonrpc: "2.0", id: requestId, result }); + return true; + } + + onJsonEvent(listener: (event: unknown) => void): () => void { + this.jsonListeners.add(listener); + return () => this.jsonListeners.delete(listener); + } + + onText(listener: (stream: "stdout" | "stderr", text: string) => void): () => void { + this.textListeners.add(listener); + return () => this.textListeners.delete(listener); + } + + onExit(listener: (exitCode: number | null) => void): () => void { + this.exitListeners.add(listener); + return () => this.exitListeners.delete(listener); + } + + isAlive(): boolean { + return this.socket !== null && this.socket.readyState !== WebSocket.CLOSED; + } + + private async bootstrap(prompt: string, threadId: string, launch?: CodexExecLaunchInput): Promise { + await this.request("initialize", { + clientInfo: { name: "remcodex", version: "0.1.0" }, + capabilities: { experimentalApi: true }, + }); + this.notify("initialized"); + + // Panoply's interactive app-server wrapper is intentionally launched with + // Codex's dangerous bypass flag. Preserve that unattended behavior when + // RemCodex resumes the same thread through a second client connection. + const approvalPolicy = REMOTE_APPROVAL_POLICY; + const sandboxMode = REMOTE_SANDBOX_MODE; + const config = await this.buildConfig(launch); + const result = await this.request("thread/resume", { + threadId, + cwd: this.cwd, + approvalPolicy, + sandbox: sandboxMode, + config, + persistExtendedHistory: true, + }) as { thread?: { id?: string } }; + const resolvedThreadId = result.thread?.id ?? threadId; + await this.request("turn/start", { + threadId: resolvedThreadId, + input: [{ type: "text", text: prompt, text_elements: [] }], + approvalPolicy, + sandboxPolicy: this.buildSandboxPolicy(sandboxMode, launch), + model: launch?.model ?? null, + effort: launch?.reasoningEffort ?? null, + }); + } + + private async buildConfig(launch: CodexExecLaunchInput | undefined): Promise | null> { + const config: Record = {}; + for (const name of launch?.enableFeatures ?? []) config[`features.${name}`] = true; + for (const name of launch?.disableFeatures ?? []) config[`features.${name}`] = false; + if (launch?.profile) config.profile = launch.profile; + + let cursor: string | null = null; + do { + const result = await this.request("mcpServerStatus/list", { + cursor, + limit: 100, + detail: "toolsAndAuthOnly", + }) as McpServerStatusList; + applyMcpAutoApprovalOverrides( + config, + (result.data ?? []).flatMap((server) => typeof server.name === "string" ? [server.name] : []), + ); + cursor = typeof result.nextCursor === "string" && result.nextCursor ? result.nextCursor : null; + } while (cursor); + + return Object.keys(config).length ? config : null; + } + + private buildSandboxPolicy(mode: CodexExecLaunchInput["sandbox"] | undefined, launch?: CodexExecLaunchInput): unknown { + if (mode === "read-only") return { type: "readOnly", access: { type: "fullAccess" }, networkAccess: false }; + if (mode === "danger-full-access") return { type: "dangerFullAccess" }; + return { + type: "workspaceWrite", + writableRoots: [this.cwd, ...(launch?.additionalWritableRoots ?? [])], + readOnlyAccess: { type: "fullAccess" }, + networkAccess: false, + excludeTmpdirEnvVar: false, + excludeSlashTmp: false, + }; + } + + private request(method: string, params?: unknown): Promise { + const id = this.requestId++; + return new Promise((resolve, reject) => { + this.pendingRequests.set(id, { resolve, reject }); + this.write({ jsonrpc: "2.0", id, method, params }); + }); + } + + private notify(method: string, params?: unknown): void { + this.write({ jsonrpc: "2.0", method, params }); + } + + private write(message: object): void { + if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { + throw new Error("Remote Codex app-server is not connected."); + } + this.socket.send(JSON.stringify(message)); + } + + private handleMessage(text: string): void { + let message: Record; + try { + message = JSON.parse(text) as Record; + } catch { + this.emitText("stdout", text); + return; + } + + if (typeof message.id === "number" && !message.method) { + const pending = this.pendingRequests.get(message.id); + if (!pending) return; + this.pendingRequests.delete(message.id); + if (message.error) pending.reject(new Error(JSON.stringify(message.error))); + else pending.resolve(message.result); + return; + } + + if (typeof message.method !== "string") return; + this.jsonListeners.forEach((listener) => listener(message)); + if (typeof message.id === "number") { + this.respondUnsupportedRequest(message.id, message.method); + return; + } + if (message.method === "turn/started") { + const params = message.params as Record | undefined; + const turn = params?.turn as Record | undefined; + this.currentTurnId = typeof turn?.id === "string" ? turn.id : null; + } + if (message.method === "turn/completed") { + const params = message.params as Record | undefined; + const turn = params?.turn as Record | undefined; + this.finish(turn?.status === "failed" ? 1 : 0); + } + } + + private respondUnsupportedRequest(id: number, method: string): void { + this.write({ jsonrpc: "2.0", id, error: { code: -32601, message: `Unsupported server request: ${method}` } }); + } + + private finish(exitCode: number): void { + if (this.finalized) return; + this.finalized = true; + this.exitListeners.forEach((listener) => listener(exitCode)); + this.socket?.close(); + } + + private rejectPendingRequests(error: Error): void { + for (const pending of this.pendingRequests.values()) pending.reject(error); + this.pendingRequests.clear(); + } + + private emitText(stream: "stdout" | "stderr", text: string): void { + if (text.trim()) this.textListeners.forEach((listener) => listener(stream, text.trimEnd())); + } + + private messageOf(error: unknown): string { + return error instanceof Error ? error.message : String(error); + } +} diff --git a/server/src/services/codex-rollout-sync.ts b/server/src/services/codex-rollout-sync.ts index a0143c1..339925c 100644 --- a/server/src/services/codex-rollout-sync.ts +++ b/server/src/services/codex-rollout-sync.ts @@ -1052,6 +1052,21 @@ export class CodexRolloutSyncService { }; } + attachAppServer( + sessionId: string, + appServer: { id: string; endpoint: string; pid: number | null }, + ): void { + this.db + .prepare( + ` + UPDATE sessions + SET app_server_id = ?, app_server_endpoint = ?, app_server_pid = ?, updated_at = ? + WHERE id = ? + `, + ) + .run(appServer.id, appServer.endpoint, appServer.pid, new Date().toISOString(), sessionId); + } + syncImportedSession(sessionId: string): SyncResult { const session = this.db .prepare( diff --git a/server/src/services/codex-runner.ts b/server/src/services/codex-runner.ts index c01afef..1f439e3 100644 --- a/server/src/services/codex-runner.ts +++ b/server/src/services/codex-runner.ts @@ -1,5 +1,6 @@ import type { CodexExecLaunchInput } from "../types/codex-launch"; import { CodexAppServerRunner } from "./codex-app-server-runner"; +import { CodexRemoteAppServerRunner } from "./codex-remote-app-server-runner"; import { CodexExecRunner } from "./codex-exec-runner"; export type CodexExecutionMode = "exec-json" | "app-server"; @@ -18,8 +19,12 @@ export function createCodexRunner( mode: CodexExecutionMode, command: string, cwd: string, + appServerEndpoint?: string | null, ): CodexRunner { if (mode === "app-server") { + if (appServerEndpoint) { + return new CodexRemoteAppServerRunner(appServerEndpoint, cwd); + } return new CodexAppServerRunner(command, cwd); } diff --git a/server/src/services/codex-stream-events.ts b/server/src/services/codex-stream-events.ts index dfde440..1b27e96 100644 --- a/server/src/services/codex-stream-events.ts +++ b/server/src/services/codex-stream-events.ts @@ -335,6 +335,7 @@ export function translateCodexAppServerMessage( readString(item?.type) || readString(params?.type); if (itemType === "agentMessage") { + const text = readMessageText(item) || ""; signals.push({ kind: "event", event: { @@ -346,7 +347,8 @@ export function translateCodexAppServerMessage( normalizePhase(item?.phase) || "final_answer", payload: { - text: readMessageText(item) || "", + textDelta: text, + text, }, }, }); @@ -362,6 +364,7 @@ export function translateCodexAppServerMessage( turnId, messageId: readMessageId(params), payload: { + textDelta: summary, text: summary, summary, }, diff --git a/server/src/services/project-manager.ts b/server/src/services/project-manager.ts index 0991e8f..3f2db94 100644 --- a/server/src/services/project-manager.ts +++ b/server/src/services/project-manager.ts @@ -48,6 +48,42 @@ export class ProjectManager { ); } + getProjectByPath(projectPath: string): ProjectRecord | null { + const trimmedPath = projectPath.trim(); + if (!trimmedPath) { + return null; + } + + const resolvedPath = path.resolve(trimmedPath); + return ( + this.listProjects().find((project) => path.resolve(project.path) === resolvedPath) ?? null + ); + } + + isProjectPathAncestor(parentProjectId: string, childProjectId: string): boolean { + const parent = this.getProject(parentProjectId); + const child = this.getProject(childProjectId); + if (!parent || !child) { + return false; + } + + const relative = path.relative(path.resolve(parent.path), path.resolve(child.path)); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); + } + + getOrCreateProjectByPath(projectPath: string, name?: string): ProjectRecord { + const existing = this.getProjectByPath(projectPath); + if (existing) { + return existing; + } + + const resolvedPath = path.resolve(projectPath.trim()); + return this.createProject({ + name: name?.trim() || path.basename(resolvedPath) || resolvedPath, + path: resolvedPath, + }); + } + createProject(input: { name: string; path: string; createMissing?: boolean }): ProjectRecord { const name = input.name.trim(); const resolvedPath = path.resolve(input.path.trim()); diff --git a/server/src/services/session-manager.ts b/server/src/services/session-manager.ts index 74730f5..0fb2963 100644 --- a/server/src/services/session-manager.ts +++ b/server/src/services/session-manager.ts @@ -21,6 +21,10 @@ import type { import { AppError } from "../utils/errors"; import { createId } from "../utils/ids"; import { appendCappedText } from "../utils/output-limits"; +import { + composeMessageContentWithStartingPrompt, + normalizeSessionStartingPrompt, +} from "../utils/session-starting-prompt"; import type { CodexJsonEvent } from "./codex-exec-runner"; import { createCodexRunner, type CodexExecutionMode, type CodexRunner } from "./codex-runner"; import { @@ -70,6 +74,10 @@ interface PatchRuntimeState { interface RunnerState { runner: CodexRunner; + cwd: string; + command: string; + stderr: string; + stderrTruncated: boolean; stopRequested: boolean; transientSeqCursor: number; turnId: string; @@ -180,6 +188,31 @@ function normalizeDemoPrompt(projectPath: string, message: string): string { return `${message.trim()}\n\nUse this exact target path: ${targetPath}`; } +function normalizeSessionTags(tags: string[] | undefined): string[] { + return [...new Set((tags ?? []).map((tag) => String(tag).trim()).filter(Boolean))]; +} + +function parseSessionJson(value: unknown, fallback: T): T { + if (typeof value !== "string" || !value.trim()) { + return fallback; + } + try { + return JSON.parse(value) as T; + } catch { + return fallback; + } +} + +function hydrateSession(row: T): T { + const raw = row as T & { tags_json?: unknown; metadata_json?: unknown }; + const { tags_json: tagsJson, metadata_json: metadataJson, ...session } = raw; + return { + ...session, + tags: parseSessionJson(tagsJson, []), + metadata: parseSessionJson>(metadataJson, {}), + } as T; +} + export class SessionManager { private readonly runners = new Map(); private readonly pendingApprovals = new Map>(); @@ -188,7 +221,7 @@ export class SessionManager { constructor(private readonly options: SessionManagerOptions) {} listSessions(): SessionListRecord[] { - return this.options.db + const rows = this.options.db .prepare( ` SELECT @@ -198,12 +231,20 @@ export class SessionManager { s.status, s.pid, s.codex_thread_id, + s.parent_session_id, + s.starting_prompt, + s.description, + s.tags_json, + s.metadata_json, s.source_kind, s.source_rollout_path, s.source_thread_id, s.source_sync_cursor, s.source_last_synced_at, s.source_rollout_has_open_turn, + s.app_server_id, + s.app_server_endpoint, + s.app_server_pid, s.created_at, s.updated_at, ( @@ -238,7 +279,46 @@ export class SessionManager { ORDER BY COALESCE(last_event_at, s.updated_at) DESC `, ) - .all() as SessionListRecord[]; + .all() as Array>; + return rows + .map((session) => hydrateSession(session) as unknown as SessionListRecord); + } + + findSessionByProjectPath(projectPath: string): SessionRecord | null { + const trimmedPath = projectPath.trim(); + if (!trimmedPath) { + return null; + } + + const project = this.options.projectManager.getProjectByPath(trimmedPath); + if (!project) { + return null; + } + + const row = ( + this.options.db + .prepare( + ` + SELECT * + FROM sessions + WHERE project_id = ? + AND status NOT IN ('completed', 'failed') + ORDER BY updated_at DESC + LIMIT 1 + `, + ) + .get(project.id) as Record | undefined + ); + return row ? (hydrateSession(row) as unknown as SessionRecord) : null; + } + + listSessionsByProjectPath(projectPath: string): SessionListRecord[] { + const project = this.options.projectManager.getProjectByPath(projectPath); + if (!project) { + return []; + } + + return this.listSessions().filter((session) => session.project_id === project.id); } isLiveBusy(sessionId: string): boolean { @@ -256,8 +336,8 @@ export class SessionManager { } getSession(sessionId: string): SessionRecord | null { - return ( - (this.options.db + const row = ( + this.options.db .prepare( ` SELECT @@ -267,20 +347,29 @@ export class SessionManager { status, pid, codex_thread_id, + parent_session_id, + starting_prompt, + description, + tags_json, + metadata_json, source_kind, source_rollout_path, source_thread_id, source_sync_cursor, source_last_synced_at, source_rollout_has_open_turn, + app_server_id, + app_server_endpoint, + app_server_pid, created_at, updated_at FROM sessions WHERE id = ? `, ) - .get(sessionId) as SessionRecord | undefined) ?? null + .get(sessionId) as Record | undefined ); + return row ? (hydrateSession(row) as unknown as SessionRecord) : null; } getPendingApproval(sessionId: string): SessionApprovalPayload | null { @@ -314,11 +403,86 @@ export class SessionManager { }; } - createSession(input: { projectId: string; title?: string }): SessionRecord { + resumeSession(sessionId: string, parentSessionId?: string | null): SessionRecord { + const session = this.getSessionOrThrow(sessionId); + const requestedParentId = String(parentSessionId || "").trim(); + if (requestedParentId) { + if (requestedParentId === session.id) { + throw new AppError(400, "A session cannot be its own parent."); + } + + const parent = this.getSession(requestedParentId); + if (!parent) { + throw new AppError(404, "Parent session not found."); + } + if (!this.options.projectManager.isProjectPathAncestor(parent.project_id, session.project_id)) { + throw new AppError(400, "Parent session directory must be an ancestor of the resumed session directory."); + } + } + + const nextStatus = ["completed", "failed"].includes(session.status) ? "idle" : session.status; + this.options.db + .prepare( + ` + UPDATE sessions + SET parent_session_id = ?, status = ?, updated_at = ? + WHERE id = ? + `, + ) + .run(requestedParentId || session.parent_session_id, nextStatus, nowIso(), session.id); + + return this.getSessionOrThrow(session.id); + } + + updateSessionMetadata(input: { + sessionId: string; + description?: string | null; + tags?: string[]; + metadata?: Record; + }): SessionRecord { + const session = this.getSessionOrThrow(input.sessionId); + const description = + input.description === undefined ? session.description : input.description?.trim() || null; + const tags = input.tags === undefined ? session.tags : normalizeSessionTags(input.tags); + const metadata = input.metadata === undefined ? session.metadata : input.metadata; + + this.options.db + .prepare( + ` + UPDATE sessions + SET description = ?, tags_json = ?, metadata_json = ?, updated_at = ? + WHERE id = ? + `, + ) + .run(description, JSON.stringify(tags), JSON.stringify(metadata), nowIso(), session.id); + + return this.getSessionOrThrow(session.id); + } + + createSession(input: { + projectId: string; + title?: string; + startingPrompt?: string; + description?: string | null; + tags?: string[]; + metadata?: Record; + parentSessionId?: string | null; + }): SessionRecord { const project = this.options.projectManager.getProject(input.projectId); if (!project) { throw new AppError(404, "Project not found."); } + const parentSessionId = String(input.parentSessionId || "").trim(); + const parentSession = parentSessionId ? this.getSession(parentSessionId) : null; + if (parentSessionId && !parentSession) { + throw new AppError(404, "Parent session not found."); + } + if ( + parentSession && + !this.options.projectManager.isProjectPathAncestor(parentSession.project_id, project.id) + ) { + throw new AppError(400, "Parent session directory must be an ancestor of the child session directory."); + } const timestamp = nowIso(); const session: SessionRecord = { @@ -328,12 +492,20 @@ export class SessionManager { status: "idle", pid: null, codex_thread_id: null, + parent_session_id: parentSession?.id ?? null, + starting_prompt: normalizeSessionStartingPrompt(input.startingPrompt), + description: input.description?.trim() || null, + tags: normalizeSessionTags(input.tags), + metadata: input.metadata ?? {}, source_kind: "native", source_rollout_path: null, source_thread_id: null, source_sync_cursor: null, source_last_synced_at: null, source_rollout_has_open_turn: 0, + app_server_id: null, + app_server_endpoint: null, + app_server_pid: null, created_at: timestamp, updated_at: timestamp, }; @@ -348,16 +520,24 @@ export class SessionManager { status, pid, codex_thread_id, + parent_session_id, + starting_prompt, + description, + tags_json, + metadata_json, source_kind, source_rollout_path, source_thread_id, source_sync_cursor, source_last_synced_at, source_rollout_has_open_turn, + app_server_id, + app_server_endpoint, + app_server_pid, created_at, updated_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, ) .run( @@ -367,12 +547,20 @@ export class SessionManager { session.status, session.pid, session.codex_thread_id, + session.parent_session_id, + session.starting_prompt, + session.description, + JSON.stringify(session.tags), + JSON.stringify(session.metadata), session.source_kind, session.source_rollout_path, session.source_thread_id, session.source_sync_cursor, session.source_last_synced_at, session.source_rollout_has_open_turn, + session.app_server_id, + session.app_server_endpoint, + session.app_server_pid, session.created_at, session.updated_at, ); @@ -395,7 +583,8 @@ export class SessionManager { if (!project) { throw new AppError(404, "Project not found for session."); } - const runtimePrompt = normalizeDemoPrompt(project.path, message); + const userMessage = composeMessageContentWithStartingPrompt(message, session.starting_prompt); + const runtimePrompt = normalizeDemoPrompt(project.path, userMessage); const currentRunner = this.runners.get(sessionId); const busyStatuses: SessionStatus[] = ["starting", "running", "stopping"]; @@ -412,7 +601,7 @@ export class SessionManager { WHERE id = ? `, ) - .run(deriveSessionTitleFromMessage(message), nowIso(), sessionId); + .run(deriveSessionTitleFromMessage(userMessage), nowIso(), sessionId); } const turnId = createId("turn"); @@ -425,10 +614,22 @@ export class SessionManager { phase: null, stream: null, payload: { - text: message, + text: userMessage, }, }); + if (session.starting_prompt.trim()) { + this.options.db + .prepare( + ` + UPDATE sessions + SET starting_prompt = '', updated_at = ? + WHERE id = ? + `, + ) + .run(nowIso(), sessionId); + } + this.startRunner( sessionId, project.path, @@ -552,10 +753,20 @@ export class SessionManager { return existing; } - const runner = createCodexRunner(this.options.codexMode, this.options.codexCommand, cwd); + const session = this.getSessionOrThrow(sessionId); + const runner = createCodexRunner( + this.options.codexMode, + this.options.codexCommand, + cwd, + session.app_server_endpoint, + ); const effectiveLaunch = this.withSessionWritableRoots(sessionId, codexLaunch); const runtime: RunnerState = { runner, + cwd, + command: this.options.codexCommand, + stderr: "", + stderrTruncated: false, stopRequested: false, transientSeqCursor: this.options.eventStore.latestSeq(sessionId), turnId, @@ -590,8 +801,13 @@ export class SessionManager { } }); - runner.onText((_stream, _text) => { - // raw stdout/stderr no longer belongs to the primary event protocol + runner.onText((stream, text) => { + if (stream === "stderr") { + const separator = runtime.stderr && !runtime.stderr.endsWith("\n") ? "\n" : ""; + const capped = appendCappedText(runtime.stderr, `${separator}${text}`); + runtime.stderr = capped.nextText; + runtime.stderrTruncated ||= capped.truncated; + } }); runner.onExit((exitCode) => { @@ -845,12 +1061,16 @@ export class SessionManager { this.ensureTurnStarted(sessionId, runtime, event.turnId || runtime.turnId); const phase = event.phase || "final_answer"; const messageId = this.resolveAssistantMessageId(runtime, phase, event.messageId); + const textDelta = + this.readRawText(event.payload.textDelta) || + this.readRawText(event.payload.text) || + ""; this.appendAssistantDelta( sessionId, runtime, phase, messageId, - this.readRawText(event.payload.textDelta) || "", + textDelta, ); return; } @@ -880,11 +1100,16 @@ export class SessionManager { case "reasoning.delta": { this.ensureTurnStarted(sessionId, runtime, event.turnId || runtime.turnId); const messageId = this.resolveReasoningMessageId(runtime, event.messageId); + const textDelta = + this.readRawText(event.payload.textDelta) || + this.readRawText(event.payload.text) || + this.readString(event.payload.summary) || + ""; this.appendReasoningDelta( sessionId, runtime, messageId, - this.readRawText(event.payload.textDelta) || "", + textDelta, this.readString(event.payload.summary) || null, ); return; @@ -989,7 +1214,12 @@ export class SessionManager { runtime.turnId, `Process exited with code ${exitCode ?? -1}.`, { + command: runtime.command, + cwd: runtime.cwd, exitCode, + stderr: runtime.stderr || null, + stderrTruncated: runtime.stderrTruncated, + executionMode: this.options.codexMode, }, ); this.completeTurn(sessionId, runtime, "turn.aborted", { diff --git a/server/src/services/speech-to-text.ts b/server/src/services/speech-to-text.ts new file mode 100644 index 0000000..6a74487 --- /dev/null +++ b/server/src/services/speech-to-text.ts @@ -0,0 +1,305 @@ +import { + accessSync, + constants, + existsSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; + +import { resolveExecutable } from "../utils/command"; + +export interface SpeechToTextOptions { + preferredBinary?: string; + modelPath?: string; +} + +export interface SpeechToTextSelfTestResult { + ok: boolean; + binaryPath: string | null; + modelPath: string | null; + details: string; +} + +const DEFAULT_BINARY_CANDIDATES = [ + "whisper", + "whisper.cpp", + "whisper-ctranslate2", +]; + +export class SpeechToTextService { + private readonly modelPath: string | null; + private readonly binaryCandidates: string[]; + private binaryIndex = 0; + private binaryPath: string | null; + + constructor(options: SpeechToTextOptions = {}) { + const preferredBinary = String(options.preferredBinary || "").trim(); + this.binaryCandidates = preferredBinary + ? [ + preferredBinary, + ...DEFAULT_BINARY_CANDIDATES.filter((candidate) => candidate !== preferredBinary), + ] + : [...DEFAULT_BINARY_CANDIDATES]; + this.modelPath = String(options.modelPath || "").trim() || null; + this.binaryPath = this.discoverBinary(); + } + + isAvailable(): boolean { + return Boolean(this.binaryPath); + } + + getBinaryPath(): string | null { + return this.binaryPath; + } + + getModelPath(): string | null { + return this.modelPath; + } + + transcribe(audioBytes: Buffer, mimeType?: string | null, filename?: string | null): string { + if (!this.binaryPath) { + throw new Error("No speech-to-text backend is available. Install a whisper CLI to enable voice notes."); + } + + const tmpRoot = mkdtempSync(path.join(os.tmpdir(), "remcodex-stt-")); + try { + const suffix = guessAudioSuffix(mimeType, filename); + const inputPath = path.join(tmpRoot, `audio${suffix}`); + writeFileSync(inputPath, audioBytes); + + let outputPath = replaceExtension(inputPath, ".txt"); + let runResult = this.runBinary(this.binaryPath, inputPath); + if (!runResult.ok) { + const previousBinary = this.binaryPath; + const stderr = runResult.stderr; + this.fallbackToNextBinary(); + if (this.binaryPath && this.binaryPath !== previousBinary) { + outputPath = replaceExtension(inputPath, ".txt"); + runResult = this.runBinary(this.binaryPath, inputPath); + } + if (!runResult.ok) { + throw new Error( + `Transcription failed (exit ${runResult.exitCode}): ${runResult.stderr || stderr || "unknown error"}`, + ); + } + } + + if (!fileExists(outputPath)) { + const candidate = findTranscriptFile(tmpRoot); + if (candidate) { + outputPath = candidate; + } + } + if (!fileExists(outputPath)) { + throw new Error("Transcription completed but no transcript file was produced."); + } + + const transcript = readFileSync(outputPath, "utf8").trim(); + if (!transcript) { + throw new Error("Transcription produced an empty result."); + } + return transcript; + } finally { + rmSync(tmpRoot, { recursive: true, force: true }); + } + } + + selfTest(): SpeechToTextSelfTestResult { + if (!this.binaryPath) { + return { + ok: false, + binaryPath: null, + modelPath: this.modelPath, + details: "No speech-to-text backend is available.", + }; + } + + if (this.modelPath && !existsSync(this.modelPath)) { + return { + ok: false, + binaryPath: this.binaryPath, + modelPath: this.modelPath, + details: `Configured model path does not exist: ${this.modelPath}`, + }; + } + + const tmpRoot = mkdtempSync(path.join(os.tmpdir(), "remcodex-stt-self-test-")); + try { + const inputPath = path.join(tmpRoot, "self-test.wav"); + writeFileSync(inputPath, createSilentWavBuffer()); + + const result = this.runBinary(this.binaryPath, inputPath); + const transcriptPath = replaceExtension(inputPath, ".txt"); + const transcriptExists = + fileExists(transcriptPath) || Boolean(findTranscriptFile(tmpRoot)); + + if (result.ok) { + return { + ok: true, + binaryPath: this.binaryPath, + modelPath: this.modelPath, + details: transcriptExists + ? "STT backend accepted the invocation and produced a transcript artifact." + : "STT backend accepted the invocation.", + }; + } + + const stderr = String(result.stderr || "").trim(); + const invocationLooksWrong = + /usage:|unrecognized arguments|invalid choice|no such option|error:/i.test(stderr); + + return { + ok: false, + binaryPath: this.binaryPath, + modelPath: this.modelPath, + details: invocationLooksWrong + ? `STT backend rejected the invocation: ${stderr || "unknown error"}` + : `STT backend failed during test transcription (exit ${result.exitCode}): ${stderr || "unknown error"}`, + }; + } finally { + rmSync(tmpRoot, { recursive: true, force: true }); + } + } + + private discoverBinary(): string | null { + for (let index = this.binaryIndex; index < this.binaryCandidates.length; index += 1) { + const candidate = this.binaryCandidates[index]; + const resolved = resolveExecutable(candidate); + if (resolved && isExecutableBinary(resolved)) { + this.binaryIndex = index; + return resolved; + } + } + return null; + } + + private fallbackToNextBinary(): void { + this.binaryIndex += 1; + this.binaryPath = this.discoverBinary(); + } + + private runBinary(binaryPath: string, inputPath: string): { ok: boolean; exitCode: number; stderr: string } { + const args = buildTranscriptionArgs(binaryPath, inputPath, this.modelPath); + try { + execFileSync(binaryPath, args, { + cwd: path.dirname(inputPath), + stdio: ["ignore", "pipe", "pipe"], + }); + return { ok: true, exitCode: 0, stderr: "" }; + } catch (error) { + const exitCode = + typeof (error as { status?: unknown }).status === "number" + ? Number((error as { status: number }).status) + : 1; + const stderr = Buffer.isBuffer((error as { stderr?: unknown }).stderr) + ? (error as { stderr: Buffer }).stderr.toString("utf8").trim() + : String((error as { stderr?: unknown }).stderr || "").trim(); + return { ok: false, exitCode, stderr }; + } + } +} + +function buildTranscriptionArgs(binaryPath: string, inputPath: string, modelPath: string | null): string[] { + const args: string[] = []; + const binaryName = path.basename(binaryPath).toLowerCase(); + if (binaryName.includes("ctranslate2")) { + if (modelPath) { + args.push("--model_directory", modelPath); + } + args.push("--output_dir", path.dirname(inputPath)); + args.push("--output_format", "txt"); + args.push("--verbose", "False"); + } else if (modelPath) { + args.push("-m", modelPath); + } + args.push(inputPath); + return args; +} + +function replaceExtension(filePath: string, extension: string): string { + return /\.[^.]+$/.test(filePath) ? filePath.replace(/\.[^.]+$/, extension) : `${filePath}${extension}`; +} + +function createSilentWavBuffer(durationMs = 800, sampleRate = 16000): Buffer { + const channels = 1; + const bitsPerSample = 16; + const bytesPerSample = bitsPerSample / 8; + const sampleCount = Math.max(1, Math.round((durationMs / 1000) * sampleRate)); + const dataSize = sampleCount * channels * bytesPerSample; + const buffer = Buffer.alloc(44 + dataSize); + + buffer.write("RIFF", 0); + buffer.writeUInt32LE(36 + dataSize, 4); + buffer.write("WAVE", 8); + buffer.write("fmt ", 12); + buffer.writeUInt32LE(16, 16); + buffer.writeUInt16LE(1, 20); + buffer.writeUInt16LE(channels, 22); + buffer.writeUInt32LE(sampleRate, 24); + buffer.writeUInt32LE(sampleRate * channels * bytesPerSample, 28); + buffer.writeUInt16LE(channels * bytesPerSample, 32); + buffer.writeUInt16LE(bitsPerSample, 34); + buffer.write("data", 36); + buffer.writeUInt32LE(dataSize, 40); + + return buffer; +} + +function guessAudioSuffix(mimeType?: string | null, filename?: string | null): string { + const trimmedFilename = String(filename || "").trim(); + if (trimmedFilename) { + const parsed = path.parse(trimmedFilename); + if (parsed.ext) { + return parsed.ext; + } + } + + const normalizedType = String(mimeType || "").trim().toLowerCase(); + switch (normalizedType) { + case "audio/mpeg": + case "audio/mp3": + return ".mp3"; + case "audio/ogg": + return ".ogg"; + case "audio/opus": + return ".opus"; + case "audio/webm": + return ".webm"; + case "audio/wav": + case "audio/x-wav": + return ".wav"; + case "audio/flac": + return ".flac"; + default: + return ".wav"; + } +} + +function fileExists(filePath: string): boolean { + return existsSync(filePath); +} + +function isExecutableBinary(filePath: string): boolean { + try { + accessSync(filePath, constants.X_OK); + return true; + } catch { + return false; + } +} + +function findTranscriptFile(dirPath: string): string | null { + try { + const candidates = readdirSync(dirPath); + const transcript = candidates.find((name) => name.toLowerCase().endsWith(".txt")); + return transcript ? path.join(dirPath, transcript) : null; + } catch { + return null; + } +} diff --git a/server/src/types/models.ts b/server/src/types/models.ts index 4bdcb04..e3bd4a8 100644 --- a/server/src/types/models.ts +++ b/server/src/types/models.ts @@ -183,6 +183,15 @@ export interface ProjectRecord { created_at: string; } +export interface AgentProfileRecord { + id: string; + name: string; + starting_prompt: string; + default_directory: string; + created_at: string; + updated_at: string; +} + export interface SessionRecord { id: string; title: string | null; @@ -190,6 +199,11 @@ export interface SessionRecord { status: SessionStatus; pid: number | null; codex_thread_id: string | null; + parent_session_id: string | null; + starting_prompt: string; + description: string | null; + tags: string[]; + metadata: Record; source_kind: SessionSourceKind; source_rollout_path: string | null; source_thread_id: string | null; @@ -197,6 +211,9 @@ export interface SessionRecord { source_last_synced_at: string | null; /** 1/0 from DB: external Codex rollout still has an unclosed turn (not local runner state). */ source_rollout_has_open_turn: number; + app_server_id: string | null; + app_server_endpoint: string | null; + app_server_pid: number | null; created_at: string; updated_at: string; } diff --git a/server/src/utils/remcodex-config.ts b/server/src/utils/remcodex-config.ts new file mode 100644 index 0000000..94fdb6f --- /dev/null +++ b/server/src/utils/remcodex-config.ts @@ -0,0 +1,192 @@ +import { existsSync, readFileSync } from "node:fs"; + +import type { AgentProfileSeed } from "../services/agent-profile-manager"; + +export interface RemCodexConfig { + profiles: AgentProfileSeed[]; +} + +function stripInlineComment(line: string): string { + let inDoubleQuotes = false; + let inSingleQuotes = false; + let escaped = false; + + for (let index = 0; index < line.length; index += 1) { + const char = line[index]; + + if (escaped) { + escaped = false; + continue; + } + + if (char === "\\") { + escaped = true; + continue; + } + + if (char === '"' && !inSingleQuotes) { + inDoubleQuotes = !inDoubleQuotes; + continue; + } + + if (char === "'" && !inDoubleQuotes) { + inSingleQuotes = !inSingleQuotes; + continue; + } + + if (char === "#" && !inDoubleQuotes && !inSingleQuotes) { + return line.slice(0, index).trimEnd(); + } + } + + return line.trimEnd(); +} + +function unescapeTomlBasicString(value: string): string { + let out = ""; + + for (let index = 0; index < value.length; index += 1) { + const char = value[index]; + if (char !== "\\") { + out += char; + continue; + } + + index += 1; + const next = value[index]; + if (next === undefined) { + throw new Error("Invalid TOML escape at end of string."); + } + + switch (next) { + case "\\": + out += "\\"; + break; + case '"': + out += '"'; + break; + case "n": + out += "\n"; + break; + case "r": + out += "\r"; + break; + case "t": + out += "\t"; + break; + default: + out += next; + break; + } + } + + return out; +} + +function parseTomlString(rawValue: string, lineNumber: number): string { + const value = rawValue.trim(); + if (!value) { + return ""; + } + + if (value.startsWith('"') && value.endsWith('"')) { + return unescapeTomlBasicString(value.slice(1, -1)); + } + + if (value.startsWith("'") && value.endsWith("'")) { + return value.slice(1, -1); + } + + throw new Error(`Line ${lineNumber}: expected a quoted TOML string.`); +} + +function finalizeProfile( + profile: Partial | null, + lineNumber: number, +): AgentProfileSeed | null { + if (!profile) { + return null; + } + + const name = String(profile.name || "").trim(); + const startingPrompt = String(profile.startingPrompt || "").trim(); + const defaultDirectory = String(profile.defaultDirectory || "").trim(); + if (!name && !startingPrompt && !defaultDirectory) { + return null; + } + + if (!name || !startingPrompt || !defaultDirectory) { + throw new Error( + `Line ${lineNumber}: each [[profiles]] entry needs name, starting_prompt, and default_directory.`, + ); + } + + return { + name, + startingPrompt, + defaultDirectory, + }; +} + +export function loadRemCodexConfig(configPath: string): RemCodexConfig { + if (!existsSync(configPath)) { + return { profiles: [] }; + } + + const raw = readFileSync(configPath, "utf8"); + const profiles: AgentProfileSeed[] = []; + let currentProfile: Partial | null = null; + let currentLineNumber = 0; + + const commitCurrent = (lineNumber: number) => { + const profile = finalizeProfile(currentProfile, lineNumber); + if (profile) { + profiles.push(profile); + } + currentProfile = null; + currentLineNumber = 0; + }; + + raw.split(/\r?\n/).forEach((line, index) => { + const lineNumber = index + 1; + const trimmed = stripInlineComment(line).trim(); + if (!trimmed) { + return; + } + + if (trimmed === "[[profiles]]") { + commitCurrent(currentLineNumber || lineNumber); + currentProfile = {}; + currentLineNumber = lineNumber; + return; + } + + const match = trimmed.match(/^([A-Za-z_][A-Za-z0-9_-]*)\s*=\s*(.+)$/); + if (!match) { + throw new Error(`Line ${lineNumber}: only [[profiles]] tables and key/value pairs are supported.`); + } + + if (!currentProfile) { + throw new Error(`Line ${lineNumber}: profile fields must appear inside a [[profiles]] table.`); + } + + const key = match[1]; + const value = parseTomlString(match[2], lineNumber); + if (key === "name") { + currentProfile.name = value; + } else if (key === "starting_prompt") { + currentProfile.startingPrompt = value; + } else if (key === "default_directory") { + currentProfile.defaultDirectory = value; + } + }); + + commitCurrent(currentLineNumber || raw.split(/\r?\n/).length); + + const deduped = new Map(); + for (const profile of profiles) { + deduped.set(profile.name, profile); + } + + return { profiles: [...deduped.values()] }; +} diff --git a/server/src/utils/remcodex-directory.ts b/server/src/utils/remcodex-directory.ts new file mode 100644 index 0000000..55b2a42 --- /dev/null +++ b/server/src/utils/remcodex-directory.ts @@ -0,0 +1,57 @@ +export interface RemCodexDirectoryInstance { + name: string; + url: string; + description: string | null; +} + +function normalizeInstance(value: unknown, index: number): RemCodexDirectoryInstance { + if (!value || typeof value !== "object") { + throw new Error(`Directory instance ${index + 1} must be an object.`); + } + + const record = value as Record; + const name = typeof record.name === "string" ? record.name.trim() : ""; + const url = typeof record.url === "string" ? record.url.trim() : ""; + const description = typeof record.description === "string" ? record.description.trim() : ""; + + if (!name || !url) { + throw new Error(`Directory instance ${index + 1} must include name and url.`); + } + + let parsedUrl: URL; + try { + parsedUrl = new URL(url); + } catch { + throw new Error(`Directory instance ${index + 1} has an invalid URL.`); + } + + if (!(["http:", "https:"].includes(parsedUrl.protocol))) { + throw new Error(`Directory instance ${index + 1} URL must use http or https.`); + } + + return { + name, + url: parsedUrl.toString().replace(/\/$/, ""), + description: description || null, + }; +} + +export function parseRemCodexDirectoryInstances(rawValue = ""): RemCodexDirectoryInstance[] { + const raw = rawValue.trim(); + if (!raw) { + return []; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new Error("REMCODEX_DIRECTORY_INSTANCES must contain valid JSON."); + } + + if (!Array.isArray(parsed)) { + throw new Error("REMCODEX_DIRECTORY_INSTANCES must be a JSON array."); + } + + return parsed.map(normalizeInstance); +} diff --git a/server/src/utils/remcodex-url.ts b/server/src/utils/remcodex-url.ts new file mode 100644 index 0000000..25bc1f5 --- /dev/null +++ b/server/src/utils/remcodex-url.ts @@ -0,0 +1,25 @@ +export function resolveRemCodexPublicBaseUrl(rawValue = process.env.REMCODEX_HOSTNAME ?? ""): string | null { + const raw = rawValue.trim(); + if (!raw) { + return null; + } + + const candidate = /^[a-z][a-z\d+.-]*:\/\//i.test(raw) ? raw : `https://${raw}`; + let parsed: URL; + try { + parsed = new URL(candidate); + } catch { + throw new Error("REMCODEX_HOSTNAME must be a valid hostname or HTTP(S) URL."); + } + + if (!(parsed.protocol === "http:" || parsed.protocol === "https:")) { + throw new Error("REMCODEX_HOSTNAME must use http or https."); + } + + return parsed.toString().replace(/\/$/, ""); +} + +export function buildRemCodexSessionUrl(sessionId: string, publicBaseUrl = resolveRemCodexPublicBaseUrl()): string { + const sessionPath = `/#/sessions/${encodeURIComponent(sessionId)}`; + return publicBaseUrl ? `${publicBaseUrl}${sessionPath}` : sessionPath; +} diff --git a/server/src/utils/runtime-paths.ts b/server/src/utils/runtime-paths.ts index c426bc9..8a73fd5 100644 --- a/server/src/utils/runtime-paths.ts +++ b/server/src/utils/runtime-paths.ts @@ -30,3 +30,7 @@ export function resolvePackageRoot(startDir = __dirname): string { export function resolveDefaultDatabasePath(): string { return path.join(homedir(), ".remcodex", "remcodex.db"); } + +export function resolveDefaultConfigPath(): string { + return path.join(homedir(), ".remcodex", "config.toml"); +} diff --git a/server/src/utils/session-starting-prompt.ts b/server/src/utils/session-starting-prompt.ts new file mode 100644 index 0000000..6f4c4c2 --- /dev/null +++ b/server/src/utils/session-starting-prompt.ts @@ -0,0 +1,18 @@ +export function normalizeSessionStartingPrompt(value: string | null | undefined): string { + return String(value || "").trim(); +} + +export function composeMessageContentWithStartingPrompt( + content: string | null | undefined, + startingPrompt: string | null | undefined, +): string { + const prompt = normalizeSessionStartingPrompt(startingPrompt); + const userContent = String(content || "").trim(); + if (!prompt) { + return userContent; + } + if (!userContent) { + return prompt; + } + return `${prompt}\n\n${userContent}`; +} diff --git a/tests/app-server-item-updated-regression.test.ts b/tests/app-server-item-updated-regression.test.ts new file mode 100644 index 0000000..bc8864b --- /dev/null +++ b/tests/app-server-item-updated-regression.test.ts @@ -0,0 +1,131 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { createDatabase } from "../server/src/db/client"; +import { runMigrations } from "../server/src/db/migrations"; +import { EventStore } from "../server/src/services/event-store"; +import { ProjectManager } from "../server/src/services/project-manager"; +import { SessionManager } from "../server/src/services/session-manager"; + +function makeRuntime(turnId: string) { + return { + runner: { + isAlive() { + return true; + }, + }, + cwd: "/tmp/remcodex-test", + command: "codex", + stderr: "", + stderrTruncated: false, + stopRequested: false, + transientSeqCursor: 0, + turnId, + appTurnId: null, + turnStarted: false, + turnFinalized: false, + assistantByPhase: new Map(), + messagesById: new Map(), + reasoning: null, + commandsByCallId: new Map(), + patchesByCallId: new Map(), + activeCommandCallId: null, + activePatchCallId: null, + }; +} + +test("item/updated agent messages persist assistant deltas", () => { + const db = createDatabase(":memory:"); + runMigrations(db); + + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "remcodex-test-")); + const projectPath = path.join(tmpRoot, "project"); + fs.mkdirSync(projectPath, { recursive: true }); + + const eventStore = new EventStore(db); + const projectManager = new ProjectManager(db, tmpRoot, process.cwd()); + const manager = new SessionManager({ + db, + eventStore, + projectManager, + codexCommand: "codex", + codexMode: "app-server", + }); + + const project = projectManager.createProject({ + name: "Regression Project", + path: projectPath, + }); + const session = manager.createSession({ + projectId: project.id, + title: "Regression Session", + }); + const runtime = makeRuntime("turn-local-1"); + + manager["handleAppServerMessage"](session.id, runtime, { + method: "item/updated", + params: { + turnId: "turn-remote-1", + item: { + id: "msg-1", + type: "agentMessage", + phase: "final_answer", + content: [{ type: "text", text: "assistant from item updated" }], + }, + }, + }); + + const events = eventStore.listAll(session.id); + const assistantDelta = events.find((event) => event.type === "message.assistant.delta"); + assert.ok(assistantDelta, "expected an assistant delta event"); + assert.equal(assistantDelta?.payload?.textDelta, "assistant from item updated"); +}); + +test("item/updated reasoning messages persist reasoning deltas", () => { + const db = createDatabase(":memory:"); + runMigrations(db); + + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "remcodex-test-")); + const projectPath = path.join(tmpRoot, "project"); + fs.mkdirSync(projectPath, { recursive: true }); + + const eventStore = new EventStore(db); + const projectManager = new ProjectManager(db, tmpRoot, process.cwd()); + const manager = new SessionManager({ + db, + eventStore, + projectManager, + codexCommand: "codex", + codexMode: "app-server", + }); + + const project = projectManager.createProject({ + name: "Regression Project", + path: projectPath, + }); + const session = manager.createSession({ + projectId: project.id, + title: "Regression Session", + }); + const runtime = makeRuntime("turn-local-2"); + + manager["handleAppServerMessage"](session.id, runtime, { + method: "item/updated", + params: { + turnId: "turn-remote-2", + item: { + id: "reasoning-1", + type: "reasoning", + summary: "reasoning summary from item updated", + }, + }, + }); + + const events = eventStore.listAll(session.id); + const reasoningDelta = events.find((event) => event.type === "reasoning.delta"); + assert.ok(reasoningDelta, "expected a reasoning delta event"); + assert.equal(reasoningDelta?.payload?.textDelta, "reasoning summary from item updated"); +}); diff --git a/tests/child-process-diagnostics.test.ts b/tests/child-process-diagnostics.test.ts new file mode 100644 index 0000000..61d129c --- /dev/null +++ b/tests/child-process-diagnostics.test.ts @@ -0,0 +1,96 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { createDatabase } from "../server/src/db/client"; +import { runMigrations } from "../server/src/db/migrations"; +import { EventStore } from "../server/src/services/event-store"; +import { ProjectManager } from "../server/src/services/project-manager"; +import { SessionManager } from "../server/src/services/session-manager"; + +function makeProject(codexCommand: string, codexMode: "exec-json" | "app-server") { + const db = createDatabase(":memory:"); + runMigrations(db); + const root = fs.mkdtempSync(path.join(os.tmpdir(), "remcodex-diagnostics-")); + const projectPath = path.join(root, "project"); + fs.mkdirSync(projectPath); + const eventStore = new EventStore(db); + const projectManager = new ProjectManager(db, root, process.cwd()); + const manager = new SessionManager({ + db, + eventStore, + projectManager, + codexCommand, + codexMode, + }); + const project = projectManager.createProject({ name: "Diagnostics", path: projectPath }); + const session = manager.createSession({ projectId: project.id }); + return { eventStore, manager, projectPath, session }; +} + +function writeLauncher(root: string, script: string): string { + const scriptPath = path.join(root, "runner.cjs"); + const launcherPath = path.join(root, "runner"); + fs.writeFileSync(scriptPath, script); + fs.writeFileSync(launcherPath, `#!/bin/sh\nexec ${process.execPath} ${scriptPath} "$@"\n`); + fs.chmodSync(launcherPath, 0o755); + return launcherPath; +} + +async function waitForFailed(manager: SessionManager, sessionId: string): Promise { + for (let attempt = 0; attempt < 100; attempt += 1) { + if (manager.getSession(sessionId)?.status === "failed") { + return; + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } + assert.fail("session did not reach failed state"); +} + +test("persists stderr, command, cwd, and exit status for failed exec processes", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "remcodex-exec-runner-")); + const command = writeLauncher(root, "process.stderr.write('exec diagnostic\\n'); process.exit(7);\n"); + const { eventStore, manager, projectPath, session } = makeProject(command, "exec-json"); + + manager.sendMessage(session.id, "trigger failure"); + await waitForFailed(manager, session.id); + + const error = eventStore.listAll(session.id).find((event) => event.type === "error"); + assert.ok(error); + assert.equal(error.payload.details?.command, command); + assert.equal(error.payload.details?.cwd, projectPath); + assert.equal(error.payload.details?.exitCode, 7); + assert.equal(error.payload.details?.stderr, "exec diagnostic"); + assert.equal(error.payload.details?.executionMode, "exec-json"); +}); + +test("persists stderr from app-server bootstrap failures", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "remcodex-app-server-")); + const command = writeLauncher( + root, + `const readline = require("node:readline"); +process.stderr.write("bootstrap diagnostic\\n"); +const rl = readline.createInterface({ input: process.stdin }); +rl.on("line", (line) => { + const request = JSON.parse(line); + if (request.method === "initialize") { + process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: request.id, result: {} }) + "\\n"); + return; + } + process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: request.id, error: { message: "bootstrap failed" } }) + "\\n"); +}); +`, + ); + const { eventStore, manager, session } = makeProject(command, "app-server"); + + manager.sendMessage(session.id, "trigger bootstrap failure"); + await waitForFailed(manager, session.id); + + const error = eventStore.listAll(session.id).find((event) => event.type === "error"); + assert.ok(error); + assert.match(String(error.payload.details?.stderr), /bootstrap diagnostic/); + assert.equal(error.payload.details?.exitCode, 1); + assert.equal(error.payload.details?.executionMode, "app-server"); +}); diff --git a/tests/codex-app-server-registry.test.ts b/tests/codex-app-server-registry.test.ts new file mode 100644 index 0000000..c5ff395 --- /dev/null +++ b/tests/codex-app-server-registry.test.ts @@ -0,0 +1,44 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { CodexAppServerRegistryService } from "../server/src/services/codex-app-server-registry"; + +test("lists app-server instances only while their socket is present", () => { + const registryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "remcodex-app-server-registry-")); + const socketPath = path.join(registryDirectory, "session.sock"); + const registryPath = path.join(registryDirectory, "session.json"); + fs.writeFileSync(socketPath, ""); + fs.writeFileSync( + registryPath, + JSON.stringify({ + id: "tui-session", + endpoint: "unix://session.sock", + wsEndpoint: `ws+unix://${socketPath}:/`, + socketPath, + pid: process.pid, + cwd: registryDirectory, + startedAt: new Date().toISOString(), + source: "test", + }), + ); + + const previousDirectory = process.env.CODEX_APP_SERVER_REGISTRY_DIR; + process.env.CODEX_APP_SERVER_REGISTRY_DIR = registryDirectory; + try { + const registry = new CodexAppServerRegistryService({} as never); + assert.deepEqual(registry.list().map((instance) => instance.id), ["tui-session"]); + + fs.unlinkSync(socketPath); + assert.deepEqual(registry.list(), []); + } finally { + if (previousDirectory === undefined) { + delete process.env.CODEX_APP_SERVER_REGISTRY_DIR; + } else { + process.env.CODEX_APP_SERVER_REGISTRY_DIR = previousDirectory; + } + fs.rmSync(registryDirectory, { recursive: true, force: true }); + } +}); diff --git a/tests/codex-remote-app-server-runner.test.ts b/tests/codex-remote-app-server-runner.test.ts new file mode 100644 index 0000000..dab61c5 --- /dev/null +++ b/tests/codex-remote-app-server-runner.test.ts @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + applyMcpAutoApprovalOverrides, + REMOTE_APPROVAL_POLICY, + REMOTE_SANDBOX_MODE, +} from "../server/src/services/codex-remote-app-server-runner"; + +test("remote sessions bypass interactive approvals", () => { + assert.equal(REMOTE_APPROVAL_POLICY, "never"); + assert.equal(REMOTE_SANDBOX_MODE, "danger-full-access"); +}); + +test("remote sessions auto-approve every discovered MCP server", () => { + const config: Record = { + profile: "orchestrator", + }; + + applyMcpAutoApprovalOverrides(config, ["rhizomatic_server", " vikunja ", ""]); + + assert.deepEqual(config, { + profile: "orchestrator", + "mcp_servers.rhizomatic_server.default_tools_approval_mode": "approve", + "mcp_servers.vikunja.default_tools_approval_mode": "approve", + }); +}); diff --git a/tests/mock-codex-app-server.js b/tests/mock-codex-app-server.js new file mode 100644 index 0000000..74a6892 --- /dev/null +++ b/tests/mock-codex-app-server.js @@ -0,0 +1,284 @@ +#!/usr/bin/env node + +const readline = require("node:readline"); + +const chunkDelayMs = readPositiveInt(process.env.MOCK_CODEX_CHUNK_DELAY_MS, 8); +const chunkCount = readPositiveInt(process.env.MOCK_CODEX_CHUNK_COUNT, 6); +const streamMode = readStreamMode(process.env.MOCK_CODEX_STREAM_MODE); + +let nextThreadId = 1; + +const rl = readline.createInterface({ + input: process.stdin, + crlfDelay: Infinity, +}); + +rl.on("line", (line) => { + void handleLine(line); +}); + +rl.on("close", () => { + process.exit(0); +}); + +function readPositiveInt(raw, fallback) { + const parsed = Number.parseInt(String(raw || ""), 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function readStreamMode(raw) { + const normalized = String(raw || "").trim().toLowerCase(); + if (normalized === "item-updated") { + return "item-updated"; + } + return "delta"; +} + +function send(message) { + process.stdout.write(`${JSON.stringify(message)}\n`); +} + +function respond(id, result) { + send({ + jsonrpc: "2.0", + id, + result, + }); +} + +function notify(method, params) { + send({ + jsonrpc: "2.0", + method, + params, + }); +} + +async function handleLine(line) { + const trimmed = String(line || "").trim(); + if (!trimmed) { + return; + } + + let message = null; + try { + message = JSON.parse(trimmed); + } catch (error) { + process.stderr.write(`mock-codex: failed to parse input: ${error.message}\n`); + return; + } + + if (!message || typeof message !== "object") { + return; + } + + const method = typeof message.method === "string" ? message.method : ""; + const id = Number.isFinite(message.id) ? message.id : null; + const params = message.params && typeof message.params === "object" ? message.params : {}; + + if (method === "initialize" && id !== null) { + respond(id, { + protocolVersion: "2025-01-01", + serverInfo: { + name: "mock-codex-app-server", + version: "0.0.0-test", + }, + capabilities: { + experimentalApi: true, + }, + }); + return; + } + + if (method === "initialized") { + return; + } + + if ((method === "thread/start" || method === "thread/resume") && id !== null) { + const threadId = + method === "thread/resume" && typeof params.threadId === "string" && params.threadId.trim() + ? params.threadId.trim() + : `mock-thread-${nextThreadId++}`; + respond(id, { + thread: { + id: threadId, + }, + }); + notify("thread/started", { + thread: { + id: threadId, + }, + }); + return; + } + + if (method === "turn/start" && id !== null) { + const threadId = + typeof params.threadId === "string" && params.threadId.trim() + ? params.threadId.trim() + : `mock-thread-${nextThreadId++}`; + const prompt = readPromptText(params); + const turnId = deriveTurnId(prompt); + const messageId = deriveMessageId(prompt); + + respond(id, { + turn: { + id: turnId, + status: "running", + }, + }); + + void emitTurn(threadId, turnId, messageId, prompt); + return; + } + + if (id !== null) { + respond(id, {}); + } +} + +function readPromptText(params) { + const input = Array.isArray(params.input) ? params.input : []; + const first = input.find((item) => item && typeof item === "object" && item.type === "text"); + if (first && typeof first.text === "string" && first.text.trim()) { + return first.text.trim(); + } + + return "empty prompt"; +} + +function slugifyPrompt(prompt) { + return String(prompt || "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 48); +} + +function readPromptOrdinal(prompt) { + const match = String(prompt || "").match(/turn-(\d+)-marker/i); + if (!match) { + return ""; + } + + const parsed = Number.parseInt(match[1], 10); + return Number.isFinite(parsed) && parsed > 0 ? String(parsed) : ""; +} + +function deriveTurnId(prompt) { + const ordinal = readPromptOrdinal(prompt); + if (ordinal) { + return `mock-turn-${ordinal}`; + } + return `mock-turn-${slugifyPrompt(prompt) || "1"}`; +} + +function deriveMessageId(prompt) { + const ordinal = readPromptOrdinal(prompt); + if (ordinal) { + return `mock-message-${ordinal}`; + } + return `mock-message-${slugifyPrompt(prompt) || "1"}`; +} + +function buildReplyText(turnId, prompt) { + return `Mock reply ${turnId}: ${prompt}`; +} + +function splitIntoChunks(text, count) { + const safeText = String(text || ""); + if (!safeText) { + return [""]; + } + + const chunkSize = Math.max(1, Math.ceil(safeText.length / Math.max(1, count))); + const parts = []; + for (let index = 0; index < safeText.length; index += chunkSize) { + parts.push(safeText.slice(index, index + chunkSize)); + } + return parts; +} + +function delay(ms) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +async function emitTurn(threadId, turnId, messageId, prompt) { + const replyText = buildReplyText(turnId, prompt); + const chunks = splitIntoChunks(replyText, chunkCount); + + notify("turn/started", { + threadId, + turn: { + id: turnId, + status: "running", + }, + }); + + notify("item/started", { + turnId, + item: { + id: messageId, + type: "agentMessage", + phase: "final_answer", + content: [], + }, + }); + + let cumulativeText = ""; + for (const chunk of chunks) { + await delay(chunkDelayMs); + cumulativeText += chunk; + if (streamMode === "item-updated") { + notify("item/updated", { + turnId, + item: { + id: messageId, + type: "agentMessage", + phase: "final_answer", + content: [ + { + type: "text", + text: cumulativeText, + }, + ], + }, + }); + continue; + } + + notify("item/agentMessage/delta", { + turnId, + messageId, + phase: "final_answer", + delta: chunk, + }); + } + + notify("item/completed", { + turnId, + item: { + id: messageId, + type: "agentMessage", + phase: "final_answer", + text: replyText, + content: [ + { + type: "text", + text: replyText, + }, + ], + finishReason: "stop", + }, + }); + + notify("turn/completed", { + turnId, + turn: { + id: turnId, + status: "completed", + }, + }); +} diff --git a/tests/remcodex-config.test.ts b/tests/remcodex-config.test.ts new file mode 100644 index 0000000..6247f5f --- /dev/null +++ b/tests/remcodex-config.test.ts @@ -0,0 +1,93 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { createDatabase } from "../server/src/db/client"; +import { runMigrations } from "../server/src/db/migrations"; +import { + AgentProfileManager, + DEFAULT_ORCHESTRATOR_PROFILE, +} from "../server/src/services/agent-profile-manager"; +import { loadRemCodexConfig } from "../server/src/utils/remcodex-config"; + +test("remcodex config loads startup profiles from TOML", () => { + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "remcodex-config-")); + const configPath = path.join(tmpRoot, "config.toml"); + fs.writeFileSync( + configPath, + [ + "# sample config", + "", + "[[profiles]]", + `name = "remcodex-demo"`, + `starting_prompt = "Use RemCodex to inspect the current session."`, + `default_directory = "/home/evak/programming/by_category/agentic/remcodex"`, + "", + "[[profiles]]", + `name = 'remcodex-review'`, + `starting_prompt = 'Review the current RemCodex worktree.'`, + `default_directory = '/home/evak/programming/by_category/agentic/remcodex'`, + "", + ].join("\n"), + "utf8", + ); + + const config = loadRemCodexConfig(configPath); + + assert.deepEqual(config.profiles, [ + { + name: "remcodex-demo", + startingPrompt: "Use RemCodex to inspect the current session.", + defaultDirectory: "/home/evak/programming/by_category/agentic/remcodex", + }, + { + name: "remcodex-review", + startingPrompt: "Review the current RemCodex worktree.", + defaultDirectory: "/home/evak/programming/by_category/agentic/remcodex", + }, + ]); +}); + +test("profile manager seeds configured profiles on startup", () => { + const db = createDatabase(":memory:"); + runMigrations(db); + + const manager = new AgentProfileManager(db, { + initialProfiles: [ + { + name: DEFAULT_ORCHESTRATOR_PROFILE.name, + startingPrompt: "Custom orchestrator prompt.", + defaultDirectory: "/home/evak/programming/custom", + }, + { + name: "remcodex-demo", + startingPrompt: "Demo prompt.", + defaultDirectory: "/home/evak/programming/by_category/agentic/remcodex", + }, + ], + }); + + const profiles = manager.listProfiles(); + assert.equal(profiles.length, 2); + assert.deepEqual( + profiles.map((profile) => ({ + name: profile.name, + starting_prompt: profile.starting_prompt, + default_directory: profile.default_directory, + })), + [ + { + name: "orchestrator", + starting_prompt: "Custom orchestrator prompt.", + default_directory: "/home/evak/programming/custom", + }, + { + name: "remcodex-demo", + starting_prompt: "Demo prompt.", + default_directory: "/home/evak/programming/by_category/agentic/remcodex", + }, + ], + ); +}); diff --git a/tests/remcodex-debug-output.playwright.js b/tests/remcodex-debug-output.playwright.js new file mode 100644 index 0000000..06f8ed0 --- /dev/null +++ b/tests/remcodex-debug-output.playwright.js @@ -0,0 +1,198 @@ +#!/usr/bin/env node + +const fs = require("node:fs/promises"); +const path = require("node:path"); + +const playwrightCorePath = process.env.PLAYWRIGHT_CORE_PATH || "playwright-core"; +const { chromium } = require(playwrightCorePath); + +const baseUrl = process.env.REMCODEX_BASE_URL || "http://127.0.0.1:18840"; +const chromiumBin = process.env.CHROMIUM_BIN || "chromium"; +const turnCount = readPositiveInt(process.env.REMCODEX_TURN_COUNT, 150); +const timeoutMs = readPositiveInt(process.env.REMCODEX_REPLY_TIMEOUT_MS, 12000); +const artifactDir = process.env.REMCODEX_ARTIFACT_DIR || "/tmp/remcodex-debug-output"; +const projectPath = process.env.REMCODEX_PROJECT_PATH || "/workspace/e2e-project"; +const projectName = process.env.REMCODEX_PROJECT_NAME || "remcodex-e2e"; +const expectedStreamMode = process.env.REMCODEX_EXPECT_STREAM_MODE || "delta"; + +async function main() { + await fs.mkdir(artifactDir, { recursive: true }); + const health = await requestJson("/health"); + await writeJson("health.json", health); + + const project = await requestJson("/api/projects", { + method: "POST", + body: JSON.stringify({ + name: projectName, + path: projectPath, + createMissing: true, + }), + }); + const session = await requestJson("/api/sessions", { + method: "POST", + body: JSON.stringify({ + projectId: project.projectId, + title: "Debug output e2e session", + }), + }); + + const browser = await chromium.launch({ + executablePath: chromiumBin, + headless: true, + args: [ + "--no-sandbox", + "--disable-dev-shm-usage", + "--disable-gpu", + ], + }); + + const page = await browser.newPage(); + const consoleLines = []; + const pageErrors = []; + + page.on("console", (message) => { + consoleLines.push(`[${message.type()}] ${message.text()}`); + }); + page.on("pageerror", (error) => { + pageErrors.push(String(error && error.stack ? error.stack : error)); + }); + + try { + await page.goto(`${baseUrl}/#/sessions/${session.sessionId}`, { + waitUntil: "networkidle", + timeout: timeoutMs, + }); + await page.waitForSelector('textarea[name="content"]', { timeout: timeoutMs }); + + const prompts = []; + + for (let index = 1; index <= turnCount; index += 1) { + const marker = `turn-${index}-marker-${String(index).padStart(4, "0")}`; + const prompt = `Repeated reply visibility check ${marker}`; + const expectedReply = `Mock reply mock-turn-${index}: ${prompt}`; + prompts.push({ index, prompt, expectedReply }); + + await page.fill('textarea[name="content"]', prompt); + await page.waitForFunction(() => { + const action = document.querySelector("#composer-action"); + return action instanceof HTMLButtonElement && !action.disabled; + }, { timeout: timeoutMs }); + await page.click("#composer-action"); + + try { + await page.waitForFunction( + (replyText) => document.body.innerText.includes(replyText), + expectedReply, + { timeout: timeoutMs }, + ); + await page.waitForFunction( + (expectedReplies) => { + const rows = Array.from(document.querySelectorAll(".timeline-row-final .msg-bubble-body")); + const texts = rows + .map((row) => String(row.textContent || "").replace(/\s+/g, " ").trim()) + .filter(Boolean); + if (texts.length < expectedReplies.length) { + return false; + } + return expectedReplies.every((reply, index) => texts[index] === reply); + }, + prompts.map((entry) => entry.expectedReply), + { timeout: timeoutMs }, + ); + } catch (error) { + await captureFailure(page, consoleLines, pageErrors, { + status: "missing-reply", + failedTurn: index, + prompt, + expectedReply, + prompts, + error: String(error && error.stack ? error.stack : error), + }); + throw error; + } + + await page.waitForFunction(() => { + const action = document.querySelector("#composer-action"); + return ( + action instanceof HTMLButtonElement && + action.classList.contains("composer-action-fab--send") && + action.getAttribute("aria-label") === "Send" + ); + }, { timeout: timeoutMs }); + } + + const timeline = await requestJson(`/api/sessions/${session.sessionId}/timeline?limit=1000`); + const finalReplies = await page.evaluate(() => + Array.from(document.querySelectorAll(".timeline-row-final .msg-bubble-body")) + .map((row) => String(row.textContent || "").replace(/\s+/g, " ").trim()) + .filter(Boolean), + ); + await writeJson("summary.json", { + status: "ok", + expectedStreamMode, + turnCount, + sessionId: session.sessionId, + projectId: project.projectId, + projectPath, + timelineCount: Array.isArray(timeline.items) ? timeline.items.length : 0, + prompts, + finalReplies, + consoleLines, + pageErrors, + }); + } finally { + await browser.close(); + } +} + +async function captureFailure(page, consoleLines, pageErrors, summary) { + await writeJson("summary.json", { + ...summary, + consoleLines, + pageErrors, + }); + await fs.writeFile(path.join(artifactDir, "page.html"), await page.content(), "utf8"); + await page.screenshot({ + path: path.join(artifactDir, "failure.png"), + fullPage: true, + }); +} + +async function requestJson(pathname, options = {}) { + const response = await fetch(`${baseUrl}${pathname}`, { + ...options, + headers: { + "Content-Type": "application/json", + ...(options.headers || {}), + }, + }); + + const text = await response.text(); + const data = text ? JSON.parse(text) : null; + if (!response.ok) { + throw new Error(data && data.error ? data.error : `Request failed for ${pathname}: ${response.status}`); + } + + return data; +} + +function readPositiveInt(raw, fallback) { + const parsed = Number.parseInt(String(raw || ""), 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +async function writeJson(name, value) { + await fs.writeFile( + path.join(artifactDir, name), + `${JSON.stringify(value, null, 2)}\n`, + "utf8", + ); +} + +main().catch(async (error) => { + await writeJson("fatal.json", { + error: String(error && error.stack ? error.stack : error), + }).catch(() => null); + process.stderr.write(`${error && error.stack ? error.stack : String(error)}\n`); + process.exitCode = 1; +}); diff --git a/tests/remcodex-directory.test.ts b/tests/remcodex-directory.test.ts new file mode 100644 index 0000000..5c9cb33 --- /dev/null +++ b/tests/remcodex-directory.test.ts @@ -0,0 +1,30 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { parseRemCodexDirectoryInstances } from "../server/src/utils/remcodex-directory"; + +test("parses and normalizes configured RemCodex directory instances", () => { + assert.deepEqual( + parseRemCodexDirectoryInstances( + JSON.stringify([ + { name: "Home", url: "https://remcodex.example/", description: "Primary workspace" }, + { name: "Lab", url: "http://lab.example" }, + ]), + ), + [ + { name: "Home", url: "https://remcodex.example", description: "Primary workspace" }, + { name: "Lab", url: "http://lab.example", description: null }, + ], + ); +}); + +test("rejects malformed directory instance configuration", () => { + assert.throws( + () => parseRemCodexDirectoryInstances(JSON.stringify([{ name: "Home", url: "ftp://example.com" }])), + /must use http or https/, + ); + assert.throws( + () => parseRemCodexDirectoryInstances("not-json"), + /must contain valid JSON/, + ); +}); diff --git a/tests/remcodex-url.test.ts b/tests/remcodex-url.test.ts new file mode 100644 index 0000000..fa3b00c --- /dev/null +++ b/tests/remcodex-url.test.ts @@ -0,0 +1,28 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildRemCodexSessionUrl, + resolveRemCodexPublicBaseUrl, +} from "../server/src/utils/remcodex-url"; + +test("normalizes a configured RemCodex hostname or public URL", () => { + assert.equal(resolveRemCodexPublicBaseUrl("remcodex.example/"), "https://remcodex.example"); + assert.equal(resolveRemCodexPublicBaseUrl("https://remcodex.example/app/"), "https://remcodex.example/app"); + assert.equal(resolveRemCodexPublicBaseUrl(""), null); +}); + +test("uses the public hostname for session links and keeps relative fallback", () => { + assert.equal( + buildRemCodexSessionUrl("session/one", "https://remcodex.example"), + "https://remcodex.example/#/sessions/session%2Fone", + ); + assert.equal(buildRemCodexSessionUrl("session-one", null), "/#/sessions/session-one"); +}); + +test("rejects unsupported public hostname protocols", () => { + assert.throws( + () => resolveRemCodexPublicBaseUrl("ftp://remcodex.example"), + /must use http or https/, + ); +}); diff --git a/tests/session-parent-session.test.ts b/tests/session-parent-session.test.ts new file mode 100644 index 0000000..1ff4c57 --- /dev/null +++ b/tests/session-parent-session.test.ts @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { createDatabase } from "../server/src/db/client"; +import { runMigrations } from "../server/src/db/migrations"; +import { EventStore } from "../server/src/services/event-store"; +import { ProjectManager } from "../server/src/services/project-manager"; +import { SessionManager } from "../server/src/services/session-manager"; + +test("sessions can reference a parent session within the same project", () => { + const db = createDatabase(":memory:"); + runMigrations(db); + + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "remcodex-parent-session-")); + const projectPath = path.join(tmpRoot, "project"); + fs.mkdirSync(projectPath, { recursive: true }); + + const eventStore = new EventStore(db); + const projectManager = new ProjectManager(db, tmpRoot, process.cwd()); + const manager = new SessionManager({ + db, + eventStore, + projectManager, + codexCommand: "codex", + codexMode: "app-server", + }); + + const project = projectManager.createProject({ + name: "Parent Project", + path: projectPath, + }); + const parent = manager.createSession({ + projectId: project.id, + title: "Parent Session", + }); + const child = manager.createSession({ + projectId: project.id, + title: "Child Session", + parentSessionId: parent.id, + }); + + assert.equal(child.parent_session_id, parent.id); + assert.equal(manager.getSession(child.id)?.parent_session_id, parent.id); + assert.equal(manager.listSessions().find((session) => session.id === child.id)?.parent_session_id, parent.id); + assert.throws( + () => + manager.createSession({ + projectId: project.id, + parentSessionId: "missing-parent", + }), + /Parent session not found/, + ); +}); diff --git a/tests/session-starting-prompt-server.test.ts b/tests/session-starting-prompt-server.test.ts new file mode 100644 index 0000000..2c3d7e8 --- /dev/null +++ b/tests/session-starting-prompt-server.test.ts @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { createDatabase } from "../server/src/db/client"; +import { runMigrations } from "../server/src/db/migrations"; +import { EventStore } from "../server/src/services/event-store"; +import { ProjectManager } from "../server/src/services/project-manager"; +import { SessionManager } from "../server/src/services/session-manager"; +import { composeMessageContentWithStartingPrompt } from "../server/src/utils/session-starting-prompt"; + +test("server stores and composes session starting prompts", () => { + assert.equal( + composeMessageContentWithStartingPrompt( + "Inspect the current session state.", + "Use RemCodex to keep the workspace in view.", + ), + "Use RemCodex to keep the workspace in view.\n\nInspect the current session state.", + ); + + const db = createDatabase(":memory:"); + runMigrations(db); + + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "remcodex-starting-prompt-")); + const projectPath = path.join(tmpRoot, "project"); + fs.mkdirSync(projectPath, { recursive: true }); + + const eventStore = new EventStore(db); + const projectManager = new ProjectManager(db, tmpRoot, process.cwd()); + const manager = new SessionManager({ + db, + eventStore, + projectManager, + codexCommand: "codex", + codexMode: "app-server", + }); + + const project = projectManager.createProject({ + name: "Prompt Project", + path: projectPath, + }); + const session = manager.createSession({ + projectId: project.id, + startingPrompt: "Use the profile prompt.", + }); + + assert.equal(session.starting_prompt, "Use the profile prompt."); + assert.equal(manager.getSession(session.id)?.starting_prompt, "Use the profile prompt."); + assert.equal(manager.listSessions()[0]?.starting_prompt, "Use the profile prompt."); +}); diff --git a/tests/session-starting-prompt.test.ts b/tests/session-starting-prompt.test.ts new file mode 100644 index 0000000..3f9af99 --- /dev/null +++ b/tests/session-starting-prompt.test.ts @@ -0,0 +1,21 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { composeMessageContentWithStartingPrompt } from "../web/utils/session-starting-prompt.js"; + +test("starting prompt is prepended to the first submitted message", () => { + assert.equal( + composeMessageContentWithStartingPrompt( + "Inspect the current session state.", + "Use RemCodex to keep the workspace in view.", + ), + "Use RemCodex to keep the workspace in view.\n\nInspect the current session state.", + ); +}); + +test("starting prompt is ignored when no prompt is configured", () => { + assert.equal( + composeMessageContentWithStartingPrompt("Inspect the current session state.", ""), + "Inspect the current session state.", + ); +}); diff --git a/tests/session-working-directory.test.ts b/tests/session-working-directory.test.ts new file mode 100644 index 0000000..51bec6b --- /dev/null +++ b/tests/session-working-directory.test.ts @@ -0,0 +1,169 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { createDatabase } from "../server/src/db/client"; +import { runMigrations } from "../server/src/db/migrations"; +import { EventStore } from "../server/src/services/event-store"; +import { ProjectManager } from "../server/src/services/project-manager"; +import { SessionManager } from "../server/src/services/session-manager"; + +function makeManagers() { + const db = createDatabase(":memory:"); + runMigrations(db); + const root = fs.mkdtempSync(path.join(os.tmpdir(), "remcodex-working-directory-")); + const projectManager = new ProjectManager(db, root, process.cwd()); + const sessionManager = new SessionManager({ + db, + eventStore: new EventStore(db), + projectManager, + codexCommand: "codex", + codexMode: "app-server", + }); + return { db, projectManager, root, sessionManager }; +} + +test("finds the reusable session for a canonicalized working directory", () => { + const { db, projectManager, root, sessionManager } = makeManagers(); + const projectPath = path.join(root, "workspace"); + fs.mkdirSync(projectPath); + const project = projectManager.getOrCreateProjectByPath(`${projectPath}/`, "Workspace"); + const session = sessionManager.createSession({ projectId: project.id, title: "Existing session" }); + + assert.equal( + sessionManager.findSessionByProjectPath(path.join(projectPath, "."))?.id, + session.id, + ); + + db.prepare("UPDATE sessions SET status = 'completed' WHERE id = ?").run(session.id); + assert.equal(sessionManager.findSessionByProjectPath(projectPath), null); +}); + +test("registers an untracked working directory as a project", () => { + const { projectManager, root } = makeManagers(); + const projectPath = path.join(root, "new-workspace"); + fs.mkdirSync(projectPath); + + const project = projectManager.getOrCreateProjectByPath(projectPath); + + assert.equal(project.name, "new-workspace"); + assert.equal(project.path, projectPath); + assert.equal(projectManager.getProjectByPath(`${projectPath}/.`)?.id, project.id); +}); + +test("lists every session previously started in a working directory", () => { + const { projectManager, root, sessionManager } = makeManagers(); + const projectPath = path.join(root, "workspace"); + fs.mkdirSync(projectPath); + const project = projectManager.getOrCreateProjectByPath(projectPath); + const first = sessionManager.createSession({ projectId: project.id, title: "First session" }); + const second = sessionManager.createSession({ projectId: project.id, title: "Second session" }); + + const sessions = sessionManager.listSessionsByProjectPath(path.join(projectPath, ".")); + assert.equal(sessions.length, 2); + assert.deepEqual(new Set(sessions.map((session) => session.id)), new Set([first.id, second.id])); + assert.deepEqual(sessionManager.listSessionsByProjectPath(path.join(root, "missing")), []); +}); + +test("resumes a terminal session and optionally assigns a same-project parent", () => { + const { db, projectManager, root, sessionManager } = makeManagers(); + const projectPath = path.join(root, "workspace"); + fs.mkdirSync(projectPath); + const project = projectManager.getOrCreateProjectByPath(projectPath); + const parent = sessionManager.createSession({ projectId: project.id, title: "Parent" }); + const session = sessionManager.createSession({ projectId: project.id, title: "Resumable" }); + db.prepare("UPDATE sessions SET status = 'failed' WHERE id = ?").run(session.id); + + const resumed = sessionManager.resumeSession(session.id, parent.id); + + assert.equal(resumed.status, "idle"); + assert.equal(resumed.parent_session_id, parent.id); +}); + +test("rejects a parent from another project when resuming", () => { + const { projectManager, root, sessionManager } = makeManagers(); + const firstPath = path.join(root, "first"); + const secondPath = path.join(root, "second"); + fs.mkdirSync(firstPath); + fs.mkdirSync(secondPath); + const first = projectManager.getOrCreateProjectByPath(firstPath); + const second = projectManager.getOrCreateProjectByPath(secondPath); + const session = sessionManager.createSession({ projectId: first.id }); + const parent = sessionManager.createSession({ projectId: second.id }); + + assert.throws( + () => sessionManager.resumeSession(session.id, parent.id), + /Parent session directory must be an ancestor/, + ); +}); + +test("allows parent sessions only along ancestor directory paths", () => { + const { projectManager, root, sessionManager } = makeManagers(); + const programmingPath = path.join(root, "programming"); + const agenticPath = path.join(programmingPath, "by_category", "agentic"); + const remcodexPath = path.join(agenticPath, "remcodex"); + const productivityPath = path.join(programmingPath, "by_category", "productivity"); + for (const directory of [agenticPath, remcodexPath, productivityPath]) { + fs.mkdirSync(directory, { recursive: true }); + } + + const programming = projectManager.getOrCreateProjectByPath(programmingPath); + const agentic = projectManager.getOrCreateProjectByPath(agenticPath); + const remcodex = projectManager.getOrCreateProjectByPath(remcodexPath); + const productivity = projectManager.getOrCreateProjectByPath(productivityPath); + const programmingSession = sessionManager.createSession({ projectId: programming.id }); + const agenticSession = sessionManager.createSession({ + projectId: agentic.id, + parentSessionId: programmingSession.id, + }); + + const remcodexSession = sessionManager.createSession({ + projectId: remcodex.id, + parentSessionId: agenticSession.id, + }); + assert.equal(remcodexSession.parent_session_id, agenticSession.id); + + const productivitySession = sessionManager.createSession({ projectId: productivity.id }); + assert.throws( + () => sessionManager.createSession({ projectId: agentic.id, parentSessionId: productivitySession.id }), + /Parent session directory must be an ancestor/, + ); +}); + +test("stores and updates session descriptions, tags, and metadata", () => { + const { projectManager, root, sessionManager } = makeManagers(); + const projectPath = path.join(root, "workspace"); + fs.mkdirSync(projectPath); + const project = projectManager.getOrCreateProjectByPath(projectPath); + + const session = sessionManager.createSession({ + projectId: project.id, + description: "Initial session context", + tags: [" agentic ", "remcodex", "agentic"], + metadata: { owner: "orchestrator", priority: 2 }, + }); + + assert.equal(session.description, "Initial session context"); + assert.deepEqual(session.tags, ["agentic", "remcodex"]); + assert.deepEqual(session.metadata, { owner: "orchestrator", priority: 2 }); + + const updated = sessionManager.updateSessionMetadata({ + sessionId: session.id, + description: null, + tags: ["follow-up"], + metadata: { owner: "worker", priority: 3 }, + }); + assert.equal(updated.description, null); + assert.deepEqual(updated.tags, ["follow-up"]); + assert.deepEqual(updated.metadata, { owner: "worker", priority: 3 }); + + const descriptionOnly = sessionManager.updateSessionMetadata({ + sessionId: session.id, + description: "Updated context", + }); + assert.equal(descriptionOnly.description, "Updated context"); + assert.deepEqual(descriptionOnly.tags, ["follow-up"]); + assert.deepEqual(descriptionOnly.metadata, { owner: "worker", priority: 3 }); +}); diff --git a/tests/test_remcodex_tui.py b/tests/test_remcodex_tui.py new file mode 100644 index 0000000..61245c9 --- /dev/null +++ b/tests/test_remcodex_tui.py @@ -0,0 +1,59 @@ +import importlib.util +import json +import subprocess +import tempfile +import unittest +from pathlib import Path + + +SCRIPT = Path(__file__).parents[1] / "scripts" / "remcodex-tui.py" +SPEC = importlib.util.spec_from_file_location("remcodex_tui", SCRIPT) +MODULE = importlib.util.module_from_spec(SPEC) +assert SPEC and SPEC.loader +SPEC.loader.exec_module(MODULE) + + +class RemcodexTuiTests(unittest.TestCase): + def test_app_server_command_matches_panoply_wrapper(self): + self.assertEqual( + MODULE.build_app_server_command("codex", "unix:///tmp/codex.sock"), + [ + "codex", + "--dangerously-bypass-approvals-and-sandbox", + "app-server", + "--listen", + "unix:///tmp/codex.sock", + ], + ) + + def test_tui_command_uses_remote_app_server(self): + self.assertEqual( + MODULE.build_tui_command("codex", "unix:///tmp/codex.sock"), + [ + "codex", + "--dangerously-bypass-approvals-and-sandbox", + "--remote", + "unix:///tmp/codex.sock", + ], + ) + + def test_registry_metadata_is_discoverable(self): + with tempfile.TemporaryDirectory() as directory: + metadata_path = Path(directory) / "codex-test.json" + process = subprocess.Popen(["true"]) + MODULE.write_registry_metadata( + metadata_path, + "codex-test", + "unix:///tmp/codex-test.sock", + Path("/tmp/codex-test.sock"), + process, + ) + process.wait() + metadata = json.loads(metadata_path.read_text()) + self.assertEqual(metadata["id"], "codex-test") + self.assertEqual(metadata["endpoint"], "unix:///tmp/codex-test.sock") + self.assertEqual(metadata["source"], "codex") + + +if __name__ == "__main__": + unittest.main() diff --git a/web/api.js b/web/api.js index ec9c4b0..c4e3d4c 100644 --- a/web/api.js +++ b/web/api.js @@ -28,6 +28,10 @@ export function getProjects() { return request("/api/projects"); } +export function getProfiles() { + return request("/api/profiles"); +} + export function createProject(payload) { return request("/api/projects", { method: "POST", @@ -62,6 +66,13 @@ export function getSession(sessionId) { return request(`/api/sessions/${sessionId}`); } +export function updateSessionMetadata(sessionId, payload) { + return request(`/api/sessions/${sessionId}`, { + method: "PATCH", + body: JSON.stringify(payload), + }); +} + export function syncImportedSession(sessionId) { return request(`/api/sessions/${sessionId}/sync`, { method: "POST", @@ -125,6 +136,31 @@ export function sendMessage(sessionId, payload) { }); } +export async function sendVoiceMessage(sessionId, audioBlob, options = {}) { + const headers = {}; + if (options.mimeType) { + headers["Content-Type"] = options.mimeType; + } + if (options.filename) { + headers["X-Filename"] = options.filename; + } + if (options.modelId) { + headers["X-Codex-Model"] = options.modelId; + } + if (options.reasoningEffort) { + headers["X-Codex-Reasoning"] = options.reasoningEffort; + } + if (options.profile) { + headers["X-Codex-Profile"] = options.profile; + } + + return request(`/api/sessions/${sessionId}/messages/voice`, { + method: "POST", + headers, + body: audioBlob, + }); +} + export function stopSession(sessionId) { return request(`/api/sessions/${sessionId}/stop`, { method: "POST", diff --git a/web/app.js b/web/app.js index 91ad67a..80375ee 100644 --- a/web/app.js +++ b/web/app.js @@ -10,6 +10,7 @@ import { getCodexStatus, getHealth, getProjects, + getProfiles, getSession, getSessionEvents, getSessionTimelineEvents, @@ -17,6 +18,7 @@ import { resolveSessionApproval, retrySessionApproval, sendMessage, + sendVoiceMessage, stopSession, syncImportedSession, } from "./api.js"; @@ -28,7 +30,12 @@ import { loadCodexLaunchPrefs, normalizeCodexLaunchAgainstUi, renderComposerInput, + persistCodexLaunchPrefs, } from "./components/composer.js"; +import { + composeMessageContentWithStartingPrompt, + normalizeSessionStartingPrompt, +} from "./utils/session-starting-prompt.js"; import { renderSessionTopBar } from "./components/session-workbench.js"; import { normalizeRawSessionEvent, @@ -56,6 +63,11 @@ const app = document.querySelector("#app"); const SESSION_VIEW_STORAGE_KEY = "remote-agent-console.sessions.view"; const CODEX_QUOTA_CACHE_PREFIX = "remote-agent-console.codexQuota:"; const WORKSPACE_UI_STORAGE_KEY = "remote-agent-console.workspace.ui"; +const WORKSPACE_SESSION_EXPANDED_STORAGE_KEY = "remote-agent-console.workspace.expandedSessions.v1"; +const WORKSPACE_SESSION_COLLAPSED_STORAGE_KEY = "remote-agent-console.workspace.collapsedSessions.v1"; +const WORKSPACE_NEW_SESSION_PROFILE_STORAGE_KEY = "remote-agent-console.workspace.newSessionProfile.v1"; +const WORKSPACE_SESSION_STARTING_PROMPT_STORAGE_PREFIX = + "remote-agent-console.workspace.sessionStartingPrompt.v1:"; const SLOW_COMMAND_SECONDS = 5; const LONG_RUNNING_COMMAND_SECONDS = 10; const COMMAND_PREVIEW_HEAD_LINES = 3; @@ -68,8 +80,13 @@ const INITIAL_DETAIL_EVENT_PAGE_LIMIT = 200; const INITIAL_DETAIL_MIN_TURNS = 4; const INITIAL_DETAIL_MAX_PAGES = 5; const DETAIL_RENDER_BATCH_MS = 0; +const CLIENT_DEBUG_LOG_LIMIT = 120; let lastToastMessage = ""; let lastToastAt = 0; +let activeVoiceRecorder = null; +let activeVoiceStream = null; +let activeVoiceChunks = []; +let activeVoiceMimeType = ""; const GENERIC_SESSION_TITLES = new Set([ "未命名会话", "新会话", @@ -91,8 +108,131 @@ const DEFAULT_DETAIL_VIEW = { search: "", autoScroll: true, rawStdoutBuckets: {}, + clientLogs: [], }; +function getWorkspaceSessionStartingPromptStorageKey(sessionId) { + const normalizedSessionId = String(sessionId || "").trim(); + return normalizedSessionId + ? `${WORKSPACE_SESSION_STARTING_PROMPT_STORAGE_PREFIX}${normalizedSessionId}` + : ""; +} + +function loadWorkspaceSessionStartingPrompt(sessionId) { + const storageKey = getWorkspaceSessionStartingPromptStorageKey(sessionId); + if (!storageKey) { + return ""; + } + + try { + return normalizeSessionStartingPrompt(window.localStorage?.getItem(storageKey) || ""); + } catch { + return ""; + } +} + +function persistWorkspaceSessionStartingPrompt(sessionId, prompt) { + const storageKey = getWorkspaceSessionStartingPromptStorageKey(sessionId); + if (!storageKey) { + return; + } + + const normalizedPrompt = normalizeSessionStartingPrompt(prompt); + try { + if (normalizedPrompt) { + window.localStorage?.setItem(storageKey, normalizedPrompt); + } else { + window.localStorage?.removeItem(storageKey); + } + } catch { + /* ignore */ + } +} + +function clearWorkspaceSessionStartingPrompt(sessionId) { + persistWorkspaceSessionStartingPrompt(sessionId, ""); +} + +function browserSupportsVoiceRecording() { + return Boolean(window.MediaRecorder && navigator.mediaDevices?.getUserMedia); +} + +function getPreferredVoiceMimeType() { + if (!window.MediaRecorder?.isTypeSupported) { + return ""; + } + + const candidates = [ + "audio/webm;codecs=opus", + "audio/webm", + "audio/ogg;codecs=opus", + "audio/ogg", + ]; + return candidates.find((candidate) => window.MediaRecorder.isTypeSupported(candidate)) || ""; +} + +function formatClientLogDetails(details) { + if (details == null) { + return ""; + } + + if (typeof details === "string") { + return details; + } + + if (details instanceof Error) { + return details.stack || `${details.name}: ${details.message}`; + } + + try { + return JSON.stringify(details, null, 2); + } catch (_error) { + return String(details); + } +} + +function pushClientDebugLog(level, message, details) { + const normalizedLevel = ["debug", "info", "warn", "error"].includes(level) ? level : "info"; + const entry = { + ts: Date.now(), + level: normalizedLevel, + message: String(message || ""), + details: details ?? null, + }; + + state.detail.clientLogs = [...(state.detail.clientLogs || []), entry].slice(-CLIENT_DEBUG_LOG_LIMIT); + + const consoleMethod = typeof console[normalizedLevel] === "function" ? console[normalizedLevel] : console.log; + const prefix = `[remcodex] ${entry.message}`; + if (entry.details == null) { + consoleMethod.call(console, prefix); + } else { + consoleMethod.call(console, prefix, entry.details); + } + + return entry; +} + +function stopActiveVoiceStream() { + if (activeVoiceStream?.getTracks) { + activeVoiceStream.getTracks().forEach((track) => { + try { + track.stop(); + } catch { + /* ignore */ + } + }); + } + activeVoiceStream = null; +} + +function resetVoiceCaptureState() { + activeVoiceRecorder = null; + activeVoiceChunks = []; + activeVoiceMimeType = ""; + stopActiveVoiceStream(); +} + function renderAppChrome({ variant, title, @@ -182,6 +322,97 @@ function writeWorkspaceUiState() { } } +function readWorkspaceExpandedSessionIds() { + try { + const raw = window.localStorage?.getItem(WORKSPACE_SESSION_EXPANDED_STORAGE_KEY) || ""; + if (!raw) { + return {}; + } + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + return {}; + } + return parsed.reduce((acc, value) => { + const sessionId = String(value || "").trim(); + if (sessionId) { + acc[sessionId] = true; + } + return acc; + }, {}); + } catch { + return {}; + } +} + +function readWorkspaceCollapsedSessionIds() { + try { + const raw = window.localStorage?.getItem(WORKSPACE_SESSION_COLLAPSED_STORAGE_KEY) || ""; + if (!raw) { + return {}; + } + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + return {}; + } + return parsed.reduce((acc, value) => { + const sessionId = String(value || "").trim(); + if (sessionId) { + acc[sessionId] = true; + } + return acc; + }, {}); + } catch { + return {}; + } +} + +function writeWorkspaceExpandedSessionIds() { + try { + window.localStorage?.setItem( + WORKSPACE_SESSION_EXPANDED_STORAGE_KEY, + JSON.stringify(Object.keys(state.workspace.expandedSessionIds || {}).filter((sessionId) => state.workspace.expandedSessionIds[sessionId])), + ); + } catch { + /* ignore */ + } +} + +function writeWorkspaceCollapsedSessionIds() { + try { + window.localStorage?.setItem( + WORKSPACE_SESSION_COLLAPSED_STORAGE_KEY, + JSON.stringify(Object.keys(state.workspace.collapsedSessionIds || {}).filter((sessionId) => state.workspace.collapsedSessionIds[sessionId])), + ); + } catch { + /* ignore */ + } +} + +function setWorkspaceSessionExpanded(sessionId, expanded, selectedSessionId = state.workspace.activeSessionId) { + const normalizedSessionId = String(sessionId || "").trim(); + if (!normalizedSessionId) { + return; + } + + const next = { ...(state.workspace.expandedSessionIds || {}) }; + const nextCollapsed = { ...(state.workspace.collapsedSessionIds || {}) }; + if (expanded) { + next[normalizedSessionId] = true; + delete nextCollapsed[normalizedSessionId]; + } else { + delete next[normalizedSessionId]; + nextCollapsed[normalizedSessionId] = true; + } + state.workspace.expandedSessionIds = next; + state.workspace.collapsedSessionIds = nextCollapsed; + writeWorkspaceExpandedSessionIds(); + writeWorkspaceCollapsedSessionIds(); + patchWorkspaceSidebar(selectedSessionId); + if (parseHashRoute(window.location.hash || "").path === "#/sessions") { + renderSessionsList(); + } +} + function getCurrentPageHost() { const hostname = typeof window !== "undefined" ? window.location.hostname : ""; return typeof hostname === "string" ? hostname.trim() : ""; @@ -769,6 +1000,17 @@ function mergeDetailTimelineRawEvents(nextRawEvents) { return; } + pushClientDebugLog("debug", "Merging timeline events", { + sessionId: activeSessionId, + incomingCount: nextRawEvents.length, + appendedCount: appended.length, + incremental: canApplyIncrementally, + seqRange: { + first: appended[0]?.seq || 0, + last: appended[appended.length - 1]?.seq || 0, + }, + }); + state.detail.rawEvents = [...state.detail.rawEvents, ...appended].sort( (a, b) => (a.seq || 0) - (b.seq || 0), ); @@ -804,37 +1046,61 @@ async function catchUpSessionEvents(sessionId, afterSeq) { return; } - for (let page = 0; page < 10; page += 1) { - if (!isActiveDetailSession(normalizedSessionId)) { - return; - } + pushClientDebugLog("debug", "Catching up session events", { + sessionId: normalizedSessionId, + afterSeq: nextAfter, + }); + try { + for (let page = 0; page < 10; page += 1) { + if (!isActiveDetailSession(normalizedSessionId)) { + return; + } - const payload = await getSessionEvents(normalizedSessionId, { - after: nextAfter, - limit: 200, - }); - if (!isActiveDetailSession(normalizedSessionId)) { - return; - } + const payload = await getSessionEvents(normalizedSessionId, { + after: nextAfter, + limit: 200, + }); + if (!isActiveDetailSession(normalizedSessionId)) { + return; + } - const items = Array.isArray(payload?.items) - ? payload.items.filter((item) => { - const eventSessionId = String(item?.sessionId || item?.session_id || "").trim(); - return !eventSessionId || eventSessionId === normalizedSessionId; - }) - : []; - if (items.length === 0) { - return; - } + const items = Array.isArray(payload?.items) + ? payload.items.filter((item) => { + const eventSessionId = String(item?.sessionId || item?.session_id || "").trim(); + return !eventSessionId || eventSessionId === normalizedSessionId; + }) + : []; + if (items.length === 0) { + pushClientDebugLog("debug", "Catch-up returned no new events", { + sessionId: normalizedSessionId, + page, + afterSeq: nextAfter, + }); + return; + } - trackUnseenEvents(items); - mergeDetailTimelineRawEvents(items); - nextAfter = Number(payload?.nextCursor || nextAfter); - state.detail.cursor = Math.max(state.detail.cursor, nextAfter); + trackUnseenEvents(items); + mergeDetailTimelineRawEvents(items); + nextAfter = Number(payload?.nextCursor || nextAfter); + state.detail.cursor = Math.max(state.detail.cursor, nextAfter); + pushClientDebugLog("debug", "Catch-up applied events", { + sessionId: normalizedSessionId, + page, + appliedCount: items.length, + nextCursor: nextAfter, + }); - if (items.length < 200) { - return; + if (items.length < 200) { + return; + } } + } catch (error) { + pushClientDebugLog("warn", "Catch-up failed", { + sessionId: normalizedSessionId, + afterSeq: nextAfter, + error: messageOf(error), + }); + throw error; } } @@ -1346,7 +1612,11 @@ const state = { workspace: { sidebarCollapsed: readWorkspaceUiState().sidebarCollapsed, localeMenuOpen: false, + newSessionProfileMenuOpen: false, activeSessionId: "", + profiles: [], + expandedSessionIds: readWorkspaceExpandedSessionIds(), + collapsedSessionIds: readWorkspaceCollapsedSessionIds(), createDialog: { open: false, mode: "pick-project", @@ -1409,6 +1679,10 @@ const state = { liveResumeTimerId: 0, importedSyncTimerId: 0, composerEnvironmentMenuOpen: false, + voiceRecording: false, + voiceTranscribing: false, + startingPrompt: "", + composerRefreshRequested: false, slashMenuOpen: false, slashCommands: [], slashCommandsLoading: false, @@ -1442,6 +1716,7 @@ window.addEventListener("pageshow", () => { function renderRoute() { state.detail.loadRequestId = Number(state.detail.loadRequestId || 0) + 1; + resetVoiceCaptureState(); cleanupSocket(); cleanupDetailClock(); cleanupLiveResumeSync(); @@ -1465,6 +1740,10 @@ function renderRoute() { ...state.detail, ...DEFAULT_DETAIL_VIEW, optimisticSend: null, + voiceRecording: false, + voiceTranscribing: false, + startingPrompt: "", + composerRefreshRequested: false, }; } @@ -1489,7 +1768,7 @@ function resolveWorkspaceSessionId(routeSessionId) { return state.workspace.activeSessionId; } - return getWorkspaceFilteredSessions()[0]?.sessionId || state.sessions.items[0]?.sessionId || ""; + return buildWorkspaceSessionRows(state.sessions.items, state.sessions, "")[0]?.session.sessionId || state.sessions.items[0]?.sessionId || ""; } function renderWorkspaceEmptyState() { @@ -1537,17 +1816,433 @@ function findExistingProjectByPath(pathValue) { return null; } - return ( - state.sessions.projects.find( - (project) => normalizeProjectPathForComparison(project.path) === normalizedTargetPath, - ) || null + return ( + state.sessions.projects.find( + (project) => normalizeProjectPathForComparison(project.path) === normalizedTargetPath, + ) || null + ); +} + +function getDefaultWorkspaceProjectBrowsePath() { + return normalizeProjectPathForComparison( + state.detail.session?.projectPath || state.sessions.projects[0]?.path || "", + ); +} + +function getWorkspaceNewSessionProfileItems() { + const profiles = Array.isArray(state.workspace.profiles) ? state.workspace.profiles : []; + const uniqueProfiles = []; + const seenNames = new Set(); + + for (const profile of profiles) { + const name = String(profile?.name || "").trim(); + if (!name || seenNames.has(name)) { + continue; + } + seenNames.add(name); + uniqueProfiles.push(profile); + } + + return [ + { name: "", label: t("workspace.sidebar.newSessionCustom") }, + ...uniqueProfiles.map((profile) => ({ + name: String(profile.name || "").trim(), + label: String(profile.name || "").trim(), + defaultDirectory: String(profile.default_directory || "").trim(), + })), + ]; +} + +function getWorkspaceNewSessionProfileName() { + try { + const stored = window.localStorage?.getItem(WORKSPACE_NEW_SESSION_PROFILE_STORAGE_KEY) || ""; + const profileName = String(stored || "").trim(); + if (!profileName) { + return ""; + } + + const profiles = Array.isArray(state.workspace.profiles) ? state.workspace.profiles : []; + if (!profiles.length) { + return profileName; + } + + return profiles.some((profile) => String(profile?.name || "").trim() === profileName) + ? profileName + : ""; + } catch { + return ""; + } +} + +function getWorkspaceNewSessionProfileLabel() { + const profileName = getWorkspaceNewSessionProfileName(); + return profileName || t("workspace.sidebar.newSessionCustom"); +} + +function getWorkspaceNewSessionProfile(profileName) { + const name = String(profileName || "").trim(); + if (!name) { + return null; + } + + const profiles = Array.isArray(state.workspace.profiles) ? state.workspace.profiles : []; + return ( + profiles.find((profile) => String(profile?.name || "").trim() === name) || null + ); +} + +function persistWorkspaceNewSessionProfile(profileName) { + const nextProfile = String(profileName || "").trim(); + try { + window.localStorage?.setItem(WORKSPACE_NEW_SESSION_PROFILE_STORAGE_KEY, nextProfile); + } catch { + /* ignore */ + } +} + +function closeWorkspaceNewSessionProfileMenu(selectedSessionId = state.workspace.activeSessionId) { + if (!state.workspace.newSessionProfileMenuOpen) { + return; + } + + state.workspace.newSessionProfileMenuOpen = false; + patchWorkspaceSidebar(selectedSessionId); +} + +function setWorkspaceNewSessionProfile(profileName, selectedSessionId = state.workspace.activeSessionId) { + persistWorkspaceNewSessionProfile(profileName); + state.workspace.newSessionProfileMenuOpen = false; + patchWorkspaceSidebar(selectedSessionId); +} + +function getSessionParentId(session) { + return String(session?.parentSessionId || session?.parent_session_id || "").trim(); +} + +function renderAppServerLivenessIndicator(session) { + if (!session?.appServerId) { + return ""; + } + + const connected = Boolean(session.appServerConnected); + const stateLabel = connected ? "connected to interactive TUI" : "previously connected to interactive TUI"; + return ``; +} + +function getWorkspaceSessionAncestorIds(sessionId, sessionMap) { + const ancestors = new Set(); + const visited = new Set(); + let currentId = String(sessionId || "").trim(); + + while (currentId && !visited.has(currentId)) { + visited.add(currentId); + const currentSession = sessionMap.get(currentId); + if (!currentSession) { + break; + } + + const parentId = getSessionParentId(currentSession); + if (!parentId) { + break; + } + + ancestors.add(parentId); + currentId = parentId; + } + + return ancestors; +} + +function buildWorkspaceSessionRows(sessions, filters, selectedSessionId = "") { + const projectMap = new Map(state.sessions.projects.map((project) => [project.projectId, project])); + const sessionMap = new Map(sessions.map((session) => [session.sessionId, session])); + const directVisibility = new Map(); + const includedCache = new Map(); + const childrenMap = new Map(); + + for (const session of sessions) { + const parentId = getSessionParentId(session); + if (!parentId || !sessionMap.has(parentId)) { + continue; + } + + const children = childrenMap.get(parentId) || []; + children.push(session); + childrenMap.set(parentId, children); + } + + const sortKey = String(filters?.sort || state.sessions.sort || "activity_desc"); + + function isDirectlyVisible(session) { + const sessionId = String(session?.sessionId || "").trim(); + if (!sessionId) { + return false; + } + if (directVisibility.has(sessionId)) { + return directVisibility.get(sessionId); + } + + const visible = matchesSessionFilters(session, projectMap.get(session.projectId), filters); + directVisibility.set(sessionId, visible); + return visible; + } + + function hasIncludedDescendant(sessionId) { + const normalizedSessionId = String(sessionId || "").trim(); + if (!normalizedSessionId) { + return false; + } + if (includedCache.has(normalizedSessionId)) { + return includedCache.get(normalizedSessionId); + } + + const descendants = sortSessions(childrenMap.get(normalizedSessionId) || [], sortKey); + const included = descendants.some((child) => isDirectlyVisible(child) || hasIncludedDescendant(child.sessionId)); + includedCache.set(normalizedSessionId, included); + return included; + } + + const forcedExpandedIds = getWorkspaceSessionAncestorIds(selectedSessionId, sessionMap); + const rows = []; + + function visit(items, level) { + for (const session of sortSessions(items, sortKey)) { + const included = isDirectlyVisible(session) || hasIncludedDescendant(session.sessionId); + if (!included) { + continue; + } + + const childItems = sortSessions(childrenMap.get(session.sessionId) || [], sortKey).filter( + (child) => isDirectlyVisible(child) || hasIncludedDescendant(child.sessionId), + ); + const hasChildren = childItems.length > 0; + const collapsed = Boolean(state.workspace.collapsedSessionIds?.[session.sessionId]); + const expanded = + hasChildren && + !collapsed && + (forcedExpandedIds.has(session.sessionId) || Boolean(state.workspace.expandedSessionIds?.[session.sessionId])); + + rows.push({ + session, + level, + hasChildren, + expanded, + directVisible: isDirectlyVisible(session), + }); + + if (hasChildren && expanded) { + visit(childItems, level + 1); + } + } + } + + const rootItems = sessions.filter((session) => { + const parentId = getSessionParentId(session); + return !parentId || !sessionMap.has(parentId); + }); + visit(rootItems, 0); + return rows; +} + +function renderWorkspaceSessionRow(row, selectedSessionId = "", selectedSessionIdSet = null) { + const session = row.session; + const project = state.sessions.projects.find((candidate) => candidate.projectId === session.projectId); + const displayStatus = getSessionDisplayStatus(session); + const showStatusPill = ["starting", "running", "stopping", "failed"].includes(displayStatus); + const isSelected = session.sessionId === selectedSessionId; + const indentStyle = row.level > 0 ? ` style="--session-indent:${row.level}"` : ""; + const childClass = row.level > 0 ? " workspace-session-item--child" : ""; + const directVisibleClass = row.directVisible ? "" : " workspace-session-item--ancestor"; + + return ` +
+ +
+ `; +} + +async function createWorkspaceSessionForProjectPath(projectPath, startingPrompt = "") { + const normalizedProjectPath = normalizeProjectPathForComparison(projectPath); + if (!normalizedProjectPath) { + throw new Error("Profile default directory is missing."); + } + + const refreshedProjects = await getProjects(); + state.sessions.projects = Array.isArray(refreshedProjects.items) ? refreshedProjects.items : []; + + let project = findExistingProjectByPath(normalizedProjectPath); + if (!project) { + const projectName = deriveProjectNameFromPath(normalizedProjectPath); + if (!projectName) { + throw new Error("Profile default directory is invalid."); + } + + const createdProject = await createProject({ + name: projectName, + path: normalizedProjectPath, + createMissing: false, + }); + const refreshedAfterCreate = await getProjects(); + state.sessions.projects = Array.isArray(refreshedAfterCreate.items) ? refreshedAfterCreate.items : []; + project = + state.sessions.projects.find( + (candidate) => normalizeProjectPathForComparison(candidate.path) === normalizedProjectPath, + ) || { + projectId: createdProject.projectId, + name: projectName, + path: normalizedProjectPath, + }; + } + + const session = await createSession({ + projectId: project.projectId, + startingPrompt: normalizeSessionStartingPrompt(startingPrompt || ""), + }); + return session; +} + +async function startWorkspaceNewSessionFromSelectedProfile(selectedSessionId = state.workspace.activeSessionId) { + const profileName = getWorkspaceNewSessionProfileName(); + if (!profileName) { + openWorkspaceCreateSessionDialog(); + return; + } + + const profile = getWorkspaceNewSessionProfile(profileName); + const defaultDirectory = String(profile?.default_directory || "").trim(); + if (!defaultDirectory) { + showToast(t("workspace.create.pathRequired")); + return; + } + + state.workspace.newSessionProfileMenuOpen = false; + const launchPrefs = loadCodexLaunchPrefs(); + if (launchPrefs.profile) { + launchPrefs.profile = ""; + persistCodexLaunchPrefs(launchPrefs); + } + if (state.detail.codexLaunch?.profile) { + state.detail.codexLaunch.profile = ""; + } + const startingPrompt = normalizeSessionStartingPrompt(profile?.starting_prompt || ""); + const session = await createWorkspaceSessionForProjectPath(defaultDirectory, startingPrompt); + window.location.hash = buildSessionDetailHash( + session.sessionId, + state.detail.filter, + state.detail.severity, + state.detail.search, + state.detail.autoScroll, ); } -function getDefaultWorkspaceProjectBrowsePath() { - return normalizeProjectPathForComparison( - state.detail.session?.projectPath || state.sessions.projects[0]?.path || "", +function ensureWorkspaceSidebarGlobalListeners() { + if (window.__workspaceSidebarListenersBound) { + return; + } + + window.__workspaceSidebarListenersBound = true; + + document.addEventListener( + "click", + (event) => { + const target = event.target instanceof Element ? event.target : null; + if (!target) { + return; + } + + const treeToggle = target.closest("[data-toggle-session-tree]"); + if (treeToggle) { + event.preventDefault(); + event.stopPropagation(); + const sessionId = treeToggle.getAttribute("data-toggle-session-tree") || ""; + const isExpanded = treeToggle.getAttribute("aria-expanded") === "true"; + setWorkspaceSessionExpanded(sessionId, !isExpanded, state.workspace.activeSessionId); + return; + } + + if (state.workspace.localeMenuOpen && !target.closest(".workspace-sidebar-locale")) { + state.workspace.localeMenuOpen = false; + patchWorkspaceSidebar(state.workspace.activeSessionId); + return; + } + + if ( + state.workspace.newSessionProfileMenuOpen && + !target.closest(".workspace-sidebar-new-session-group") + ) { + state.workspace.newSessionProfileMenuOpen = false; + patchWorkspaceSidebar(state.workspace.activeSessionId); + } + }, + true, ); + + document.addEventListener("keydown", (event) => { + const target = event.target instanceof Element ? event.target : null; + const treeToggle = target?.closest("[data-toggle-session-tree]") || null; + if (treeToggle && (event.key === "Enter" || event.key === " ")) { + event.preventDefault(); + event.stopPropagation(); + const sessionId = treeToggle.getAttribute("data-toggle-session-tree") || ""; + const isExpanded = treeToggle.getAttribute("aria-expanded") === "true"; + setWorkspaceSessionExpanded(sessionId, !isExpanded, state.workspace.activeSessionId); + return; + } + + if (event.key !== "Escape") { + return; + } + + if (state.workspace.newSessionProfileMenuOpen) { + state.workspace.newSessionProfileMenuOpen = false; + patchWorkspaceSidebar(state.workspace.activeSessionId); + return; + } + + if (state.workspace.localeMenuOpen) { + state.workspace.localeMenuOpen = false; + patchWorkspaceSidebar(state.workspace.activeSessionId); + } + }); } function shouldAutotitleSession(session) { @@ -1842,8 +2537,7 @@ function renderWorkspaceModalSlot() { } function renderWorkspaceSidebar(selectedSessionId = "") { - const projectMap = new Map(state.sessions.projects.map((project) => [project.projectId, project])); - const filteredSessions = getWorkspaceFilteredSessions(); + const workspaceSessionRows = buildWorkspaceSessionRows(state.sessions.items, state.sessions, selectedSessionId); const localeOptions = listSupportedLocales(); return ` @@ -1907,42 +2601,55 @@ function renderWorkspaceSidebar(selectedSessionId = "") {
- +
+ + + ${ + state.workspace.newSessionProfileMenuOpen + ? ` + + ` + : "" + } +
${ - filteredSessions.length > 0 - ? filteredSessions - .map((session) => { - const project = projectMap.get(session.projectId); - const displayStatus = getSessionDisplayStatus(session); - const showStatusPill = ["starting", "running", "stopping", "failed"].includes(displayStatus); - const selected = session.sessionId === selectedSessionId; - return ` - - `; - }) - .join("") + workspaceSessionRows.length > 0 + ? workspaceSessionRows.map((row) => renderWorkspaceSessionRow(row, selectedSessionId)).join("") : `
${escapeHtml(t("workspace.sidebar.empty"))}
` }
@@ -1992,6 +2699,7 @@ function closeWorkspaceCreateDialog() { state.workspace.createDialog.browserParentPath = ""; state.workspace.createDialog.browserItems = []; state.workspace.createDialog.error = ""; + state.workspace.newSessionProfileMenuOpen = false; patchWorkspaceModalSlot(); } @@ -2012,6 +2720,7 @@ function openWorkspaceCreateSessionDialog() { state.workspace.createDialog.browserParentPath = ""; state.workspace.createDialog.browserItems = []; state.workspace.createDialog.error = ""; + state.workspace.newSessionProfileMenuOpen = false; patchWorkspaceModalSlot(); } @@ -2344,11 +3053,14 @@ function bindWorkspaceCreateDialogControls() { } function bindWorkspaceSidebarControls(selectedSessionId = "") { + ensureWorkspaceSidebarGlobalListeners(); + const toggleButton = document.querySelector("#workspace-sidebar-toggle"); if (toggleButton instanceof HTMLButtonElement) { toggleButton.onclick = () => { state.workspace.sidebarCollapsed = !state.workspace.sidebarCollapsed; state.workspace.localeMenuOpen = false; + state.workspace.newSessionProfileMenuOpen = false; writeWorkspaceUiState(); syncWorkspaceShellState(); patchWorkspaceSidebar(selectedSessionId); @@ -2358,15 +3070,16 @@ function bindWorkspaceSidebarControls(selectedSessionId = "") { const overlay = document.querySelector("#workspace-sidebar-overlay"); if (overlay instanceof HTMLElement) { overlay.onclick = () => { - if (state.workspace.sidebarCollapsed) { - return; - } - state.workspace.sidebarCollapsed = true; - state.workspace.localeMenuOpen = false; - writeWorkspaceUiState(); - syncWorkspaceShellState(); - patchWorkspaceSidebar(selectedSessionId); - }; + if (state.workspace.sidebarCollapsed) { + return; + } + state.workspace.sidebarCollapsed = true; + state.workspace.localeMenuOpen = false; + state.workspace.newSessionProfileMenuOpen = false; + writeWorkspaceUiState(); + syncWorkspaceShellState(); + patchWorkspaceSidebar(selectedSessionId); + }; } const closeButton = document.querySelector("#workspace-sidebar-close"); @@ -2374,6 +3087,7 @@ function bindWorkspaceSidebarControls(selectedSessionId = "") { closeButton.onclick = () => { state.workspace.sidebarCollapsed = true; state.workspace.localeMenuOpen = false; + state.workspace.newSessionProfileMenuOpen = false; writeWorkspaceUiState(); syncWorkspaceShellState(); patchWorkspaceSidebar(selectedSessionId); @@ -2385,6 +3099,9 @@ function bindWorkspaceSidebarControls(selectedSessionId = "") { localeToggle.onclick = (event) => { event.stopPropagation(); state.workspace.localeMenuOpen = !state.workspace.localeMenuOpen; + if (state.workspace.localeMenuOpen) { + state.workspace.newSessionProfileMenuOpen = false; + } patchWorkspaceSidebar(selectedSessionId); }; } @@ -2409,18 +3126,43 @@ function bindWorkspaceSidebarControls(selectedSessionId = "") { if (createButton instanceof HTMLButtonElement) { createButton.onclick = async () => { state.workspace.localeMenuOpen = false; + state.workspace.newSessionProfileMenuOpen = false; try { - openWorkspaceCreateSessionDialog(); + await startWorkspaceNewSessionFromSelectedProfile(selectedSessionId); } catch (error) { showToast(messageOf(error)); } }; } + const profileToggle = document.querySelector("#workspace-new-session-profile-toggle"); + if (profileToggle instanceof HTMLButtonElement) { + profileToggle.onclick = (event) => { + event.stopPropagation(); + state.workspace.newSessionProfileMenuOpen = !state.workspace.newSessionProfileMenuOpen; + if (state.workspace.newSessionProfileMenuOpen) { + state.workspace.localeMenuOpen = false; + } + patchWorkspaceSidebar(selectedSessionId); + }; + } + + document.querySelectorAll("[data-workspace-new-session-profile]").forEach((button) => { + if (!(button instanceof HTMLButtonElement)) { + return; + } + + button.onclick = () => { + const profileName = String(button.dataset.workspaceNewSessionProfile || ""); + setWorkspaceNewSessionProfile(profileName, selectedSessionId); + }; + }); + const importButton = document.querySelector("#workspace-import-session"); if (importButton instanceof HTMLButtonElement) { importButton.onclick = async () => { state.workspace.localeMenuOpen = false; + state.workspace.newSessionProfileMenuOpen = false; try { await handleImportCodexSession(); } catch (error) { @@ -2433,7 +3175,8 @@ function bindWorkspaceSidebarControls(selectedSessionId = "") { if (emptyCreateButton instanceof HTMLButtonElement) { emptyCreateButton.onclick = async () => { try { - openWorkspaceCreateSessionDialog(); + state.workspace.newSessionProfileMenuOpen = false; + await startWorkspaceNewSessionFromSelectedProfile(selectedSessionId); } catch (error) { showToast(messageOf(error)); } @@ -2444,6 +3187,7 @@ function bindWorkspaceSidebarControls(selectedSessionId = "") { if (emptyImportButton instanceof HTMLButtonElement) { emptyImportButton.onclick = async () => { try { + state.workspace.newSessionProfileMenuOpen = false; await handleImportCodexSession(); } catch (error) { showToast(messageOf(error)); @@ -2775,23 +3519,24 @@ async function handleImportCodexSession() { } function renderSessionsList() { + ensureWorkspaceSidebarGlobalListeners(); const projectMap = new Map(state.sessions.projects.map((project) => [project.projectId, project])); const filteredSessions = state.sessions.items.filter((session) => matchesSessionFilters(session, projectMap.get(session.projectId), state.sessions), ); + const sessionRows = buildWorkspaceSessionRows(state.sessions.items, state.sessions, ""); const statusOptions = getSessionStatusOptions(state.sessions.items); const projectOptions = getSessionProjectOptions(state.sessions.projects, state.sessions.items); const threadOptions = getThreadFilterOptions(state.sessions.items); const sortOptions = getSessionSortOptions(); const activeFilters = countActiveSessionFilters(state.sessions); - const sortedSessions = sortSessions(filteredSessions, state.sessions.sort); - const totalPages = getPageCount(sortedSessions.length, state.sessions.pageSize); + const totalPages = getPageCount(sessionRows.length, state.sessions.pageSize); state.sessions.page = clampPage(state.sessions.page, totalPages); const pageStart = (state.sessions.page - 1) * state.sessions.pageSize; - const pagedSessions = sortedSessions.slice(pageStart, pageStart + state.sessions.pageSize); + const pagedSessions = sessionRows.slice(pageStart, pageStart + state.sessions.pageSize); const pageNumbers = getVisiblePageNumbers(state.sessions.page, totalPages); - const pageEnd = sortedSessions.length - ? Math.min(pageStart + state.sessions.pageSize, sortedSessions.length) + const pageEnd = sessionRows.length + ? Math.min(pageStart + state.sessions.pageSize, sessionRows.length) : 0; persistSessionsViewState(); @@ -2904,48 +3649,75 @@ function renderSessionsList() { ${ pagedSessions.length > 0 ? pagedSessions - .map((session) => { + .map((row) => { + const session = row.session; const project = projectMap.get(session.projectId); const displayStatus = getSessionDisplayStatus(session); + const isSelected = state.workspace.activeSessionId === session.sessionId; return ` -
-
-

${escapeHtml(session.title || t("workspace.session.untitled"))}

- ${escapeHtml(sessionStatusLabel(displayStatus))} -
-

${escapeHtml(t("sessions.projectMeta", { value: project?.name || session.projectId }))}

-

${escapeHtml(t("sessions.lastEventMeta", { value: session.lastEventAt || t("generic.none") }))}

-
- ${escapeHtml(t("sessions.eventCount", { count: session.eventCount ?? 0 }))} +
0 ? `style="--session-indent:${row.level}"` : ""}> +
- ${ - session.pendingApproval?.reason - ? `

${escapeHtml(t("sessions.pendingApprovalTitle"))} ${escapeHtml(shortenText(session.pendingApproval.reason, 120))}

` - : session.pendingApproval?.command - ? `

${escapeHtml(t("sessions.pendingApprovalTitle"))} ${escapeHtml(shortenText(session.pendingApproval.command, 120))}

` + ${ + session.lastAssistantContent + ? `

${escapeHtml(t("sessions.lastReplyTitle"))} ${escapeHtml(shortenText(session.lastAssistantContent, 140))}

` : "" - } - ${ - session.lastCommand - ? `

${escapeHtml(t("sessions.lastCommandTitle"))} ${escapeHtml(shortenText(session.lastCommand, 120))}

` - : "" - } - ${ - session.lastAssistantContent - ? `

${escapeHtml(t("sessions.lastReplyTitle"))} ${escapeHtml(shortenText(session.lastAssistantContent, 140))}

` - : "" - } -
+ } + + `; }) .join("") @@ -2954,7 +3726,7 @@ function renderSessionsList() { ${ - sortedSessions.length > 0 + sessionRows.length > 0 ? `
@@ -2962,7 +3734,7 @@ function renderSessionsList() { t("sessions.pageRange", { start: pageStart + 1, end: pageEnd, - total: sortedSessions.length, + total: sessionRows.length, }), )} ${escapeHtml( @@ -3129,11 +3901,18 @@ async function renderSessionDetailPage(sessionId) { const previousSessionId = String(state.detail.session?.sessionId || "").trim(); if (previousSessionId !== normalizedSessionId) { state.detail.dismissedApprovalKeys = {}; + state.detail.clientLogs = []; + state.detail.startingPrompt = ""; } state.workspace.activeSessionId = normalizedSessionId; const loadRequestId = Number(state.detail.loadRequestId || 0) + 1; state.detail.loadRequestId = loadRequestId; + pushClientDebugLog("info", "Opening session detail", { + sessionId: normalizedSessionId, + previousSessionId, + loadRequestId, + }); const isStaleDetailLoad = () => state.detail.loadRequestId !== loadRequestId || state.workspace.activeSessionId !== normalizedSessionId; const mainSlot = document.querySelector("#workspace-main-slot"); @@ -3156,15 +3935,23 @@ async function renderSessionDetailPage(sessionId) { return; } - const [session, eventData, uiOptionsResult, hostsResult] = await Promise.all([ + const [session, eventData, uiOptionsResult, hostsResult, profilesResult] = await Promise.all([ getSession(normalizedSessionId), loadInitialSessionEvents(normalizedSessionId), getCodexUiOptions().catch(() => null), getCodexHosts().catch(() => null), + getProfiles().catch(() => null), ]); if (isStaleDetailLoad()) { return; } + pushClientDebugLog("info", "Loaded session snapshot", { + sessionId: normalizedSessionId, + eventCount: Array.isArray(eventData.items) ? eventData.items.length : 0, + beforeCursor: Number(eventData.beforeCursor || 0), + lastSeq: Number(eventData.lastSeq || eventData.nextCursor || 0), + hasMoreBefore: Boolean(eventData.hasMoreBefore), + }); const uiOptions = uiOptionsResult && @@ -3199,6 +3986,12 @@ async function renderSessionDetailPage(sessionId) { state.detail.rawStdoutBuckets = {}; state.detail.codexUiOptions = uiOptions; state.detail.codexLaunch = normalizeCodexLaunchAgainstUi(loadCodexLaunchPrefs(), uiOptions); + state.detail.startingPrompt = normalizeSessionStartingPrompt(session.startingPrompt || ""); + state.detail.composerRefreshRequested = false; + state.workspace.profiles = + profilesResult && Array.isArray(profilesResult.items) + ? profilesResult.items.filter((profile) => profile && typeof profile.name === "string") + : []; state.detail.remoteHosts = hostsResult && Array.isArray(hostsResult.hosts) ? hostsResult.hosts.filter((item) => typeof item === "string" && item.trim()) @@ -3228,6 +4021,12 @@ async function renderSessionDetailPage(sessionId) { state.detail.codexQuota = readCachedCodexQuota(normalizedSessionId); state.detail.codexStatus = codexStatus; state.socketState = "connecting"; + pushClientDebugLog("debug", "Prepared session detail state", { + sessionId: normalizedSessionId, + socketState: state.socketState, + autoScroll: state.detail.autoScroll, + remoteHosts: state.detail.remoteHosts.length, + }); const detailQuery = parseHashRoute(window.location.hash || "").query || ""; const followParam = new URLSearchParams(detailQuery).get("follow"); @@ -3250,6 +4049,10 @@ async function renderSessionDetailPage(sessionId) { .catch(() => null); scheduleImportedSessionSync(normalizedSessionId); } catch (error) { + pushClientDebugLog("error", "Failed to load session detail", { + sessionId: normalizedSessionId, + error: messageOf(error), + }); if (isStaleDetailLoad()) { return; } @@ -3448,15 +4251,21 @@ function renderSessionDetail() { if ( composerSlot && (!shellMounted || state.detail.lastComposerHtml !== composerInputHtml) && - (!shellMounted || !composerFocused) + (!shellMounted || + !composerFocused || + state.detail.voiceRecording || + state.detail.voiceTranscribing || + state.detail.composerRefreshRequested) ) { composerSlot.innerHTML = composerInputHtml; state.detail.lastComposerHtml = composerInputHtml; + state.detail.composerRefreshRequested = false; } const composerTextarea = document.querySelector('textarea[name="content"]'); const messageFormEl = document.querySelector("#message-form"); const composerActionFab = document.querySelector("#composer-action"); + const composerVoiceFab = document.querySelector("#composer-voice-action"); function syncComposerActionState() { if (!composerActionFab || !composerTextarea) { @@ -3465,25 +4274,39 @@ function renderSessionDetail() { if (composerIsBusy) { composerActionFab.disabled = false; - return; + } else { + composerActionFab.disabled = + state.detail.voiceRecording || + state.detail.voiceTranscribing || + !composerTextarea.value.trim(); } - composerActionFab.disabled = !composerTextarea.value.trim(); + if (composerVoiceFab) { + composerVoiceFab.disabled = composerIsBusy || state.detail.voiceTranscribing; + } } - async function sendComposerMessage() { - const content = String(composerTextarea?.value || "").trim(); - if (!content) { + async function submitPromptContent(content, submitRequest, options = {}) { + const promptText = String(content || "").trim(); + const clearDraft = options.clearDraft !== false; + const restoreDraftOnError = options.restoreDraftOnError !== false; + const restoreDraftText = String(options.restoreDraftText ?? promptText).trim(); + if (!promptText) { return; } + pushClientDebugLog("info", "Submitting prompt", { + sessionId: session.sessionId, + promptLength: promptText.length, + clearDraft, + }); const optimisticTimestamp = new Date().toISOString(); const optimisticSend = { sessionId: session.sessionId, tempTurnId: `optimistic-turn:${Date.now()}`, userItemId: `optimistic-user:${Date.now()}`, thinkingItemId: `optimistic-thinking:${Date.now()}`, - text: content, + text: promptText, createdAt: optimisticTimestamp, confirmed: false, turnId: null, @@ -3495,13 +4318,8 @@ function renderSessionDetail() { }; try { - const codex = buildCodexLaunchPayload( - state.detail.codexLaunch, - state.detail.codexUiOptions, - ); - const payload = codex ? { content, codex } : { content }; if (state.detail.session && shouldAutotitleSession(state.detail.session)) { - const nextTitle = deriveSessionTitleFromMessage(content); + const nextTitle = deriveSessionTitleFromMessage(promptText); state.detail.session.title = nextTitle; state.sessions.items = state.sessions.items.map((item) => item.sessionId === session.sessionId ? { ...item, title: nextTitle } : item, @@ -3514,8 +4332,10 @@ function renderSessionDetail() { state.detail.session.updatedAt = optimisticTimestamp; } state.detail.optimisticSend = optimisticSend; - state.detail.draft = ""; - if (composerTextarea) { + if (clearDraft) { + state.detail.draft = ""; + } + if (clearDraft && composerTextarea) { composerTextarea.value = ""; adjustComposerHeight(composerTextarea); window.requestAnimationFrame(() => adjustComposerHeight(composerTextarea)); @@ -3523,7 +4343,13 @@ function renderSessionDetail() { syncComposerActionState(); scheduleSessionDetailRender(); - const result = await sendMessage(session.sessionId, payload); + const result = await submitRequest(); + pushClientDebugLog("info", "Prompt submission acknowledged", { + sessionId: session.sessionId, + eventId: result.eventId || "", + seq: Number(result.seq || 0), + turnId: result.turnId || "", + }); if (state.detail.optimisticSend?.userItemId === optimisticSend.userItemId) { state.detail.optimisticSend = { ...state.detail.optimisticSend, @@ -3540,15 +4366,19 @@ function renderSessionDetail() { type: "message.user", turnId: result.turnId, payload: { - text: content, + text: promptText, }, }, ]); state.detail.cursor = Math.max(state.detail.cursor, Number(result.seq || 0)); scheduleSessionDetailRender(); } catch (error) { + pushClientDebugLog("error", "Prompt submission failed", { + sessionId: session.sessionId, + error: messageOf(error), + }); clearOptimisticSend({ - restoreDraft: content, + restoreDraft: restoreDraftOnError ? restoreDraftText : "", restoreSession: true, restoreTitle: true, }); @@ -3559,11 +4389,138 @@ function renderSessionDetail() { } } + async function sendComposerMessage() { + const content = String(composerTextarea?.value || "").trim(); + const finalContent = composeMessageContentWithStartingPrompt( + content, + state.detail.startingPrompt, + ); + if (state.detail.startingPrompt) { + state.detail.startingPrompt = ""; + state.detail.composerRefreshRequested = true; + scheduleSessionDetailRender(); + } + const codex = buildCodexLaunchPayload( + state.detail.codexLaunch, + state.detail.codexUiOptions, + ); + const payload = codex ? { content: finalContent, codex } : { content: finalContent }; + pushClientDebugLog("debug", "Dispatching composer message", { + sessionId: session.sessionId, + contentLength: finalContent.length, + hasCodexOverrides: Boolean(codex), + }); + await submitPromptContent(finalContent, () => sendMessage(session.sessionId, payload), { + restoreDraftText: content, + }); + } + + async function startVoiceRecording() { + if (!browserSupportsVoiceRecording()) { + showToast(t("composer.voice.unsupported")); + return; + } + + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + const mimeType = getPreferredVoiceMimeType(); + const recorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream); + activeVoiceRecorder = recorder; + activeVoiceStream = stream; + activeVoiceChunks = []; + activeVoiceMimeType = recorder.mimeType || mimeType || "audio/webm"; + + recorder.ondataavailable = (event) => { + if (event.data && event.data.size > 0) { + activeVoiceChunks.push(event.data); + } + }; + recorder.onstop = () => { + stopActiveVoiceStream(); + }; + recorder.start(); + state.detail.voiceRecording = true; + state.detail.voiceTranscribing = false; + syncComposerActionState(); + scheduleSessionDetailRender(); + } + + async function stopVoiceRecordingAndSubmit() { + const recorder = activeVoiceRecorder; + if (!recorder) { + state.detail.voiceRecording = false; + syncComposerActionState(); + scheduleSessionDetailRender(); + return; + } + + const chunks = await new Promise((resolve, reject) => { + recorder.addEventListener( + "stop", + () => resolve([...activeVoiceChunks]), + { once: true }, + ); + recorder.addEventListener( + "error", + (event) => reject(event.error || new Error("Voice recording failed.")), + { once: true }, + ); + recorder.stop(); + }); + + const mimeType = activeVoiceMimeType || recorder.mimeType || "audio/webm"; + resetVoiceCaptureState(); + state.detail.voiceRecording = false; + state.detail.voiceTranscribing = true; + syncComposerActionState(); + scheduleSessionDetailRender(); + + try { + const blob = new Blob(chunks, { type: mimeType }); + const codex = buildCodexLaunchPayload( + state.detail.codexLaunch, + state.detail.codexUiOptions, + ); + const result = await sendVoiceMessage(session.sessionId, blob, { + mimeType, + filename: `voice-note${mimeType.includes("ogg") ? ".ogg" : mimeType.includes("wav") ? ".wav" : ".webm"}`, + modelId: codex?.model, + reasoningEffort: codex?.reasoningEffort, + profile: codex?.profile, + }); + state.detail.voiceTranscribing = false; + syncComposerActionState(); + scheduleSessionDetailRender(); + const finalTranscript = composeMessageContentWithStartingPrompt( + result.transcript, + state.detail.startingPrompt, + ); + if (state.detail.startingPrompt) { + state.detail.startingPrompt = ""; + state.detail.composerRefreshRequested = true; + scheduleSessionDetailRender(); + } + await submitPromptContent( + finalTranscript, + () => Promise.resolve(result), + { clearDraft: false, restoreDraftOnError: false, restoreDraftText: result.transcript }, + ); + } catch (error) { + state.detail.voiceTranscribing = false; + syncComposerActionState(); + scheduleSessionDetailRender(); + showToast(messageOf(error)); + } + } + async function refreshBusySessionBeforeSend() { if (!isSessionLiveBusy(state.detail.session)) { return false; } + pushClientDebugLog("debug", "Refreshing busy session before send", { + sessionId: state.detail.session?.sessionId || session.sessionId, + socketState: state.socketState, + }); await resumeActiveSessionDetail("pre-send"); return isSessionLiveBusy(state.detail.session); } @@ -3590,6 +4547,9 @@ function renderSessionDetail() { } event.preventDefault(); + if (state.detail.voiceRecording || state.detail.voiceTranscribing) { + return; + } if (await refreshBusySessionBeforeSend()) { return; } @@ -3613,13 +4573,44 @@ function renderSessionDetail() { return; } + if (state.detail.voiceRecording || state.detail.voiceTranscribing) { + return; + } + await sendComposerMessage(); }; } + if (composerVoiceFab) { + composerVoiceFab.onclick = async () => { + if (state.detail.voiceTranscribing) { + return; + } + + try { + if (state.detail.voiceRecording) { + await stopVoiceRecordingAndSubmit(); + return; + } + + await startVoiceRecording(); + } catch (error) { + resetVoiceCaptureState(); + state.detail.voiceRecording = false; + state.detail.voiceTranscribing = false; + syncComposerActionState(); + scheduleSessionDetailRender(); + showToast(messageOf(error)); + } + }; + } + if (messageFormEl) { messageFormEl.onsubmit = async (event) => { event.preventDefault(); + if (state.detail.voiceRecording || state.detail.voiceTranscribing) { + return; + } if (await refreshBusySessionBeforeSend()) { return; } @@ -3787,12 +4778,22 @@ function attachSessionSocket(sessionId) { } cleanupSocket(); + pushClientDebugLog("debug", "Connecting session socket", { + sessionId: normalizedSessionId, + }); const socket = connectSessionSocket(normalizedSessionId, { + onLog(entry) { + pushClientDebugLog(entry.level, entry.message, entry.details); + }, onStateChange(nextState) { if (state.ws !== socket || !isActiveDetailSession(normalizedSessionId)) { return; } + pushClientDebugLog("debug", "Session socket state changed", { + sessionId: normalizedSessionId, + state: nextState, + }); state.socketState = nextState; if (state.detail.session) { scheduleSessionDetailRender(); @@ -3808,6 +4809,13 @@ function attachSessionSocket(sessionId) { return; } + pushClientDebugLog("debug", "Session socket event received", { + sessionId: normalizedSessionId, + eventType: event.type || "", + seq: Number(event.seq || 0), + turnId: event.turnId || "", + status: event.status || "", + }); if (state.detail.session) { state.detail.session.updatedAt = new Date().toISOString(); if (event.type === "turn.started") { @@ -4486,6 +5494,37 @@ function renderRawEventList(events) { `; } +function renderClientLogList(logs) { + const items = Array.isArray(logs) ? [...logs].slice(-80).reverse() : []; + if (items.length === 0) { + return `
No client logs yet.
`; + } + + return ` +
+ ${items + .map((entry) => { + const details = formatClientLogDetails(entry.details); + return ` +
+
+ ${escapeHtml(String(entry.level || "info"))} + ${escapeHtml(entry.ts ? formatTs(Math.floor(entry.ts / 1000)) : "—")} +
+

${escapeHtml(entry.message || "—")}

+ ${ + details + ? `
${escapeHtml(shortenText(details, 1200))}
` + : "" + } +
+ `; + }) + .join("")} +
+ `; +} + function formatRuntimeValue(value, fallback = t("generic.notSynced")) { const text = typeof value === "string" ? value.trim() : ""; return text || fallback; @@ -4677,6 +5716,10 @@ function renderInspectSessionSection(session) { ${escapeHtml(t("inspect.autoScroll", { value: t(state.detail.autoScroll ? "generic.on" : "generic.off") }))}
+
+ ${escapeHtml("Client logs")} + ${renderClientLogList(state.detail.clientLogs)} +
${escapeHtml(t("inspect.rawEventsDebug"))} ${renderRawEventList(state.detail.rawEvents)} @@ -7853,6 +8896,11 @@ async function maybeLoadOlderSessionEvents(sessionId, list) { }); } catch (error) { state.detail.historyLoading = false; + pushClientDebugLog("warn", "Failed to load older session events", { + sessionId, + beforeCursor: state.detail.beforeCursor, + error: messageOf(error), + }); showToast(messageOf(error)); } } @@ -7953,12 +9001,22 @@ async function resumeActiveSessionDetail(reason = "resume") { state.detail.resumeSyncInFlight = true; state.detail.lastResumeSyncAt = now; + pushClientDebugLog("debug", "Resuming active session detail", { + sessionId, + reason, + socketState: state.socketState, + }); try { const shouldForceReconnect = reason === "visibility" || reason === "focus" || reason === "pageshow"; if (shouldForceReconnect || state.socketState === "closed" || state.socketState === "error") { + pushClientDebugLog("debug", "Reconnecting session socket during resume", { + sessionId, + reason, + previousSocketState: state.socketState, + }); cleanupSocket(); state.socketState = "connecting"; attachSessionSocket(sessionId); @@ -7981,6 +9039,17 @@ async function resumeActiveSessionDetail(reason = "resume") { scheduleSessionDetailRender(); scheduleImportedSessionSync(sessionId, 1000); } + pushClientDebugLog("debug", "Resume sync finished", { + sessionId, + reason, + }); + } catch (error) { + pushClientDebugLog("error", "Resume sync failed", { + sessionId, + reason, + error: messageOf(error), + }); + throw error; } finally { state.detail.resumeSyncInFlight = false; } @@ -8002,6 +9071,10 @@ function scheduleLiveResumeSync(sessionId, delayMs = 3200) { return; } + pushClientDebugLog("debug", "Scheduling live resume sync", { + sessionId, + delayMs, + }); state.detail.liveResumeTimerId = window.setTimeout(async () => { state.detail.liveResumeTimerId = 0; @@ -8011,6 +9084,11 @@ function scheduleLiveResumeSync(sessionId, delayMs = 3200) { try { await resumeActiveSessionDetail("live-heartbeat"); + } catch (error) { + pushClientDebugLog("warn", "Live resume sync failed", { + sessionId, + error: messageOf(error), + }); } finally { if ( state.detail.session && @@ -8053,6 +9131,10 @@ function scheduleImportedSessionSync(sessionId, delayMs = 1200) { return; } + pushClientDebugLog("debug", "Scheduling imported session sync", { + sessionId, + delayMs, + }); state.detail.importedSyncTimerId = window.setTimeout(async () => { state.detail.importedSyncTimerId = 0; @@ -8081,7 +9163,11 @@ function scheduleImportedSessionSync(sessionId, delayMs = 1200) { scheduleSessionDetailRender(); scheduleImportedSessionSync(sessionId, 1600); - } catch { + } catch (error) { + pushClientDebugLog("warn", "Imported session sync failed", { + sessionId, + error: messageOf(error), + }); scheduleImportedSessionSync(sessionId, 2400); } }, delayMs); diff --git a/web/apple-touch-icon.png b/web/apple-touch-icon.png new file mode 100644 index 0000000..918b511 Binary files /dev/null and b/web/apple-touch-icon.png differ diff --git a/web/components/composer.js b/web/components/composer.js index 848dc94..8398410 100644 --- a/web/components/composer.js +++ b/web/components/composer.js @@ -1,5 +1,6 @@ import { getCodexHosts, getCodexQuota } from "../api.js"; import { t } from "../i18n/index.js"; +import { normalizeSessionStartingPrompt } from "../utils/session-starting-prompt.js"; const CODEX_LAUNCH_STORAGE_KEY = "remote-agent-console.codexLaunch.v1"; @@ -215,6 +216,20 @@ function renderComposerQuotaPopover(detailState) { `; } +export function renderComposerStartingPromptBanner(detailState) { + const startingPrompt = normalizeSessionStartingPrompt(detailState?.startingPrompt || ""); + if (!startingPrompt) { + return ""; + } + + return ` +
+ ${escapeHtml(t("composer.startingPromptBanner"))} + ${escapeHtml(startingPrompt)} +
+ `; +} + function ensureDetailCodexLaunch(detailState, uiOptions) { const current = detailState.codexLaunch ?? loadCodexLaunchPrefs(); const normalized = normalizeCodexLaunchAgainstUi(current, uiOptions); @@ -480,6 +495,8 @@ function persistCodexLaunchPrefs(prefs) { } } +export { persistCodexLaunchPrefs }; + export function buildCodexLaunchPayload(launch, uiOptions) { if (!launch) { return undefined; @@ -535,9 +552,17 @@ export function renderComposerInput({ session, detailState, uiOptions }) { .join(""); const activeHost = detailState.activeRemoteHost || "--"; const isBusy = isSessionComposerBusy(session); + const voiceRecording = Boolean(detailState.voiceRecording); + const voiceTranscribing = Boolean(detailState.voiceTranscribing); + const voiceButtonLabel = voiceTranscribing + ? t("composer.voice.transcribing") + : voiceRecording + ? t("composer.voice.stop") + : t("composer.voice.start"); return `
+ ${renderComposerStartingPromptBanner(detailState)}