feat: added shared collab wire protocol and web guest collaboration client

- Added @oh-my-pi/pi-wire and reworked collab protocol types into shared contracts.
- Added wire-compatibility guards in coding-agent host to block unsupported events.
- Added standalone collab-web package with guest UI, mock-host tooling, and local relay.
- Added secure room-link validation, WebCrypto framing, and safer socket routing.
This commit is contained in:
can1357
2026-06-12 11:51:11 +02:00
parent ed5eb70dda
commit 8fa1f6c250
56 changed files with 6304 additions and 80 deletions
+4 -2
View File
@@ -172,9 +172,9 @@ jobs:
echo "cross-platform-run-id=$cross_platform_run_id"
} >> "$GITHUB_OUTPUT"
# Fast lint + type check (no Rust, no native build needed)
# Fast lint, type check, and browser bundle build (no Rust, no native build needed)
check:
name: Lint & type check
name: Lint, type check & web build
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
@@ -189,6 +189,8 @@ jobs:
- run: bun install --frozen-lockfile
- name: Type check workspace
run: bun run ci:check:full
- name: Build collab web
run: bun run collab:web:build
# Linux x64 baseline + modern: required by `test`, so it runs on every PR
# unless native_artifact_lookup found a cached run. Release runs always
+11 -4
View File
@@ -394,16 +394,21 @@ The same prompt cards surface over ACP, so editors get the picker without writin
Node and TypeScript hosts pull the engine in directly. The package exposes `ModelRegistry`, `SessionManager`, `createAgentSession`, and `discoverAuthStorage`; the session emits typed events you subscribe to.
```ts
import { ModelRegistry, SessionManager, createAgentSession, discoverAuthStorage } from "@oh-my-pi/pi-coding-agent";
import {
ModelRegistry,
SessionManager,
createAgentSession,
discoverAuthStorage,
} from "@oh-my-pi/pi-coding-agent";
const auth = await discoverAuthStorage();
const models = new ModelRegistry(auth);
await models.refresh();
const { session } = await createAgentSession({
sessionManager: SessionManager.inMemory(),
authStorage: auth,
modelRegistry: models,
sessionManager: SessionManager.inMemory(),
authStorage: auth,
modelRegistry: models,
});
await session.prompt("list .ts files");
```
@@ -481,6 +486,7 @@ For architecture and contribution guidelines, see [packages/coding-agent/DEVELOP
| Package | Description |
| --------------------------------------------------------- | -------------------------------------------------------------------------- |
| **[@oh-my-pi/collab-web](packages/collab-web)** | Browser guest client, mock host, and local relay for collab live sessions |
| **[@oh-my-pi/pi-ai](packages/ai)** | Multi-provider LLM client with streaming and model/provider integration |
| **[@oh-my-pi/pi-catalog](packages/catalog)** | Model catalog: bundled model database, provider descriptors, and identity |
| **[@oh-my-pi/pi-agent-core](packages/agent)** | Agent runtime with tool calling and state management |
@@ -489,6 +495,7 @@ For architecture and contribution guidelines, see [packages/coding-agent/DEVELOP
| **[@oh-my-pi/pi-natives](packages/natives)** | N-API bindings for grep, shell, image, text, syntax highlighting, and more |
| **[@oh-my-pi/omp-stats](packages/stats)** | Local observability dashboard for AI usage statistics |
| **[@oh-my-pi/pi-utils](packages/utils)** | Shared utilities (logging, streams, dirs/env/process helpers) |
| **[@oh-my-pi/pi-wire](packages/wire)** | Shared collab live-session protocol types and relay constants |
| **[@oh-my-pi/hashline](packages/hashline)** | Line-anchored patch language and applier behind the `edit` tool |
| **[@oh-my-pi/pi-mnemopi](packages/mnemopi)** | Local SQLite memory engine for Oh My Pi agents |
| **[@oh-my-pi/snapcompact](packages/snapcompact)** | Bitmap-frame context compression package and SQuAD eval suite |
+29
View File
@@ -77,6 +77,7 @@
"@oh-my-pi/pi-natives": "catalog:",
"@oh-my-pi/pi-tui": "catalog:",
"@oh-my-pi/pi-utils": "catalog:",
"@oh-my-pi/pi-wire": "catalog:",
"@oh-my-pi/snapcompact": "catalog:",
"@opentelemetry/api": "catalog:",
"@opentelemetry/context-async-hooks": "catalog:",
@@ -106,6 +107,22 @@
"@huggingface/transformers": "catalog:",
},
},
"packages/collab-web": {
"name": "@oh-my-pi/collab-web",
"version": "15.11.7",
"dependencies": {
"@oh-my-pi/pi-wire": "catalog:",
"lucide-react": "catalog:",
"marked": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
},
"devDependencies": {
"@types/bun": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
},
},
"packages/hashline": {
"name": "@oh-my-pi/hashline",
"version": "15.11.7",
@@ -252,6 +269,13 @@
"@types/bun": "catalog:",
},
},
"packages/wire": {
"name": "@oh-my-pi/pi-wire",
"version": "15.11.7",
"devDependencies": {
"@types/bun": "catalog:",
},
},
"python/robomp/web": {
"name": "robomp-web",
"version": "0.1.0",
@@ -290,6 +314,7 @@
"@oh-my-pi/pi-natives": "15.11.7",
"@oh-my-pi/pi-tui": "15.11.7",
"@oh-my-pi/pi-utils": "15.11.7",
"@oh-my-pi/pi-wire": "15.11.7",
"@oh-my-pi/snapcompact": "15.11.7",
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/context-async-hooks": "^2.7.1",
@@ -667,6 +692,8 @@
"@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="],
"@oh-my-pi/collab-web": ["@oh-my-pi/collab-web@workspace:packages/collab-web"],
"@oh-my-pi/hashline": ["@oh-my-pi/hashline@workspace:packages/hashline"],
"@oh-my-pi/omp-stats": ["@oh-my-pi/omp-stats@workspace:packages/stats"],
@@ -687,6 +714,8 @@
"@oh-my-pi/pi-utils": ["@oh-my-pi/pi-utils@workspace:packages/utils"],
"@oh-my-pi/pi-wire": ["@oh-my-pi/pi-wire@workspace:packages/wire"],
"@oh-my-pi/snapcompact": ["@oh-my-pi/snapcompact@workspace:packages/snapcompact"],
"@oh-my-pi/swarm-extension": ["@oh-my-pi/swarm-extension@workspace:packages/swarm-extension"],
+4
View File
@@ -68,6 +68,10 @@ Everything that mutates the host session or machine is host-only: `/model`, `/co
Known v1 limit for guests: a turn already streaming when you join becomes visible from its next message boundary.
## Web client
`packages/collab-web` is a standalone browser client for the same links — no omp install needed on the guest side. 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.
## Settings
| Setting | Default | Meaning |
+5
View File
@@ -31,6 +31,7 @@
"@oh-my-pi/pi-natives": "15.11.7",
"@oh-my-pi/pi-tui": "15.11.7",
"@oh-my-pi/pi-utils": "15.11.7",
"@oh-my-pi/pi-wire": "15.11.7",
"@oh-my-pi/snapcompact": "15.11.7",
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/context-async-hooks": "^2.7.1",
@@ -92,6 +93,10 @@
"dev": "bun --cwd=packages/coding-agent src/cli.ts",
"dev:timing": "PI_TIMING=x bun --cwd=packages/coding-agent --preload ../utils/src/module-timer.ts src/cli.ts",
"stats": "bun --cwd=packages/coding-agent src/cli.ts stats",
"collab:web:dev": "bun --cwd=packages/collab-web run dev",
"collab:relay": "bun --cwd=packages/collab-web run relay",
"collab:mock-host": "bun --cwd=packages/collab-web run mock-host",
"collab:web:build": "bun --cwd=packages/collab-web run build",
"claude:trace": "bun scripts/claude-trace.ts",
"build": "bun run --workspaces --if-present build",
"build:native": "bun --cwd=packages/natives run build",
+1
View File
@@ -11,6 +11,7 @@
### Changed
- Moved collab live-session wire contracts into `@oh-my-pi/pi-wire` and stopped broadcasting unsupported session events or entries to collab guests.
- The Ctrl+P role-cycle track and the plan-approval model slider now color segments by track position from the theme's own palette (accent/success/warning/error + markdown/syntax hues, deduplicated per theme since many themes alias them) instead of role-keyed colors with a gray fallback for custom roles
### Security
+1
View File
@@ -57,6 +57,7 @@
"@oh-my-pi/pi-natives": "catalog:",
"@oh-my-pi/pi-tui": "catalog:",
"@oh-my-pi/pi-utils": "catalog:",
"@oh-my-pi/pi-wire": "catalog:",
"@oh-my-pi/snapcompact": "catalog:",
"@opentelemetry/api": "catalog:",
"@opentelemetry/context-async-hooks": "catalog:",
+46 -4
View File
@@ -12,11 +12,13 @@ import * as fs from "node:fs/promises";
import * as os from "node:os";
import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai";
import { logger } from "@oh-my-pi/pi-utils";
import type { BusChannel, AgentEvent as WireAgentEvent, SessionEntry as WireSessionEntry } from "@oh-my-pi/pi-wire";
import type { InteractiveModeContext } from "../modes/types";
import { AgentLifecycleManager } from "../registry/agent-lifecycle";
import { AgentRegistry } from "../registry/agent-registry";
import type { AgentSessionEvent } from "../session/agent-session";
import { stripImagesFromMessage, USER_INTERRUPT_LABEL } from "../session/messages";
import type { SessionEntry as StoredSessionEntry } from "../session/session-manager";
import { TASK_SUBAGENT_LIFECYCLE_CHANNEL, TASK_SUBAGENT_PROGRESS_CHANNEL } from "../task";
import { generateRoomKey, importRoomKey } from "./crypto";
import {
@@ -46,6 +48,45 @@ const STATE_DEBOUNCE_MS = 100;
const AGENTS_DEBOUNCE_MS = 100;
const STREAMING_STATE_INTERVAL_MS = 2000;
const WELCOME_IMAGE_STRIP_THRESHOLD = 24 * 1024 * 1024;
const WIRE_AGENT_EVENT_TYPES: Record<WireAgentEvent["type"], true> = {
agent_start: true,
agent_end: true,
turn_start: true,
turn_end: true,
message_start: true,
message_update: true,
message_end: true,
tool_execution_start: true,
tool_execution_update: true,
tool_execution_end: true,
notice: true,
auto_compaction_start: true,
auto_compaction_end: true,
auto_retry_start: true,
auto_retry_end: true,
thinking_level_changed: true,
};
const WIRE_SESSION_ENTRY_TYPES: Record<WireSessionEntry["type"], true> = {
message: true,
custom_message: true,
compaction: true,
branch_summary: true,
model_change: true,
thinking_level_change: true,
};
const COLLAB_BUS_CHANNELS = [
TASK_SUBAGENT_LIFECYCLE_CHANNEL,
TASK_SUBAGENT_PROGRESS_CHANNEL,
] as const satisfies readonly BusChannel[];
function isWireAgentEvent(event: AgentSessionEvent): event is AgentSessionEvent & WireAgentEvent {
return event.type in WIRE_AGENT_EVENT_TYPES;
}
function isWireSessionEntry(entry: StoredSessionEntry): entry is StoredSessionEntry & WireSessionEntry {
return entry.type in WIRE_SESSION_ENTRY_TYPES;
}
const CONNECT_TIMEOUT_MS = 15_000;
/** Max bytes served per fetch-transcript reply (guest re-requests from `newSize`). */
const TRANSCRIPT_READ_CAP = 4 * 1024 * 1024;
@@ -145,18 +186,18 @@ export class CollabHost {
}
this.#unsubscribe = this.#ctx.session.subscribe(event => {
this.#broadcast({ t: "event", event });
if (isWireAgentEvent(event)) this.#broadcast({ t: "event", event });
this.#onEventForState(event);
});
const bus = this.#ctx.eventBus;
if (bus) {
for (const channel of [TASK_SUBAGENT_LIFECYCLE_CHANNEL, TASK_SUBAGENT_PROGRESS_CHANNEL]) {
for (const channel of COLLAB_BUS_CHANNELS) {
this.#busUnsubscribers.push(bus.on(channel, data => this.#broadcast({ t: "bus", channel, data })));
}
}
this.#registryUnsubscribe = AgentRegistry.global().onChange(() => this.#scheduleAgentsBroadcast());
this.#ctx.sessionManager.onEntryAppended = entry => {
this.#broadcast({ t: "entry", entry });
if (isWireSessionEntry(entry)) this.#broadcast({ t: "entry", entry });
// Model/thinking/title changes land as entries while idle; refresh
// guest state promptly (debounce + JSON diff dedupe).
this.#scheduleStateBroadcast();
@@ -249,12 +290,13 @@ export class CollabHost {
}
logger.info("collab welcome exceeded size threshold; stripped images", { stripped });
}
const entries = snapshot.entries.filter(isWireSessionEntry);
this.#socket?.send(
{
t: "welcome",
proto: COLLAB_PROTO,
header: snapshot.header,
entries: snapshot.entries,
entries,
state: this.#buildState(),
agents: this.#snapshotAgents(),
},
+31 -65
View File
@@ -6,68 +6,54 @@
* plaintext envelope (`[4B uint32 BE peerId][sealed payload]`) plus TEXT JSON
* control messages that carry no session data.
*/
import type { ImageContent, Model } from "@oh-my-pi/pi-ai";
import type {
BusChannel,
GuestFrame,
ParsedCollabLink,
Participant,
SessionState,
AgentSnapshot as WireAgentSnapshot,
} from "@oh-my-pi/pi-wire";
import { DEFAULT_RELAY_URL, ENVELOPE_HEADER_LENGTH, ROOM_ID_BYTES } from "@oh-my-pi/pi-wire";
import type { ContextUsage } from "../extensibility/extensions/types";
import type { AgentSessionEvent } from "../session/agent-session";
import type { SessionEntry, SessionHeader } from "../session/session-manager";
export const COLLAB_PROTO = 1;
export type {
CollabPromptDetails,
ParsedCollabLink,
RelayControlMessage,
RelayControlToGuest,
RelayControlToHost,
} from "@oh-my-pi/pi-wire";
export { COLLAB_PROMPT_MESSAGE_TYPE, COLLAB_PROTO } from "@oh-my-pi/pi-wire";
export { DEFAULT_RELAY_URL, ENVELOPE_HEADER_LENGTH, ROOM_ID_BYTES };
/** customType of guest prompts injected on the host (rendered with an author badge). */
export const COLLAB_PROMPT_MESSAGE_TYPE = "collab-prompt";
/** Display metadata attached to collab guest prompts. */
export interface CollabPromptDetails {
from?: string;
}
export interface CollabParticipant {
name: string;
role: "host" | "guest";
}
/** Serializable mirror of an {@link AgentRef} (live session handle stripped). */
export interface AgentSnapshot {
id: string;
displayName: string;
kind: "main" | "sub";
parentId?: string;
status: "running" | "idle" | "parked" | "aborted";
/** Whether the host has a transcript file for this agent (gates remote transcript fetch). */
hasSessionFile: boolean;
createdAt: number;
lastActivity: number;
}
export type CollabParticipant = Participant;
export type AgentSnapshot = WireAgentSnapshot;
/** Debounced footer snapshot broadcast by the host. */
export interface CollabSessionState {
isStreaming: boolean;
queuedMessageCount: number;
sessionName?: string;
/** Host cwd — display/title/relativization only; guest never chdirs. */
cwd: string;
export type CollabSessionState = SessionState & {
/**
* Host model (full catalog object). Guests apply it to their replica
* agent state so model display and context-window math are native.
*/
model?: Model;
/** Host effective thinking level (ThinkingLevel value). */
thinkingLevel?: string;
/** Host status-line context numbers (guest system prompt/tools differ, so local estimates drift). */
contextUsage?: ContextUsage;
participants: CollabParticipant[];
}
};
/** Encrypted payload frames (inside AES-GCM, JSON). */
/**
* Encrypted payload frames (inside AES-GCM, JSON). The wire package pins the
* JSON skeleton (`WireFrame`); host-side frames carry the rich session types
* that serialize into those shapes.
*/
export type CollabFrame =
// guest -> host
| { t: "hello"; proto: number; name: string }
// guest -> host (hello/abort/agent-cmd/fetch-transcript are taken verbatim from the wire grammar)
| Exclude<GuestFrame, { t: "prompt" }>
| { t: "prompt"; text: string; images?: ImageContent[] }
| { t: "abort" }
/** Agent Hub action routed to the host (chat requires `text`). */
| { t: "agent-cmd"; cmd: "chat" | "kill" | "revive"; agentId: string; text?: string }
/** Incremental subagent-transcript read (mirrors the hub's readFileIncremental contract). */
| { t: "fetch-transcript"; reqId: number; agentId: string; fromByte: number }
// host -> guest
| {
t: "welcome";
@@ -81,7 +67,7 @@ export type CollabFrame =
| { t: "event"; event: AgentSessionEvent }
| { t: "state"; state: CollabSessionState }
/** Mirrored EventBus traffic (task subagent lifecycle/progress channels only). */
| { t: "bus"; channel: string; data: unknown }
| { t: "bus"; channel: BusChannel; data: unknown }
/** Full agent-registry snapshot (debounced on registry change). */
| { t: "agents"; agents: AgentSnapshot[] }
/** Targeted reply to fetch-transcript; `text` is decoded JSONL from `fromByte`, `newSize` the next offset base. */
@@ -89,20 +75,12 @@ export type CollabFrame =
| { t: "bye"; reason: string }
| { t: "error"; message: string };
/** Relay → host control message (TEXT JSON, unencrypted, no session data). */
export type RelayControlToHost = { t: "peer-joined" | "peer-left"; peer: number };
/** Relay → guest control message (TEXT JSON, unencrypted). */
export type RelayControlToGuest = { t: "room-closed" };
export type RelayControlMessage = RelayControlToHost | RelayControlToGuest;
// ═══════════════════════════════════════════════════════════════════════════
// Wire envelope: [4B uint32 BE peerId][sealed payload]
// Host→relay: peerId 0 broadcasts to all guests; peerId N targets guest N.
// Guest→relay: always 0; the relay rewrites it to the sender's id.
// ═══════════════════════════════════════════════════════════════════════════
export const ENVELOPE_HEADER_LENGTH = 4;
export function packEnvelope(peerId: number, sealed: Uint8Array): Uint8Array {
const out = new Uint8Array(ENVELOPE_HEADER_LENGTH + sealed.byteLength);
new DataView(out.buffer).setUint32(0, peerId, false);
@@ -125,23 +103,11 @@ export function rewriteEnvelopePeer(data: Uint8Array, peerId: number): void {
// Link format: wss://<host[:port]>/r/<roomId>#<base64url-32-byte-key>
// ═══════════════════════════════════════════════════════════════════════════
export const ROOM_ID_BYTES = 16;
/** Default public relay; bare `<roomId>#<key>` links resolve against it. */
export const DEFAULT_RELAY_URL = "wss://relay.omp.sh";
const ROOM_PATH_RE = /^\/r\/([A-Za-z0-9_-]{10,64})$/;
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 };
export interface ParsedCollabLink {
/** wss://host[:port]/r/<roomId> — no query, no fragment. */
wsUrl: string;
roomId: string;
key: Uint8Array;
}
export function generateRoomId(): string {
const bytes = new Uint8Array(ROOM_ID_BYTES);
crypto.getRandomValues(bytes);
+19
View File
@@ -0,0 +1,19 @@
# Changelog
## [Unreleased]
### Added
- Added deep-link auto-connection support from `#<roomId>#<key>` URLs when opening the web app
- Added subagent-focused UI with a side rail and detail drawer that surfaces each subagent’s lifecycle, running progress, and per-subagent transcript
- Added session status controls in the shell, including connection banners, toast notifications, and rejoin/new-link actions after a session ends
- Added the collab web package with the browser guest client, mock host, local relay, and relay contract tests.
### Changed
- Changed relay socket behavior to retry transient disconnections with exponential backoff while treating terminal relay-close conditions and decryption failures as non-retriable
- Changed subagent transcript decoding to handle streamed JSONL payload chunks incrementally by preserving carry-over data across chunks
- Replaced the vendored collab wire type mirror with shared `@oh-my-pi/pi-wire` protocol contracts.
### Security
- Hardened transcript Markdown rendering by escaping embedded HTML and allowing only safe link schemes
+36
View File
@@ -0,0 +1,36 @@
# @oh-my-pi/collab-web
Web client for [omp collab sessions](../../docs/collab.md). Paste a `/collab` link into the browser and you get the same live session guests see in the TUI: streaming transcript, tool-call cards, subagent panel with live transcripts, and a composer that prompts (or interrupts) the host agent.
## Quick start
```sh
# dev server (Bun HTML dev server with HMR) — http://localhost:3000
bun run dev
# offline demo: local relay + scripted mock host; prints a ws://localhost link
bun run mock-host
```
Host a session from any omp instance (`/collab`, or `/collab ws://localhost:7466` to use the mock relay), then paste the printed link into the connect screen. Deep links work too: `http://localhost:3000/#<roomId>#<key>` auto-connects on load.
## Build & deploy
```sh
bun run build # static site in dist/
```
`dist/` is a fully static SPA — host it anywhere. Two runtime requirements:
- **Secure context**: room keys are unwrapped with WebCrypto (`crypto.subtle`), which browsers expose only on `https://` or `localhost`.
- **Relay reachability**: the client connects straight to the relay over WebSocket (`wss://` for anything that isn't localhost). The default relay is `wss://relay.omp.sh`; bare `<roomId>#<key>` links resolve against it.
The room key never leaves the URL fragment — it is not sent to the relay or any server.
## Architecture
- `src/lib/` — vendored wire codec (`codec.ts` AES-256-GCM, `link.ts` envelope + link grammar), `socket.ts` reconnecting relay socket, `client.ts` guest session store (`GuestClient` + immutable snapshots for `useSyncExternalStore`). Shared protocol shapes come from `@oh-my-pi/pi-wire`.
- `src/components/` — `transcript/` (entries, markdown, tool cards), `agents/` (panel + transcript drawer), `shell/` (connect screen, header, composer, banners, toasts).
- `scripts/` — `local-relay.ts` (content-blind relay on `Bun.serve`), `mock-host.ts` + `fixture.ts` (scripted host for offline dev).
The package is intentionally standalone — no dependency on `@oh-my-pi/pi-coding-agent` at runtime or type level. Wire-shape drift is prevented by consuming the same `@oh-my-pi/pi-wire` contracts as the host, with sealed-frame interop still covered by `test/codec.test.ts`.
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark" />
<title>omp collab</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./src/main.tsx"></script>
</body>
</html>
+61
View File
@@ -0,0 +1,61 @@
{
"type": "module",
"name": "@oh-my-pi/collab-web",
"version": "15.11.7",
"private": true,
"description": "Browser guest client and local relay tools for omp collab live sessions",
"homepage": "https://omp.sh",
"author": "Can Boluk",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/can1357/oh-my-pi.git",
"directory": "packages/collab-web"
},
"bugs": {
"url": "https://github.com/can1357/oh-my-pi/issues"
},
"keywords": [
"collab",
"relay",
"websocket",
"react",
"agent"
],
"scripts": {
"dev": "bun ./index.html",
"mock-host": "bun scripts/mock-host.ts",
"relay": "bun scripts/local-relay.ts",
"build": "bun build ./index.html --outdir=dist --minify",
"prepack": "bun run build",
"test": "bun test --parallel",
"check": "biome check . && bun run check:types",
"check:types": "tsgo -p tsconfig.json --noEmit",
"lint": "biome lint .",
"fix": "biome check --write --unsafe .",
"fmt": "biome format --write ."
},
"dependencies": {
"@oh-my-pi/pi-wire": "catalog:",
"lucide-react": "catalog:",
"marked": "catalog:",
"react": "catalog:",
"react-dom": "catalog:"
},
"devDependencies": {
"@types/bun": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:"
},
"engines": {
"bun": ">=1.3.14"
},
"files": [
"dist",
"src",
"scripts",
"index.html",
"README.md",
"CHANGELOG.md"
]
}
+656
View File
@@ -0,0 +1,656 @@
/**
* Canned session data for the offline collab harness.
*
* One realistic host session (header + ~14 entries covering every renderer
* branch), an agent registry (main + a running sub with ticking progress + a
* parked sub with a transcript), a subagent transcript JSONL blob, and a
* scripted streaming turn the mock host replays on every guest prompt.
*/
import type {
AgentEvent,
AgentSnapshot,
AssistantMessage,
SessionEntry,
SessionHeader,
SubagentProgressPayload,
ToolCallContent,
ToolResultMessage,
WireModel,
WireUsage,
} from "@oh-my-pi/pi-wire";
export const HOST_DISPLAY_NAME = "kai";
export const fixtureModel: WireModel = {
id: "claude-haiku-4-5",
name: "Claude Haiku 4.5",
provider: "anthropic",
contextWindow: 200_000,
};
const NOW = Date.now();
const MIN = 60_000;
function iso(tsMs: number): string {
return new Date(tsMs).toISOString();
}
function mkUsage(input: number, output: number, cacheRead: number, cost: number): WireUsage {
return { input, output, cacheRead, cacheWrite: 0, totalTokens: input + output + cacheRead, cost: { total: cost } };
}
export const fixtureHeader: SessionHeader = {
type: "session",
id: "mock-collab-session",
title: "relay reconnect audit",
timestamp: iso(NOW - 32 * MIN),
cwd: "/Users/kai/Projects/pi",
};
const ASSISTANT_AUDIT_TEXT = `## Reconnect audit
Three things to verify before touching anything:
- backoff window (1s → 30s, exponential)
- fatal close codes that must *never* retry
- the resync \`hello\` → \`welcome\` handshake on reopen
\`\`\`ts
const FATAL = new Set([4001, 4004, 4009, 4029]);
const delay = Math.min(1000 * 2 ** attempt, 30_000);
\`\`\`
Checking the actual implementation now.`;
export const fixtureEntries: SessionEntry[] = [
{
id: "e01",
parentId: null,
timestamp: iso(NOW - 31 * MIN),
type: "message",
message: {
role: "user",
content:
"the guest socket sometimes stays dead after a relay redeploy — can you audit the reconnect path in relay-client.ts?",
timestamp: NOW - 31 * MIN,
},
},
{
id: "e02",
parentId: "e01",
timestamp: iso(NOW - 30 * MIN),
type: "message",
message: {
role: "assistant",
content: [
{
type: "thinking",
thinking:
"Reconnect semantics live in two places: the backoff loop in relay-client.ts and the fatal close codes in protocol.ts. I should read both before claiming anything — a redeploy closes with 1001, which must be treated as transient.",
},
{ type: "text", text: ASSISTANT_AUDIT_TEXT },
{
type: "toolCall",
id: "call-bash-01",
name: "bash",
arguments: { command: 'rg -n "BACKOFF|MAX_PENDING" packages/coding-agent/src/collab/relay-client.ts' },
intent: "Checking backoff constants",
},
{
type: "toolCall",
id: "call-read-01",
name: "read",
arguments: { path: "packages/coding-agent/src/collab/relay-clinet.ts", offset: 160, limit: 60 },
intent: "Reading the close handler",
},
],
model: fixtureModel.id,
usage: mkUsage(2_410, 386, 18_200, 0.0119),
stopReason: "toolUse",
timestamp: NOW - 30 * MIN,
},
},
{
id: "e03",
parentId: "e02",
timestamp: iso(NOW - 30 * MIN + 4_000),
type: "message",
message: {
role: "toolResult",
toolCallId: "call-bash-01",
toolName: "bash",
content: [
{
type: "text",
text: "20:const BACKOFF_BASE_MS = 1_000;\n21:const BACKOFF_MAX_MS = 30_000;\n23:const MAX_PENDING_SENDS = 256;",
},
],
isError: false,
timestamp: NOW - 30 * MIN + 4_000,
},
},
{
id: "e04",
parentId: "e03",
timestamp: iso(NOW - 30 * MIN + 5_000),
type: "message",
message: {
role: "toolResult",
toolCallId: "call-read-01",
toolName: "read",
content: [
{
type: "text",
text: "ENOENT: no such file or directory\n open 'packages/coding-agent/src/collab/relay-clinet.ts'\n at open (node:internal/fs/promises:642:23)\n at readRange (src/tools/read.ts:88:9)",
},
],
isError: true,
timestamp: NOW - 30 * MIN + 5_000,
},
},
{
id: "e05",
parentId: "e04",
timestamp: iso(NOW - 29 * MIN),
type: "message",
message: {
role: "assistant",
content: [
{
type: "text",
text: "Typo on my end — the file is `relay-client.ts`. The backoff constants match the spec (1s base, 30s cap, 256-frame reconnect buffer); reading the close handler next.",
},
],
model: fixtureModel.id,
usage: mkUsage(3_080, 64, 20_540, 0.0124),
stopReason: "stop",
timestamp: NOW - 29 * MIN,
},
},
{
id: "e06",
parentId: "e05",
timestamp: iso(NOW - 26 * MIN),
type: "message",
message: {
role: "user",
content: "what about half-open sockets after a NAT rebind? do we ping at all?",
timestamp: NOW - 26 * MIN,
},
},
{
id: "e07",
parentId: "e06",
timestamp: iso(NOW - 25 * MIN),
type: "message",
message: {
role: "assistant",
content: [
{
type: "text",
text: "The relay sends protocol-level pings, so a half-open socket dies within the idle window and lands in the normal close path. From there the client splits three ways:\n\n- transient drop → exponential retry, fresh `hello` on reopen\n- fatal close code (4001/4004/4009/4029) → surface the reason, stay down\n- decrypt failure → treated as fatal, since the key cannot heal",
},
],
model: fixtureModel.id,
usage: mkUsage(3_420, 142, 21_900, 0.0131),
stopReason: "stop",
timestamp: NOW - 25 * MIN,
},
},
{
id: "e08",
parentId: "e07",
timestamp: iso(NOW - 21 * MIN),
type: "custom_message",
customType: "collab-prompt",
content:
"also verify the relay drops the send buffer past 256 frames — we saw memory creep on a 6h session last week",
details: { from: "ada" },
display: true,
},
{
id: "e09",
parentId: "e08",
timestamp: iso(NOW - 20 * MIN),
type: "message",
message: {
role: "assistant",
content: [
{
type: "text",
text: "Good catch ada — `MAX_PENDING_SENDS` caps the reconnect buffer at 256 sealed envelopes and overflow is dropped (debug-logged), so no unbounded growth. The cost is silent frame loss, which the `welcome` resync on reopen covers.",
},
],
model: fixtureModel.id,
usage: mkUsage(3_900, 98, 23_400, 0.0138),
stopReason: "stop",
timestamp: NOW - 20 * MIN,
},
},
{
id: "e10",
parentId: "e09",
timestamp: iso(NOW - 14 * MIN),
type: "compaction",
summary:
"Audited the collab reconnect path: exponential backoff 1s→30s confirmed; fatal close codes 4001/4004/4009/4029 never retry; decrypt failures are terminal; the reconnect buffer is capped at 256 sealed frames with drop-on-overflow, recovered by the welcome resync. Open question: ping cadence on idle relays behind aggressive NATs.",
shortSummary: "reconnect audit findings",
firstKeptEntryId: "e08",
tokensBefore: 48_213,
},
{
id: "e11",
parentId: "e10",
timestamp: iso(NOW - 13 * MIN),
type: "model_change",
model: fixtureModel.id,
},
{
id: "e12",
parentId: "e11",
timestamp: iso(NOW - 13 * MIN + 5_000),
type: "thinking_level_change",
thinkingLevel: "medium",
},
{
id: "e13",
parentId: "e12",
timestamp: iso(NOW - 9 * MIN),
type: "message",
message: {
role: "user",
content: "summarize what changed and what's left",
timestamp: NOW - 9 * MIN,
},
},
{
id: "e14",
parentId: "e13",
timestamp: iso(NOW - 8 * MIN),
type: "message",
message: {
role: "assistant",
content: [
{
type: "text",
text: "Done: backoff window verified, fatal-code table cross-checked against the relay, buffer cap documented. Left: an integration test that kills the relay mid-stream and asserts the guest resyncs from `welcome` without duplicated entries — RelayProbe is running that now, DocSweep already swept the docs.",
},
],
model: fixtureModel.id,
usage: mkUsage(12_640, 187, 31_780, 0.0212),
stopReason: "stop",
timestamp: NOW - 8 * MIN,
},
},
];
// ─── agents ──────────────────────────────────────────────────────────────────
export const fixtureAgents: AgentSnapshot[] = [
{
id: "main",
displayName: "Main",
kind: "main",
status: "running",
hasSessionFile: true,
createdAt: NOW - 32 * MIN,
lastActivity: NOW - 5_000,
},
{
id: "RelayProbe",
displayName: "RelayProbe",
kind: "sub",
parentId: "main",
status: "running",
hasSessionFile: true,
createdAt: NOW - 6 * MIN,
lastActivity: NOW - 2_000,
},
{
id: "DocSweep",
displayName: "DocSweep",
kind: "sub",
parentId: "main",
status: "parked",
hasSessionFile: true,
createdAt: NOW - 25 * MIN,
lastActivity: NOW - 11 * MIN,
},
];
const PROBE_TOOLS = ["bash", "read", "search", "edit"] as const;
const PROBE_TOOL_ARGS: Record<(typeof PROBE_TOOLS)[number], string> = {
bash: "bun test packages/coding-agent/test/collab --filter reconnect",
read: "packages/coding-agent/src/collab/relay-client.ts:168-197",
search: "scheduleRetry|failFatal",
edit: "packages/coding-agent/test/collab/reconnect.test.ts",
};
/** Progress payload for the running sub; `tick` advances the counters. */
export function makeProbeProgress(tick: number): SubagentProgressPayload {
const tool = PROBE_TOOLS[tick % PROBE_TOOLS.length]!;
const recentTools = [1, 2, 3].map(back => {
const prior = PROBE_TOOLS[(tick + PROBE_TOOLS.length * back - back) % PROBE_TOOLS.length]!;
return { tool: prior, args: PROBE_TOOL_ARGS[prior], endMs: Date.now() - back * 2_000 };
});
return {
index: 0,
agent: "task",
task: "probe relay reconnect under packet loss",
parentToolCallId: "call-task-01",
assignment: "Kill the relay mid-stream and assert the guest resyncs from welcome without duplicate entries.",
sessionFile: "/tmp/omp/agents/RelayProbe.jsonl",
progress: {
index: 0,
id: "RelayProbe",
agent: "task",
status: "running",
task: "probe relay reconnect under packet loss",
description: "relay reconnect probe",
lastIntent: "Replaying drop scenario",
currentTool: tool,
currentToolArgs: PROBE_TOOL_ARGS[tool],
recentTools,
recentOutput: ["3 sockets reconnected in 1.2s", "0 duplicate entries after resync"],
toolCount: 9 + tick,
requests: 4 + Math.floor(tick / 3),
tokens: 18_400 + tick * 450,
contextTokens: 22_300 + tick * 510,
contextWindow: fixtureModel.contextWindow,
cost: 0.041 + tick * 0.0012,
durationMs: 95_000 + tick * 2_000,
resolvedModel: fixtureModel.id,
},
};
}
// ─── subagent transcript ─────────────────────────────────────────────────────
const SUB_T0 = NOW - 25 * MIN;
const subagentTranscriptLines: unknown[] = [
{ type: "session", id: "mock-docsweep", timestamp: iso(SUB_T0), cwd: "/Users/kai/Projects/pi" },
// Unknown entry type — guests must skip it (tolerant default branch).
{ type: "session_init", id: "s00", parentId: null, timestamp: iso(SUB_T0), version: 3 },
{
id: "s01",
parentId: null,
timestamp: iso(SUB_T0 + 2_000),
type: "message",
message: {
role: "user",
content: "Sweep docs/collab.md for stale close-code references and report mismatches.",
timestamp: SUB_T0 + 2_000,
},
},
{
id: "s02",
parentId: "s01",
timestamp: iso(SUB_T0 + 9_000),
type: "message",
message: {
role: "assistant",
content: [
{ type: "thinking", thinking: "Grep the doc for 4xxx codes, then diff against protocol.ts." },
{ type: "text", text: "Scanning `docs/collab.md` for close-code mentions." },
{
type: "toolCall",
id: "sub-call-01",
name: "search",
arguments: { pattern: "40\\d\\d", path: "docs/collab.md" },
intent: "Finding close codes",
},
],
model: fixtureModel.id,
usage: mkUsage(1_180, 96, 0, 0.0021),
stopReason: "toolUse",
timestamp: SUB_T0 + 9_000,
},
},
{
id: "s03",
parentId: "s02",
timestamp: iso(SUB_T0 + 11_000),
type: "message",
message: {
role: "toolResult",
toolCallId: "sub-call-01",
toolName: "search",
content: [
{
type: "text",
text: "docs/collab.md:41: 4001 room closed\ndocs/collab.md:42: 4004 no such room\ndocs/collab.md:43: 4009 host conflict\ndocs/collab.md:44: 4029 room full",
},
],
isError: false,
timestamp: SUB_T0 + 11_000,
},
},
{
id: "s04",
parentId: "s03",
timestamp: iso(SUB_T0 + 20_000),
type: "message",
message: {
role: "assistant",
content: [
{
type: "toolCall",
id: "sub-call-02",
name: "read",
arguments: { path: "packages/coding-agent/src/collab/relay-client.ts", offset: 13, limit: 6 },
intent: "Cross-checking the fatal table",
},
],
model: fixtureModel.id,
usage: mkUsage(1_460, 41, 980, 0.0024),
stopReason: "toolUse",
timestamp: SUB_T0 + 20_000,
},
},
{
id: "s05",
parentId: "s04",
timestamp: iso(SUB_T0 + 22_000),
type: "message",
message: {
role: "toolResult",
toolCallId: "sub-call-02",
toolName: "read",
content: [
{
type: "text",
text: '13:const FATAL_CLOSE_REASONS: Record<number, string> = {\n14:\t4001: "room closed",\n15:\t4004: "no such room",\n16:\t4009: "a host is already connected for this room",\n17:\t4029: "room is full",\n18:};',
},
],
isError: false,
timestamp: SUB_T0 + 22_000,
},
},
{
id: "s06",
parentId: "s05",
timestamp: iso(SUB_T0 + 30_000),
type: "message",
message: {
role: "assistant",
content: [
{
type: "text",
text: "All four close codes in `docs/collab.md` match `FATAL_CLOSE_REASONS` — no stale references. The doc could additionally mention that decrypt failures are fatal; flagged as a suggestion, not a mismatch.",
},
],
model: fixtureModel.id,
usage: mkUsage(1_720, 88, 1_240, 0.0029),
stopReason: "stop",
timestamp: SUB_T0 + 30_000,
},
},
];
/** DocSweep's session file, served by the mock host's fetch-transcript handler. */
export const subagentTranscriptJsonl: string = `${subagentTranscriptLines
.map(line => JSON.stringify(line))
.join("\n")}\n`;
// ─── scripted streaming turn ─────────────────────────────────────────────────
export type ScriptedStep =
| { kind: "event"; event: AgentEvent }
| { kind: "entry"; entry: SessionEntry }
| { kind: "state"; streaming: boolean };
const TURN_THINKING_1 = "Guest wants a live check. ";
const TURN_THINKING_2 = "Guest wants a live check. I'll run the reconnect suite once and summarize the result.";
const TURN_TEXT_1 = "Kicking off a live reconnect probe ";
const TURN_TEXT_2 = "Kicking off a live reconnect probe — one suite run, then a verdict.";
const TURN_CLOSE_1 = "Probe passed: 3 reconnects, ";
const TURN_CLOSE_2 = "Probe passed: 3 reconnects, 0 duplicate entries after resync. The reconnect path holds.";
/**
* One scripted streaming turn, replayed by the mock host at ~40ms cadence.
*
* `seq` keeps ids unique across replays; `parentId` chains the appended
* entries onto the current transcript tail.
*/
export function makeScriptedTurn(seq: number, parentId: string | null): ScriptedStep[] {
const ts = Date.now();
const a1Id = `turn${seq}-a1`;
const callId = `turn${seq}-call1`;
const r1Id = `turn${seq}-r1`;
const a2Id = `turn${seq}-a2`;
const command = "bun test packages/coding-agent/test/collab --filter reconnect";
const toolResultText =
"3 tests passed (reconnect.test.ts)\n3 sockets reconnected in 1.2s\n0 duplicate entries after resync";
const partial = (content: AssistantMessage["content"]): AssistantMessage => ({
role: "assistant",
content,
model: fixtureModel.id,
usage: mkUsage(0, 0, 0, 0),
stopReason: "stop",
timestamp: ts,
});
const toolCall: ToolCallContent = {
type: "toolCall",
id: callId,
name: "bash",
arguments: { command },
intent: "Running the reconnect suite",
};
const a1Final: AssistantMessage = {
role: "assistant",
content: [{ type: "thinking", thinking: TURN_THINKING_2 }, { type: "text", text: TURN_TEXT_2 }, toolCall],
model: fixtureModel.id,
usage: mkUsage(4_310, 164, 24_800, 0.0147),
stopReason: "toolUse",
timestamp: ts,
};
const r1Message: ToolResultMessage = {
role: "toolResult",
toolCallId: callId,
toolName: "bash",
content: [{ type: "text", text: toolResultText }],
isError: false,
timestamp: ts,
};
const a2Final: AssistantMessage = {
role: "assistant",
content: [{ type: "text", text: TURN_CLOSE_2 }],
model: fixtureModel.id,
usage: mkUsage(4_690, 52, 25_400, 0.0153),
stopReason: "stop",
timestamp: ts,
};
return [
{ kind: "event", event: { type: "agent_start" } },
{ kind: "state", streaming: true },
{ kind: "event", event: { type: "turn_start" } },
{ kind: "event", event: { type: "message_start", message: partial([]) } },
{
kind: "event",
event: { type: "message_update", message: partial([{ type: "thinking", thinking: TURN_THINKING_1 }]) },
},
{
kind: "event",
event: { type: "message_update", message: partial([{ type: "thinking", thinking: TURN_THINKING_2 }]) },
},
{
kind: "event",
event: {
type: "message_update",
message: partial([
{ type: "thinking", thinking: TURN_THINKING_2 },
{ type: "text", text: TURN_TEXT_1 },
]),
},
},
{
kind: "event",
event: {
type: "message_update",
message: partial([
{ type: "thinking", thinking: TURN_THINKING_2 },
{ type: "text", text: TURN_TEXT_2 },
]),
},
},
{
kind: "event",
event: {
type: "message_update",
message: partial([
{ type: "thinking", thinking: TURN_THINKING_2 },
{ type: "text", text: TURN_TEXT_2 },
toolCall,
]),
},
},
{ kind: "event", event: { type: "message_end", message: a1Final } },
{ kind: "entry", entry: { id: a1Id, parentId, timestamp: iso(ts), type: "message", message: a1Final } },
{
kind: "event",
event: {
type: "tool_execution_start",
toolCallId: callId,
toolName: "bash",
args: { command },
intent: "Running the reconnect suite",
},
},
{
kind: "event",
event: {
type: "tool_execution_update",
toolCallId: callId,
toolName: "bash",
args: { command },
partialResult: "3 sockets reconnected in 1.2s",
},
},
{
kind: "event",
event: {
type: "tool_execution_end",
toolCallId: callId,
toolName: "bash",
result: toolResultText,
isError: false,
},
},
{ kind: "entry", entry: { id: r1Id, parentId: a1Id, timestamp: iso(ts), type: "message", message: r1Message } },
{ kind: "event", event: { type: "message_start", message: partial([]) } },
{ kind: "event", event: { type: "message_update", message: partial([{ type: "text", text: TURN_CLOSE_1 }]) } },
{ kind: "event", event: { type: "message_update", message: partial([{ type: "text", text: TURN_CLOSE_2 }]) } },
{ kind: "event", event: { type: "message_end", message: a2Final } },
{ kind: "entry", entry: { id: a2Id, parentId: r1Id, timestamp: iso(ts), type: "message", message: a2Final } },
{ kind: "event", event: { type: "turn_end" } },
{ kind: "event", event: { type: "agent_end" } },
{ kind: "state", streaming: false },
];
}
+169
View File
@@ -0,0 +1,169 @@
/**
* Offline stand-in for the public collab relay (`wss://relay.omp.sh`).
*
* Speaks the exact relay contract the real clients expect:
* - `GET /r/<roomId>?role=host|guest` upgrades to a WebSocket.
* - The host creates the room; a second host is rejected with close 4009 and
* a guest joining a missing room with close 4004.
* - Host binary frames: envelope peerId 0 broadcasts to every guest, peerId N
* targets that guest only — forwarded unchanged either way.
* - Guest binary frames: the first 4 envelope bytes are rewritten to the
* sender's peerId, then forwarded to the host.
* - TEXT control to the host: `{"t":"peer-joined","peer":N}` / `{"t":"peer-left","peer":N}`.
* - Host disconnect: TEXT `{"t":"room-closed"}` to every guest, then close 4001
* and the room is garbage-collected.
*
* The relay never sees plaintext: payloads stay sealed end to end.
*/
import { rewriteEnvelopePeer, unpackEnvelope } from "../src/lib/link";
const ROOM_PATH_RE = /^\/r\/([A-Za-z0-9_-]{10,64})$/;
const DEFAULT_PORT = 7466;
interface SocketData {
roomId: string;
role: "host" | "guest";
/** Assigned on open for guests; the host stays 0. */
peerId: number;
}
type RelaySocket = Bun.ServerWebSocket<SocketData>;
interface Room {
host: RelaySocket;
guests: Map<number, RelaySocket>;
nextPeerId: number;
}
export interface LocalRelay {
/** ws://localhost:<port> — append `/r/<roomId>?role=…` to connect. */
url: string;
/** Closes every room and stops the server. Idempotent. */
stop(): void;
}
export function startLocalRelay(port = 0): LocalRelay {
const rooms = new Map<string, Room>();
const server = Bun.serve({
port,
fetch(req, srv): Response | undefined {
const url = new URL(req.url);
const match = ROOM_PATH_RE.exec(url.pathname);
const role = url.searchParams.get("role");
if (!match || (role !== "host" && role !== "guest")) {
return new Response("not found", { status: 404 });
}
const data: SocketData = { roomId: match[1]!, role, peerId: 0 };
if (srv.upgrade(req, { data })) return undefined;
return new Response("websocket upgrade required", { status: 426 });
},
websocket: {
open(ws: RelaySocket): void {
const { roomId, role } = ws.data;
if (role === "host") {
if (rooms.has(roomId)) {
ws.close(4009, "a host is already connected for this room");
return;
}
rooms.set(roomId, { host: ws, guests: new Map(), nextPeerId: 1 });
return;
}
const room = rooms.get(roomId);
if (!room) {
ws.close(4004, "no such room");
return;
}
const peerId = room.nextPeerId++;
ws.data.peerId = peerId;
room.guests.set(peerId, ws);
room.host.send(JSON.stringify({ t: "peer-joined", peer: peerId }));
},
message(ws: RelaySocket, message: string | Buffer): void {
if (typeof message === "string") return; // clients never send TEXT
const room = rooms.get(ws.data.roomId);
if (!room) return;
if (ws.data.role === "host") {
const envelope = unpackEnvelope(message);
if (!envelope) return;
if (envelope.peerId === 0) {
for (const guest of room.guests.values()) guest.send(message);
} else {
room.guests.get(envelope.peerId)?.send(message);
}
return;
}
if (message.byteLength < 4) return;
rewriteEnvelopePeer(message, ws.data.peerId);
room.host.send(message);
},
close(ws: RelaySocket): void {
const { roomId, role, peerId } = ws.data;
const room = rooms.get(roomId);
if (!room) return;
if (role === "host") {
// Rejected second host: the live room is not ours to tear down.
if (room.host !== ws) return;
rooms.delete(roomId);
const closure = JSON.stringify({ t: "room-closed" });
for (const guest of room.guests.values()) {
guest.send(closure);
guest.close(4001, "room closed");
}
room.guests.clear();
return;
}
if (room.guests.delete(peerId)) {
room.host.send(JSON.stringify({ t: "peer-left", peer: peerId }));
}
},
},
});
return {
url: `ws://localhost:${server.port}`,
stop(): void {
for (const room of rooms.values()) {
const closure = JSON.stringify({ t: "room-closed" });
for (const guest of room.guests.values()) {
guest.send(closure);
guest.close(4001, "room closed");
}
room.host.close(1001, "relay shutting down");
}
rooms.clear();
server.stop(true);
},
};
}
function parsePort(argv: readonly string[]): number {
let raw: string | undefined;
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]!;
if (arg === "--port") raw = argv[i + 1];
else if (arg.startsWith("--port=")) raw = arg.slice("--port=".length);
}
if (raw === undefined) return DEFAULT_PORT;
const port = Number(raw);
if (!Number.isInteger(port) || port < 0 || port > 65_535) {
console.error(`local-relay: invalid --port ${raw}`);
process.exit(1);
}
return port;
}
if (import.meta.main) {
const relay = startLocalRelay(parsePort(Bun.argv.slice(2)));
let stopping = false;
const shutdown = (): void => {
if (stopping) return;
stopping = true;
relay.stop();
process.exit(0);
};
console.log(`local collab relay listening on ${relay.url}`);
console.log("connect with /r/<roomId>?role=host|guest; Ctrl+C stops the relay");
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
}
+357
View File
@@ -0,0 +1,357 @@
/**
* Offline mock collab host: starts the local relay, opens a room as host, and
* serves the canned fixture session to any collab-web guest that joins.
*
* bun scripts/mock-host.ts [--port 7466]
*
* Replays a scripted streaming turn on every guest prompt, ticks subagent
* progress on the bus every 2s, and answers fetch-transcript with byte slices
* of the fixture JSONL — exactly the frames a real `omp /collab` host emits.
*/
import type { AgentSnapshot, HostFrame, SessionEntry, SessionState, WireFrame } from "@oh-my-pi/pi-wire";
import { generateRoomKey, importRoomKey, open, seal } from "../src/lib/codec";
import { COLLAB_PROTO, formatCollabLink, generateRoomId, packEnvelope, unpackEnvelope } from "../src/lib/link";
import {
fixtureAgents,
fixtureEntries,
fixtureHeader,
fixtureModel,
HOST_DISPLAY_NAME,
makeProbeProgress,
makeScriptedTurn,
type ScriptedStep,
subagentTranscriptJsonl,
} from "./fixture";
import { startLocalRelay } from "./local-relay";
const DEFAULT_PORT = 7466;
const STEP_INTERVAL_MS = 40;
const TICK_INTERVAL_MS = 2_000;
const AGENTS_SNAPSHOT_EVERY = 5;
function parsePort(argv: string[]): number {
let raw: string | undefined;
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]!;
if (arg === "--port") raw = argv[i + 1];
else if (arg.startsWith("--port=")) raw = arg.slice("--port=".length);
}
if (raw === undefined) return DEFAULT_PORT;
const port = Number(raw);
if (!Number.isInteger(port) || port < 1 || port > 65_535) {
console.error(`mock-host: invalid --port ${raw}`);
process.exit(1);
}
return port;
}
const port = parsePort(Bun.argv.slice(2));
const relay = startLocalRelay(port);
const roomId = generateRoomId();
const rawKey = generateRoomKey();
const key = await importRoomKey(rawKey);
const link = formatCollabLink(relay.url, roomId, rawKey);
// ── mutable session state ────────────────────────────────────────────────────
const entries: SessionEntry[] = [...fixtureEntries];
const agents: AgentSnapshot[] = fixtureAgents.map(agent => ({ ...agent }));
const peers = new Map<number, string>();
const transcriptBytes = new TextEncoder().encode(subagentTranscriptJsonl);
const transcriptDecoder = new TextDecoder();
let lastEntryId: string | null = entries[entries.length - 1]?.id ?? null;
let streaming = false;
let queuedPrompts = 0;
let turnSeq = 0;
let liveEntrySeq = 0;
let replayQueue: ScriptedStep[] = [];
let replayTimer: Timer | null = null;
let tick = 0;
let shuttingDown = false;
// ── sealed transport (order-preserving, mirrors relay-client) ────────────────
const ws = new WebSocket(`${relay.url}/r/${roomId}?role=host`);
ws.binaryType = "arraybuffer";
let sendChain: Promise<void> = Promise.resolve();
let recvChain: Promise<void> = Promise.resolve();
/** Seal and send a frame; peerId 0 broadcasts, N targets that guest. */
function sendFrame(frame: HostFrame, targetPeer = 0): void {
sendChain = sendChain
.then(async () => {
if (ws.readyState !== WebSocket.OPEN) return;
const sealed = await seal(key, frame);
ws.send(packEnvelope(targetPeer, sealed));
})
.catch((err: unknown) => {
console.error("mock-host: send failed:", err);
});
}
function buildState(): SessionState {
const participants: SessionState["participants"] = [{ name: HOST_DISPLAY_NAME, role: "host" }];
for (const name of peers.values()) participants.push({ name, role: "guest" });
const tokens = 51_000 + entries.length * 120;
return {
isStreaming: streaming,
queuedMessageCount: queuedPrompts,
sessionName: fixtureHeader.title,
cwd: fixtureHeader.cwd,
model: fixtureModel,
thinkingLevel: "medium",
contextUsage: {
tokens,
contextWindow: fixtureModel.contextWindow,
percent: (tokens / fixtureModel.contextWindow) * 100,
},
participants,
};
}
function broadcastState(): void {
sendFrame({ t: "state", state: buildState() });
}
function appendEntry(entry: SessionEntry): void {
entries.push(entry);
lastEntryId = entry.id;
sendFrame({ t: "entry", entry });
}
function notice(level: "info" | "warning" | "error", message: string): void {
sendFrame({ t: "event", event: { type: "notice", level, message, source: "collab" } });
}
// ── scripted turn replay ─────────────────────────────────────────────────────
function startReplay(): void {
turnSeq++;
replayQueue = makeScriptedTurn(turnSeq, lastEntryId);
scheduleStep();
}
function scheduleStep(): void {
replayTimer = setTimeout(() => {
replayTimer = null;
const step = replayQueue.shift();
if (step) applyStep(step);
if (replayQueue.length > 0) {
scheduleStep();
return;
}
if (queuedPrompts > 0) {
queuedPrompts--;
startReplay();
}
}, STEP_INTERVAL_MS);
}
function applyStep(step: ScriptedStep): void {
switch (step.kind) {
case "event":
sendFrame({ t: "event", event: step.event });
break;
case "entry":
appendEntry(step.entry);
break;
case "state":
streaming = step.streaming;
broadcastState();
break;
}
}
function cancelReplay(): void {
if (replayTimer !== null) {
clearTimeout(replayTimer);
replayTimer = null;
}
replayQueue = [];
}
// ── guest frame handling ─────────────────────────────────────────────────────
function peerName(fromPeer: number): string {
return peers.get(fromPeer) ?? `guest-${fromPeer}`;
}
function handleHello(name: string, proto: number, fromPeer: number): void {
if (proto !== COLLAB_PROTO) {
sendFrame(
{ t: "error", message: `protocol mismatch: host speaks v${COLLAB_PROTO}, guest sent v${proto}` },
fromPeer,
);
return;
}
const cleanName = name.trim().slice(0, 64) || `guest-${fromPeer}`;
peers.set(fromPeer, cleanName);
sendFrame(
{
t: "welcome",
proto: COLLAB_PROTO,
header: fixtureHeader,
entries: [...entries],
state: buildState(),
agents: agents.map(agent => ({ ...agent })),
},
fromPeer,
);
console.log(`mock-host: ${cleanName} joined (peer ${fromPeer})`);
broadcastState();
}
function handlePrompt(text: string, fromPeer: number): void {
liveEntrySeq++;
appendEntry({
id: `live-${liveEntrySeq}`,
parentId: lastEntryId,
timestamp: new Date().toISOString(),
type: "custom_message",
customType: "collab-prompt",
content: text,
details: { from: peerName(fromPeer) },
display: true,
});
if (replayTimer !== null || replayQueue.length > 0) {
queuedPrompts++;
broadcastState();
return;
}
startReplay();
}
function handleAbort(fromPeer: number): void {
const wasReplaying = replayTimer !== null || replayQueue.length > 0;
cancelReplay();
queuedPrompts = 0;
notice("info", `${peerName(fromPeer)} interrupted`);
if (wasReplaying) sendFrame({ t: "event", event: { type: "agent_end" } });
streaming = false;
broadcastState();
}
function handleAgentCmd(cmd: string, agentId: string, fromPeer: number): void {
notice("info", `${peerName(fromPeer)} sent agent-cmd ${cmd} → ${agentId}`);
}
function handleFetchTranscript(reqId: number, fromByte: number, fromPeer: number): void {
const total = transcriptBytes.byteLength;
const start = Math.max(0, Math.min(fromByte, total));
const text = start >= total ? "" : transcriptDecoder.decode(transcriptBytes.subarray(start));
// We always serve to EOF, so the next offset base is the full size.
sendFrame({ t: "transcript", reqId, text, newSize: total }, fromPeer);
}
function handleFrame(frame: WireFrame, fromPeer: number): void {
switch (frame.t) {
case "hello":
handleHello(frame.name, frame.proto, fromPeer);
break;
case "prompt":
handlePrompt(frame.text, fromPeer);
break;
case "abort":
handleAbort(fromPeer);
break;
case "agent-cmd":
handleAgentCmd(frame.cmd, frame.agentId, fromPeer);
break;
case "fetch-transcript":
handleFetchTranscript(frame.reqId, frame.fromByte, fromPeer);
break;
default:
// Host-frame echoes or unknown types: ignore.
break;
}
}
function handleControl(text: string): void {
let msg: unknown;
try {
msg = JSON.parse(text);
} catch {
return;
}
if (typeof msg !== "object" || msg === null) return;
const control = msg as { t?: unknown; peer?: unknown };
if (control.t === "peer-left" && typeof control.peer === "number") {
const name = peers.get(control.peer);
peers.delete(control.peer);
if (name) console.log(`mock-host: ${name} left (peer ${control.peer})`);
broadcastState();
}
}
ws.onopen = () => {
console.log("mock collab host ready");
console.log(`join link: ${link}`);
console.log("paste the link into the collab-web connect screen (bun ./index.html), Ctrl+C stops the host");
};
ws.onmessage = event => {
const data: unknown = event.data;
if (typeof data === "string") {
handleControl(data);
return;
}
if (!(data instanceof ArrayBuffer)) return;
const envelope = unpackEnvelope(new Uint8Array(data));
if (!envelope) return;
recvChain = recvChain
.then(async () => {
const frame = await open(key, envelope.payload);
handleFrame(frame, envelope.peerId);
})
.catch((err: unknown) => {
console.error("mock-host: dropping undecryptable frame:", err);
});
};
ws.onclose = event => {
if (shuttingDown) return;
console.error(`mock-host: relay socket closed (${event.code} ${event.reason || "no reason"})`);
shutdown(1);
};
// ── progress ticker ──────────────────────────────────────────────────────────
const tickInterval: Timer = setInterval(() => {
tick++;
sendFrame({ t: "bus", channel: "task:subagent:progress", data: makeProbeProgress(tick) });
const now = Date.now();
for (const agent of agents) {
if (agent.status === "running") agent.lastActivity = now;
}
if (tick % AGENTS_SNAPSHOT_EVERY === 0) {
sendFrame({ t: "agents", agents: agents.map(agent => ({ ...agent })) });
}
}, TICK_INTERVAL_MS);
// ── shutdown ─────────────────────────────────────────────────────────────────
function shutdown(code: number): void {
if (shuttingDown) return;
shuttingDown = true;
cancelReplay();
clearInterval(tickInterval);
sendFrame({ t: "bye", reason: "mock host shutting down" });
// Let the bye flush through the send chain before tearing the room down.
void sendChain.finally(() => {
try {
ws.close(1000);
} catch {
// already closing
}
relay.stop();
process.exit(code);
});
}
process.on("SIGINT", () => {
console.log("\nmock-host: shutting down");
shutdown(0);
});
+170
View File
@@ -0,0 +1,170 @@
import type { ReactNode } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { AgentDrawer } from "./components/agents/AgentDrawer";
import { AgentsPanel } from "./components/agents/AgentsPanel";
import { Banners } from "./components/shell/Banners";
import { Composer } from "./components/shell/Composer";
import { ConnectScreen } from "./components/shell/ConnectScreen";
import { HeaderBar } from "./components/shell/HeaderBar";
import { Toasts } from "./components/shell/Toasts";
import { Transcript } from "./components/transcript/Transcript";
import { GuestClient } from "./lib/client";
import { useGuestSnapshot } from "./lib/use-guest";
import "./components/shell/shell.css";
const NAME_KEY = "omp.collab.name";
interface Creds {
link: string;
name: string;
}
function storedName(): string {
try {
return localStorage.getItem(NAME_KEY) ?? "guest";
} catch {
return "guest";
}
}
/** Deep link = everything after the FIRST `#` (the link itself contains another `#`). */
function hashLink(): string | null {
const href = window.location.href;
const i = href.indexOf("#");
if (i < 0 || i + 1 >= href.length) return null;
return href.slice(i + 1);
}
export function App(): ReactNode {
const [client, setClient] = useState<GuestClient | null>(null);
const [connectError, setConnectError] = useState<string | null>(null);
const credsRef = useRef<Creds | null>(null);
const connect = useCallback((link: string, name: string): void => {
let next: GuestClient;
try {
next = new GuestClient(link, name);
} catch (err) {
setConnectError(err instanceof Error ? err.message : String(err));
return;
}
next.connect();
try {
localStorage.setItem(NAME_KEY, name);
} catch {
// storage unavailable (private mode) — non-fatal
}
credsRef.current = { link, name };
window.location.hash = link;
setConnectError(null);
setClient(prev => {
prev?.close();
return next;
});
}, []);
const leave = useCallback((): void => {
setClient(prev => {
prev?.close();
return null;
});
history.replaceState(null, "", window.location.pathname + window.location.search);
}, []);
const rejoin = useCallback((): void => {
const creds = credsRef.current;
if (creds) connect(creds.link, creds.name);
}, [connect]);
// Deep link: a page load with a hash auto-connects.
useEffect(() => {
const link = hashLink();
if (link) connect(link, storedName());
}, [connect]);
useEffect(() => {
if (!client) document.title = "omp collab";
}, [client]);
if (!client) {
return <ConnectScreen defaultName={storedName()} error={connectError} onConnect={connect} />;
}
return <Session client={client} onLeave={leave} onRejoin={rejoin} />;
}
interface SessionProps {
client: GuestClient;
onLeave(): void;
onRejoin(): void;
}
function Session({ client, onLeave, onRejoin }: SessionProps): ReactNode {
const snap = useGuestSnapshot(client);
const [railOpen, setRailOpen] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
const autoOpenedRef = useRef(false);
const subCount = useMemo(() => snap.agents.filter(a => a.kind === "sub").length, [snap.agents]);
// Auto-open the rail the first time a subagent appears.
useEffect(() => {
if (subCount > 0 && !autoOpenedRef.current) {
autoOpenedRef.current = true;
setRailOpen(true);
}
}, [subCount]);
const title = snap.header?.title ?? snap.state?.sessionName ?? "session";
useEffect(() => {
document.title = `${title} · omp collab`;
}, [title]);
const drawerAgent = selectedId != null ? snap.agents.find(a => a.id === selectedId) : undefined;
return (
<div className="sh-app">
<HeaderBar
snapshot={snap}
subCount={subCount}
railOpen={railOpen}
onToggleRail={() => setRailOpen(open => !open)}
onLeave={onLeave}
/>
<main className="sh-main">
<section className="sh-content" data-rail={railOpen ? "true" : "false"}>
<div className="sh-transcript">
<Transcript
entries={snap.entries}
stream={snap.stream}
streamDone={snap.streamDone}
activeTools={snap.activeTools}
working={snap.working}
/>
</div>
</section>
{railOpen && (
<aside className="sh-rail">
<AgentsPanel
agents={snap.agents}
progress={snap.progress}
lifecycle={snap.lifecycle}
selectedId={selectedId}
onSelect={setSelectedId}
/>
</aside>
)}
</main>
<Composer client={client} snapshot={snap} />
{drawerAgent && (
<AgentDrawer
agent={drawerAgent}
progress={snap.progress.get(drawerAgent.id)}
client={client}
onClose={() => setSelectedId(null)}
/>
)}
<Banners phase={snap.phase} endedReason={snap.endedReason} onRejoin={onRejoin} onNewLink={onLeave} />
<Toasts notices={snap.notices} />
</div>
);
}
@@ -0,0 +1,184 @@
import type { AgentSnapshot, SessionEntry, SubagentProgressPayload } from "@oh-my-pi/pi-wire";
import { OctagonX, RotateCcw, SendHorizontal, X } from "lucide-react";
import type { ReactNode } from "react";
import { useEffect, useState } from "react";
import type { GuestClient } from "../../lib/client";
import { fmtCost, fmtDuration, fmtTokens } from "../../lib/format";
import { parseJsonl } from "../../lib/jsonl";
import type { TranscriptProps } from "../transcript/Transcript";
import { Transcript } from "../transcript/Transcript";
const EMPTY_TOOLS: TranscriptProps["activeTools"] = new Map();
const POLL_MS = 1200;
export function AgentDrawer(props: {
agent: AgentSnapshot;
progress?: SubagentProgressPayload;
client: GuestClient;
onClose(): void;
}): ReactNode {
const { agent, progress, client, onClose } = props;
const [entries, setEntries] = useState<readonly SessionEntry[]>([]);
const [draft, setDraft] = useState("");
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
// Live transcript: poll the host-side session file while the drawer is
// open, appending parsed JSONL entries. State resets when the agent
// changes; the interval and any in-flight reply are dropped on cleanup.
useEffect(() => {
setEntries([]);
if (!agent.hasSessionFile) return;
let disposed = false;
let inFlight = false;
let cursor = 0;
let carry = "";
let acc: readonly SessionEntry[] = [];
const poll = async (): Promise<void> => {
if (disposed || inFlight) return;
inFlight = true;
try {
const reply = await client.fetchTranscript(agent.id, cursor);
if (disposed || reply === null) return; // timeout/error → keep polling
cursor = reply.newSize;
if (!reply.text) return;
const parsed = parseJsonl(reply.text, carry);
carry = parsed.carry;
const fresh: SessionEntry[] = [];
for (const item of parsed.items) {
if (typeof item !== "object" || item === null) continue;
if ((item as { type?: unknown }).type === "session") continue;
fresh.push(item as SessionEntry);
}
if (fresh.length > 0) {
acc = [...acc, ...fresh];
setEntries(acc);
}
} finally {
inFlight = false;
}
};
void poll();
const timer = setInterval(() => {
void poll();
}, POLL_MS);
return () => {
disposed = true;
clearInterval(timer);
};
}, [agent.id, agent.hasSessionFile, client]);
const sendChat = () => {
const text = draft.trim();
if (!text) return;
client.sendAgentCmd("chat", agent.id, text);
setDraft("");
};
const p = progress?.progress;
const model = p?.resolvedModel;
const ctxPct =
p?.contextTokens !== undefined && p.contextWindow
? Math.min(100, (p.contextTokens / p.contextWindow) * 100)
: null;
return (
<aside className="ag-drawer" role="dialog" aria-label={agent.displayName}>
<header className="ag-drawer-head">
<div className="ag-drawer-title">
<span className="ag-drawer-name">{agent.displayName}</span>
<span className={`ag-chip ag-chip--${agent.status}`}>{agent.status}</span>
{model ? <span className="ag-chip ag-chip--model">{model}</span> : null}
</div>
<div className="ag-drawer-actions">
{agent.status === "running" ? (
<button
type="button"
className="ag-btn ag-btn--danger"
onClick={() => client.sendAgentCmd("kill", agent.id)}
>
<OctagonX size={13} aria-hidden />
kill
</button>
) : null}
{agent.status === "parked" || agent.status === "aborted" ? (
<button type="button" className="ag-btn" onClick={() => client.sendAgentCmd("revive", agent.id)}>
<RotateCcw size={13} aria-hidden />
revive
</button>
) : null}
<button type="button" className="ag-iconbtn" aria-label="close" onClick={onClose}>
<X size={15} aria-hidden />
</button>
</div>
</header>
{p ? (
<div className="ag-stats">
<span className="ag-stat">
<span className="ag-stat-label">tok</span>
<span className="ag-stat-value">{fmtTokens(p.tokens)}</span>
</span>
{ctxPct !== null ? (
<span className="ag-stat" title={`context ${fmtTokens(p.contextTokens ?? 0)}`}>
<span className="ag-stat-label">ctx</span>
<span className="ag-gauge">
<span
className={ctxPct > 80 ? "ag-gauge-fill ag-gauge-fill--warn" : "ag-gauge-fill"}
style={{ width: `${ctxPct}%` }}
/>
</span>
</span>
) : null}
<span className="ag-stat">
<span className="ag-stat-label">cost</span>
<span className="ag-stat-value">{fmtCost(p.cost)}</span>
</span>
<span className="ag-stat">
<span className="ag-stat-label">tools</span>
<span className="ag-stat-value">{p.toolCount}</span>
</span>
<span className="ag-stat">
<span className="ag-stat-value">{fmtDuration(p.durationMs)}</span>
</span>
</div>
) : null}
<div className="ag-drawer-body">
{agent.hasSessionFile ? (
<Transcript
compact
entries={entries}
stream={null}
streamDone={false}
activeTools={EMPTY_TOOLS}
working={agent.status === "running"}
/>
) : (
<div className="ag-empty">no transcript available</div>
)}
</div>
<form
className="ag-chat"
onSubmit={e => {
e.preventDefault();
sendChat();
}}
>
<input
className="ag-chat-input"
value={draft}
placeholder={`message ${agent.displayName}…`}
onChange={e => setDraft(e.target.value)}
/>
<button type="submit" className="ag-iconbtn" aria-label="send" disabled={draft.trim().length === 0}>
<SendHorizontal size={15} aria-hidden />
</button>
</form>
</aside>
);
}
@@ -0,0 +1,131 @@
import type {
AgentProgress,
AgentSnapshot,
SubagentLifecyclePayload,
SubagentProgressPayload,
} from "@oh-my-pi/pi-wire";
import type { ReactNode } from "react";
import { useEffect, useMemo, useState } from "react";
import { fmtCost, fmtDuration, fmtTokens, relTime } from "../../lib/format";
import "./agents.css";
/** Re-render tick so running-tool durations and relative times stay live. */
function useNow(intervalMs: number): number {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const timer = setInterval(() => setNow(Date.now()), intervalMs);
return () => clearInterval(timer);
}, [intervalMs]);
return now;
}
/**
* Best-effort start timestamp for the in-flight tool. The host serializes the
* full AgentProgress (which carries `currentToolStartMs`); the wire mirror
* omits it, so read it tolerantly and fall back to the last tool's end time.
*/
function toolStartMs(p: AgentProgress): number | null {
const start = (p as { currentToolStartMs?: unknown }).currentToolStartMs;
if (typeof start === "number") return start;
const lastEnd = p.recentTools[0]?.endMs;
return typeof lastEnd === "number" ? lastEnd : null;
}
function activityLine(
agent: AgentSnapshot,
p: AgentProgress | undefined,
lc: SubagentLifecyclePayload | undefined,
now: number,
): string {
if (p?.currentTool) {
const start = toolStartMs(p);
if (start !== null) return `${p.currentTool} · ${fmtDuration(Math.max(0, now - start))}`;
return p.currentTool;
}
if (p?.lastIntent) return p.lastIntent;
if (lc) return lc.status;
return agent.status;
}
function AgentRow(props: {
agent: AgentSnapshot;
payload: SubagentProgressPayload | undefined;
lifecycle: SubagentLifecyclePayload | undefined;
selected: boolean;
now: number;
onSelect(id: string | null): void;
}): ReactNode {
const { agent, payload, lifecycle, selected, now, onSelect } = props;
const p = payload?.progress;
return (
<button
type="button"
className={selected ? "ag-row ag-row--selected" : "ag-row"}
onClick={() => onSelect(selected ? null : agent.id)}
>
<span className="ag-row-head">
<span className={`ag-dot ag-dot--${agent.status}`} />
<span className="ag-row-name">{agent.displayName}</span>
<span className="ag-chip">{agent.kind}</span>
</span>
<span className="ag-row-activity">{activityLine(agent, p, lifecycle, now)}</span>
<span className="ag-row-meta">
{p ? <span>{fmtTokens(p.tokens)} tok</span> : null}
{p ? <span>{fmtCost(p.cost)}</span> : null}
<span className="ag-row-meta-when">{relTime(agent.lastActivity)}</span>
</span>
</button>
);
}
export function AgentsPanel(props: {
agents: readonly AgentSnapshot[];
progress: ReadonlyMap<string, SubagentProgressPayload>;
lifecycle: ReadonlyMap<string, SubagentLifecyclePayload>;
selectedId: string | null;
onSelect(id: string | null): void;
}): ReactNode {
const { agents, progress, lifecycle, selectedId, onSelect } = props;
const now = useNow(1000);
const sorted = useMemo(() => {
const mains: AgentSnapshot[] = [];
const subs: AgentSnapshot[] = [];
for (const agent of agents) (agent.kind === "main" ? mains : subs).push(agent);
subs.sort((a, b) => {
const ar = a.status === "running" ? 0 : 1;
const br = b.status === "running" ? 0 : 1;
if (ar !== br) return ar - br;
return b.lastActivity - a.lastActivity;
});
return { mains, subs };
}, [agents]);
return (
<div className="ag-panel">
{sorted.mains.map(agent => (
<AgentRow
key={agent.id}
agent={agent}
payload={progress.get(agent.id)}
lifecycle={lifecycle.get(agent.id)}
selected={selectedId === agent.id}
now={now}
onSelect={onSelect}
/>
))}
{sorted.subs.map(agent => (
<AgentRow
key={agent.id}
agent={agent}
payload={progress.get(agent.id)}
lifecycle={lifecycle.get(agent.id)}
selected={selectedId === agent.id}
now={now}
onSelect={onSelect}
/>
))}
{sorted.subs.length === 0 ? <div className="ag-empty">no subagents</div> : null}
</div>
);
}
@@ -0,0 +1,384 @@
/* Agents rail + drawer (T3). `ag-*` prefix; design-token vars only. */
/* ── Panel ────────────────────────────────────────────────────────── */
.ag-panel {
display: flex;
flex-direction: column;
gap: 2px;
min-height: 0;
padding: 8px;
overflow-y: auto;
font-size: 13px;
}
.ag-row {
display: flex;
flex-direction: column;
gap: 3px;
width: 100%;
padding: 7px 9px;
border: 1px solid transparent;
border-radius: var(--radius);
background: none;
color: var(--fg);
font: inherit;
text-align: left;
cursor: pointer;
transition: background 150ms ease-out;
}
.ag-row:hover {
background: var(--bg-raised);
}
.ag-row--selected {
border-color: var(--border);
background: var(--bg-raised);
}
.ag-row:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
.ag-row-head {
display: flex;
align-items: center;
gap: 7px;
min-width: 0;
}
.ag-row-name {
flex: 1;
min-width: 0;
overflow: hidden;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.ag-row-activity {
overflow: hidden;
padding-left: 14px;
color: var(--fg-muted);
font-family: var(--font-mono);
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.ag-row-meta {
display: flex;
gap: 10px;
padding-left: 14px;
color: var(--fg-faint);
font-family: var(--font-mono);
font-size: 11px;
}
.ag-row-meta-when {
margin-left: auto;
}
.ag-empty {
padding: 14px 10px;
color: var(--fg-faint);
font-size: 12px;
font-style: italic;
text-align: center;
}
/* ── Status dot ───────────────────────────────────────────────────── */
.ag-dot {
flex: none;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--fg-faint);
}
.ag-dot--running {
background: var(--ok);
animation: ag-pulse 1.6s ease-out infinite;
}
.ag-dot--parked {
background: var(--warn);
}
.ag-dot--aborted {
background: var(--err);
}
@keyframes ag-pulse {
0% {
box-shadow: 0 0 0 0 color-mix(in srgb, var(--ok) 45%, transparent);
}
70%,
100% {
box-shadow: 0 0 0 6px transparent;
}
}
/* ── Chips ────────────────────────────────────────────────────────── */
.ag-chip {
flex: none;
padding: 1px 5px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--fg-muted);
font-family: var(--font-mono);
font-size: 10px;
line-height: 1.5;
white-space: nowrap;
}
.ag-chip--running {
border-color: color-mix(in srgb, var(--ok) 35%, transparent);
color: var(--ok);
}
.ag-chip--parked {
border-color: color-mix(in srgb, var(--warn) 35%, transparent);
color: var(--warn);
}
.ag-chip--aborted {
border-color: color-mix(in srgb, var(--err) 35%, transparent);
color: var(--err);
}
.ag-chip--model {
overflow: hidden;
max-width: 160px;
color: var(--accent-muted);
text-overflow: ellipsis;
}
/* ── Drawer ───────────────────────────────────────────────────────── */
.ag-drawer {
position: fixed;
top: 0;
right: 0;
bottom: 0;
z-index: 40;
display: flex;
flex-direction: column;
width: min(440px, 92vw);
border-left: 1px solid var(--border-strong);
background: var(--bg-overlay);
box-shadow:
-12px 0 32px color-mix(in srgb, var(--bg-inset) 55%, transparent),
-32px 0 80px color-mix(in srgb, var(--bg-inset) 35%, transparent);
animation: ag-drawer-in 150ms ease-out;
}
@keyframes ag-drawer-in {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
.ag-drawer-head {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-bottom: 1px solid var(--border);
}
.ag-drawer-title {
display: flex;
flex: 1;
align-items: center;
gap: 7px;
min-width: 0;
}
.ag-drawer-name {
overflow: hidden;
font-size: 13px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.ag-drawer-actions {
display: flex;
flex: none;
align-items: center;
gap: 6px;
}
/* ── Buttons ──────────────────────────────────────────────────────── */
.ag-btn {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 8px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-raised);
color: var(--fg-muted);
font: inherit;
font-size: 12px;
cursor: pointer;
transition:
background 150ms ease-out,
color 150ms ease-out;
}
.ag-btn:hover {
background: color-mix(in srgb, var(--bg-raised) 94%, var(--fg));
color: var(--fg);
}
.ag-btn:active {
transform: scale(0.97);
}
.ag-btn:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
.ag-btn--danger {
border-color: color-mix(in srgb, var(--err) 35%, transparent);
color: var(--err);
}
.ag-btn--danger:hover {
color: var(--err);
}
.ag-iconbtn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 3px;
border: none;
border-radius: var(--radius-sm);
background: none;
color: var(--fg-muted);
cursor: pointer;
transition: color 150ms ease-out;
}
.ag-iconbtn:hover {
color: var(--fg);
}
.ag-iconbtn:active {
transform: scale(0.97);
}
.ag-iconbtn:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
/* ── Stats strip ──────────────────────────────────────────────────── */
.ag-stats {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
padding: 8px 14px;
border-bottom: 1px solid var(--border);
color: var(--fg-muted);
font-family: var(--font-mono);
font-size: 11px;
}
.ag-stat {
display: flex;
align-items: baseline;
gap: 5px;
}
.ag-stat-label {
color: var(--fg-faint);
}
.ag-stat-value {
color: var(--fg);
}
.ag-gauge {
display: inline-block;
overflow: hidden;
align-self: center;
width: 72px;
height: 4px;
border-radius: var(--radius-sm);
background: var(--bg-inset);
}
.ag-gauge-fill {
display: block;
height: 100%;
border-radius: inherit;
background: var(--accent);
}
.ag-gauge-fill--warn {
background: var(--warn);
}
/* ── Drawer body & chat ───────────────────────────────────────────── */
.ag-drawer-body {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
padding: 10px 14px;
overflow-y: auto;
}
.ag-chat {
display: flex;
flex: none;
gap: 6px;
padding: 10px 14px;
border-top: 1px solid var(--border);
}
.ag-chat-input {
flex: 1;
min-width: 0;
padding: 6px 9px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-inset);
color: var(--fg);
font: inherit;
font-size: 13px;
}
.ag-chat-input::placeholder {
color: var(--fg-faint);
}
.ag-chat-input:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
.ag-drawer {
animation: none;
}
.ag-dot--running {
animation: none;
}
}
@@ -0,0 +1,47 @@
import type { ReactNode } from "react";
import type { ConnectionPhase } from "../../lib/client";
export interface BannersProps {
phase: ConnectionPhase;
endedReason: string | null;
onRejoin(): void;
onNewLink(): void;
}
export function Banners({ phase, endedReason, onRejoin, onNewLink }: BannersProps): ReactNode {
if (phase === "connecting" || phase === "waiting") {
return (
<div className="sh-banner" role="status">
<span className="sh-banner-dot" />
{phase === "connecting" ? "connecting to relay…" : "joining session…"}
</div>
);
}
if (phase === "reconnecting") {
return (
<div className="sh-banner" role="status">
<span className="sh-banner-dot" />
reconnecting…
</div>
);
}
if (phase === "ended") {
return (
<div className="sh-ended" role="alertdialog" aria-label="session ended">
<div className="sh-ended-card">
<div className="sh-ended-title">session ended</div>
{endedReason && <div className="sh-ended-reason">{endedReason}</div>}
<div className="sh-ended-actions">
<button type="button" className="sh-btn sh-btn-primary" onClick={onRejoin}>
Rejoin
</button>
<button type="button" className="sh-btn" onClick={onNewLink}>
New link
</button>
</div>
</div>
</div>
);
}
return null;
}
@@ -0,0 +1,88 @@
import { SendHorizontal, Square } from "lucide-react";
import type { KeyboardEvent, ReactNode } from "react";
import { useCallback, useLayoutEffect, useRef, useState } from "react";
import type { GuestClient, GuestSnapshot } from "../../lib/client";
export interface ComposerProps {
client: GuestClient;
snapshot: GuestSnapshot;
}
/** Textarea metrics: line-height 20px + 8px vertical padding × 2 (kept in sync with shell.css). */
const LINE_PX = 20;
const PAD_Y = 16;
const MAX_ROWS = 8;
export function Composer({ client, snapshot }: ComposerProps): ReactNode {
const [text, setText] = useState("");
const taRef = useRef<HTMLTextAreaElement | null>(null);
const live = snapshot.phase === "live";
const busy = snapshot.working || (snapshot.state?.isStreaming ?? false);
const queued = snapshot.state?.queuedMessageCount ?? 0;
const canSend = live && text.trim().length > 0;
useLayoutEffect(() => {
const el = taRef.current;
if (!el) return;
el.style.height = "0px";
const max = MAX_ROWS * LINE_PX + PAD_Y;
el.style.height = `${Math.max(LINE_PX + PAD_Y, Math.min(el.scrollHeight, max))}px`;
el.style.overflowY = el.scrollHeight > max ? "auto" : "hidden";
}, [text]);
const send = useCallback((): void => {
const trimmed = text.trim();
if (!trimmed || !live) return;
client.sendPrompt(trimmed);
setText("");
}, [client, live, text]);
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>): void => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
send();
}
};
return (
<div className="sh-composer">
<div className="sh-composer-inner">
<textarea
ref={taRef}
className="sh-composer-input"
value={text}
onChange={e => setText(e.target.value)}
onKeyDown={onKeyDown}
placeholder={live ? "prompt the host agent…" : "waiting for session…"}
disabled={!live}
rows={1}
spellCheck={false}
/>
<div className="sh-composer-actions">
{busy && queued > 0 && <span className="sh-queued">queued ×{queued}</span>}
{busy && (
<button
type="button"
className="sh-btn sh-btn-stop"
onClick={() => client.sendAbort()}
disabled={!live}
title="stop the current turn"
>
<Square size={11} /> Stop
</button>
)}
<button
type="button"
className="sh-btn sh-btn-primary"
onClick={send}
disabled={!canSend}
title="send (Enter)"
>
<SendHorizontal size={12} /> Send
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,67 @@
import type { FormEvent, ReactNode } from "react";
import { useState } from "react";
export interface ConnectScreenProps {
defaultName: string;
error: string | null;
onConnect(link: string, name: string): void;
}
export function ConnectScreen({ defaultName, error, onConnect }: ConnectScreenProps): ReactNode {
const [link, setLink] = useState("");
const [name, setName] = useState(defaultName);
const [localError, setLocalError] = useState<string | null>(null);
const submit = (e: FormEvent<HTMLFormElement>): void => {
e.preventDefault();
const trimmed = link.trim();
if (!trimmed) {
setLocalError("paste a join link first");
return;
}
setLocalError(null);
onConnect(trimmed, name.trim() || "guest");
};
const shown = localError ?? error;
return (
<div className="sh-connect">
<form className="sh-connect-card" onSubmit={submit}>
<div className="sh-lockup">
<span className="sh-lockup-pi">π</span> omp collab
</div>
<label className="sh-field">
<span className="sh-field-label">join link</span>
<input
className="sh-input sh-input-mono"
type="text"
value={link}
onChange={e => setLink(e.target.value)}
placeholder="ws://host:port/r/room#key"
spellCheck={false}
autoComplete="off"
autoFocus
/>
</label>
<label className="sh-field">
<span className="sh-field-label">display name</span>
<input
className="sh-input"
type="text"
value={name}
onChange={e => setName(e.target.value)}
placeholder="guest"
spellCheck={false}
autoComplete="off"
maxLength={32}
/>
</label>
{shown && <div className="sh-connect-error">{shown}</div>}
<button className="sh-btn sh-btn-primary sh-connect-submit" type="submit">
Connect
</button>
</form>
</div>
);
}
@@ -0,0 +1,80 @@
import { LogOut, PanelRight } from "lucide-react";
import type { ReactNode } from "react";
import type { GuestSnapshot } from "../../lib/client";
import { fmtPercent, shortenPath } from "../../lib/format";
export interface HeaderBarProps {
snapshot: GuestSnapshot;
subCount: number;
railOpen: boolean;
onToggleRail(): void;
onLeave(): void;
}
export function HeaderBar({ snapshot, subCount, railOpen, onToggleRail, onLeave }: HeaderBarProps): ReactNode {
const { header, state, phase } = snapshot;
const title = header?.title ?? state?.sessionName ?? "session";
const usage = state?.contextUsage;
let pct: number | null = null;
if (usage) {
pct =
usage.percent ??
(usage.tokens != null && usage.contextWindow > 0 ? (usage.tokens / usage.contextWindow) * 100 : null);
}
return (
<header className="sh-header">
<div className="sh-header-left">
<span className="sh-title" title={title}>
{title}
</span>
{state?.cwd && (
<span className="sh-cwd" title={state.cwd}>
{shortenPath(state.cwd)}
</span>
)}
</div>
<div className="sh-header-right">
{state?.model && <span className="sh-chip">{state.model.name}</span>}
{state?.thinkingLevel && <span className="sh-chip">{state.thinkingLevel}</span>}
{pct != null && (
<span
className={pct > 80 ? "sh-gauge sh-gauge-warn" : "sh-gauge"}
title={`context · ${fmtPercent(pct)}`}
>
<span className="sh-gauge-track">
<span className="sh-gauge-fill" style={{ width: `${Math.min(100, Math.max(0, pct))}%` }} />
</span>
<span className="sh-gauge-pct">{fmtPercent(pct)}</span>
</span>
)}
{state && state.participants.length > 0 && (
<span className="sh-avatars">
{state.participants.map((p, i) => (
<span
key={`${p.name}:${i}`}
className={p.role === "host" ? "sh-avatar sh-avatar-host" : "sh-avatar"}
title={`${p.name} · ${p.role}`}
>
{(p.name[0] ?? "?").toUpperCase()}
</span>
))}
</span>
)}
<span className={`sh-dot sh-dot-${phase}`} title={phase} />
<button
type="button"
className={railOpen ? "sh-btn sh-btn-icon sh-btn-on" : "sh-btn sh-btn-icon"}
onClick={onToggleRail}
title={railOpen ? "hide agents" : "show agents"}
>
<PanelRight size={14} />
{subCount > 0 && <span className="sh-badge">{subCount}</span>}
</button>
<button type="button" className="sh-btn sh-btn-icon" onClick={onLeave} title="leave session">
<LogOut size={14} />
</button>
</div>
</header>
);
}
@@ -0,0 +1,64 @@
import { X } from "lucide-react";
import type { ReactNode } from "react";
import { useEffect, useState } from "react";
import type { Notice } from "../../lib/client";
const INFO_TTL_MS = 4000;
const WARNING_TTL_MS = 8000;
const MAX_VISIBLE = 4;
export function Toasts({ notices }: { notices: readonly Notice[] }): ReactNode {
// Dynamic membership keyed by notice id — runtime collection.
const [dismissed, setDismissed] = useState<Set<number>>(() => new Set());
useEffect(() => {
const timers: number[] = [];
for (const n of notices) {
if (n.level === "error" || dismissed.has(n.id)) continue;
const ttl = n.level === "info" ? INFO_TTL_MS : WARNING_TTL_MS;
const remaining = n.at + ttl - Date.now();
timers.push(
window.setTimeout(
() => {
setDismissed(prev => {
if (prev.has(n.id)) return prev;
const next = new Set(prev);
next.add(n.id);
return next;
});
},
Math.max(0, remaining),
),
);
}
return () => {
for (const t of timers) window.clearTimeout(t);
};
}, [notices, dismissed]);
const visible = notices.filter(n => !dismissed.has(n.id)).slice(-MAX_VISIBLE);
if (visible.length === 0) return null;
const close = (id: number): void => {
setDismissed(prev => {
const next = new Set(prev);
next.add(id);
return next;
});
};
return (
<div className="sh-toasts">
{visible.map(n => (
<div key={n.id} className={`sh-toast sh-toast-${n.level}`} role="status">
<span className="sh-toast-msg">{n.message}</span>
{n.level === "error" && (
<button type="button" className="sh-toast-close" onClick={() => close(n.id)} title="dismiss">
<X size={12} />
</button>
)}
</div>
))}
</div>
);
}
@@ -0,0 +1,501 @@
/* Shell styles (sh-*). Token vars only — no raw colors. */
/* ---- app frame ---- */
.sh-app {
display: grid;
grid-template-rows: auto 1fr auto;
height: 100%;
}
.sh-main {
display: flex;
min-height: 0;
overflow: hidden;
}
.sh-content {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
justify-content: center;
overflow: hidden;
}
.sh-transcript {
width: 100%;
max-width: 880px;
min-height: 0;
display: flex;
flex-direction: column;
}
.sh-content[data-rail="true"] .sh-transcript {
max-width: none;
}
.sh-transcript > * {
flex: 1;
min-height: 0;
}
.sh-rail {
width: 300px;
flex: none;
min-height: 0;
overflow-y: auto;
border-left: 1px solid var(--border);
background: var(--bg);
}
/* ---- shared controls ---- */
.sh-btn {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 10px;
font-size: 12px;
font-weight: 500;
color: var(--fg-muted);
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: var(--radius);
transition:
background 150ms ease-out,
color 150ms ease-out,
border-color 150ms ease-out;
}
.sh-btn:hover:not(:disabled) {
background: var(--bg-overlay);
color: var(--fg);
border-color: var(--border-strong);
}
.sh-btn:active:not(:disabled) {
transform: scale(0.97);
}
.sh-btn:disabled {
opacity: 0.45;
cursor: default;
}
.sh-btn-primary {
color: var(--accent-fg);
background: var(--accent);
border-color: var(--accent);
}
.sh-btn-primary:hover:not(:disabled) {
color: var(--accent-fg);
background: var(--accent);
border-color: var(--accent);
filter: brightness(1.04);
}
.sh-btn-stop {
color: var(--err);
border-color: var(--border-strong);
}
.sh-btn-icon {
position: relative;
padding: 4px 6px;
}
.sh-btn-on {
color: var(--accent);
border-color: var(--border-strong);
}
.sh-badge {
position: absolute;
top: -5px;
right: -5px;
min-width: 14px;
height: 14px;
padding: 0 3px;
font: 600 9px / 14px var(--font-mono);
text-align: center;
color: var(--accent-fg);
background: var(--accent);
border-radius: 7px;
}
.sh-chip {
padding: 1px 6px;
font: 400 11px / 1.5 var(--font-mono);
color: var(--fg-muted);
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
white-space: nowrap;
}
/* ---- header ---- */
.sh-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 12px;
border-bottom: 1px solid var(--border);
background: var(--bg);
}
.sh-header-left {
display: flex;
align-items: baseline;
gap: 10px;
min-width: 0;
}
.sh-header-right {
display: flex;
align-items: center;
gap: 8px;
flex: none;
}
.sh-title {
font-size: 13px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sh-cwd {
font: 400 11px / 1.5 var(--font-mono);
color: var(--fg-faint);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sh-gauge {
display: inline-flex;
align-items: center;
gap: 6px;
}
.sh-gauge-track {
width: 72px;
height: 3px;
background: var(--bg-inset);
border-radius: var(--radius-sm);
overflow: hidden;
}
.sh-gauge-fill {
display: block;
height: 100%;
background: var(--accent);
border-radius: inherit;
transition: width 150ms ease-out;
}
.sh-gauge-warn .sh-gauge-fill {
background: var(--warn);
}
.sh-gauge-pct {
font: 400 11px / 1 var(--font-mono);
color: var(--fg-faint);
}
.sh-gauge-warn .sh-gauge-pct {
color: var(--warn);
}
.sh-avatars {
display: inline-flex;
}
.sh-avatar {
width: 20px;
height: 20px;
font: 600 10px / 18px var(--font-ui);
text-align: center;
color: var(--fg-muted);
background: var(--bg-raised);
border: 1px solid var(--border-strong);
border-radius: 50%;
margin-left: -4px;
}
.sh-avatar:first-child {
margin-left: 0;
}
.sh-avatar-host {
color: var(--accent);
border-color: var(--accent);
}
.sh-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--fg-faint);
}
.sh-dot-live {
background: var(--ok);
}
.sh-dot-reconnecting {
background: var(--warn);
animation: pulse 1.2s ease-in-out infinite;
}
.sh-dot-ended {
background: var(--err);
}
/* ---- composer ---- */
.sh-composer {
padding: 10px 12px;
border-top: 1px solid var(--border);
background: var(--bg);
}
.sh-composer-inner {
display: flex;
align-items: flex-end;
gap: 8px;
max-width: 880px;
margin: 0 auto;
}
.sh-composer-input {
flex: 1;
min-width: 0;
padding: 8px 10px;
font-size: 13px;
line-height: 20px;
color: var(--fg);
background: var(--bg-inset);
border: 1px solid var(--border);
border-radius: var(--radius);
resize: none;
}
.sh-composer-input::placeholder {
color: var(--fg-faint);
}
.sh-composer-input:disabled {
opacity: 0.55;
}
.sh-composer-actions {
display: flex;
align-items: center;
gap: 6px;
flex: none;
padding-bottom: 1px;
}
.sh-queued {
padding: 1px 6px;
font: 400 11px / 1.5 var(--font-mono);
color: var(--accent);
background: var(--accent-muted);
border-radius: var(--radius-sm);
white-space: nowrap;
}
/* ---- banners ---- */
.sh-banner {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 40;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 3px 0;
font-size: 12px;
color: var(--warn);
background: var(--bg-overlay);
border-bottom: 1px solid var(--border);
}
.sh-banner-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--warn);
animation: pulse 1.2s ease-in-out infinite;
}
.sh-ended {
position: fixed;
inset: 0;
z-index: 50;
display: grid;
place-items: center;
background: var(--backdrop);
}
.sh-ended-card {
width: 320px;
padding: 24px;
display: flex;
flex-direction: column;
gap: 10px;
background: var(--bg-overlay);
border: 1px solid var(--border-strong);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-overlay);
}
.sh-ended-title {
font-size: 14px;
font-weight: 600;
}
.sh-ended-reason {
font-size: 12px;
color: var(--fg-muted);
}
.sh-ended-actions {
display: flex;
gap: 8px;
margin-top: 6px;
}
/* ---- toasts ---- */
.sh-toasts {
position: fixed;
right: 12px;
bottom: 12px;
z-index: 60;
display: flex;
flex-direction: column;
gap: 8px;
width: 320px;
}
.sh-toast {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 8px 10px;
font-size: 12px;
color: var(--fg);
background: var(--bg-overlay);
border: 1px solid var(--border-strong);
border-left-width: 2px;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-overlay);
}
.sh-toast-info {
border-left-color: var(--accent);
}
.sh-toast-warning {
border-left-color: var(--warn);
}
.sh-toast-error {
border-left-color: var(--err);
}
.sh-toast-msg {
flex: 1;
min-width: 0;
overflow-wrap: anywhere;
}
.sh-toast-close {
flex: none;
display: inline-flex;
padding: 1px;
color: var(--fg-faint);
background: none;
border: none;
border-radius: var(--radius-sm);
}
.sh-toast-close:hover {
color: var(--fg);
}
/* ---- connect screen ---- */
.sh-connect {
height: 100%;
display: grid;
place-items: center;
background: var(--bg);
}
.sh-connect-card {
width: 360px;
padding: 28px;
display: flex;
flex-direction: column;
gap: 14px;
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
}
.sh-lockup {
font: 500 15px / 1.5 var(--font-mono);
letter-spacing: 0.02em;
margin-bottom: 4px;
}
.sh-lockup-pi {
color: var(--accent);
}
.sh-field {
display: flex;
flex-direction: column;
gap: 4px;
}
.sh-field-label {
font-size: 11px;
font-weight: 500;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--fg-faint);
}
.sh-input {
padding: 6px 9px;
font-size: 13px;
color: var(--fg);
background: var(--bg-inset);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.sh-input::placeholder {
color: var(--fg-faint);
}
.sh-input-mono {
font-family: var(--font-mono);
font-size: 12px;
}
.sh-connect-error {
font-size: 12px;
color: var(--err);
}
.sh-connect-submit {
justify-content: center;
padding: 7px 10px;
margin-top: 2px;
}
@@ -0,0 +1,47 @@
import { Marked } from "marked";
import type { ReactNode } from "react";
import { memo, useMemo } from "react";
function escapeHtml(s: string): string {
return s
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function safeHref(href: string): string | null {
const trimmed = href.trim();
if (/^(?:https?:|mailto:)/i.test(trimmed)) return trimmed;
if (/^[a-z][a-z0-9+.-]*:/i.test(trimmed)) return null; // unknown scheme (javascript:, data:, …)
return trimmed; // relative / fragment
}
const md = new Marked({
gfm: true,
renderer: {
// Raw HTML tokens (block + inline both arrive here) are escaped, never emitted.
html({ text }) {
return escapeHtml(text);
},
link({ href, title, tokens }) {
const inner = this.parser.parseInline(tokens);
const url = safeHref(href);
if (url === null) return inner;
const titleAttr = title ? ` title="${escapeHtml(title)}"` : "";
return `<a href="${escapeHtml(url)}"${titleAttr} target="_blank" rel="noopener">${inner}</a>`;
},
},
});
export const Markdown = memo(function Markdown({ text }: { text: string }): ReactNode {
const html = useMemo(() => {
try {
return md.parse(text, { async: false });
} catch {
return escapeHtml(text);
}
}, [text]);
return <div className="tr-md" dangerouslySetInnerHTML={{ __html: html }} />;
});
@@ -0,0 +1,142 @@
import type { ImageContent, ToolResultMessage } from "@oh-my-pi/pi-wire";
import { ChevronRight, Loader2 } from "lucide-react";
import type { ReactNode } from "react";
import { memo, useState } from "react";
import { messageText } from "../../lib/format";
const RESULT_CLAMP = 16 * 1024;
export interface ToolCardProps {
toolCallId: string;
name: string;
args: unknown;
intent?: string;
result?: ToolResultMessage;
running?: boolean;
partialResult?: unknown;
}
function argDigest(args: unknown): string {
if (args === null || args === undefined) return "";
let text: string;
try {
text = typeof args === "string" ? args : (JSON.stringify(args) ?? "");
} catch {
text = String(args);
}
text = text.replace(/\s+/g, " ");
return text.length > 96 ? `${text.slice(0, 96)}…` : text;
}
function prettyJson(value: unknown): string {
try {
return JSON.stringify(value, null, 2) ?? String(value);
} catch {
return String(value);
}
}
function ClampedText({ text, className }: { text: string; className: string }): ReactNode {
const [showAll, setShowAll] = useState(false);
const clamped = !showAll && text.length > RESULT_CLAMP;
return (
<>
<pre className={className}>{clamped ? text.slice(0, RESULT_CLAMP) : text}</pre>
{clamped && (
<button type="button" className="tr-more" onClick={() => setShowAll(true)}>
show more ({text.length - RESULT_CLAMP} more chars)
</button>
)}
</>
);
}
function openImage(img: ImageContent): void {
try {
const bin = atob(img.data);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
const url = URL.createObjectURL(new Blob([bytes], { type: img.mimeType }));
window.open(url, "_blank", "noopener");
setTimeout(() => URL.revokeObjectURL(url), 60_000);
} catch {
// undecodable image data — thumbnail already conveys the failure
}
}
export const ToolCard = memo(function ToolCard(props: ToolCardProps): ReactNode {
const { name, intent, args, result, running, partialResult } = props;
const [open, setOpen] = useState(false);
const isError = result?.isError === true;
const status = running ? "run" : isError ? "err" : result ? "ok" : "pending";
const images: ImageContent[] = [];
let resultText = "";
if (result) {
const textParts: string[] = [];
for (const block of result.content) {
switch (block.type) {
case "text":
textParts.push(block.text);
break;
case "image":
images.push(block);
break;
default:
break;
}
}
resultText = textParts.join("\n");
}
const partialText =
running && !result ? (typeof partialResult === "string" ? partialResult : messageText(partialResult)) : "";
return (
<div className={`tr-tool${isError ? " tr-tool--error" : ""}`}>
<button type="button" className="tr-tool-head" onClick={() => setOpen(v => !v)}>
{status === "run" ? (
<Loader2 size={12} className="tr-tool-spin" aria-label="running" />
) : (
<span className={`tr-tool-dot tr-tool-dot--${status}`} />
)}
<span className="tr-tool-name">{name}</span>
<span className="tr-tool-intent">{intent || argDigest(args)}</span>
<ChevronRight size={12} className={`tr-tool-chev${open ? " tr-tool-chev--open" : ""}`} />
</button>
{open && (
<div className="tr-tool-body">
<div className="tr-tool-label">args</div>
<pre className="tr-tool-pre">{prettyJson(args)}</pre>
{resultText.length > 0 && (
<>
<div className="tr-tool-label">{isError ? "error" : "result"}</div>
<ClampedText
text={resultText}
className={`tr-tool-pre tr-tool-result${isError ? " tr-tool-result--error" : ""}`}
/>
</>
)}
{images.length > 0 && (
<div className="tr-tool-imgs">
{images.map((img, i) => (
<img
key={i}
className="tr-tool-img"
src={`data:${img.mimeType};base64,${img.data}`}
alt={`tool result ${i + 1}`}
onClick={() => openImage(img)}
/>
))}
</div>
)}
</div>
)}
{partialText.length > 0 && (
<pre className="tr-tool-pre tr-tool-partial">
{partialText.length > 2048 ? `…${partialText.slice(-2048)}` : partialText}
</pre>
)}
</div>
);
});
@@ -0,0 +1,313 @@
import type { AssistantMessage, ImageContent, SessionEntry, TextContent, ToolResultMessage } from "@oh-my-pi/pi-wire";
import { ChevronRight } from "lucide-react";
import type { ReactNode } from "react";
import { memo, useEffect, useMemo, useRef, useState } from "react";
import type { ActiveTool } from "../../lib/client";
import { fmtTokens } from "../../lib/format";
import { Markdown } from "./Markdown";
import { ToolCard } from "./ToolCard";
import "./transcript.css";
export interface TranscriptProps {
entries: readonly SessionEntry[];
stream: AssistantMessage | null;
streamDone: boolean;
activeTools: ReadonlyMap<string, ActiveTool>;
working: boolean;
compact?: boolean; // dense variant for the agent drawer
}
function Row({
kind,
gutter,
title,
children,
}: {
kind: "user" | "assistant" | "custom" | "marker";
gutter: ReactNode;
title?: string;
children: ReactNode;
}): ReactNode {
return (
<div className={`tr-row tr-row--${kind}`}>
<div className="tr-gutter" title={title}>
{gutter}
</div>
<div className="tr-body">{children}</div>
</div>
);
}
function ThinkingBlock({ text, redacted }: { text: string; redacted?: boolean }): ReactNode {
const [open, setOpen] = useState(false);
return (
<div className="tr-think">
<button type="button" className="tr-think-head" onClick={() => setOpen(v => !v)}>
<ChevronRight size={11} className={`tr-chev${open ? " tr-chev--open" : ""}`} />
thinking{redacted ? " · redacted" : ""}
</button>
{open && <div className="tr-think-body">{redacted ? "(redacted by provider)" : text}</div>}
</div>
);
}
/** Plain text + image thumbnails for user / custom message content. */
function MsgContent({ content }: { content: string | readonly (TextContent | ImageContent)[] }): ReactNode {
if (typeof content === "string") return <div className="tr-text">{content}</div>;
return (
<>
{content.map((block, i) => {
switch (block.type) {
case "text":
return (
<div key={i} className="tr-text">
{block.text}
</div>
);
case "image":
return (
<img
key={i}
className="tr-msg-img"
src={`data:${block.mimeType};base64,${block.data}`}
alt="attachment"
/>
);
default:
return null;
}
})}
</>
);
}
function AssistantBody({
message,
results,
active,
pending,
}: {
message: AssistantMessage;
results: ReadonlyMap<string, ToolResultMessage>;
active: ReadonlyMap<string, ActiveTool>;
/** Still streaming — suppress stop-reason chips on the partial message. */
pending: boolean;
}): ReactNode {
const blocks = message.content.map((block, i) => {
switch (block.type) {
case "thinking":
return <ThinkingBlock key={i} text={block.thinking} />;
case "redactedThinking":
return <ThinkingBlock key={i} text="" redacted />;
case "text":
return <Markdown key={i} text={block.text} />;
case "toolCall": {
const act = active.get(block.id);
const result = results.get(block.id);
return (
<ToolCard
key={block.id}
toolCallId={block.id}
name={block.name}
intent={block.intent ?? act?.intent}
args={block.arguments}
result={result}
running={!result && (act !== undefined || pending)}
partialResult={act?.partialResult}
/>
);
}
default:
return null;
}
});
const stop = message.stopReason;
const failed = !pending && (stop === "error" || stop === "aborted");
return (
<>
{blocks}
{failed && (
<div className="tr-stop">
<span className={`tr-chip ${stop === "error" ? "tr-chip--err" : "tr-chip--warn"}`}>{stop}</span>
{message.errorMessage !== undefined && message.errorMessage.length > 0 && (
<span className="tr-stop-msg">{message.errorMessage}</span>
)}
</div>
)}
</>
);
}
interface EntryRowProps {
entry: SessionEntry;
results: ReadonlyMap<string, ToolResultMessage>;
active: ReadonlyMap<string, ActiveTool>;
}
/** Re-render only when the entry itself or one of its tool pairings changed. */
function entryRowEqual(prev: EntryRowProps, next: EntryRowProps): boolean {
if (prev.entry !== next.entry) return false;
const e = next.entry;
if (e.type !== "message" || e.message.role !== "assistant") return true;
for (const block of e.message.content) {
if (block.type !== "toolCall") continue;
if (prev.results.get(block.id) !== next.results.get(block.id)) return false;
if (prev.active.get(block.id) !== next.active.get(block.id)) return false;
}
return true;
}
const EntryRow = memo(function EntryRow({ entry, results, active }: EntryRowProps): ReactNode {
switch (entry.type) {
case "message": {
const msg = entry.message;
switch (msg.role) {
case "user":
return (
<Row kind="user" gutter="host" title={entry.timestamp}>
<MsgContent content={msg.content} />
</Row>
);
case "assistant":
return (
<Row kind="assistant" gutter="agent" title={entry.timestamp}>
<AssistantBody message={msg} results={results} active={active} pending={false} />
</Row>
);
default:
// toolResult entries are consumed via pairing; developer & unknown roles skipped
return null;
}
}
case "custom_message": {
if (entry.customType === "collab-prompt") {
const details = entry.details;
const from =
details !== null &&
typeof details === "object" &&
typeof (details as Record<string, unknown>).from === "string"
? ((details as Record<string, unknown>).from as string)
: "guest";
return (
<Row kind="user" gutter={<span className="tr-badge">{from}</span>} title={entry.timestamp}>
<MsgContent content={entry.content} />
</Row>
);
}
if (!entry.display) return null;
return (
<Row kind="custom" gutter="" title={entry.timestamp}>
<div className="tr-custom">
<span className="tr-chip">{entry.customType}</span>
<MsgContent content={entry.content} />
</div>
</Row>
);
}
case "compaction":
return (
<div className="tr-divider" title={entry.shortSummary ?? entry.summary}>
<span>context compacted · {fmtTokens(entry.tokensBefore)} tokens</span>
</div>
);
case "branch_summary":
return (
<div className="tr-divider" title={entry.summary}>
<span>branch summary</span>
</div>
);
case "model_change":
return (
<Row kind="marker" gutter="" title={entry.timestamp}>
<span className="tr-marker">model → {entry.model}</span>
</Row>
);
case "thinking_level_change":
return (
<Row kind="marker" gutter="" title={entry.timestamp}>
<span className="tr-marker">thinking → {entry.thinkingLevel ?? "off"}</span>
</Row>
);
default:
// unknown entry types from newer hosts — skip tolerantly
return null;
}
}, entryRowEqual);
export function Transcript(props: TranscriptProps): ReactNode {
const { entries, stream, streamDone, activeTools, working, compact } = props;
const results = useMemo(() => {
const map = new Map<string, ToolResultMessage>();
for (const entry of entries) {
if (entry.type === "message" && entry.message.role === "toolResult") {
map.set(entry.message.toolCallId, entry.message);
}
}
return map;
}, [entries]);
const rootRef = useRef<HTMLDivElement | null>(null);
const lockRef = useRef(true);
// Follow the tail while bottom-locked; releasing/re-arming happens in onScroll.
useEffect(() => {
const el = rootRef.current;
if (el !== null && lockRef.current) el.scrollTop = el.scrollHeight;
}, [entries, stream, activeTools, working]);
// Active tools not already represented as toolCall blocks in the stream ghost.
const streamIds = new Set<string>();
if (stream !== null) {
for (const block of stream.content) {
if (block.type === "toolCall") streamIds.add(block.id);
}
}
const tailTools: ActiveTool[] = [];
for (const tool of activeTools.values()) {
if (!streamIds.has(tool.toolCallId)) tailTools.push(tool);
}
return (
<div
ref={rootRef}
className={`tr-root${compact === true ? " tr-root--compact" : ""}`}
onScroll={() => {
const el = rootRef.current;
if (el !== null) {
lockRef.current = el.scrollHeight - el.scrollTop - el.clientHeight <= 40;
}
}}
>
{entries.length === 0 && stream === null && !working && <div className="tr-empty">no activity yet</div>}
{entries.map(entry => (
<EntryRow key={entry.id} entry={entry} results={results} active={activeTools} />
))}
{stream !== null && (
<Row kind="assistant" gutter="agent">
<AssistantBody message={stream} results={results} active={activeTools} pending={!streamDone} />
</Row>
)}
{tailTools.length > 0 && (
<Row kind="assistant" gutter={stream === null ? "agent" : ""}>
{tailTools.map(tool => (
<ToolCard
key={tool.toolCallId}
toolCallId={tool.toolCallId}
name={tool.toolName}
intent={tool.intent}
args={tool.args}
running
partialResult={tool.partialResult}
/>
))}
</Row>
)}
{working && stream === null && (
<Row kind="assistant" gutter="agent">
<div className="tr-shimmer">thinking…</div>
</Row>
)}
</div>
);
}
@@ -0,0 +1,541 @@
/* Transcript — dense gutter-labeled rows, terminal heritage. tr-* namespace. */
.tr-root {
height: 100%;
overflow-y: auto;
padding: 12px 16px 24px;
font-family: var(--font-ui);
font-size: 13px;
line-height: 1.5;
color: var(--fg);
}
.tr-empty {
padding: 24px 0;
text-align: center;
color: var(--fg-faint);
font-family: var(--font-mono);
font-size: 12px;
}
/* ---- rows ---- */
.tr-row {
display: grid;
grid-template-columns: 72px 1fr;
gap: 0 12px;
padding: 5px 0;
}
.tr-gutter {
padding-top: 3px;
text-align: right;
font-family: var(--font-mono);
font-size: 10px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--fg-faint);
user-select: none;
}
.tr-row--user .tr-gutter {
color: var(--accent);
}
.tr-body {
min-width: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.tr-body > .tr-md,
.tr-body > .tr-text {
align-self: stretch;
}
.tr-text {
white-space: pre-wrap;
word-break: break-word;
}
.tr-row--custom .tr-body {
color: var(--fg-muted);
}
.tr-custom {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.tr-msg-img {
max-height: 240px;
max-width: 100%;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
}
/* ---- chips & badges ---- */
.tr-badge {
display: inline-block;
padding: 0 5px;
border-radius: var(--radius-sm);
background: var(--accent-muted);
color: var(--accent);
font-family: var(--font-mono);
font-size: 10px;
line-height: 16px;
text-transform: none;
letter-spacing: 0;
}
.tr-chip {
display: inline-flex;
align-items: center;
height: 16px;
padding: 0 6px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-inset);
color: var(--fg-muted);
font-family: var(--font-mono);
font-size: 10px;
white-space: nowrap;
}
.tr-chip--err {
border-color: color-mix(in oklch, var(--err) 50%, var(--border));
color: var(--err);
background: color-mix(in oklch, var(--err) 10%, transparent);
}
.tr-chip--warn {
border-color: color-mix(in oklch, var(--warn) 50%, var(--border));
color: var(--warn);
background: color-mix(in oklch, var(--warn) 10%, transparent);
}
.tr-stop {
display: flex;
align-items: baseline;
gap: 8px;
margin-top: 2px;
}
.tr-stop-msg {
color: var(--err);
font-family: var(--font-mono);
font-size: 11.5px;
white-space: pre-wrap;
word-break: break-word;
}
/* ---- thinking ---- */
.tr-think-head {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 0;
border: none;
background: none;
cursor: pointer;
color: var(--fg-faint);
font-family: var(--font-ui);
font-size: 12px;
font-style: italic;
}
.tr-think-head:hover {
color: var(--fg-muted);
}
.tr-think-body {
align-self: stretch;
margin: 2px 0;
padding: 2px 0 2px 10px;
border-left: 2px solid var(--border);
color: var(--fg-muted);
font-style: italic;
font-size: 12px;
white-space: pre-wrap;
word-break: break-word;
}
.tr-chev {
flex: none;
color: var(--fg-faint);
transition: transform 150ms ease-out;
}
.tr-chev--open {
transform: rotate(90deg);
}
/* ---- dividers & markers ---- */
.tr-divider {
display: flex;
align-items: center;
gap: 10px;
margin: 10px 0;
color: var(--fg-faint);
font-family: var(--font-mono);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
user-select: none;
}
.tr-divider::before,
.tr-divider::after {
content: "";
flex: 1;
height: 1px;
background: var(--border);
}
.tr-marker {
color: var(--fg-faint);
font-family: var(--font-mono);
font-size: 11px;
}
.tr-shimmer {
color: var(--fg-muted);
font-size: 12.5px;
animation: tr-pulse 1.4s ease-in-out infinite;
}
/* ---- tool cards ---- */
.tr-tool {
align-self: stretch;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-raised);
overflow: hidden;
}
.tr-tool--error {
border-color: color-mix(in oklch, var(--err) 40%, var(--border));
}
.tr-tool-head {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-width: 0;
padding: 4px 8px;
border: none;
background: none;
cursor: pointer;
text-align: left;
font: inherit;
color: var(--fg-muted);
}
.tr-tool-head:hover {
background: var(--bg-inset);
}
.tr-tool-dot {
flex: none;
width: 7px;
height: 7px;
border-radius: 50%;
}
.tr-tool-dot--ok {
background: var(--ok);
}
.tr-tool-dot--err {
background: var(--err);
}
.tr-tool-dot--pending {
background: var(--fg-faint);
}
.tr-tool-spin {
flex: none;
color: var(--accent);
animation: tr-spin 0.8s linear infinite;
}
.tr-tool-name {
flex: none;
font-family: var(--font-mono);
font-size: 12px;
color: var(--fg);
}
.tr-tool-intent {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
color: var(--fg-faint);
}
.tr-tool-chev {
flex: none;
color: var(--fg-faint);
transition: transform 150ms ease-out;
}
.tr-tool-chev--open {
transform: rotate(90deg);
}
.tr-tool-body {
display: flex;
flex-direction: column;
gap: 4px;
padding: 6px 8px 8px;
border-top: 1px solid var(--border);
}
.tr-tool-label {
font-family: var(--font-mono);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--fg-faint);
user-select: none;
}
.tr-tool-pre {
margin: 0;
padding: 6px 8px;
max-height: 320px;
overflow: auto;
border-radius: var(--radius-sm);
background: var(--bg-inset);
font-family: var(--font-mono);
font-size: 11.5px;
line-height: 1.45;
white-space: pre;
color: var(--fg-muted);
}
.tr-tool-result--error {
color: var(--err);
}
.tr-tool-partial {
margin: 0 8px 8px;
white-space: pre-wrap;
word-break: break-word;
}
.tr-tool-imgs {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.tr-tool-img {
max-height: 240px;
max-width: 100%;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
cursor: zoom-in;
}
.tr-more {
align-self: flex-start;
padding: 2px 0;
border: none;
background: none;
cursor: pointer;
color: var(--accent);
font-family: var(--font-mono);
font-size: 11px;
}
.tr-more:hover {
text-decoration: underline;
}
/* ---- markdown ---- */
.tr-md {
line-height: 1.55;
word-break: break-word;
}
.tr-md > :first-child {
margin-top: 0;
}
.tr-md > :last-child {
margin-bottom: 0;
}
.tr-md p {
margin: 6px 0;
}
.tr-md h1,
.tr-md h2,
.tr-md h3,
.tr-md h4,
.tr-md h5,
.tr-md h6 {
margin: 14px 0 6px;
font-weight: 600;
line-height: 1.3;
}
.tr-md h1 {
font-size: 17px;
}
.tr-md h2 {
font-size: 15px;
}
.tr-md h3 {
font-size: 14px;
}
.tr-md h4,
.tr-md h5,
.tr-md h6 {
font-size: 13px;
}
.tr-md ul,
.tr-md ol {
margin: 6px 0;
padding-left: 22px;
}
.tr-md li {
margin: 2px 0;
}
.tr-md li > p {
margin: 2px 0;
}
.tr-md code {
padding: 1px 4px;
border-radius: var(--radius-sm);
background: var(--bg-inset);
font-family: var(--font-mono);
font-size: 12px;
}
.tr-md pre {
margin: 8px 0;
padding: 8px 10px;
overflow-x: auto;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-inset);
}
.tr-md pre code {
padding: 0;
border-radius: 0;
background: none;
font-size: 12px;
line-height: 1.5;
}
.tr-md blockquote {
margin: 8px 0;
padding: 2px 12px;
border-left: 3px solid var(--border-strong);
color: var(--fg-muted);
}
.tr-md table {
margin: 8px 0;
border-collapse: collapse;
font-size: 12.5px;
}
.tr-md th,
.tr-md td {
padding: 3px 8px;
border: 1px solid var(--border);
text-align: left;
}
.tr-md th {
background: var(--bg-inset);
font-weight: 600;
}
.tr-md a {
color: var(--accent);
text-decoration: none;
}
.tr-md a:hover {
text-decoration: underline;
}
.tr-md hr {
margin: 12px 0;
border: none;
height: 1px;
background: var(--border);
}
.tr-md img {
max-width: 100%;
max-height: 320px;
border-radius: var(--radius-sm);
}
/* ---- compact variant (agent drawer) ---- */
.tr-root--compact {
padding: 8px 10px 16px;
font-size: 12px;
}
.tr-root--compact .tr-row {
grid-template-columns: 52px 1fr;
gap: 0 8px;
padding: 3px 0;
}
.tr-root--compact .tr-gutter {
font-size: 9px;
}
.tr-root--compact .tr-tool-pre {
max-height: 200px;
}
/* ---- animation ---- */
@keyframes tr-spin {
to {
transform: rotate(360deg);
}
}
@keyframes tr-pulse {
0%,
100% {
opacity: 0.45;
}
50% {
opacity: 1;
}
}
+1
View File
@@ -0,0 +1 @@
declare module "*.css";
+410
View File
@@ -0,0 +1,410 @@
/**
* Guest-side session replica for the collab web client.
*
* Owns the relay socket, applies host frames in strict arrival order, and
* exposes an immutable {@link GuestSnapshot} through a
* `useSyncExternalStore`-compatible subscribe/getSnapshot pair. The snapshot
* object (and every replaced collection inside it) gets a new reference per
* applied frame, so React change detection is reference equality all the way.
*/
import type {
AgentSnapshot,
AssistantMessage,
HostFrame,
SessionEntry,
SessionHeader,
SessionState,
SubagentLifecyclePayload,
SubagentProgressPayload,
} from "@oh-my-pi/pi-wire";
import { importRoomKey } from "./codec";
import { COLLAB_PROTO, parseCollabLink } from "./link";
import { CollabSocket } from "./socket";
export type ConnectionPhase = "connecting" | "waiting" | "live" | "reconnecting" | "ended";
export interface ActiveTool {
toolCallId: string;
toolName: string;
args: unknown;
intent?: string;
partialResult?: unknown;
startedAt: number;
}
export interface Notice {
id: number;
level: "info" | "warning" | "error";
message: string;
at: number;
}
export interface GuestSnapshot {
phase: ConnectionPhase;
endedReason: string | null;
header: SessionHeader | null;
entries: readonly SessionEntry[];
state: SessionState | null;
agents: readonly AgentSnapshot[];
/** Keyed by `payload.progress.id`. */
progress: ReadonlyMap<string, SubagentProgressPayload>;
/** Keyed by `payload.id`. */
lifecycle: ReadonlyMap<string, SubagentLifecyclePayload>;
/** Streaming assistant ghost; held until the matching entry lands. */
stream: AssistantMessage | null;
streamDone: boolean;
activeTools: ReadonlyMap<string, ActiveTool>;
/** agent_start..agent_end, reconciled by state.isStreaming. */
working: boolean;
/** Capped at 50, newest last. */
notices: readonly Notice[];
}
const MAX_NOTICES = 50;
const TRANSCRIPT_TIMEOUT_MS = 10_000;
/** Mirrors the TUI guest's WELCOME_TIMEOUT_MS: a host that never answers hello ends the join. */
const WELCOME_TIMEOUT_MS = 30_000;
interface PendingTranscript {
resolve: (result: { text: string; newSize: number } | null) => void;
timer: Timer;
}
export class GuestClient {
readonly #socket: CollabSocket;
readonly #name: string;
readonly #listeners = new Set<() => void>();
readonly #pendingTranscripts = new Map<number, PendingTranscript>();
#reqSeq = 0;
#noticeSeq = 0;
#everConnected = false;
#welcomed = false;
#welcomeTimer: Timer | null = null;
#phase: ConnectionPhase = "connecting";
#endedReason: string | null = null;
#header: SessionHeader | null = null;
#entries: readonly SessionEntry[] = [];
#state: SessionState | null = null;
#agents: readonly AgentSnapshot[] = [];
#progress: ReadonlyMap<string, SubagentProgressPayload> = new Map();
#lifecycle: ReadonlyMap<string, SubagentLifecyclePayload> = new Map();
#stream: AssistantMessage | null = null;
#streamDone = false;
#activeTools: ReadonlyMap<string, ActiveTool> = new Map();
#working = false;
#notices: readonly Notice[] = [];
#snapshot: GuestSnapshot;
/** @throws Error when the link does not parse. */
constructor(link: string, displayName: string) {
const parsed = parseCollabLink(link);
if ("error" in parsed) throw new Error(parsed.error);
this.#name = displayName;
this.#socket = new CollabSocket({ wsUrl: parsed.wsUrl, role: "guest", key: importRoomKey(parsed.key) });
this.#socket.onOpen = () => this.#handleOpen();
this.#socket.onFrame = frame => this.#applyFrameSafe(frame);
this.#socket.onControl = msg => {
if (msg.t === "room-closed") this.#end("room closed");
};
this.#socket.onClose = (reason, willReconnect) => this.#handleClose(reason, willReconnect);
this.#snapshot = this.#buildSnapshot();
}
connect(): void {
if (this.#phase === "ended") {
this.#phase = "connecting";
this.#endedReason = null;
this.#commit();
}
this.#socket.connect();
if (!this.#welcomed && this.#welcomeTimer === null) {
this.#welcomeTimer = setTimeout(() => {
this.#welcomeTimer = null;
if (!this.#welcomed) this.#end("timed out waiting for the host's welcome");
}, WELCOME_TIMEOUT_MS);
}
}
close(): void {
this.#clearWelcomeTimer();
this.#socket.close();
}
subscribe(listener: () => void): () => void {
this.#listeners.add(listener);
return () => {
this.#listeners.delete(listener);
};
}
/** Cached stable reference; replaced (with fresh collection refs) per applied frame. */
getSnapshot(): GuestSnapshot {
return this.#snapshot;
}
sendPrompt(text: string): void {
this.#socket.send({ t: "prompt", text });
}
sendAbort(): void {
this.#socket.send({ t: "abort" });
}
sendAgentCmd(cmd: "chat" | "kill" | "revive", agentId: string, text?: string): void {
this.#socket.send({ t: "agent-cmd", cmd, agentId, text });
}
/** Incremental subagent-transcript read. Resolves null on error reply or 10s timeout. */
fetchTranscript(agentId: string, fromByte: number): Promise<{ text: string; newSize: number } | null> {
const reqId = ++this.#reqSeq;
const { promise, resolve } = Promise.withResolvers<{ text: string; newSize: number } | null>();
const timer = setTimeout(() => {
this.#pendingTranscripts.delete(reqId);
resolve(null);
}, TRANSCRIPT_TIMEOUT_MS);
this.#pendingTranscripts.set(reqId, { resolve, timer });
this.#socket.send({ t: "fetch-transcript", reqId, agentId, fromByte });
return promise;
}
/** Test seam: apply a synthetic host frame through the real apply path. */
applyFrameForTest(frame: HostFrame): void {
this.#applyFrameSafe(frame);
}
#handleOpen(): void {
this.#socket.send({ t: "hello", proto: COLLAB_PROTO, name: this.#name });
this.#phase = this.#everConnected ? "reconnecting" : "waiting";
this.#everConnected = true;
this.#commit();
}
#handleClose(reason: string, willReconnect: boolean): void {
if (this.#phase === "ended") return;
if (willReconnect) {
this.#phase = "reconnecting";
this.#commit();
return;
}
this.#end(reason);
}
#end(reason: string): void {
if (this.#phase === "ended") return;
this.#clearWelcomeTimer();
this.#phase = "ended";
this.#endedReason = reason;
for (const [, pending] of this.#pendingTranscripts) {
clearTimeout(pending.timer);
pending.resolve(null);
}
this.#pendingTranscripts.clear();
this.#commit();
this.#socket.close();
}
#clearWelcomeTimer(): void {
if (this.#welcomeTimer !== null) {
clearTimeout(this.#welcomeTimer);
this.#welcomeTimer = null;
}
}
/** Surfaces apply failures instead of letting the socket's recv chain swallow them. */
#applyFrameSafe(frame: HostFrame): void {
try {
this.#applyFrame(frame);
} catch (err) {
console.warn("collab: failed to apply frame", frame.t, err);
if (frame.t === "welcome" && !this.#welcomed) {
this.#end(`failed to apply session snapshot: ${err instanceof Error ? err.message : String(err)}`);
return;
}
this.#pushNotice("error", `failed to apply ${frame.t} frame`);
this.#commit();
}
}
#applyFrame(frame: HostFrame): void {
switch (frame.t) {
case "welcome":
this.#header = frame.header;
this.#entries = [...frame.entries];
this.#state = frame.state;
this.#agents = [...frame.agents];
this.#stream = null;
this.#streamDone = false;
this.#activeTools = new Map();
this.#progress = new Map();
this.#lifecycle = new Map();
this.#working = frame.state.isStreaming;
this.#phase = "live";
this.#endedReason = null;
this.#welcomed = true;
this.#clearWelcomeTimer();
break;
case "entry":
this.#entries = [...this.#entries, frame.entry];
if (this.#streamDone && frame.entry.type === "message" && frame.entry.message.role === "assistant") {
this.#stream = null;
this.#streamDone = false;
}
break;
case "event":
this.#applyEvent(frame.event);
break;
case "state":
this.#state = frame.state;
if (!frame.state.isStreaming) {
this.#working = false;
if (this.#streamDone) {
this.#stream = null;
this.#streamDone = false;
}
}
break;
case "agents":
this.#agents = [...frame.agents];
break;
case "bus":
if (frame.channel === "task:subagent:progress") {
const payload = frame.data as SubagentProgressPayload;
this.#progress = new Map(this.#progress).set(payload.progress.id, payload);
} else if (frame.channel === "task:subagent:lifecycle") {
const payload = frame.data as SubagentLifecyclePayload;
this.#lifecycle = new Map(this.#lifecycle).set(payload.id, payload);
}
break;
case "transcript": {
const pending = this.#pendingTranscripts.get(frame.reqId);
if (pending) {
this.#pendingTranscripts.delete(frame.reqId);
clearTimeout(pending.timer);
pending.resolve(frame.error !== undefined ? null : { text: frame.text, newSize: frame.newSize });
}
break;
}
case "bye":
this.#end(frame.reason);
return; // #end already committed
case "error":
this.#pushNotice("error", frame.message);
break;
default:
// unknown frame type from a newer host — ignore
break;
}
this.#commit();
}
#applyEvent(event: Extract<HostFrame, { t: "event" }>["event"]): void {
switch (event.type) {
case "message_start":
case "message_update":
if (event.message.role === "assistant") {
this.#stream = event.message;
this.#streamDone = false;
}
break;
case "message_end":
if (event.message.role === "assistant") {
this.#stream = event.message;
this.#streamDone = true;
}
break;
case "tool_execution_start": {
const tool: ActiveTool = {
toolCallId: event.toolCallId,
toolName: event.toolName,
args: event.args,
intent: event.intent,
startedAt: Date.now(),
};
this.#activeTools = new Map(this.#activeTools).set(event.toolCallId, tool);
break;
}
case "tool_execution_update": {
const existing = this.#activeTools.get(event.toolCallId);
const tool: ActiveTool = existing
? { ...existing, partialResult: event.partialResult }
: {
toolCallId: event.toolCallId,
toolName: event.toolName,
args: event.args,
partialResult: event.partialResult,
startedAt: Date.now(),
};
this.#activeTools = new Map(this.#activeTools).set(event.toolCallId, tool);
break;
}
case "tool_execution_end": {
const next = new Map(this.#activeTools);
next.delete(event.toolCallId);
this.#activeTools = next;
break;
}
case "agent_start":
this.#working = true;
break;
case "agent_end":
this.#working = false;
break;
case "notice":
this.#pushNotice(event.level, event.message);
break;
case "auto_retry_start":
this.#pushNotice("info", `retry ${event.attempt}/${event.maxAttempts}: ${event.errorMessage}`);
break;
case "auto_compaction_start":
this.#pushNotice("info", `compacting context (${event.reason})`);
break;
case "auto_compaction_end":
if (!event.skipped) {
this.#pushNotice(
"info",
event.aborted
? "compaction aborted"
: event.errorMessage
? `compaction failed: ${event.errorMessage}`
: "context compacted",
);
}
break;
default:
// turn_start/turn_end/thinking_level_changed/unknown — ignore
break;
}
}
#pushNotice(level: Notice["level"], message: string): void {
const notice: Notice = { id: ++this.#noticeSeq, level, message, at: Date.now() };
const next = [...this.#notices, notice];
if (next.length > MAX_NOTICES) next.splice(0, next.length - MAX_NOTICES);
this.#notices = next;
}
#buildSnapshot(): GuestSnapshot {
return {
phase: this.#phase,
endedReason: this.#endedReason,
header: this.#header,
entries: this.#entries,
state: this.#state,
agents: this.#agents,
progress: this.#progress,
lifecycle: this.#lifecycle,
stream: this.#stream,
streamDone: this.#streamDone,
activeTools: this.#activeTools,
working: this.#working,
notices: this.#notices,
};
}
#commit(): void {
this.#snapshot = this.#buildSnapshot();
for (const listener of this.#listeners) listener();
}
}
+58
View File
@@ -0,0 +1,58 @@
/**
* AES-256-GCM sealing for collab frames (browser-safe vendored mirror of
* `@oh-my-pi/pi-coding-agent/src/collab/crypto.ts` — WebCrypto only).
*
* The room key lives only in the link fragment; the relay sees opaque bytes.
* Sealed layout: `[12B IV][ciphertext+tag]`.
*/
import type { WireFrame } from "@oh-my-pi/pi-wire";
const AES_ALGORITHM = "AES-GCM";
const IV_LENGTH = 12;
const KEY_LENGTH = 32;
const TEXT_ENCODER = new TextEncoder();
const TEXT_DECODER = new TextDecoder();
export function generateRoomKey(): Uint8Array {
const key = new Uint8Array(KEY_LENGTH);
crypto.getRandomValues(key);
return key;
}
export function importRoomKey(raw: Uint8Array): Promise<CryptoKey> {
if (raw.byteLength !== KEY_LENGTH) {
throw new Error(`Room key must be ${KEY_LENGTH} bytes, got ${raw.byteLength}`);
}
return crypto.subtle.importKey("raw", asStrict(raw), AES_ALGORITHM, false, ["encrypt", "decrypt"]);
}
export async function seal(key: CryptoKey, frame: WireFrame): Promise<Uint8Array> {
const iv = new Uint8Array(IV_LENGTH);
crypto.getRandomValues(iv);
const plaintext = TEXT_ENCODER.encode(JSON.stringify(frame));
const ciphertext = new Uint8Array(await crypto.subtle.encrypt({ name: AES_ALGORITHM, iv }, key, plaintext));
const out = new Uint8Array(IV_LENGTH + ciphertext.byteLength);
out.set(iv, 0);
out.set(ciphertext, IV_LENGTH);
return out;
}
/** Inverse of {@link seal}. Throws on auth failure or malformed input. */
export async function open(key: CryptoKey, data: Uint8Array): Promise<WireFrame> {
if (data.byteLength <= IV_LENGTH) {
throw new Error("Sealed frame too short");
}
const iv = asStrict(data.subarray(0, IV_LENGTH));
const ciphertext = asStrict(data.subarray(IV_LENGTH));
const plaintext = new Uint8Array(await crypto.subtle.decrypt({ name: AES_ALGORITHM, iv }, key, ciphertext));
return JSON.parse(TEXT_DECODER.decode(plaintext)) as WireFrame;
}
function asStrict(bytes: Uint8Array): Uint8Array<ArrayBuffer> {
if (bytes.buffer instanceof ArrayBuffer && bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength) {
return bytes as Uint8Array<ArrayBuffer>;
}
const copy = new Uint8Array(bytes.byteLength);
copy.set(bytes);
return copy;
}
+87
View File
@@ -0,0 +1,87 @@
/** Small pure formatting helpers shared across collab-web components. */
/** "950", "12.3k", "1.2M" — tolerant of non-finite input. */
export function fmtTokens(n: number): string {
if (!Number.isFinite(n) || n <= 0) return "0";
if (n < 1000) return String(Math.round(n));
if (n < 1_000_000) {
const k = n / 1000;
return `${k >= 100 ? Math.round(k) : k.toFixed(1)}k`;
}
const m = n / 1_000_000;
return `${m >= 100 ? Math.round(m) : m.toFixed(1)}M`;
}
/** "$0.004", "$0.42", "$4.20" — tolerant of non-finite input. */
export function fmtCost(usd: number): string {
if (!Number.isFinite(usd) || usd <= 0) return "$0.00";
return `$${usd >= 1 ? usd.toFixed(2) : usd.toFixed(3)}`;
}
/** "847ms", "12.3s", "4m05s", "1h12m". */
export function fmtDuration(ms: number): string {
if (!Number.isFinite(ms) || ms < 0) return "0ms";
if (ms < 1000) return `${Math.round(ms)}ms`;
const s = ms / 1000;
if (s < 60) return `${s.toFixed(1)}s`;
const min = Math.floor(s / 60);
if (min < 60) return `${min}m${String(Math.round(s % 60)).padStart(2, "0")}s`;
const h = Math.floor(min / 60);
return `${h}h${String(min % 60).padStart(2, "0")}m`;
}
/** "now", "42s ago", "5m ago", "3h ago", "2d ago". Input: epoch ms. */
export function relTime(tsMs: number): string {
if (!Number.isFinite(tsMs)) return "";
const delta = Date.now() - tsMs;
if (delta < 10_000) return "now";
const s = Math.floor(delta / 1000);
if (s < 60) return `${s}s ago`;
const min = Math.floor(s / 60);
if (min < 60) return `${min}m ago`;
const h = Math.floor(min / 60);
if (h < 24) return `${h}h ago`;
return `${Math.floor(h / 24)}d ago`;
}
/** "73%" from a 0–100 percent; em dash for null/non-finite. */
export function fmtPercent(p: number | null | undefined): string {
if (p === null || p === undefined || !Number.isFinite(p)) return "—";
return `${Math.round(Math.min(100, Math.max(0, p)))}%`;
}
/** Home-relative, middle-elided path: "~/…/packages/collab-web". */
export function shortenPath(p: string): string {
if (typeof p !== "string" || p.length === 0) return "";
let out = p.replace(/^\/(?:Users|home)\/[^/]+(?=\/|$)/, "~");
const segs = out.split("/");
if (segs.length > 4) out = `${segs[0]}/…/${segs.slice(-2).join("/")}`;
return out;
}
/** Tolerant text extraction from string | content-block array | message-like objects. */
export function messageText(m: unknown): string {
if (typeof m === "string") return m;
if (m === null || m === undefined) return "";
if (Array.isArray(m)) {
const parts: string[] = [];
for (const block of m) {
if (typeof block === "string") {
parts.push(block);
continue;
}
if (block && typeof block === "object") {
const rec = block as Record<string, unknown>;
if (typeof rec.text === "string") parts.push(rec.text);
else if (typeof rec.thinking === "string") parts.push(rec.thinking);
}
}
return parts.join("\n");
}
if (typeof m === "object") {
const rec = m as Record<string, unknown>;
if (typeof rec.text === "string") return rec.text;
if ("content" in rec) return messageText(rec.content);
}
return "";
}
+22
View File
@@ -0,0 +1,22 @@
/**
* Incremental JSONL parsing for streamed subagent transcripts.
*
* Transcript bytes arrive in arbitrary chunks; the trailing partial line is
* returned as `carry` and prepended to the next chunk by the caller.
* Unparseable complete lines are skipped (tolerant decode).
*/
export function parseJsonl(text: string, carry: string): { items: unknown[]; carry: string } {
const lines = (carry + text).split("\n");
const nextCarry = lines.pop() ?? "";
const items: unknown[] = [];
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
items.push(JSON.parse(trimmed));
} catch {
// skip unparseable line
}
}
return { items, carry: nextCarry };
}
+153
View File
@@ -0,0 +1,153 @@
/**
* Collab link + wire-envelope handling (browser-safe vendored mirror of the
* link/envelope half of `@oh-my-pi/pi-coding-agent/src/collab/protocol.ts`;
* base64url goes through atob/btoa instead of Buffer).
*
* Link format: `wss://<host[:port]>/r/<roomId>#<base64url-32-byte-key>`
* Wire envelope: `[4B uint32 BE peerId][sealed payload]` — the guest always
* sends peerId 0; the relay rewrites it to the sender's id.
*/
import type { ParsedCollabLink } from "@oh-my-pi/pi-wire";
import { DEFAULT_RELAY_URL, ENVELOPE_HEADER_LENGTH, ROOM_ID_BYTES } from "@oh-my-pi/pi-wire";
export { COLLAB_PROTO } from "@oh-my-pi/pi-wire";
export type { ParsedCollabLink };
export { DEFAULT_RELAY_URL, ENVELOPE_HEADER_LENGTH, ROOM_ID_BYTES };
const ROOM_PATH_RE = /^\/r\/([A-Za-z0-9_-]{10,64})$/;
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 };
// ═══════════════════════════════════════════════════════════════════════════
// base64url (no Buffer in the browser)
// ═══════════════════════════════════════════════════════════════════════════
export function encodeBase64Url(bytes: Uint8Array): string {
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
}
export function decodeBase64Url(text: string): Uint8Array | null {
if (!B64URL_RE.test(text)) return null;
const base64 = text.replaceAll("-", "+").replaceAll("_", "/");
const padded = base64.length % 4 === 0 ? base64 : base64 + "=".repeat(4 - (base64.length % 4));
let binary: string;
try {
binary = atob(padded);
} catch {
return null;
}
const out = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
return out;
}
// ═══════════════════════════════════════════════════════════════════════════
// Wire envelope
// ═══════════════════════════════════════════════════════════════════════════
export function packEnvelope(peerId: number, sealed: Uint8Array): Uint8Array<ArrayBuffer> {
const out = new Uint8Array(ENVELOPE_HEADER_LENGTH + sealed.byteLength);
new DataView(out.buffer).setUint32(0, peerId, false);
out.set(sealed, ENVELOPE_HEADER_LENGTH);
return out;
}
export function unpackEnvelope(data: Uint8Array): { peerId: number; payload: Uint8Array } | null {
if (data.byteLength < ENVELOPE_HEADER_LENGTH) return null;
const peerId = new DataView(data.buffer, data.byteOffset, ENVELOPE_HEADER_LENGTH).getUint32(0, false);
return { peerId, payload: data.subarray(ENVELOPE_HEADER_LENGTH) };
}
/** Rewrite the peerId in place without copying the payload. */
export function rewriteEnvelopePeer(data: Uint8Array, peerId: number): void {
new DataView(data.buffer, data.byteOffset, ENVELOPE_HEADER_LENGTH).setUint32(0, peerId, false);
}
// ═══════════════════════════════════════════════════════════════════════════
// Link format
// ═══════════════════════════════════════════════════════════════════════════
export function generateRoomId(): string {
const bytes = new Uint8Array(ROOM_ID_BYTES);
crypto.getRandomValues(bytes);
return encodeBase64Url(bytes);
}
/** Normalize a relay base URL (ws/wss/http/https) into a ws/wss origin, or an error. */
function normalizeRelayOrigin(relayUrl: string): { origin: string } | { error: string } {
let url: URL;
try {
url = new URL(relayUrl);
} catch {
return { error: `Invalid relay URL: ${relayUrl}` };
}
let scheme: string;
switch (url.protocol) {
case "wss:":
case "https:":
scheme = "wss:";
break;
case "ws:":
case "http:":
scheme = "ws:";
break;
default:
return { error: `Unsupported relay URL scheme: ${url.protocol}` };
}
if (scheme === "ws:" && !LOCAL_HOSTNAMES[url.hostname]) {
return { error: "relay link must be wss:// (plain ws:// is only allowed for localhost)" };
}
const port = url.port ? `:${url.port}` : "";
return { origin: `${scheme}//${url.hostname}${port}` };
}
/**
* Render the shareable link. Compact forms: the default relay collapses to
* `<roomId>#<key>`; custom wss relays drop the scheme (`host[:port]/r/…`);
* plain-ws localhost relays keep the full `ws://` URL.
*/
export function formatCollabLink(relayUrl: string, roomId: string, key: Uint8Array): string {
const normalized = normalizeRelayOrigin(relayUrl);
if ("error" in normalized) throw new Error(normalized.error);
const keyText = encodeBase64Url(key);
if (normalized.origin === DEFAULT_RELAY_URL) return `${roomId}#${keyText}`;
const compact = normalized.origin.startsWith("wss://")
? normalized.origin.slice("wss://".length)
: normalized.origin;
return `${compact}/r/${roomId}#${keyText}`;
}
export function parseCollabLink(link: string): ParsedCollabLink | { error: string } {
let text = link.trim();
// Bare `<roomId>#<key>` → default relay.
const bare = BARE_LINK_RE.exec(text);
if (bare) text = `${DEFAULT_RELAY_URL}/r/${bare[1]}#${bare[2]}`;
// Scheme-less `host[:port]/r/…` → wss.
else if (!text.includes("://")) text = `wss://${text}`;
let url: URL;
try {
url = new URL(text);
} catch {
return { error: `Invalid collab link: ${link}` };
}
const normalized = normalizeRelayOrigin(url.origin);
if ("error" in normalized) return normalized;
const match = ROOM_PATH_RE.exec(url.pathname);
if (!match) {
return { error: "Collab link must contain a /r/<roomId> path" };
}
const roomId = match[1] as string;
const fragment = url.hash.startsWith("#") ? url.hash.slice(1) : url.hash;
if (!fragment) {
return { error: "Collab link is missing the #<key> fragment" };
}
const key = decodeBase64Url(fragment);
if (key?.byteLength !== 32) {
return { error: "Collab link key must be 32 base64url bytes" };
}
return { wsUrl: `${normalized.origin}/r/${roomId}`, roomId, key };
}
+219
View File
@@ -0,0 +1,219 @@
/**
* Browser WebSocket wrapper for collab live-session sharing (vendored mirror
* of `@oh-my-pi/pi-coding-agent/src/collab/relay-client.ts` semantics).
*
* Connects to a relay room, seals/opens AES-GCM frames in strict order, and
* reconnects with exponential backoff on transient drops. Fatal relay close
* codes (room gone, host conflict, room full) and decryption failures never
* reconnect.
*/
import type { GuestFrame, HostFrame, RelayControlMessage } from "@oh-my-pi/pi-wire";
import { open, seal } from "./codec";
import { packEnvelope, unpackEnvelope } from "./link";
const FATAL_CLOSE_REASONS: Record<number, string> = {
4001: "room closed",
4004: "no such room",
4009: "a host is already connected for this room",
4029: "room is full",
};
const BACKOFF_BASE_MS = 1_000;
const BACKOFF_MAX_MS = 30_000;
/** Max enveloped frames buffered while a reconnect is pending; overflow is dropped. */
const MAX_PENDING_SENDS = 256;
export interface CollabSocketOptions {
/** wss://host[:port]/r/<roomId> — no query string. */
wsUrl: string;
role: "host" | "guest";
/** Room key; a pending import promise is awaited inside the seal/open chains. */
key: CryptoKey | PromiseLike<CryptoKey>;
}
export class CollabSocket {
/** Fires after every successful (re)connect. */
onOpen?: () => void;
onFrame?: (frame: HostFrame, fromPeer: number) => void;
onControl?: (msg: RelayControlMessage) => void;
/** Fires once per terminal close (intentional, fatal code, or bad key). willReconnect=true for transient drops that will retry. */
onClose?: (reason: string, willReconnect: boolean) => void;
readonly #opts: CollabSocketOptions;
#ws: WebSocket | null = null;
#retryTimer: Timer | undefined;
#attempt = 0;
/** Terminal state: intentional close or fatal failure. Cleared by connect(). */
#closed = false;
/** Serializes seal() so frames hit the wire in send() order. */
#sendChain: Promise<void> = Promise.resolve();
/** Serializes open() so frames are delivered in arrival order. */
#recvChain: Promise<void> = Promise.resolve();
/** Envelopes sealed while disconnected, flushed on the next open. */
#pendingSends: Uint8Array<ArrayBuffer>[] = [];
constructor(opts: CollabSocketOptions) {
this.#opts = opts;
}
get isOpen(): boolean {
return this.#ws?.readyState === WebSocket.OPEN;
}
connect(): void {
if (this.#ws || this.#retryTimer) return;
this.#closed = false;
this.#attempt = 0;
this.#openSocket();
}
send(frame: GuestFrame, targetPeer = 0): void {
this.#sendChain = this.#sendChain
.then(async () => {
if (this.#closed) return;
const sealed = await seal(await this.#opts.key, frame);
const envelope = packEnvelope(targetPeer, sealed);
const ws = this.#ws;
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(envelope);
return;
}
if (this.#pendingSends.length >= MAX_PENDING_SENDS) return;
this.#pendingSends.push(envelope);
})
.catch(() => {
// dropped frame; the socket-level close path reports actionable failures
});
}
/** Intentional close: clears any retry timer, suppresses reconnect. A later connect() starts fresh. */
close(): void {
const hadActivity = this.#ws !== null || this.#retryTimer !== undefined;
this.#clearRetry();
const wasClosed = this.#closed;
this.#closed = true;
this.#pendingSends.length = 0;
const ws = this.#ws;
this.#ws = null;
if (ws) {
try {
ws.close(1000);
} catch {
// already closing/closed
}
}
if (hadActivity && !wasClosed) this.onClose?.("closed", false);
}
#openSocket(): void {
const ws = new WebSocket(`${this.#opts.wsUrl}?role=${this.#opts.role}`);
ws.binaryType = "arraybuffer";
this.#ws = ws;
ws.onopen = () => {
if (this.#ws !== ws) return;
this.#attempt = 0;
for (const envelope of this.#pendingSends) ws.send(envelope);
this.#pendingSends.length = 0;
this.onOpen?.();
};
ws.onmessage = (event: MessageEvent) => {
if (this.#ws !== ws) return;
this.#handleMessage(ws, event.data);
};
ws.onerror = () => {
// The paired close event carries the actionable state; nothing to do here.
};
ws.onclose = (event: CloseEvent) => {
if (this.#ws !== ws) return;
this.#ws = null;
this.#handleClose(event.code, event.reason);
};
}
#handleMessage(ws: WebSocket, data: unknown): void {
if (typeof data === "string") {
try {
this.onControl?.(JSON.parse(data) as RelayControlMessage);
} catch {
console.warn("collab: ignoring malformed control message");
}
return;
}
const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data instanceof Uint8Array ? data : null;
if (!bytes) {
console.warn("collab: ignoring binary message of unexpected shape");
return;
}
const envelope = unpackEnvelope(bytes);
if (!envelope) {
console.warn("collab: ignoring truncated envelope");
return;
}
this.#recvChain = this.#recvChain
.then(async () => {
if (this.#ws !== ws) return;
let frame: HostFrame;
try {
frame = (await open(await this.#opts.key, envelope.payload)) as HostFrame;
} catch {
this.#failFatal("bad key or corrupted frame");
return;
}
if (this.#ws !== ws) return;
this.onFrame?.(frame, envelope.peerId);
})
.catch(() => {
// listener threw; keep the receive chain alive
});
}
#handleClose(code: number, reason: string): void {
if (this.#closed) return;
const fatalReason = FATAL_CLOSE_REASONS[code];
if (fatalReason !== undefined) {
this.#closed = true;
this.#pendingSends.length = 0;
this.onClose?.(fatalReason, false);
return;
}
this.onClose?.(reason || `connection lost (code ${code})`, true);
this.#scheduleRetry();
}
/** Decryption failure: wrong key or corrupted frame. Never reconnect. */
#failFatal(reason: string): void {
if (this.#closed) return;
this.#closed = true;
this.#clearRetry();
this.#pendingSends.length = 0;
const ws = this.#ws;
this.#ws = null;
if (ws) {
try {
ws.close(1000);
} catch {
// already closing/closed
}
}
this.onClose?.(reason, false);
}
#scheduleRetry(): void {
const base = Math.min(BACKOFF_BASE_MS * 2 ** this.#attempt, BACKOFF_MAX_MS);
this.#attempt++;
const delay = base * (0.75 + Math.random() * 0.5);
this.#retryTimer = setTimeout(() => {
this.#retryTimer = undefined;
if (this.#closed) return;
this.#openSocket();
}, delay);
}
#clearRetry(): void {
if (this.#retryTimer !== undefined) {
clearTimeout(this.#retryTimer);
this.#retryTimer = undefined;
}
}
}
+11
View File
@@ -0,0 +1,11 @@
/** React binding for {@link GuestClient} via `useSyncExternalStore`. */
import { useSyncExternalStore } from "react";
import type { GuestClient, GuestSnapshot } from "./client";
export function useGuestSnapshot(client: GuestClient): GuestSnapshot {
return useSyncExternalStore(
listener => client.subscribe(listener),
() => client.getSnapshot(),
() => client.getSnapshot(),
);
}
+8
View File
@@ -0,0 +1,8 @@
import { createRoot } from "react-dom/client";
import { App } from "./app";
import "./styles/tokens.css";
import "./styles/base.css";
const root = document.getElementById("root");
if (!root) throw new Error("missing #root element");
createRoot(root).render(<App />);
+96
View File
@@ -0,0 +1,96 @@
/* Base reset + globals. Token vars only. */
*,
*::before,
*::after {
box-sizing: border-box;
}
* {
margin: 0;
}
html,
body,
#root {
height: 100%;
}
body {
font: 400 13px / 1.5 var(--font-ui);
background: var(--bg);
color: var(--fg);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
overflow: hidden;
}
button,
input,
textarea,
select {
font: inherit;
color: inherit;
}
button {
cursor: pointer;
}
a {
color: var(--accent);
}
code,
pre,
kbd {
font-family: var(--font-mono);
}
:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
::selection {
background: var(--accent-muted);
}
/* subtle scrollbars */
* {
scrollbar-width: thin;
scrollbar-color: var(--border-strong) transparent;
}
*::-webkit-scrollbar {
width: 8px;
height: 8px;
}
*::-webkit-scrollbar-thumb {
background: var(--border-strong);
border-radius: var(--radius);
}
*::-webkit-scrollbar-track {
background: transparent;
}
/* shared animations */
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.35;
}
}
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
+44
View File
@@ -0,0 +1,44 @@
/* Design tokens — dark-only devtool aesthetic, warm amber-gold accent.
* The ONLY file allowed to contain raw color values. */
:root {
color-scheme: dark;
/* surfaces — neutral ramp tinted by the accent hue (80) */
--bg: oklch(0.16 0.008 80);
--bg-raised: oklch(0.19 0.01 80);
--bg-inset: oklch(0.13 0.006 80);
--bg-overlay: oklch(0.22 0.012 80);
/* text */
--fg: oklch(0.92 0.012 80);
--fg-muted: oklch(0.71 0.014 80);
--fg-faint: oklch(0.53 0.012 80);
/* one accent: warm amber-gold */
--accent: oklch(0.8 0.13 80);
--accent-fg: oklch(0.2 0.05 80);
--accent-muted: oklch(0.8 0.13 80 / 18%);
/* status — used sparingly */
--ok: oklch(0.76 0.14 150);
--warn: oklch(0.79 0.14 85);
--err: oklch(0.66 0.19 25);
/* hairlines */
--border: oklch(1 0 0 / 9%);
--border-strong: oklch(1 0 0 / 13%);
--ring: oklch(0.8 0.13 80 / 70%);
/* type — system stacks only */
--font-ui: ui-sans-serif, system-ui, -apple-system, "Segoe UI", "Helvetica Neue", sans-serif;
--font-mono: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Mono", Menlo, Consolas, monospace;
/* radii */
--radius-sm: 2px;
--radius: 4px;
--radius-lg: 8px;
/* non-pinned helpers (kept here so no raw colors leak outside this file) */
--shadow-overlay: 0 12px 32px oklch(0 0 0 / 45%), 0 2px 8px oklch(0 0 0 / 30%);
--backdrop: oklch(0.1 0.005 80 / 62%);
}
+202
View File
@@ -0,0 +1,202 @@
import { describe, expect, it } from "bun:test";
import type {
AgentSnapshot,
AssistantMessage,
HostFrame,
SessionEntry,
SessionHeader,
SessionState,
SubagentProgressPayload,
WireMessage,
} from "@oh-my-pi/pi-wire";
import { GuestClient } from "../src/lib/client";
import { encodeBase64Url } from "../src/lib/link";
const LINK = `roomroomroom1234#${encodeBase64Url(new Uint8Array(32))}`;
const HEADER: SessionHeader = { type: "session", id: "s1", timestamp: "2026-06-12T00:00:00Z", cwd: "/work" };
const STATE: SessionState = {
isStreaming: false,
queuedMessageCount: 0,
cwd: "/work",
participants: [{ name: "host", role: "host" }],
};
const AGENTS: AgentSnapshot[] = [
{
id: "main",
displayName: "Main",
kind: "main",
status: "running",
hasSessionFile: true,
createdAt: 1,
lastActivity: 2,
},
];
function assistantMessage(text: string): AssistantMessage {
return {
role: "assistant",
content: [{ type: "text", text }],
model: "test/model",
usage: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0, totalTokens: 3, cost: { total: 0 } },
stopReason: "stop",
timestamp: 1,
};
}
function messageEntry(id: string, message: WireMessage): SessionEntry {
return { type: "message", id, parentId: null, timestamp: "2026-06-12T00:00:01Z", message };
}
function welcomeFrame(entries: SessionEntry[] = []): HostFrame {
return { t: "welcome", proto: 1, header: HEADER, entries, state: STATE, agents: AGENTS };
}
function liveClient(entries: SessionEntry[] = []): GuestClient {
const client = new GuestClient(LINK, "tester");
client.applyFrameForTest(welcomeFrame(entries));
return client;
}
describe("GuestClient frame apply", () => {
it("throws on an invalid link", () => {
expect(() => new GuestClient("not a link", "tester")).toThrow();
});
it("welcome populates the snapshot and goes live", () => {
const userEntry = messageEntry("e1", { role: "user", content: "hi", timestamp: 1 });
const client = liveClient([userEntry]);
const snap = client.getSnapshot();
expect(snap.phase).toBe("live");
expect(snap.header).toEqual(HEADER);
expect(snap.entries).toEqual([userEntry]);
expect(snap.state).toEqual(STATE);
expect(snap.agents).toEqual(AGENTS);
expect(snap.working).toBe(false);
expect(snap.stream).toBeNull();
expect(snap.activeTools.size).toBe(0);
});
it("message_update sets the stream ghost (synthesizing a missed start)", () => {
const client = liveClient();
const partial = assistantMessage("hel");
client.applyFrameForTest({ t: "event", event: { type: "message_update", message: partial } });
const snap = client.getSnapshot();
expect(snap.stream).toEqual(partial);
expect(snap.streamDone).toBe(false);
});
it("message_end keeps the ghost until the matching entry lands", () => {
const client = liveClient();
const message = assistantMessage("hello");
client.applyFrameForTest({ t: "event", event: { type: "message_update", message } });
client.applyFrameForTest({ t: "event", event: { type: "message_end", message } });
let snap = client.getSnapshot();
expect(snap.streamDone).toBe(true);
expect(snap.stream).toEqual(message);
client.applyFrameForTest({ t: "entry", entry: messageEntry("e2", message) });
snap = client.getSnapshot();
expect(snap.stream).toBeNull();
expect(snap.streamDone).toBe(false);
expect(snap.entries).toHaveLength(1);
});
it("tool start/update/end maintains activeTools", () => {
const client = liveClient();
client.applyFrameForTest({
t: "event",
event: {
type: "tool_execution_start",
toolCallId: "tc1",
toolName: "bash",
args: { command: "ls" },
intent: "Listing",
},
});
let tool = client.getSnapshot().activeTools.get("tc1");
expect(tool?.toolName).toBe("bash");
expect(tool?.intent).toBe("Listing");
client.applyFrameForTest({
t: "event",
event: {
type: "tool_execution_update",
toolCallId: "tc1",
toolName: "bash",
args: { command: "ls" },
partialResult: "src",
},
});
tool = client.getSnapshot().activeTools.get("tc1");
expect(tool?.partialResult).toBe("src");
client.applyFrameForTest({
t: "event",
event: { type: "tool_execution_end", toolCallId: "tc1", toolName: "bash", result: "src\ntest" },
});
expect(client.getSnapshot().activeTools.size).toBe(0);
});
it("agent_start/agent_end and state reconcile the working flag", () => {
const client = liveClient();
client.applyFrameForTest({ t: "event", event: { type: "agent_start" } });
expect(client.getSnapshot().working).toBe(true);
client.applyFrameForTest({ t: "state", state: { ...STATE, isStreaming: false } });
expect(client.getSnapshot().working).toBe(false);
});
it("bus progress frames update the progress map", () => {
const client = liveClient();
const payload: SubagentProgressPayload = {
index: 0,
agent: "task",
task: "do things",
progress: {
index: 0,
id: "Sub1",
agent: "task",
status: "running",
task: "do things",
recentTools: [],
recentOutput: [],
toolCount: 1,
requests: 1,
tokens: 100,
cost: 0.01,
durationMs: 1000,
},
};
client.applyFrameForTest({ t: "bus", channel: "task:subagent:progress", data: payload });
expect(client.getSnapshot().progress.get("Sub1")).toEqual(payload);
});
it("bye ends the session with a reason", () => {
const client = liveClient();
client.applyFrameForTest({ t: "bye", reason: "host left" });
const snap = client.getSnapshot();
expect(snap.phase).toBe("ended");
expect(snap.endedReason).toBe("host left");
});
it("error frames append notices", () => {
const client = liveClient();
client.applyFrameForTest({ t: "error", message: "boom" });
const notices = client.getSnapshot().notices;
expect(notices).toHaveLength(1);
expect(notices[0]).toMatchObject({ level: "error", message: "boom" });
});
it("snapshot reference is stable between frames and replaced per frame", () => {
const client = liveClient();
const before = client.getSnapshot();
expect(client.getSnapshot()).toBe(before);
client.applyFrameForTest({ t: "agents", agents: AGENTS });
const after = client.getSnapshot();
expect(after).not.toBe(before);
expect(after.agents).not.toBe(before.agents);
expect(after.entries).toBe(before.entries);
});
});
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from "bun:test";
import type { WireFrame } from "@oh-my-pi/pi-wire";
import { generateRoomKey, importRoomKey, open, seal } from "../src/lib/codec";
import { decodeBase64Url } from "../src/lib/link";
/** Interop vector generated with the real coding-agent `seal()` (see contract). */
const VECTOR_KEY = "AAcOFRwjKjE4P0ZNVFtiaXB3foWMk5qhqK-2vcTL0tk";
const VECTOR_SEALED = "m0PA1QNfpOGtl_iq1yfKhoux0moFN_WQtCExumBVOWKeHFY_yx7T4s3B5YFUSn6Dc9aAyVsjIjPQXLxqsg8_UQiZ9Q";
describe("collab codec", () => {
it("decrypts the coding-agent interop vector", async () => {
const keyBytes = decodeBase64Url(VECTOR_KEY);
const sealed = decodeBase64Url(VECTOR_SEALED);
if (!keyBytes || !sealed) throw new Error("vector constants must decode");
const key = await importRoomKey(keyBytes);
const frame = await open(key, sealed);
expect(frame).toEqual({ t: "hello", proto: 1, name: "vector" });
});
it("round-trips a frame through seal/open", async () => {
const key = await importRoomKey(generateRoomKey());
const frame: WireFrame = { t: "prompt", text: "hello there" };
const opened = await open(key, await seal(key, frame));
expect(opened).toEqual(frame);
});
it("rejects tampered ciphertext", async () => {
const key = await importRoomKey(generateRoomKey());
const sealed = await seal(key, { t: "abort" });
sealed[sealed.length - 1] ^= 0xff;
await expect(open(key, sealed)).rejects.toThrow();
});
});
+75
View File
@@ -0,0 +1,75 @@
import { describe, expect, it } from "bun:test";
import {
DEFAULT_RELAY_URL,
encodeBase64Url,
formatCollabLink,
generateRoomId,
packEnvelope,
parseCollabLink,
rewriteEnvelopePeer,
unpackEnvelope,
} from "../src/lib/link";
const KEY = Uint8Array.from({ length: 32 }, (_, i) => i);
const KEY_TEXT = encodeBase64Url(KEY);
const ROOM = "AbCdEf123456_-Xy";
describe("collab link parsing", () => {
it("parses a bare roomId#key link against the default relay", () => {
const parsed = parseCollabLink(`${ROOM}#${KEY_TEXT}`);
if ("error" in parsed) throw new Error(parsed.error);
expect(parsed.wsUrl).toBe(`${DEFAULT_RELAY_URL}/r/${ROOM}`);
expect(parsed.roomId).toBe(ROOM);
expect(parsed.key).toEqual(KEY);
});
it("infers wss for scheme-less custom hosts", () => {
const parsed = parseCollabLink(`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("allows plain ws:// for localhost", () => {
const parsed = parseCollabLink(`ws://localhost:7466/r/${ROOM}#${KEY_TEXT}`);
if ("error" in parsed) throw new Error(parsed.error);
expect(parsed.wsUrl).toBe(`ws://localhost:7466/r/${ROOM}`);
});
it("rejects plain ws:// for non-localhost hosts", () => {
expect("error" in parseCollabLink(`ws://relay.example.com/r/${ROOM}#${KEY_TEXT}`)).toBe(true);
});
it("rejects keys that are not 32 base64url bytes", () => {
const shortKey = encodeBase64Url(new Uint8Array(16));
expect("error" in parseCollabLink(`${ROOM}#${shortKey}`)).toBe(true);
});
it("round-trips format → parse for default, custom, and localhost relays", () => {
const roomId = generateRoomId();
for (const relay of [DEFAULT_RELAY_URL, "wss://relay.example.com:8443", "ws://127.0.0.1:7466"]) {
const link = formatCollabLink(relay, roomId, KEY);
const parsed = parseCollabLink(link);
if ("error" in parsed) throw new Error(`${relay}: ${parsed.error}`);
expect(parsed.roomId).toBe(roomId);
expect(parsed.key).toEqual(KEY);
}
});
});
describe("collab wire envelope", () => {
it("round-trips peer id and payload, rewrites in place", () => {
const payload = new Uint8Array([1, 2, 3, 4, 5]);
const envelope = packEnvelope(7, payload);
const unpacked = unpackEnvelope(envelope);
expect(unpacked?.peerId).toBe(7);
expect(unpacked?.payload).toEqual(payload);
rewriteEnvelopePeer(envelope, 42);
const rewritten = unpackEnvelope(envelope);
expect(rewritten?.peerId).toBe(42);
expect(rewritten?.payload).toEqual(payload);
});
it("returns null for truncated envelopes", () => {
expect(unpackEnvelope(new Uint8Array([0, 0]))).toBeNull();
});
});
@@ -0,0 +1,146 @@
import { afterEach, describe, expect, it } from "bun:test";
import { type LocalRelay, startLocalRelay } from "../scripts/local-relay";
import { packEnvelope, unpackEnvelope } from "../src/lib/link";
const ROOM = "RelayRoom_12345";
const REQUEST_TIMEOUT_MS = 1_000;
let relay: LocalRelay | null = null;
const sockets: WebSocket[] = [];
function relayHttpUrl(): string {
if (!relay) throw new Error("relay not started");
return relay.url.replace(/^ws:/, "http:").replace(/^wss:/, "https:");
}
function socket(path: string): WebSocket {
if (!relay) throw new Error("relay not started");
const ws = new WebSocket(`${relay.url}${path}`);
ws.binaryType = "arraybuffer";
sockets.push(ws);
return ws;
}
function waitEvent<T extends Event>(
ws: WebSocket,
type: string,
label: string,
timeoutMs = REQUEST_TIMEOUT_MS,
): Promise<T> {
const { promise, resolve, reject } = Promise.withResolvers<T>();
let timer: Timer | undefined;
const cleanup = (): void => {
ws.removeEventListener(type, onEvent);
if (timer !== undefined) clearTimeout(timer);
};
const onEvent = (event: Event): void => {
cleanup();
resolve(event as T);
};
timer = setTimeout(() => {
cleanup();
reject(new Error(`timed out waiting for ${label}`));
}, timeoutMs);
ws.addEventListener(type, onEvent);
return promise;
}
function waitOpen(ws: WebSocket): Promise<Event> {
if (ws.readyState === WebSocket.OPEN) return Promise.resolve(new Event("open"));
return waitEvent(ws, "open", "socket open");
}
async function waitText(ws: WebSocket, label: string): Promise<string> {
const event = await waitEvent<MessageEvent>(ws, "message", label);
if (typeof event.data !== "string") throw new Error(`${label} was not TEXT`);
return event.data;
}
async function waitBinary(ws: WebSocket, label: string): Promise<Uint8Array> {
const event = await waitEvent<MessageEvent>(ws, "message", label);
const data: unknown = event.data;
if (data instanceof ArrayBuffer) return new Uint8Array(data);
if (ArrayBuffer.isView(data)) return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
throw new Error(`${label} was not binary`);
}
function closeSocket(ws: WebSocket): void {
if (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN) ws.close(1000);
}
afterEach(() => {
for (const ws of sockets.splice(0)) closeSocket(ws);
relay?.stop();
relay = null;
});
describe("local collab relay", () => {
it("rejects non-relay requests and guests before a host creates the room", async () => {
relay = startLocalRelay();
const notFound = await fetch(`${relayHttpUrl()}/nope`);
expect(notFound.status).toBe(404);
const upgradeRequired = await fetch(`${relayHttpUrl()}/r/${ROOM}?role=host`);
expect(upgradeRequired.status).toBe(426);
const guest = socket(`/r/${ROOM}?role=guest`);
const close = await waitEvent<CloseEvent>(guest, "close", "missing-room guest close");
expect(close.code).toBe(4004);
expect(close.reason).toBe("no such room");
});
it("routes opaque envelopes without decrypting them", async () => {
relay = startLocalRelay();
const host = socket(`/r/${ROOM}?role=host`);
await waitOpen(host);
const guest1 = socket(`/r/${ROOM}?role=guest`);
await waitOpen(guest1);
expect(JSON.parse(await waitText(host, "first peer join"))).toEqual({ t: "peer-joined", peer: 1 });
const guest2 = socket(`/r/${ROOM}?role=guest`);
await waitOpen(guest2);
expect(JSON.parse(await waitText(host, "second peer join"))).toEqual({ t: "peer-joined", peer: 2 });
guest1.send(packEnvelope(0, new Uint8Array([1, 2, 3])));
const fromGuest = unpackEnvelope(await waitBinary(host, "guest envelope"));
expect(fromGuest?.peerId).toBe(1);
expect(fromGuest?.payload).toEqual(new Uint8Array([1, 2, 3]));
const broadcast1 = waitBinary(guest1, "broadcast to guest 1");
const broadcast2 = waitBinary(guest2, "broadcast to guest 2");
host.send(packEnvelope(0, new Uint8Array([9])));
expect(unpackEnvelope(await broadcast1)?.payload).toEqual(new Uint8Array([9]));
expect(unpackEnvelope(await broadcast2)?.payload).toEqual(new Uint8Array([9]));
const targeted = waitBinary(guest2, "targeted guest 2 frame");
host.send(packEnvelope(2, new Uint8Array([7])));
expect(unpackEnvelope(await targeted)?.payload).toEqual(new Uint8Array([7]));
const guest1Next = waitBinary(guest1, "next guest 1 broadcast");
host.send(packEnvelope(0, new Uint8Array([5])));
expect(unpackEnvelope(await guest1Next)?.payload).toEqual(new Uint8Array([5]));
});
it("enforces one host and closes guests when the room host leaves", async () => {
relay = startLocalRelay();
const host = socket(`/r/${ROOM}?role=host`);
await waitOpen(host);
const duplicateHost = socket(`/r/${ROOM}?role=host`);
const duplicateClose = await waitEvent<CloseEvent>(duplicateHost, "close", "duplicate host close");
expect(duplicateClose.code).toBe(4009);
expect(duplicateClose.reason).toBe("a host is already connected for this room");
const guest = socket(`/r/${ROOM}?role=guest`);
await waitOpen(guest);
expect(JSON.parse(await waitText(host, "peer join"))).toEqual({ t: "peer-joined", peer: 1 });
const closure = waitText(guest, "room close control");
const guestClose = waitEvent<CloseEvent>(guest, "close", "guest room close");
host.close(1000);
expect(JSON.parse(await closure)).toEqual({ t: "room-closed" });
expect((await guestClose).code).toBe(4001);
});
});
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../tsconfig.workspace.json",
"include": ["src", "scripts", "test"],
"compilerOptions": {
"jsx": "react-jsx",
"lib": ["ES2024", "DOM", "DOM.Iterable"],
"noEmit": true,
"types": ["bun", "react", "react-dom"]
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"emitDeclarationOnly": true,
"declaration": true,
"declarationMap": false,
"sourceMap": false,
"inlineSources": false,
"rootDir": ".",
"outDir": "dist/types",
"noCheck": true
},
"include": ["src", "scripts"],
"exclude": ["dist", "node_modules", "test"]
}
+7
View File
@@ -0,0 +1,7 @@
# Changelog
## [Unreleased]
### Added
- Added shared collab live-session wire contracts for the host CLI and browser guest client.
+31
View File
@@ -0,0 +1,31 @@
# @oh-my-pi/pi-wire
Shared TypeScript wire contracts for omp collab live sessions.
The package contains only JSON-safe protocol shapes and constants. It has no runtime dependencies and is consumed by both the host CLI (`@oh-my-pi/pi-coding-agent`) and browser guest (`@oh-my-pi/collab-web`).
## Exports
```ts
import type { GuestFrame, HostFrame, SessionEntry } from "@oh-my-pi/pi-wire";
import { COLLAB_PROTO, DEFAULT_RELAY_URL, ENVELOPE_HEADER_LENGTH } from "@oh-my-pi/pi-wire";
```
Key groups:
- message and transcript entry shapes rendered by collab guests,
- live agent event and task-subagent bus payload shapes,
- `GuestFrame`, `HostFrame`, and `WireFrame` unions for AES-GCM sealed payloads,
- relay control TEXT messages,
- link/envelope constants shared by host, guest, and local relay code.
## Protocol boundary
`pi-wire` does not encode, decode, validate, encrypt, or route frames. It defines the shared contract used at those boundaries:
1. callers build a `GuestFrame` or `HostFrame`,
2. transport code serializes it as JSON inside an encrypted payload,
3. relay code routes opaque envelopes using the plaintext peer-id prefix,
4. receivers switch on `frame.t` and tolerate unknown future fields.
Keep protocol changes backward-aware: bump `COLLAB_PROTO` only when old hosts and guests must reject each other.
+55
View File
@@ -0,0 +1,55 @@
{
"type": "module",
"name": "@oh-my-pi/pi-wire",
"version": "15.11.7",
"description": "Shared wire protocol types for Oh My Pi packages",
"homepage": "https://omp.sh",
"author": "Can Boluk",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/can1357/oh-my-pi.git",
"directory": "packages/wire"
},
"bugs": {
"url": "https://github.com/can1357/oh-my-pi/issues"
},
"keywords": [
"wire",
"protocol",
"types",
"collab"
],
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"check": "biome check . && bun run check:types",
"check:types": "tsgo -p tsconfig.json --noEmit",
"lint": "biome lint .",
"fix": "biome check --write --unsafe .",
"fmt": "biome format --write .",
"test": "bun test --parallel"
},
"devDependencies": {
"@types/bun": "catalog:"
},
"engines": {
"bun": ">=1.3.14"
},
"files": [
"src",
"README.md",
"CHANGELOG.md"
],
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
},
"./*": {
"types": "./src/*.ts",
"import": "./src/*.ts"
},
"./*.js": "./src/*.ts"
}
}
+360
View File
@@ -0,0 +1,360 @@
/**
* Shared wire types for the omp collab live-session protocol.
*
* Dependency-free JSON shapes produced by `@oh-my-pi/pi-coding-agent`
* (`src/collab/protocol.ts` and friends). Browser and test clients import this
* package instead of depending on the coding-agent runtime; conformance is
* asserted type-only in `packages/coding-agent/test/collab/web-wire.types.ts`.
*
* Unknown entry/event variants arrive over the wire as plain JSON. The unions
* below cover only the variants this client renders; consumers cast at the
* JSON boundary and every `switch` keeps a tolerant `default:` branch.
*/
// ═══════════════════════════════════════════════════════════════════════════
// Content blocks
// ═══════════════════════════════════════════════════════════════════════════
export interface TextContent {
type: "text";
text: string;
}
export interface ImageContent {
type: "image";
/** Base64-encoded image data. */
data: string;
/** e.g. "image/png". */
mimeType: string;
}
export interface ThinkingContent {
type: "thinking";
thinking: string;
}
export interface RedactedThinkingContent {
type: "redactedThinking";
data: string;
}
export interface ToolCallContent {
type: "toolCall";
id: string;
name: string;
arguments: Record<string, unknown>;
intent?: string;
}
export type AssistantContent = TextContent | ThinkingContent | RedactedThinkingContent | ToolCallContent;
export type StopReason = "stop" | "length" | "toolUse" | "error" | "aborted";
export interface WireUsage {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
totalTokens: number;
cost: { total: number };
}
// ═══════════════════════════════════════════════════════════════════════════
// Messages
// ═══════════════════════════════════════════════════════════════════════════
export interface UserMessage {
role: "user";
content: string | (TextContent | ImageContent)[];
/** True if the message was injected by the system (e.g. auto-continue). */
synthetic?: boolean;
/** Unix timestamp in milliseconds. */
timestamp: number;
}
export interface DeveloperMessage {
role: "developer";
content: string | (TextContent | ImageContent)[];
timestamp: number;
}
export interface AssistantMessage {
role: "assistant";
content: AssistantContent[];
model: string;
usage: WireUsage;
stopReason: StopReason;
errorMessage?: string;
timestamp: number;
}
export interface ToolResultMessage {
role: "toolResult";
toolCallId: string;
toolName: string;
content: (TextContent | ImageContent)[];
details?: unknown;
isError: boolean;
timestamp: number;
}
export type WireMessage = UserMessage | DeveloperMessage | AssistantMessage | ToolResultMessage;
// ═══════════════════════════════════════════════════════════════════════════
// Session entries (rendered subset; cast `as SessionEntry` at the JSON
// boundary and skip unknown `type`s in a tolerant `default:`)
// ═══════════════════════════════════════════════════════════════════════════
export interface SessionHeader {
type: "session";
id: string;
title?: string;
timestamp: string;
cwd: string;
}
export interface EntryBase {
id: string;
parentId: string | null;
timestamp: string;
}
export interface MessageEntry extends EntryBase {
type: "message";
message: WireMessage;
}
export interface CustomMessageEntry extends EntryBase {
type: "custom_message";
customType: string;
content: string | (TextContent | ImageContent)[];
details?: unknown;
display: boolean;
}
export interface CompactionEntry extends EntryBase {
type: "compaction";
summary: string;
shortSummary?: string;
firstKeptEntryId: string;
tokensBefore: number;
}
export interface BranchSummaryEntry extends EntryBase {
type: "branch_summary";
fromId: string;
summary: string;
}
export interface ModelChangeEntry extends EntryBase {
type: "model_change";
/** Model in "provider/modelId" format. */
model: string;
role?: string;
}
export interface ThinkingLevelChangeEntry extends EntryBase {
type: "thinking_level_change";
thinkingLevel?: string | null;
}
export type SessionEntry =
| MessageEntry
| CustomMessageEntry
| CompactionEntry
| BranchSummaryEntry
| ModelChangeEntry
| ThinkingLevelChangeEntry;
/** customType of collab guest prompts injected on the host. */
export const COLLAB_PROMPT_MESSAGE_TYPE = "collab-prompt";
/** `details` shape of `custom_message` entries with `customType === "collab-prompt"`. */
export interface CollabPromptDetails {
from?: string;
}
// ═══════════════════════════════════════════════════════════════════════════
// Events (handled subset)
// ═══════════════════════════════════════════════════════════════════════════
export type AgentEvent =
| { type: "agent_start" }
| { type: "agent_end" }
| { type: "turn_start" }
| { type: "turn_end" }
| { type: "message_start"; message: WireMessage }
/** Carries the FULL accumulating partial message — no delta tracking needed. */
| { type: "message_update"; message: WireMessage }
| { type: "message_end"; message: WireMessage }
| { type: "tool_execution_start"; toolCallId: string; toolName: string; args: unknown; intent?: string }
| { type: "tool_execution_update"; toolCallId: string; toolName: string; args: unknown; partialResult: unknown }
| { type: "tool_execution_end"; toolCallId: string; toolName: string; result: unknown; isError?: boolean }
| { type: "notice"; level: "info" | "warning" | "error"; message: string; source?: string }
| { type: "auto_compaction_start"; reason: string; action: string }
| { type: "auto_compaction_end"; aborted: boolean; willRetry: boolean; errorMessage?: string; skipped?: boolean }
| { type: "auto_retry_start"; attempt: number; maxAttempts: number; delayMs: number; errorMessage: string }
| { type: "auto_retry_end"; success: boolean; attempt: number; finalError?: string }
| { type: "thinking_level_changed"; thinkingLevel?: string };
// ═══════════════════════════════════════════════════════════════════════════
// State & agents
// ═══════════════════════════════════════════════════════════════════════════
export interface WireModel {
id: string;
name: string;
provider: string;
contextWindow: number;
}
export interface ContextUsage {
tokens: number | null;
contextWindow: number;
percent: number | null;
}
export interface Participant {
name: string;
role: "host" | "guest";
}
/** Debounced footer snapshot broadcast by the host. */
export interface SessionState {
isStreaming: boolean;
queuedMessageCount: number;
sessionName?: string;
/** Host cwd — display only; the guest never chdirs. */
cwd: string;
model?: WireModel;
thinkingLevel?: string;
contextUsage?: ContextUsage;
participants: Participant[];
}
export interface AgentSnapshot {
id: string;
displayName: string;
kind: "main" | "sub";
parentId?: string;
status: "running" | "idle" | "parked" | "aborted";
/** Whether the host has a transcript file for this agent (gates remote transcript fetch). */
hasSessionFile: boolean;
createdAt: number;
lastActivity: number;
}
// ═══════════════════════════════════════════════════════════════════════════
// Bus payloads (task subagent lifecycle/progress channels)
// ═══════════════════════════════════════════════════════════════════════════
export interface AgentProgress {
index: number;
id: string;
agent: string;
status: "pending" | "running" | "completed" | "failed" | "aborted";
task: string;
description?: string;
lastIntent?: string;
currentTool?: string;
currentToolArgs?: string;
currentToolStartMs?: number;
recentTools: { tool: string; args: string; endMs: number }[];
recentOutput: string[];
toolCount: number;
requests: number;
tokens: number;
contextTokens?: number;
contextWindow?: number;
cost: number;
durationMs: number;
resolvedModel?: string;
}
export interface SubagentProgressPayload {
index: number;
agent: string;
task: string;
parentToolCallId?: string;
assignment?: string;
progress: AgentProgress;
sessionFile?: string;
}
export interface SubagentLifecyclePayload {
id: string;
agent: string;
description?: string;
status: "started" | "completed" | "failed" | "aborted";
sessionFile?: string;
parentToolCallId?: string;
index: number;
}
// ═══════════════════════════════════════════════════════════════════════════
// Frames (JSON inside the AES-GCM seal)
// ═══════════════════════════════════════════════════════════════════════════
export type GuestFrame =
| { t: "hello"; proto: number; name: string }
| { t: "prompt"; text: string; images?: ImageContent[] }
| { t: "abort" }
| { t: "agent-cmd"; cmd: "chat" | "kill" | "revive"; agentId: string; text?: string }
| { t: "fetch-transcript"; reqId: number; agentId: string; fromByte: number };
/** EventBus channels mirrored to guests (task subagent traffic only). */
export type BusChannel = "task:subagent:progress" | "task:subagent:lifecycle";
export type HostFrame =
| {
t: "welcome";
proto: number;
header: SessionHeader;
entries: SessionEntry[];
state: SessionState;
agents: AgentSnapshot[];
}
| { t: "entry"; entry: SessionEntry }
| { t: "event"; event: AgentEvent }
| { t: "state"; state: SessionState }
/** Mirrored EventBus traffic (task subagent lifecycle/progress channels only). */
| { t: "bus"; channel: BusChannel; data: unknown }
| { t: "agents"; agents: AgentSnapshot[] }
/** Targeted reply to fetch-transcript; `text` is decoded JSONL from `fromByte`, `newSize` the next offset base. */
| { t: "transcript"; reqId: number; text: string; newSize: number; error?: string }
| { t: "bye"; reason: string }
| { t: "error"; message: string };
export type WireFrame = GuestFrame | HostFrame;
/** Wire protocol version carried in `hello`; the host rejects mismatches. */
export const COLLAB_PROTO = 1;
// ═══════════════════════════════════════════════════════════════════════════
// Envelope & link constants
// ═══════════════════════════════════════════════════════════════════════════
/** Plaintext envelope prefix: `[4B uint32 BE peerId][sealed payload]`. */
export const ENVELOPE_HEADER_LENGTH = 4;
export const ROOM_ID_BYTES = 16;
/** Default public relay; bare `<roomId>#<key>` links resolve against it. */
export const DEFAULT_RELAY_URL = "wss://relay.omp.sh";
export interface ParsedCollabLink {
/** wss://host[:port]/r/<roomId> — no query, no fragment. */
wsUrl: string;
roomId: string;
key: Uint8Array;
}
// ═══════════════════════════════════════════════════════════════════════════
// Relay control messages (TEXT JSON, unencrypted, no session data)
// ═══════════════════════════════════════════════════════════════════════════
/** Relay → host control message. */
export type RelayControlToHost = { t: "peer-joined" | "peer-left"; peer: number };
/** Relay → guest control message. */
export type RelayControlToGuest = { t: "room-closed" };
export type RelayControlMessage = RelayControlToHost | RelayControlToGuest;
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it } from "bun:test";
import {
COLLAB_PROMPT_MESSAGE_TYPE,
COLLAB_PROTO,
DEFAULT_RELAY_URL,
ENVELOPE_HEADER_LENGTH,
ROOM_ID_BYTES,
} from "../src";
describe("collab wire constants", () => {
it("exports the protocol constants consumed by host, guest, and relay links", () => {
expect(COLLAB_PROTO).toBe(1);
expect(COLLAB_PROMPT_MESSAGE_TYPE).toBe("collab-prompt");
expect(ENVELOPE_HEADER_LENGTH).toBe(4);
expect(ROOM_ID_BYTES).toBe(16);
expect(DEFAULT_RELAY_URL).toBe("wss://relay.omp.sh");
});
});
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "../tsconfig.workspace.json",
"include": ["src", "test"]
}
+16
View File
@@ -0,0 +1,16 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"emitDeclarationOnly": true,
"declaration": true,
"declarationMap": false,
"sourceMap": false,
"inlineSources": false,
"rootDir": "src",
"outDir": "dist/types",
"noCheck": true
},
"include": ["src"],
"exclude": ["dist", "node_modules", "test"]
}
+1
View File
@@ -85,6 +85,7 @@ function nativeLeafTagFromArgs(argv: readonly string[]): string | null {
const nativeLeafTag = nativeLeafTagFromArgs(process.argv.slice(2));
export const packages: PublishPackage[] = [
{ dir: "packages/utils", kind: "typescript" },
{ dir: "packages/wire", kind: "typescript" },
{ dir: "packages/catalog", kind: "typescript" },
{ dir: "packages/ai", kind: "typescript" },
{ dir: "packages/natives", kind: "native" },
+19 -5
View File
@@ -91,8 +91,9 @@ cp "$natives_pkg_backup" "$ROOT_DIR/packages/natives/package.json"
[ "$core_rc" -eq 0 ] || exit "$core_rc"
# 3. Pack the remaining workspace packages (natives core and coding-agent
# handled separately).
for pkg in utils hashline catalog ai mnemopi snapcompact agent tui stats; do
# handled separately). `collab-web` is private but still packed here so its
# prepack build and tarball file list stay release-safe.
for pkg in utils wire hashline catalog ai mnemopi snapcompact agent tui stats collab-web; do
(
cd "$ROOT_DIR/packages/$pkg"
bun pm pack --destination "$TARBALL_DIR" --quiet >/dev/null
@@ -116,6 +117,7 @@ cp "$agent_pkg_backup" "$ROOT_DIR/packages/coding-agent/package.json"
[ "$agent_rc" -eq 0 ] || exit "$agent_rc"
utils_tgz="$(find_tarball "$TARBALL_DIR"/oh-my-pi-pi-utils-*.tgz)"
wire_tgz="$(find_tarball "$TARBALL_DIR"/oh-my-pi-pi-wire-*.tgz)"
natives_tgz="$(find_tarball "$TARBALL_DIR"/oh-my-pi-pi-natives-[0-9]*.tgz)"
natives_leaf_tgz="$(find_tarball "$TARBALL_DIR"/oh-my-pi-pi-natives-"$host_tag"-*.tgz)"
hashline_tgz="$(find_tarball "$TARBALL_DIR"/oh-my-pi-hashline-*.tgz)"
@@ -127,6 +129,7 @@ agent_tgz="$(find_tarball "$TARBALL_DIR"/oh-my-pi-pi-agent-core-*.tgz)"
tui_tgz="$(find_tarball "$TARBALL_DIR"/oh-my-pi-pi-tui-*.tgz)"
stats_tgz="$(find_tarball "$TARBALL_DIR"/oh-my-pi-omp-stats-*.tgz)"
coding_agent_tgz="$(find_tarball "$TARBALL_DIR"/oh-my-pi-pi-coding-agent-*.tgz)"
collab_web_tgz="$(find_tarball "$TARBALL_DIR"/oh-my-pi-collab-web-*.tgz)"
TARBALL_APP_DIR="$WORK_DIR/tarball-install"
mkdir -p "$TARBALL_APP_DIR"
@@ -135,11 +138,12 @@ mkdir -p "$TARBALL_APP_DIR"
bun init -y >/dev/null
# Write overrides so bun resolves inter-package deps from tarballs, not the registry
# (version 12.x.y hasn't been published yet when CI runs pre-release)
# (the version under test has not necessarily been published yet).
node -e "
const pkg = JSON.parse(require('fs').readFileSync('package.json', 'utf8'));
pkg.overrides = {
'@oh-my-pi/pi-utils': '$utils_tgz',
'@oh-my-pi/pi-wire': '$wire_tgz',
'@oh-my-pi/pi-natives': '$natives_tgz',
'@oh-my-pi/pi-natives-$host_tag': '$natives_leaf_tgz',
'@oh-my-pi/hashline': '$hashline_tgz',
@@ -150,12 +154,13 @@ mkdir -p "$TARBALL_APP_DIR"
'@oh-my-pi/pi-agent-core': '$agent_tgz',
'@oh-my-pi/pi-tui': '$tui_tgz',
'@oh-my-pi/omp-stats': '$stats_tgz',
'@oh-my-pi/pi-coding-agent': '$coding_agent_tgz'
'@oh-my-pi/pi-coding-agent': '$coding_agent_tgz',
'@oh-my-pi/collab-web': '$collab_web_tgz'
};
require('fs').writeFileSync('package.json', JSON.stringify(pkg, null, 2));
"
bun add "$utils_tgz" "$natives_tgz" "$hashline_tgz" "$catalog_tgz" "$ai_tgz" "$mnemopi_tgz" "$snapcompact_tgz" "$agent_tgz" "$tui_tgz" "$stats_tgz" "$coding_agent_tgz"
bun add "$utils_tgz" "$wire_tgz" "$natives_tgz" "$hashline_tgz" "$catalog_tgz" "$ai_tgz" "$mnemopi_tgz" "$snapcompact_tgz" "$agent_tgz" "$tui_tgz" "$stats_tgz" "$coding_agent_tgz" "$collab_web_tgz"
# The platform leaf must arrive through the core's optionalDependencies +
# override, not as a direct dependency — assert it landed before smoking so a
# resolution regression is distinguishable from a runtime loader bug.
@@ -164,6 +169,15 @@ mkdir -p "$TARBALL_APP_DIR"
echo "Platform leaf package not installed: $leaf_dir"
exit 1
}
wire_proto="$(bun -e 'import { COLLAB_PROTO } from "@oh-my-pi/pi-wire"; process.stdout.write(String(COLLAB_PROTO));')"
[ "$wire_proto" = "1" ] || {
echo "Unexpected @oh-my-pi/pi-wire COLLAB_PROTO: $wire_proto"
exit 1
}
[ -f "node_modules/@oh-my-pi/collab-web/dist/index.html" ] || {
echo "Collab web tarball did not install built dist/index.html"
exit 1
}
smoke_cli ./node_modules/.bin/omp
)