feat(collab): added QR links and split web UI URL support
- Added collab.webUrl and rendered browser links as web UI wrappers whose fragments carry relay links. - Added one-shot /collab qrcode and /collab qrcode-view commands with terminal QR rendering. - Updated coding-agent and collab-web parsers to prefer parseable wrapper fragments while preserving legacy links. - Added regression tests and changelog entries for split-host collab links and QR commands.
This commit is contained in:
+23
-6
@@ -20,6 +20,8 @@ Collab session started!
|
||||
|
||||
The browser line is click-to-join (an OSC 8 hyperlink to the full `https://` deep link): the relay serves the web guest client at `/`, and the room id + key ride in the URL fragment. From another omp (any directory, any machine), either form works:
|
||||
|
||||
Run `/collab qrcode` to start or re-print hosting with a QR code for the full-control browser link; use `/collab qrcode-view` for a read-only QR code (`/collab qr-view` is a short alias).
|
||||
|
||||
```
|
||||
/join my.omp.sh/#mgAYTZwEnpRQtca0CTgn-Q.gdJU…
|
||||
```
|
||||
@@ -33,6 +35,8 @@ The guest's previous session is restored on `/leave` (or when the host stops).
|
||||
| `/collab` | Start sharing (or re-print the link when already hosting) |
|
||||
| `/collab <relay>` | Start sharing through a specific relay (`relay.example.com`, `ws://localhost:7475`) |
|
||||
| `/collab view` | Print a read-only (view-only) link (starts sharing first if needed) |
|
||||
| `/collab qrcode` | Print a QR code for a full-control browser link (starts sharing first if needed) |
|
||||
| `/collab qrcode-view` | Print a QR code for a read-only browser link (starts sharing first if needed); `/collab qr-view` is an alias |
|
||||
| `/collab status` | Show link + participants |
|
||||
| `/collab stop` | Stop sharing |
|
||||
| `/join <link>` | Join a shared session as a guest |
|
||||
@@ -40,19 +44,29 @@ The guest's previous session is restored on `/leave` (or when the host stops).
|
||||
|
||||
## Link format
|
||||
|
||||
Accepted by `/join <link>` and `omp join "<link>"`:
|
||||
|
||||
```
|
||||
https://host[:port]/#<link> → browser deep link (printed by /collab; /join accepts it too)
|
||||
<roomId>.<key> → default relay (my.omp.sh)
|
||||
host[:port]/r/<roomId>.<key> → custom relay, wss:// inferred
|
||||
ws://localhost:7475/r/<roomId>.<key> → plain ws, allowed for localhost only
|
||||
<roomId>.<key> → default relay (wss://my.omp.sh)
|
||||
<roomId>#<key> → legacy bare form
|
||||
host[:port]/r/<roomId>.<key> → custom relay, wss:// inferred
|
||||
host[:port]/r/<roomId>#<key> → legacy direct relay form
|
||||
https://host[:port]/r/<roomId>.<key> → direct relay URL, normalized to wss://
|
||||
wss://host[:port]/r/<roomId>.<key> → direct websocket relay URL
|
||||
ws://localhost:7475/r/<roomId>.<key> → direct plain ws, localhost only
|
||||
https://host[:port]/#<link> → browser deep link when web UI and relay share a host
|
||||
https://web-host[:port][/<path>]/#<relay-link> → browser UI wrapper with relay link in the fragment
|
||||
https://web.example/collab/#relay.example.com/r/<roomId>.<key> → web UI and relay on different hosts
|
||||
```
|
||||
|
||||
The trailing `.<key>` part is the room secret, base64url-encoded, in one of two strengths:
|
||||
`<link>` / `<relay-link>` are parsed recursively as any accepted link above. For `http(s)` browser wrappers with a parseable fragment, the fragment wins before the HTTP host/path are treated as a relay. This lets `https://web.example/collab/#relay.example.com/r/<roomId>.<key>` open the web UI at `web.example` while joining `wss://relay.example.com/r/<roomId>`. If the fragment is not a complete collab link, parsing falls back to the legacy direct relay form, so `https://relay.example.com/r/<roomId>#<key>` still means relay `relay.example.com`.
|
||||
|
||||
The trailing `.<key>` or `#<key>` part is the room secret, base64url-encoded, in one of two strengths:
|
||||
|
||||
- **Full link** — 48 bytes: the 32-byte AES-256-GCM room key followed by a 16-byte write token. Grants prompting, interrupting, and subagent control.
|
||||
- **View-only link** — the bare 32-byte key, no write token. Grants live read access only. Pre-token links parse as view-only.
|
||||
|
||||
The room secret is dot-joined rather than `#`-joined: RFC 3986 forbids a raw `#` inside a URL fragment, so strict URL stacks (macOS Foundation behind terminal click-to-open) percent-encode a second `#` to `%23` and break the link. Parsers leniently accept the legacy `#` form and the mangled `%23` form. In the browser deep link, everything after the `#` — room id and key — is a URL fragment: it never appears in any HTTP request, and neither secret is ever sent to the relay.
|
||||
The room secret is dot-joined in newly generated links because RFC 3986 forbids a raw `#` inside a URL fragment; parsers still accept legacy `#` forms and `%23`-mangled legacy deep links.
|
||||
|
||||
## End-to-end encryption
|
||||
|
||||
@@ -85,11 +99,14 @@ Known v1 limit for guests: a turn already streaming when you join becomes visibl
|
||||
|
||||
`packages/collab-web` is a standalone browser client for the same links — no omp install needed on the guest side. The relay serves it at `/`, which is what makes the `/collab` deep link click-to-join: `https://<relay>/#<link>` loads the client and auto-connects from the fragment. It renders the live transcript (streaming text, thinking, tool cards), a subagent panel with on-demand transcripts, and a composer with the same guest powers (prompt, interrupt, hub actions). Run `bun run dev` in the package for a local instance, `bun run mock-host` for an offline scripted host to develop against, and `bun run build` to emit a static `dist/` deployable anywhere (HTTPS required for WebCrypto). The client never talks to anything but the relay, and the key stays in the URL fragment.
|
||||
|
||||
Set `collab.webUrl` when the browser UI is hosted separately from the websocket relay. When empty, `/collab` derives `http(s)://host[:port]` from `collab.relayUrl`; explicit web UI URLs must use `https://` except for `http://localhost` development origins. The generated browser URL still carries the relay-specific collab link in the fragment.
|
||||
|
||||
## Settings
|
||||
|
||||
| Setting | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `collab.relayUrl` | `wss://my.omp.sh` | Relay used by `/collab` when no relay is passed inline |
|
||||
| `collab.webUrl` | empty | Browser UI URL for `/collab` links; empty derives from relay; explicit `http://` is allowed only for localhost |
|
||||
| `collab.displayName` | OS username | Name shown to other participants |
|
||||
| `share.serverUrl` | `https://my.omp.sh/s` | Share viewer/upload base used by `/share` (links are `<base>/<id>#<key>`) |
|
||||
| `share.redactSecrets` | `true` | Run the secret obfuscator over `/share` snapshots before upload |
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
"@types/babel__generator": "^7.27.0",
|
||||
"@types/babel__traverse": "^7.28.0",
|
||||
"@types/bun": "^1.3.14",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/turndown": "5.0.6",
|
||||
@@ -75,6 +76,7 @@
|
||||
"postcss": "^8.5.15",
|
||||
"prettier": "^3.8.4",
|
||||
"puppeteer-core": "^25.1.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "19.2.7",
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
"react-dom": "19.2.7",
|
||||
|
||||
@@ -241,6 +241,10 @@
|
||||
- Fixed task subagents to install their configured ordered model candidates as child-session retry fallback chains, so retryable provider failures can advance to the next subagent model instead of failing the worker ([#2750](https://github.com/can1357/oh-my-pi/issues/2750)).
|
||||
- Fixed empty reasonless aborted assistant turns to auto-retry without switching model fallback, so transient provider-side aborts after tool results do not end headless sessions ([#2685](https://github.com/can1357/oh-my-pi/issues/2685)).
|
||||
|
||||
### Added
|
||||
|
||||
- Added `/collab qrcode` and `/collab qrcode-view` one-shot terminal QR output for browser join links and a `collab.webUrl` setting for separately hosted collab web clients.
|
||||
|
||||
## [16.0.1] - 2026-06-15
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
"@opentelemetry/sdk-trace-base": "catalog:",
|
||||
"@opentelemetry/sdk-trace-node": "catalog:",
|
||||
"@puppeteer/browsers": "catalog:",
|
||||
"@types/qrcode": "catalog:",
|
||||
"@types/turndown": "catalog:",
|
||||
"@xterm/headless": "catalog:",
|
||||
"arktype": "catalog:",
|
||||
@@ -78,6 +79,7 @@
|
||||
"mammoth": "catalog:",
|
||||
"mupdf": "catalog:",
|
||||
"puppeteer-core": "catalog:",
|
||||
"qrcode": "catalog:",
|
||||
"turndown": "catalog:",
|
||||
"turndown-plugin-gfm": "catalog:",
|
||||
"zod": "catalog:"
|
||||
|
||||
@@ -133,7 +133,7 @@ export class CollabHost {
|
||||
return this.#link;
|
||||
}
|
||||
|
||||
/** Browser deep link (`https://<relay>/#<link>`) — the relay serves the web client at `/`. */
|
||||
/** Browser deep link for the configured collab web UI. */
|
||||
get webLink(): string {
|
||||
return this.#webLink;
|
||||
}
|
||||
@@ -156,15 +156,15 @@ export class CollabHost {
|
||||
return list;
|
||||
}
|
||||
|
||||
async start(relayUrl: string): Promise<void> {
|
||||
async start(relayUrl: string, webUrl = ""): Promise<void> {
|
||||
const rawKey = generateRoomKey();
|
||||
const writeToken = generateWriteToken();
|
||||
const roomId = generateRoomId();
|
||||
this.#writeToken = writeToken;
|
||||
this.#link = formatCollabLink(relayUrl, roomId, rawKey, writeToken);
|
||||
this.#webLink = formatCollabWebLink(relayUrl, roomId, rawKey, writeToken);
|
||||
this.#webLink = formatCollabWebLink(relayUrl, roomId, rawKey, writeToken, webUrl);
|
||||
this.#viewLink = formatCollabLink(relayUrl, roomId, rawKey);
|
||||
this.#webViewLink = formatCollabWebLink(relayUrl, roomId, rawKey);
|
||||
this.#webViewLink = formatCollabWebLink(relayUrl, roomId, rawKey, undefined, webUrl);
|
||||
const parsed = parseCollabLink(this.#link);
|
||||
if ("error" in parsed) throw new Error(parsed.error);
|
||||
const key = await importRoomKey(rawKey);
|
||||
|
||||
@@ -116,6 +116,10 @@ const BARE_LINK_RE = /^([A-Za-z0-9_-]{10,64})[#.]([A-Za-z0-9_-]+)$/;
|
||||
const B64URL_RE = /^[A-Za-z0-9_-]+$/;
|
||||
const LOCAL_HOSTNAMES: Record<string, true> = { localhost: true, "127.0.0.1": true, "::1": true, "[::1]": true };
|
||||
|
||||
function isLocalHostname(hostname: string): boolean {
|
||||
return LOCAL_HOSTNAMES[hostname] === true;
|
||||
}
|
||||
|
||||
export function generateRoomId(): string {
|
||||
const bytes = new Uint8Array(ROOM_ID_BYTES);
|
||||
crypto.getRandomValues(bytes);
|
||||
@@ -143,7 +147,7 @@ function normalizeRelayOrigin(relayUrl: string): { origin: string } | { error: s
|
||||
default:
|
||||
return { error: `Unsupported relay URL scheme: ${url.protocol}` };
|
||||
}
|
||||
if (scheme === "ws:" && !LOCAL_HOSTNAMES[url.hostname]) {
|
||||
if (scheme === "ws:" && !isLocalHostname(url.hostname)) {
|
||||
return { error: "relay link must be wss:// (plain ws:// is only allowed for localhost)" };
|
||||
}
|
||||
const port = url.port ? `:${url.port}` : "";
|
||||
@@ -178,24 +182,48 @@ export function formatCollabLink(relayUrl: string, roomId: string, key: Uint8Arr
|
||||
return `${compact}/r/${roomId}.${keyText}`;
|
||||
}
|
||||
|
||||
function normalizeCollabWebBaseUrl(relayUrl: string, webUrl?: string): string {
|
||||
const explicitWebUrl = webUrl?.trim();
|
||||
if (!explicitWebUrl) {
|
||||
const normalized = normalizeRelayOrigin(relayUrl);
|
||||
if ("error" in normalized) throw new Error(normalized.error);
|
||||
return normalized.origin.startsWith("wss://")
|
||||
? `https://${normalized.origin.slice("wss://".length)}`
|
||||
: `http://${normalized.origin.slice("ws://".length)}`;
|
||||
}
|
||||
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(explicitWebUrl);
|
||||
} catch {
|
||||
throw new Error("collab.webUrl must start with http:// or https://");
|
||||
}
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
throw new Error("collab.webUrl must start with http:// or https://");
|
||||
}
|
||||
if (url.protocol === "http:" && !isLocalHostname(url.hostname)) {
|
||||
throw new Error("collab.webUrl must use https:// unless it targets localhost");
|
||||
}
|
||||
if (url.search || url.hash) {
|
||||
throw new Error("collab.webUrl must not include a query string or fragment");
|
||||
}
|
||||
const path = url.pathname.replace(/\/+$/, "");
|
||||
return `${url.origin}${path}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the browser deep link: `http(s)://<relay-host>/#<collab-link>`. The
|
||||
* relay serves the web client at `/`, and the whole collab link (including the
|
||||
* room key) rides in the fragment, so it never appears in any HTTP request.
|
||||
* Terminals auto-link the https form, making it click-to-join.
|
||||
* Render the browser deep link. The browser UI may be hosted separately from
|
||||
* the relay; the fragment always carries the relay-specific collab link, so
|
||||
* room secrets stay out of HTTP path and query bytes.
|
||||
*/
|
||||
export function formatCollabWebLink(
|
||||
relayUrl: string,
|
||||
roomId: string,
|
||||
key: Uint8Array,
|
||||
writeToken?: Uint8Array,
|
||||
webUrl?: string,
|
||||
): string {
|
||||
const normalized = normalizeRelayOrigin(relayUrl);
|
||||
if ("error" in normalized) throw new Error(normalized.error);
|
||||
const httpOrigin = normalized.origin.startsWith("wss://")
|
||||
? `https://${normalized.origin.slice("wss://".length)}`
|
||||
: `http://${normalized.origin.slice("ws://".length)}`;
|
||||
return `${httpOrigin}/#${formatCollabLink(relayUrl, roomId, key, writeToken)}`;
|
||||
return `${normalizeCollabWebBaseUrl(relayUrl, webUrl)}/#${formatCollabLink(relayUrl, roomId, key, writeToken)}`;
|
||||
}
|
||||
|
||||
export function parseCollabLink(link: string): ParsedCollabLink | { error: string } {
|
||||
@@ -213,15 +241,20 @@ export function parseCollabLink(link: string): ParsedCollabLink | { error: strin
|
||||
} catch {
|
||||
return { error: `Invalid collab link: ${link}` };
|
||||
}
|
||||
if ((url.protocol === "http:" || url.protocol === "https:") && url.hash) {
|
||||
const inner = url.hash.startsWith("#") ? url.hash.slice(1) : url.hash;
|
||||
const parsed = parseCollabLink(inner);
|
||||
if (!("error" in parsed)) return parsed;
|
||||
}
|
||||
const normalized = normalizeRelayOrigin(url.origin);
|
||||
if ("error" in normalized) return normalized;
|
||||
const match = ROOM_PATH_RE.exec(url.pathname);
|
||||
if (!match) {
|
||||
// Web deep link: `http(s)://<relay>/#<collab-link>` — the fragment holds
|
||||
// the whole link, so recurse on it. The recursion terminates because
|
||||
// the inner text is a strict suffix of the input.
|
||||
// Non-http(s) deep links may also carry a complete collab link in the
|
||||
// fragment. http(s) links are handled once above so invalid fragments
|
||||
// fall through to direct relay validation instead of double-recursing.
|
||||
const inner = url.hash.startsWith("#") ? url.hash.slice(1) : url.hash;
|
||||
if (inner) return parseCollabLink(inner);
|
||||
if (inner && url.protocol !== "http:" && url.protocol !== "https:") return parseCollabLink(inner);
|
||||
return { error: "Collab link must contain a /r/<roomId> path" };
|
||||
}
|
||||
const roomId = match[1]!;
|
||||
|
||||
@@ -1519,6 +1519,18 @@ export const SETTINGS_SCHEMA = {
|
||||
},
|
||||
},
|
||||
|
||||
"collab.webUrl": {
|
||||
type: "string",
|
||||
default: "",
|
||||
ui: {
|
||||
tab: "interaction",
|
||||
group: "Collab",
|
||||
label: "Web UI URL",
|
||||
description:
|
||||
"Browser UI used by /collab links; empty derives from collab.relayUrl; explicit http:// is localhost-only",
|
||||
},
|
||||
},
|
||||
|
||||
"collab.displayName": {
|
||||
type: "string",
|
||||
default: "",
|
||||
|
||||
@@ -3,7 +3,7 @@ import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { getOAuthProviders } from "@oh-my-pi/pi-ai/oauth";
|
||||
import { setNextRequestDebugPath } from "@oh-my-pi/pi-ai/utils/request-debug";
|
||||
import type { AutocompleteItem } from "@oh-my-pi/pi-tui";
|
||||
import { type AutocompleteItem, Spacer } from "@oh-my-pi/pi-tui";
|
||||
import { APP_NAME, setProjectDir } from "@oh-my-pi/pi-utils";
|
||||
import { COLLAB_GUEST_ALLOWED_COMMANDS, CollabGuestLink } from "../collab/guest";
|
||||
import { CollabHost } from "../collab/host";
|
||||
@@ -30,6 +30,7 @@ import type { AgentSession, FreshSessionResult } from "../session/agent-session"
|
||||
import { formatShakeSummary, type ShakeMode } from "../session/shake-types";
|
||||
import { urlHyperlinkAlways } from "../tui";
|
||||
import { getChangelogPath, parseChangelog } from "../utils/changelog";
|
||||
import { CollabQrCodeComponent, renderCollabQrCode } from "./helpers/collab-qrcode";
|
||||
import { buildContextReportText } from "./helpers/context-report";
|
||||
import { formatDuration } from "./helpers/format";
|
||||
import { createMarketplaceManager } from "./helpers/marketplace-manager";
|
||||
@@ -99,6 +100,26 @@ function collabLinkHint(host: CollabHost, heading: string, view = false): string
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function showCollabQrCode(ctx: InteractiveModeContext, webLink: string): Promise<void> {
|
||||
try {
|
||||
const qrText = await renderCollabQrCode(webLink);
|
||||
ctx.present([new Spacer(1), new CollabQrCodeComponent(webLink, qrText)]);
|
||||
} catch (err) {
|
||||
ctx.showError(`Failed to render collab QR code: ${errorMessage(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function showCollabLink(
|
||||
ctx: InteractiveModeContext,
|
||||
host: CollabHost,
|
||||
heading: string,
|
||||
view = false,
|
||||
options?: { forceQr?: boolean },
|
||||
): Promise<void> {
|
||||
ctx.showStatus(collabLinkHint(host, heading, view), { dim: false });
|
||||
if (options?.forceQr) await showCollabQrCode(ctx, view ? host.webViewLink : host.webLink);
|
||||
}
|
||||
|
||||
function formatFreshSessionResult(result: FreshSessionResult): string {
|
||||
const stateLabel = result.closedProviderSessions === 1 ? "provider state" : "provider states";
|
||||
return `Fresh provider session started (${result.closedProviderSessions} ${stateLabel} pruned).`;
|
||||
@@ -578,19 +599,21 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
|
||||
{
|
||||
name: "collab",
|
||||
description: "Share this session live via a relay",
|
||||
inlineHint: "[start|view|stop|status] [relayUrl]",
|
||||
inlineHint: "[start|view|stop|status|qrcode|qrcode-view] [relayUrl]",
|
||||
subcommands: [
|
||||
{ name: "view", description: "Share a read-only link (guests can watch, not prompt)" },
|
||||
{ name: "status", description: "Show link + participants" },
|
||||
{ name: "stop", description: "Stop sharing" },
|
||||
{ name: "qrcode", description: "Print a QR code for a full-control browser link" },
|
||||
{ name: "qrcode-view", description: "Print a QR code for a read-only browser link" },
|
||||
],
|
||||
allowArgs: true,
|
||||
handleTui: async (command, runtime) => {
|
||||
const ctx = runtime.ctx;
|
||||
ctx.editor.setText("");
|
||||
const args = command.args.trim();
|
||||
const [first = ""] = args.split(/\s+/, 1);
|
||||
if (first === "stop") {
|
||||
const { verb, rest } = parseSubcommand(args);
|
||||
if (verb === "stop") {
|
||||
if (!ctx.collabHost) {
|
||||
ctx.showStatus("Not hosting a collab session");
|
||||
return;
|
||||
@@ -599,7 +622,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
|
||||
ctx.showStatus("Collab stopped");
|
||||
return;
|
||||
}
|
||||
if (first === "status") {
|
||||
if (verb === "status") {
|
||||
if (ctx.collabHost) {
|
||||
const names = ctx.collabHost.participants.map(p =>
|
||||
p.role === "host" ? `${p.name} (host)` : p.readOnly ? `${p.name} (view-only)` : p.name,
|
||||
@@ -620,15 +643,27 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
|
||||
ctx.showError("Already in a collab session as a guest (/leave first)");
|
||||
return;
|
||||
}
|
||||
const view = first === "view";
|
||||
const knownStartVerb =
|
||||
verb === "start" || verb === "view" || verb === "qrcode" || verb === "qrcode-view" || verb === "qr-view";
|
||||
const view = verb === "view" || verb === "qrcode-view" || verb === "qr-view";
|
||||
const forceQr = verb === "qrcode" || verb === "qrcode-view" || verb === "qr-view";
|
||||
if (ctx.collabHost) {
|
||||
ctx.showStatus(
|
||||
collabLinkHint(ctx.collabHost, view ? "Read-only collab link" : "Collab session active", view),
|
||||
{ dim: false },
|
||||
await showCollabLink(
|
||||
ctx,
|
||||
ctx.collabHost,
|
||||
forceQr
|
||||
? view
|
||||
? "Read-only collab QR code"
|
||||
: "Collab QR code"
|
||||
: view
|
||||
? "Read-only collab link"
|
||||
: "Collab session active",
|
||||
view,
|
||||
{ forceQr },
|
||||
);
|
||||
return;
|
||||
}
|
||||
const explicitUrl = first === "start" || view ? args.slice(first.length).trim() : args;
|
||||
const explicitUrl = knownStartVerb ? rest : args;
|
||||
const relayInput = explicitUrl || ctx.settings.get("collab.relayUrl") || "";
|
||||
if (!relayInput) {
|
||||
ctx.showError(
|
||||
@@ -638,15 +673,16 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
|
||||
}
|
||||
// Scheme-less relay args default to wss (ws:// must be spelled out for localhost).
|
||||
const relayUrl = relayInput.includes("://") ? relayInput : `wss://${relayInput}`;
|
||||
const webUrl = ctx.settings.get("collab.webUrl") || "";
|
||||
const host = new CollabHost(ctx);
|
||||
try {
|
||||
await host.start(relayUrl);
|
||||
await host.start(relayUrl, webUrl);
|
||||
} catch (err) {
|
||||
ctx.showError(`Failed to start collab session: ${errorMessage(err)}`);
|
||||
return;
|
||||
}
|
||||
ctx.collabHost = host;
|
||||
ctx.showStatus(collabLinkHint(host, "Collab session started!", view), { dim: false });
|
||||
await showCollabLink(ctx, host, "Collab session started!", view, { forceQr });
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { type Component, visibleWidth } from "@oh-my-pi/pi-tui";
|
||||
import * as QRCode from "qrcode";
|
||||
import { theme } from "../../modes/theme/theme";
|
||||
|
||||
export async function renderCollabQrCode(url: string): Promise<string> {
|
||||
return QRCode.toString(url, { type: "terminal", small: true, errorCorrectionLevel: "M" });
|
||||
}
|
||||
|
||||
export class CollabQrCodeComponent implements Component {
|
||||
readonly url: string;
|
||||
readonly #lines: readonly string[];
|
||||
readonly #minWidth: number;
|
||||
|
||||
constructor(url: string, qrText: string) {
|
||||
this.url = url;
|
||||
const lines = qrText.split(/\r?\n/);
|
||||
while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
||||
this.#lines = lines;
|
||||
this.#minWidth = 1 + lines.reduce((max, line) => Math.max(max, visibleWidth(line)), 0);
|
||||
}
|
||||
|
||||
render(width: number): readonly string[] {
|
||||
if (width < this.#minWidth) {
|
||||
const warning = `QR code hidden: terminal width ${width}; need ${this.#minWidth}. Use the browser URL above.`;
|
||||
return [` ${typeof theme === "undefined" ? warning : theme.fg("warning", warning)}`];
|
||||
}
|
||||
return this.#lines.map(line => ` ${line}`);
|
||||
}
|
||||
}
|
||||
@@ -123,6 +123,47 @@ describe("collab link format", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("wraps custom relay links in an explicit web UI origin", () => {
|
||||
const webLink = formatCollabWebLink(
|
||||
"wss://relay.example.com:8443",
|
||||
roomId,
|
||||
key,
|
||||
undefined,
|
||||
"https://web.example",
|
||||
);
|
||||
expect(webLink.startsWith("https://web.example/#relay.example.com:8443/r/")).toBe(true);
|
||||
const parsed = parseCollabLink(webLink);
|
||||
if ("error" in parsed) throw new Error(parsed.error);
|
||||
expect(parsed.wsUrl).toBe(`wss://relay.example.com:8443/r/${roomId}`);
|
||||
});
|
||||
|
||||
it("parses non-local http web UI wrappers by their relay fragment", () => {
|
||||
const keyText = Buffer.from(key).toString("base64url");
|
||||
const parsed = parseCollabLink(`http://web.example/collab/#relay.example.com:8443/r/${roomId}.${keyText}`);
|
||||
if ("error" in parsed) throw new Error(parsed.error);
|
||||
expect(parsed.wsUrl).toBe(`wss://relay.example.com:8443/r/${roomId}`);
|
||||
});
|
||||
|
||||
it("parses split web UI wrappers with full relay URLs in the fragment", () => {
|
||||
const keyText = Buffer.from(key).toString("base64url");
|
||||
const parsed = parseCollabLink(`https://web.example/collab/#wss://relay.example.com/r/${roomId}.${keyText}`);
|
||||
if ("error" in parsed) throw new Error(parsed.error);
|
||||
expect(parsed.wsUrl).toBe(`wss://relay.example.com/r/${roomId}`);
|
||||
});
|
||||
|
||||
it("falls through invalid http wrapper fragments without reparsing them", () => {
|
||||
expect(parseCollabLink("https://web.example/#not-a-collab-link")).toEqual({
|
||||
error: "Collab link must contain a /r/<roomId> path",
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers browser wrapper fragments over relay-like web paths", () => {
|
||||
const inner = formatCollabLink("wss://relay.example.com", roomId, key);
|
||||
const parsed = parseCollabLink(`https://web.example/r/abcdefghij#${inner}`);
|
||||
if ("error" in parsed) throw new Error(parsed.error);
|
||||
expect(parsed.wsUrl).toBe(`wss://relay.example.com/r/${roomId}`);
|
||||
});
|
||||
|
||||
it("parses the scheme-less display form of web deep links", () => {
|
||||
const parsed = parseCollabLink(`my.omp.sh/#${formatCollabLink(DEFAULT_RELAY_URL, roomId, key)}`);
|
||||
if ("error" in parsed) throw new Error(parsed.error);
|
||||
@@ -164,6 +205,46 @@ describe("collab link format", () => {
|
||||
expect(url.search).toBe("");
|
||||
expect(url.hash).toBe(`#${roomId}.${Buffer.from(key).toString("base64url")}`);
|
||||
});
|
||||
|
||||
it("normalizes explicit web UI roots, paths, trailing slashes, and ports", () => {
|
||||
const rootLink = formatCollabWebLink(DEFAULT_RELAY_URL, roomId, key, undefined, " https://web.example/ ");
|
||||
expect(rootLink.startsWith("https://web.example/#")).toBe(true);
|
||||
|
||||
const pathLink = formatCollabWebLink(
|
||||
DEFAULT_RELAY_URL,
|
||||
roomId,
|
||||
key,
|
||||
undefined,
|
||||
"https://web.example:8443/collab///",
|
||||
);
|
||||
expect(pathLink.startsWith("https://web.example:8443/collab/#")).toBe(true);
|
||||
|
||||
const localHttpLink = formatCollabWebLink(
|
||||
DEFAULT_RELAY_URL,
|
||||
roomId,
|
||||
key,
|
||||
undefined,
|
||||
"http://localhost:5173/app/",
|
||||
);
|
||||
expect(localHttpLink.startsWith("http://localhost:5173/app/#")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects web UI URLs without an http or https protocol", () => {
|
||||
expect(() => formatCollabWebLink(DEFAULT_RELAY_URL, roomId, key, undefined, "ftp://web.example")).toThrow(
|
||||
"collab.webUrl must start with http:// or https://",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects non-local plain-http web UI URLs", () => {
|
||||
expect(() => formatCollabWebLink(DEFAULT_RELAY_URL, roomId, key, undefined, "http://web.example")).toThrow(
|
||||
"collab.webUrl must use https:// unless it targets localhost",
|
||||
);
|
||||
});
|
||||
it("rejects web UI URLs with query strings or fragments", () => {
|
||||
expect(() => formatCollabWebLink(DEFAULT_RELAY_URL, roomId, key, undefined, "https://web.example/?x=1")).toThrow(
|
||||
"collab.webUrl must not include a query string or fragment",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("collab wire envelope", () => {
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "bun:test";
|
||||
import { CollabHost } from "@oh-my-pi/pi-coding-agent/collab/host";
|
||||
import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||
import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
|
||||
import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types";
|
||||
import {
|
||||
type BuiltinSlashCommandRuntime,
|
||||
executeBuiltinSlashCommand,
|
||||
} from "@oh-my-pi/pi-coding-agent/slash-commands/builtin-registry";
|
||||
import { CollabQrCodeComponent } from "@oh-my-pi/pi-coding-agent/slash-commands/helpers/collab-qrcode";
|
||||
import { Spacer } from "@oh-my-pi/pi-tui";
|
||||
|
||||
beforeAll(async () => {
|
||||
resetSettingsForTest();
|
||||
await Settings.init({ inMemory: true });
|
||||
await initTheme(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
resetSettingsForTest();
|
||||
});
|
||||
|
||||
function fakeHost(options?: {
|
||||
webLink?: string;
|
||||
webViewLink?: string;
|
||||
}): NonNullable<InteractiveModeContext["collabHost"]> {
|
||||
return {
|
||||
link: "relay.example.com/r/full-control",
|
||||
viewLink: "relay.example.com/r/read-only",
|
||||
webLink: options?.webLink ?? "https://my.omp.sh/#full-control",
|
||||
webViewLink: options?.webViewLink ?? "https://my.omp.sh/#read-only",
|
||||
participants: [{ name: "host", role: "host" }],
|
||||
} as unknown as NonNullable<InteractiveModeContext["collabHost"]>;
|
||||
}
|
||||
|
||||
function createRuntimeHarness(options?: { collabHost?: NonNullable<InteractiveModeContext["collabHost"]> }) {
|
||||
const setText = vi.fn();
|
||||
const showStatus = vi.fn();
|
||||
const showError = vi.fn();
|
||||
const present = vi.fn();
|
||||
const settingsGet = vi.fn((key: string) => {
|
||||
if (key === "collab.relayUrl") return "wss://relay.example.com";
|
||||
if (key === "collab.webUrl") return "";
|
||||
return "";
|
||||
});
|
||||
const ctx = {
|
||||
editor: { setText },
|
||||
showStatus,
|
||||
showError,
|
||||
present,
|
||||
settings: { get: settingsGet },
|
||||
collabHost: options?.collabHost,
|
||||
} as unknown as InteractiveModeContext;
|
||||
return {
|
||||
ctx,
|
||||
setText,
|
||||
showStatus,
|
||||
showError,
|
||||
present,
|
||||
runtime: { ctx } as BuiltinSlashCommandRuntime,
|
||||
};
|
||||
}
|
||||
|
||||
function mockStartedHostLinks() {
|
||||
return vi.spyOn(CollabHost.prototype, "start").mockImplementation(function (this: CollabHost): Promise<void> {
|
||||
Object.defineProperties(this, {
|
||||
link: { value: "relay.example.com/r/full-control", configurable: true },
|
||||
viewLink: { value: "relay.example.com/r/read-only", configurable: true },
|
||||
webLink: { value: "https://my.omp.sh/#started-full", configurable: true },
|
||||
webViewLink: { value: "https://my.omp.sh/#started-view", configurable: true },
|
||||
participants: { value: [{ name: "host", role: "host" as const }], configurable: true },
|
||||
});
|
||||
return Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe("/collab qrcode slash command", () => {
|
||||
it("starts hosting and prints a one-shot full-control QR", async () => {
|
||||
const startSpy = mockStartedHostLinks();
|
||||
const harness = createRuntimeHarness();
|
||||
|
||||
const handled = await executeBuiltinSlashCommand("/collab qrcode", harness.runtime);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(harness.setText).toHaveBeenCalledWith("");
|
||||
expect(startSpy).toHaveBeenCalledWith("wss://relay.example.com", "");
|
||||
expect(harness.ctx.collabHost).toBeInstanceOf(CollabHost);
|
||||
const statusText = harness.showStatus.mock.calls[0]?.[0] as string;
|
||||
expect(statusText).toContain("my.omp.sh/#started-full");
|
||||
const presented = harness.present.mock.calls[0]?.[0] as readonly unknown[];
|
||||
expect(presented[0]).toBeInstanceOf(Spacer);
|
||||
expect(presented[1]).toBeInstanceOf(CollabQrCodeComponent);
|
||||
const component = presented[1] as CollabQrCodeComponent;
|
||||
expect(component.url).toBe("https://my.omp.sh/#started-full");
|
||||
expect(component.render(120).join("\n")).toMatch(/\x1b\[(?:47|40)m/);
|
||||
});
|
||||
|
||||
it("starts hosting and prints a one-shot read-only QR", async () => {
|
||||
const startSpy = mockStartedHostLinks();
|
||||
const harness = createRuntimeHarness();
|
||||
|
||||
const handled = await executeBuiltinSlashCommand("/collab qrcode-view", harness.runtime);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(startSpy).toHaveBeenCalledWith("wss://relay.example.com", "");
|
||||
expect(harness.ctx.collabHost).toBeInstanceOf(CollabHost);
|
||||
const statusText = harness.showStatus.mock.calls[0]?.[0] as string;
|
||||
expect(statusText).toContain("my.omp.sh/#started-view");
|
||||
expect(statusText).not.toContain("my.omp.sh/#started-full");
|
||||
const presented = harness.present.mock.calls[0]?.[0] as readonly unknown[];
|
||||
expect(presented[0]).toBeInstanceOf(Spacer);
|
||||
expect(presented[1]).toBeInstanceOf(CollabQrCodeComponent);
|
||||
const component = presented[1] as CollabQrCodeComponent;
|
||||
expect(component.url).toBe("https://my.omp.sh/#started-view");
|
||||
});
|
||||
|
||||
it("prints the active full-control browser QR when hosting", async () => {
|
||||
const harness = createRuntimeHarness({ collabHost: fakeHost() });
|
||||
|
||||
const handled = await executeBuiltinSlashCommand("/collab qrcode", harness.runtime);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
const statusText = harness.showStatus.mock.calls[0]?.[0] as string;
|
||||
expect(statusText).toContain("my.omp.sh/#full-control");
|
||||
const presented = harness.present.mock.calls[0]?.[0] as readonly unknown[];
|
||||
expect(presented[0]).toBeInstanceOf(Spacer);
|
||||
expect(presented[1]).toBeInstanceOf(CollabQrCodeComponent);
|
||||
const component = presented[1] as CollabQrCodeComponent;
|
||||
expect(component.render(120).join("\n")).toMatch(/\x1b\[(?:47|40)m/);
|
||||
});
|
||||
|
||||
it("prints a one-shot read-only browser QR when hosting", async () => {
|
||||
const webLink = "https://my.omp.sh/#full-control";
|
||||
const webViewLink = "https://my.omp.sh/#read-only";
|
||||
const harness = createRuntimeHarness({ collabHost: fakeHost({ webLink, webViewLink }) });
|
||||
|
||||
const handled = await executeBuiltinSlashCommand("/collab qrcode-view", harness.runtime);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
const statusText = harness.showStatus.mock.calls[0]?.[0] as string;
|
||||
expect(statusText).toContain(webViewLink);
|
||||
expect(statusText).not.toContain(webLink);
|
||||
const presented = harness.present.mock.calls[0]?.[0] as readonly unknown[];
|
||||
expect(presented[0]).toBeInstanceOf(Spacer);
|
||||
expect(presented[1]).toBeInstanceOf(CollabQrCodeComponent);
|
||||
const component = presented[1] as CollabQrCodeComponent;
|
||||
expect(component.url).toBe(webViewLink);
|
||||
expect(component.render(10).join("\n")).toContain("QR code hidden");
|
||||
});
|
||||
|
||||
it("accepts qr-view as an alias for qrcode-view", async () => {
|
||||
const webLink = "https://my.omp.sh/#full-control";
|
||||
const webViewLink = "https://my.omp.sh/#read-only";
|
||||
const harness = createRuntimeHarness({ collabHost: fakeHost({ webLink, webViewLink }) });
|
||||
|
||||
const handled = await executeBuiltinSlashCommand("/collab qr-view", harness.runtime);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
const presented = harness.present.mock.calls[0]?.[0] as readonly unknown[];
|
||||
expect(presented[1]).toBeInstanceOf(CollabQrCodeComponent);
|
||||
expect((presented[1] as CollabQrCodeComponent).url).toBe(webViewLink);
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,10 @@
|
||||
|
||||
- Removed rendering support for the `render_mermaid` tool from the web tool registry
|
||||
|
||||
### Added
|
||||
|
||||
- Added support for collab browser wrapper links whose web UI host differs from the relay host, so the connect screen joins the relay encoded in the URL fragment.
|
||||
|
||||
## [15.13.3] - 2026-06-15
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -157,15 +157,20 @@ export function parseCollabLink(link: string): ParsedCollabLink | { error: strin
|
||||
} catch {
|
||||
return { error: `Invalid collab link: ${link}` };
|
||||
}
|
||||
if ((url.protocol === "http:" || url.protocol === "https:") && url.hash) {
|
||||
const inner = url.hash.startsWith("#") ? url.hash.slice(1) : url.hash;
|
||||
const parsed = parseCollabLink(inner);
|
||||
if (!("error" in parsed)) return parsed;
|
||||
}
|
||||
const normalized = normalizeRelayOrigin(url.origin);
|
||||
if ("error" in normalized) return normalized;
|
||||
const match = ROOM_PATH_RE.exec(url.pathname);
|
||||
if (!match) {
|
||||
// Web deep link: `http(s)://<relay>/#<collab-link>` — the fragment holds
|
||||
// the whole link, so recurse on it. The recursion terminates because
|
||||
// the inner text is a strict suffix of the input.
|
||||
// Non-http(s) deep links may also carry a complete collab link in the
|
||||
// fragment. http(s) links are handled once above so invalid fragments
|
||||
// fall through to direct relay validation instead of double-recursing.
|
||||
const inner = url.hash.startsWith("#") ? url.hash.slice(1) : url.hash;
|
||||
if (inner) return parseCollabLink(inner);
|
||||
if (inner && url.protocol !== "http:" && url.protocol !== "https:") return parseCollabLink(inner);
|
||||
return { error: "Collab link must contain a /r/<roomId> path" };
|
||||
}
|
||||
const roomId = match[1] as string;
|
||||
|
||||
@@ -82,6 +82,31 @@ describe("collab link parsing", () => {
|
||||
expect(local.wsUrl).toBe(`ws://localhost:7466/r/${ROOM}`);
|
||||
});
|
||||
|
||||
it("parses custom web UI wrappers by their relay fragment", () => {
|
||||
const parsed = parseCollabLink(`http://web.example/collab/#relay.example.com:8443/r/${ROOM}.${KEY_TEXT}`);
|
||||
if ("error" in parsed) throw new Error(parsed.error);
|
||||
expect(parsed.wsUrl).toBe(`wss://relay.example.com:8443/r/${ROOM}`);
|
||||
});
|
||||
|
||||
it("parses split web UI wrappers with full relay URLs in the fragment", () => {
|
||||
const parsed = parseCollabLink(`https://web.example/collab/#wss://relay.example.com/r/${ROOM}.${KEY_TEXT}`);
|
||||
if ("error" in parsed) throw new Error(parsed.error);
|
||||
expect(parsed.wsUrl).toBe(`wss://relay.example.com/r/${ROOM}`);
|
||||
});
|
||||
|
||||
it("falls through invalid http wrapper fragments without reparsing them", () => {
|
||||
expect(parseCollabLink("https://web.example/#not-a-collab-link")).toEqual({
|
||||
error: "Collab link must contain a /r/<roomId> path",
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers browser wrapper fragments over relay-like web paths", () => {
|
||||
const inner = formatCollabLink("wss://relay.example.com", ROOM, KEY);
|
||||
const parsed = parseCollabLink(`https://web.example/r/abcdefghij#${inner}`);
|
||||
if ("error" in parsed) throw new Error(parsed.error);
|
||||
expect(parsed.wsUrl).toBe(`wss://relay.example.com/r/${ROOM}`);
|
||||
});
|
||||
|
||||
it("parses dot-joined web deep links (https://<relay>/#<roomId>.<key>)", () => {
|
||||
const parsed = parseCollabLink(`https://my.omp.sh/#${ROOM}.${KEY_TEXT}`);
|
||||
if ("error" in parsed) throw new Error(parsed.error);
|
||||
@@ -89,6 +114,13 @@ describe("collab link parsing", () => {
|
||||
expect(parsed.key).toEqual(KEY);
|
||||
});
|
||||
|
||||
it("parses legacy https direct relay links with key-only fragments", () => {
|
||||
const parsed = parseCollabLink(`https://relay.example.com/r/${ROOM}#${KEY_TEXT}`);
|
||||
if ("error" in parsed) throw new Error(parsed.error);
|
||||
expect(parsed.wsUrl).toBe(`wss://relay.example.com/r/${ROOM}`);
|
||||
expect(parsed.key).toEqual(KEY);
|
||||
});
|
||||
|
||||
it("accepts %23-mangled legacy deep links (macOS Foundation re-encoding)", () => {
|
||||
const parsed = parseCollabLink(`https://my.omp.sh/#${ROOM}%23${KEY_TEXT}`);
|
||||
if ("error" in parsed) throw new Error(parsed.error);
|
||||
|
||||
Reference in New Issue
Block a user