From 08c522c986a4220868d933a4aabef8522761612b Mon Sep 17 00:00:00 2001 From: Ethan Greenfeld <39423472+ebg1223@users.noreply.github.com> Date: Fri, 24 Jul 2026 02:02:52 -0700 Subject: [PATCH 1/5] Render live omp system-notices as synthetic tool calls (#2218) Route live OMP custom messages through the existing system-notice mapper so background notices render as task notifications while advisor, hidden, and ordinary custom-message behavior stays intact. --- .../server/agent/providers/omp/agent.test.ts | 29 +++++++++++++++++++ .../src/server/agent/providers/omp/agent.ts | 7 +++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/packages/server/src/server/agent/providers/omp/agent.test.ts b/packages/server/src/server/agent/providers/omp/agent.test.ts index 35020ca45..f0adadee7 100644 --- a/packages/server/src/server/agent/providers/omp/agent.test.ts +++ b/packages/server/src/server/agent/providers/omp/agent.test.ts @@ -277,6 +277,35 @@ describe("OMP agent client and session", () => { ]); }); + test("renders a live system-notice custom message as a synthetic tool call", async () => { + const omp = new OmpHarness(); + await omp.start(); + + await omp.runPrompt("hello OMP", "done"); + omp + .runtime() + .acceptCustomMessage( + [ + "", + "Background job DocsSmokeTwo has completed.", + '', + "done", + "", + "", + ].join("\n"), + ); + omp.runtime().acceptCustomMessage("plain custom status text"); + + expect(omp.timeline().filter((item) => item.type === "tool_call")).toMatchObject([ + { callId: "omp-notice:DocsSmokeTwo", name: "task_notification", status: "completed" }, + ]); + // Non-notice custom messages still fall through as assistant messages. + expect(omp.timeline().filter((item) => item.type === "assistant_message")).toMatchObject([ + { text: "done" }, + { text: "plain custom status text" }, + ]); + }); + test("does not complete a queued model turn from OMP's local-only hint", async () => { const omp = new OmpHarness(); await omp.start(); diff --git a/packages/server/src/server/agent/providers/omp/agent.ts b/packages/server/src/server/agent/providers/omp/agent.ts index c0654a627..8337c250a 100644 --- a/packages/server/src/server/agent/providers/omp/agent.ts +++ b/packages/server/src/server/agent/providers/omp/agent.ts @@ -68,6 +68,7 @@ export { formatOmpVersionSupport, resolveOmpDiagnosticPaths } from "./provider-c import { OmpSubagentCardTracker, type OmpSubagentCardScheduler } from "./subagent-card-tracker.js"; import { shouldDisplayOmpCustomMessage } from "./custom-message.js"; import { getUserMessageText } from "./message-history.js"; +import { mapOmpSystemNoticeToToolCall } from "./system-notice.js"; import { materializeProviderImage } from "../provider-image-output.js"; import { OmpCliRuntime } from "./cli-runtime.js"; import { listOmpImportableSessions, readOmpImportSessionConfig } from "./session-descriptor.js"; @@ -2068,12 +2069,14 @@ export class OmpAgentSession implements AgentSession { if (shouldDisplayOmpCustomMessage(event.message)) { const text = getUserMessageText(event.message.content); if (text) { - const advisorItem = mapOmpAdvisorMessageToToolCall(event.message, text); + const item = + mapOmpAdvisorMessageToToolCall(event.message, text) ?? + mapOmpSystemNoticeToToolCall(text); this.emit({ type: "timeline", provider: this.provider, turnId, - item: advisorItem ?? { type: "assistant_message", text }, + item: item ?? { type: "assistant_message", text }, }); } } From 7bd4afe848fcae59b4fb7e69c461ca242ef8cddb Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 24 Jul 2026 11:57:20 +0200 Subject: [PATCH 2/5] docs(providers): add Codex setup guide (#2389) --- public-docs/codex.md | 84 ++++++++++++++++++++++++++++++ public-docs/supported-providers.md | 2 +- 2 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 public-docs/codex.md diff --git a/public-docs/codex.md b/public-docs/codex.md new file mode 100644 index 000000000..2def47a8f --- /dev/null +++ b/public-docs/codex.md @@ -0,0 +1,84 @@ +--- +title: Codex +description: Run Codex in Paseo using the official Codex CLI and your existing OpenAI account. +nav: Codex +order: 24 +category: Providers +--- + +# Codex + +Paseo runs Codex through the official `codex` CLI and its app-server interface. + +## Does Codex cost extra in Paseo? + +No. Paseo does not add a charge for Codex. Sign in to the Codex CLI with ChatGPT to use the access included with your ChatGPT plan, or sign in with an API key for usage billed through your OpenAI Platform account. + +Your plan's normal Codex limits and OpenAI's standard API pricing still apply. + +## Getting started + +Install the [Codex CLI](https://learn.chatgpt.com/docs/codex/cli) on the machine running Paseo: + +```bash +npm install -g @openai/codex +``` + +Sign in with ChatGPT for subscription access: + +```bash +codex login +``` + +Or sign in with an API key for usage billed through your OpenAI Platform account: + +```bash +# macOS or Linux +printenv OPENAI_API_KEY | codex login --with-api-key +``` + +```powershell +# Windows PowerShell +$env:OPENAI_API_KEY | codex login --with-api-key +``` + +Then confirm the CLI starts: + +```bash +codex +``` + +Paseo uses this installation and its existing authentication when you start a Codex agent. + +## Codex is missing in Paseo + +The ChatGPT desktop app and the Codex CLI are separate installs. Installing the desktop app does not make the `codex` command available to Paseo. + +Check whether the CLI is on your `PATH`: + +```bash +# macOS or Linux +which -a codex + +# Windows +where.exe codex +``` + +If the command is not found: + +1. Install the [Codex CLI](https://learn.chatgpt.com/docs/codex/cli). +2. Sign in with ChatGPT or an API key using the commands above. See [Codex authentication](https://learn.chatgpt.com/docs/auth). +3. Restart Paseo if its daemon was already running when you installed the CLI. +4. In Paseo, open **Settings → Providers → Codex** and select **Refresh**. + +The provider should become available once Paseo can find and start the `codex` command. + +## Use Codex in the Paseo terminal + +Codex also works inside the Paseo terminal. Open a terminal in your workspace and run `codex` for the standard CLI experience while keeping access to your workspace, git changes, and other Paseo tools. + +## See also + +- [Supported providers](/docs/supported-providers), for other agents you can run alongside Codex. +- [Custom providers](/docs/custom-providers), for custom binaries, third-party endpoints, or multiple Codex profiles. +- [Paseo vs Codex app](/alternatives/codex-app), for a feature comparison. diff --git a/public-docs/supported-providers.md b/public-docs/supported-providers.md index ff0bb0b58..22190bb75 100644 --- a/public-docs/supported-providers.md +++ b/public-docs/supported-providers.md @@ -15,7 +15,7 @@ For the concept and how Paseo manages providers, see [Providers](/docs/providers Work out of the box once the underlying CLI is installed and authenticated. - [Claude Code](https://docs.anthropic.com/en/docs/claude-code). Anthropic's coding agent with MCP support, streaming, and deep reasoning. -- [Codex CLI](https://github.com/openai/codex). OpenAI's workspace agent with sandbox controls and optional network access. +- [Codex](/docs/codex). OpenAI's workspace agent with sandbox controls and optional network access. - [OpenCode](https://opencode.ai/). Open-source coding assistant with multi-provider model support. - [pi](https://github.com/svkozak/pi-acp). Minimal terminal-based coding agent with multi-provider LLM support. From 779a56ed36326ee9e9fb47c17c572e93aa41d82d Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 24 Jul 2026 12:14:08 +0200 Subject: [PATCH 3/5] fix(app): hide Markdown source toggle when editing is unavailable (#2382) --- packages/app/src/file-pane/pane.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/app/src/file-pane/pane.tsx b/packages/app/src/file-pane/pane.tsx index db7a4a5ae..c34bf7108 100644 --- a/packages/app/src/file-pane/pane.tsx +++ b/packages/app/src/file-pane/pane.tsx @@ -459,6 +459,7 @@ export function FilePane({ preview, supportsEditing, }); + const canToggleMarkdownMode = isMarkdown && editable; const lineCount = preview?.kind === "text" ? (preview.content ?? "").split("\n").length : undefined; const errorMessage = getFileErrorMessage(query.error, t("panels.file.failedToLoad")); @@ -471,8 +472,8 @@ export function FilePane({ preview={preview} version={version} filename={getFileNameFromPath(location.path) ?? location.path} - markdownMode={isMarkdown ? markdownMode : undefined} - onMarkdownModeChange={isMarkdown ? setMarkdownMode : undefined} + markdownMode={canToggleMarkdownMode ? markdownMode : undefined} + onMarkdownModeChange={canToggleMarkdownMode ? setMarkdownMode : undefined} lineCount={lineCount} editable={editable} disconnectedMessage={t("workspace.terminal.hostDisconnected")} From 609f81bc11a6d34c859bf3820b853b41ab601eb5 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 24 Jul 2026 12:46:10 +0200 Subject: [PATCH 4/5] Keep development tool versions consistent across version managers (#2390) * chore(dev): centralize tool versions * docs(android): clarify SDK version source * fix(dev): keep mise setup installable --- .mise.toml | 18 +++++++++--------- docs/android.md | 4 ++-- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.mise.toml b/.mise.toml index 7c1a0bad9..068718b61 100644 --- a/.mise.toml +++ b/.mise.toml @@ -1,10 +1,10 @@ -[env] -ANDROID_HOME = "{{env.HOME}}/.local/share/mise/installs/android-sdk/21.0" -_.path = [ - "{{env.HOME}}/.local/share/mise/installs/android-sdk/21.0/cmdline-tools/21.0/bin", - "{{env.HOME}}/.local/share/mise/installs/android-sdk/21.0/platform-tools", - "{{env.HOME}}/.local/share/mise/installs/android-sdk/21.0/emulator", -] +[vars] +android_sdk_version = '{{ read_file(path=".tool-versions") | split(pat="android-sdk") | last | trim | split(pat="\n") | first | trim }}' -[tools] -java = "21" +[env] +ANDROID_HOME = "{{ env.HOME }}/.local/share/mise/installs/android-sdk/{{ vars.android_sdk_version }}" +_.path = [ + "{{ env.HOME }}/.local/share/mise/installs/android-sdk/{{ vars.android_sdk_version }}/cmdline-tools/{{ vars.android_sdk_version }}/bin", + "{{ env.HOME }}/.local/share/mise/installs/android-sdk/{{ vars.android_sdk_version }}/platform-tools", + "{{ env.HOME }}/.local/share/mise/installs/android-sdk/{{ vars.android_sdk_version }}/emulator", +] diff --git a/docs/android.md b/docs/android.md index c8f130996..81a35c684 100644 --- a/docs/android.md +++ b/docs/android.md @@ -27,13 +27,13 @@ The formula reserves three digits each for minor and patch. If either reaches `1 ## Prerequisites (local dev) -Local Android builds run on macOS (or Linux) and need the Android toolchain, pinned in `.tool-versions` (`java 21`, `android-sdk 21.0`) and wired up by `.mise.toml` (which sets `ANDROID_HOME` and puts `cmdline-tools/21.0/bin`, `platform-tools`, and `emulator` on `PATH`). With [mise](https://mise.jdx.dev): +Local Android builds run on macOS (or Linux) and need the Android toolchain, pinned in `.tool-versions` (`java 21`, `android-sdk 21.0`) and wired up by `.mise.toml` (which derives `ANDROID_HOME` and the command-line tool paths from the `android-sdk` entry). With [mise](https://mise.jdx.dev): ```bash mise install # java 21 + android-sdk 21.0 command-line tools ``` -> **Pin a real `android-sdk` version, not `latest`.** The mise `android-sdk` plugin's `latest` resolved to the ancient `1.0` bundle, whose `sdkmanager` (3.6.0) predates the `emulator` package and fails with `Failed to find package emulator`. `21.0` ships a current `sdkmanager`. If you bump it, update the version in `.tool-versions` and in all four paths in `.mise.toml`. +> **Pin a real `android-sdk` version, not `latest`.** The mise `android-sdk` plugin's `latest` resolved to the ancient `1.0` bundle, whose `sdkmanager` (3.6.0) predates the `emulator` package and fails with `Failed to find package emulator`. `21.0` ships a current `sdkmanager`. If you bump it, update only the version in `.tool-versions`; `.mise.toml` derives its paths from that tool entry. `mise install` only lays down the command-line tools. Install the rest and create an emulator. On Apple Silicon: From afcd972dd080ed5f76253edb7c02364f7ea24594 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 24 Jul 2026 12:47:23 +0200 Subject: [PATCH 5/5] Keep terminal pairing QR codes scannable (#2381) * fix(pairing): keep terminal QR codes scannable Prompt framing could wrap dense QR codes and alter long pairing links. Print pairing instructions directly, add the standard quiet zone, and suppress codes that cannot fit without auto-wrapping. * fix(pairing): contain terminal QR background --- packages/cli/src/commands/daemon/pair.ts | 10 +++- packages/cli/src/commands/onboard.ts | 11 ++-- packages/cli/src/output/pairing.test.ts | 30 +++++++++++ packages/cli/src/output/pairing.ts | 37 ++++++++++++++ packages/server/src/server/pairing-qr.test.ts | 28 +++++++++++ packages/server/src/server/pairing-qr.ts | 50 ++++--------------- 6 files changed, 119 insertions(+), 47 deletions(-) create mode 100644 packages/cli/src/output/pairing.test.ts create mode 100644 packages/cli/src/output/pairing.ts create mode 100644 packages/server/src/server/pairing-qr.test.ts diff --git a/packages/cli/src/commands/daemon/pair.ts b/packages/cli/src/commands/daemon/pair.ts index a30f0e2cc..a2ca3aab2 100644 --- a/packages/cli/src/commands/daemon/pair.ts +++ b/packages/cli/src/commands/daemon/pair.ts @@ -4,6 +4,7 @@ import { generateLocalPairingOffer, loadConfig, resolvePaseoHome } from "@getpas import { tryConnectToDaemon } from "../../utils/client.js"; import { resolveLocalDaemonState, resolveTcpHostFromListen } from "./local-daemon.js"; import { addJsonOption } from "../../utils/command-options.js"; +import { formatPairingInstructions } from "../../output/pairing.js"; interface PairOptions { home?: string; @@ -97,6 +98,11 @@ function outputPairingResult( return; } - const qrBlock = pairing.qr ? `${pairing.qr}\n` : ""; - process.stdout.write(`\nScan to pair:\n${qrBlock}${pairing.url}\n`); + process.stdout.write( + formatPairingInstructions({ + url: pairing.url, + qr: pairing.qr, + columns: process.stdout.columns, + }), + ); } diff --git a/packages/cli/src/commands/onboard.ts b/packages/cli/src/commands/onboard.ts index 2600bdb29..22e10cf4a 100644 --- a/packages/cli/src/commands/onboard.ts +++ b/packages/cli/src/commands/onboard.ts @@ -18,6 +18,7 @@ import { type DaemonStartOptions, } from "./daemon/local-daemon.js"; import { tryConnectToDaemon } from "../utils/client.js"; +import { formatPairingInstructions } from "../output/pairing.js"; interface OnboardOptions extends DaemonStartOptions { timeout?: string; @@ -520,11 +521,13 @@ export async function runOnboard(options: OnboardOptions): Promise { return; } - renderNote( - pairing.qr ?? "QR is unavailable in this terminal. Use the pairing link below.", - "Scan to pair", + process.stdout.write( + formatPairingInstructions({ + url: pairing.url, + qr: pairing.qr, + columns: process.stdout.columns, + }), ); - renderNote(pairing.url, "Pairing link"); printNextSteps(pairing.url, paseoHome, richUi); if (richUi) { outro("Paseo is ready!"); diff --git a/packages/cli/src/output/pairing.test.ts b/packages/cli/src/output/pairing.test.ts new file mode 100644 index 000000000..00ed6b0a8 --- /dev/null +++ b/packages/cli/src/output/pairing.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { formatPairingInstructions } from "./pairing.js"; + +const QR = "\u001b[47m\u001b[30m \n ████ \n \u001b[0m"; +const URL = "https://app.paseo.sh/#offer=pairing-offer"; + +describe("formatPairingInstructions", () => { + it("prints the QR and an unmodified pairing-link line when the terminal is wide enough", () => { + const output = formatPairingInstructions({ qr: QR, url: URL, columns: 7 }); + + expect(output).toContain(QR); + expect(output.split("\n")).toContain(URL); + }); + + it("does not print a QR that would reach the terminal edge", () => { + const output = formatPairingInstructions({ qr: QR, url: URL, columns: 6 }); + + expect(output).not.toContain(QR); + expect(output).toContain("Resize the terminal to at least 7 columns"); + expect(output.split("\n")).toContain(URL); + }); + + it("does not risk printing a QR when terminal width is unknown", () => { + const output = formatPairingInstructions({ qr: QR, url: URL }); + + expect(output).not.toContain(QR); + expect(output).toContain("terminal width could not be detected"); + expect(output.split("\n")).toContain(URL); + }); +}); diff --git a/packages/cli/src/output/pairing.ts b/packages/cli/src/output/pairing.ts new file mode 100644 index 000000000..6d0da5bf6 --- /dev/null +++ b/packages/cli/src/output/pairing.ts @@ -0,0 +1,37 @@ +const ANSI_PATTERN = new RegExp(`${String.fromCharCode(0x1b)}\\[[0-9;]*m`, "g"); + +interface PairingInstructions { + url: string; + qr: string | null; + columns?: number; +} + +function visibleWidth(value: string): number { + return Math.max( + ...value + .replace(ANSI_PATTERN, "") + .split("\n") + .map((line) => line.length), + ); +} + +function formatQr(qr: string | null, columns: number | undefined): string { + if (!qr) { + return "QR code is unavailable. Use the pairing link below."; + } + + if (columns === undefined) { + return "QR code not shown because terminal width could not be detected."; + } + + const width = visibleWidth(qr); + if (columns <= width) { + return `QR code not shown. Resize the terminal to at least ${width + 1} columns, then run this command again.`; + } + + return qr; +} + +export function formatPairingInstructions({ url, qr, columns }: PairingInstructions): string { + return `\nScan to pair:\n${formatQr(qr, columns)}\n\nPairing link:\n${url}\n`; +} diff --git a/packages/server/src/server/pairing-qr.test.ts b/packages/server/src/server/pairing-qr.test.ts new file mode 100644 index 000000000..27f317c6e --- /dev/null +++ b/packages/server/src/server/pairing-qr.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import * as QRCode from "qrcode"; +import { renderPairingQr } from "./pairing-qr.js"; + +const ESCAPE = String.fromCharCode(0x1b); +const ANSI_PATTERN = new RegExp(`${ESCAPE}\\[[0-9;]*m`, "g"); + +describe("renderPairingQr", () => { + it("renders a theme-independent QR code with a four-module quiet zone", async () => { + const url = "https://app.paseo.sh/#offer=test-pairing-offer"; + const qr = await renderPairingQr(url); + const styledLines = qr.split("\n"); + const visibleLines = qr.replace(ANSI_PATTERN, "").split("\n"); + const moduleCount = QRCode.create(url).modules.size; + + expect( + styledLines.every( + (line) => line.startsWith(`${ESCAPE}[47m${ESCAPE}[30m`) && line.endsWith(`${ESCAPE}[0m`), + ), + ).toBe(true); + expect(visibleLines.every((line) => line.length === moduleCount + 8)).toBe(true); + expect(visibleLines.slice(0, 2).every((line) => line.trim() === "")).toBe(true); + expect(visibleLines.slice(-2).every((line) => line.trim() === "")).toBe(true); + expect(visibleLines.every((line) => line.startsWith(" ") && line.endsWith(" "))).toBe( + true, + ); + }); +}); diff --git a/packages/server/src/server/pairing-qr.ts b/packages/server/src/server/pairing-qr.ts index 8b8f31f44..7d9d13167 100644 --- a/packages/server/src/server/pairing-qr.ts +++ b/packages/server/src/server/pairing-qr.ts @@ -1,50 +1,18 @@ import * as QRCode from "qrcode"; -import type { QRCodeToStringOptionsTerminal, QRCodeToStringOptionsOther } from "qrcode"; -import type { Logger } from "pino"; +import type { QRCodeToStringOptionsOther } from "qrcode"; -function parseBooleanEnv(value: string | undefined): boolean | undefined { - if (value === undefined) return undefined; - const normalized = value.trim().toLowerCase(); - if (["1", "true", "yes", "y", "on"].includes(normalized)) return true; - if (["0", "false", "no", "n", "off"].includes(normalized)) return false; - return undefined; -} - -function shouldPrintPairingQr(): boolean { - const env = parseBooleanEnv(process.env.PASEO_PAIRING_QR); - if (env !== undefined) return env; - return process.stdout.isTTY ?? false; -} +const BLACK_ON_WHITE = "\u001b[47m\u001b[30m"; +const RESET_COLORS = "\u001b[0m"; export async function renderPairingQr(url: string): Promise { - const terminalOptions: QRCodeToStringOptionsTerminal = { - type: "terminal", - small: true, - }; - const utf8Options: QRCodeToStringOptionsOther = { type: "utf8", + margin: 4, }; - try { - return await QRCode.toString(url, terminalOptions); - } catch { - return await QRCode.toString(url, utf8Options); - } -} - -export async function printPairingQrIfEnabled(args: { - url: string; - logger?: Logger; -}): Promise { - if (!shouldPrintPairingQr()) return; - - const qr = await renderPairingQr(args.url); - const out = `\nScan to pair:\n${qr}\n${args.url}\n`; - - try { - process.stdout.write(out); - } catch (error) { - args.logger?.debug({ error }, "Failed to print pairing QR"); - } + const qr = await QRCode.toString(url, utf8Options); + return qr + .split("\n") + .map((line) => `${BLACK_ON_WHITE}${line}${RESET_COLORS}`) + .join("\n"); }