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:
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
@@ -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`.
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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 },
|
||||
];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
declare module "*.css";
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 "";
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
@@ -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 />);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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%);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Added shared collab live-session wire contracts for the host CLI and browser guest client.
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../tsconfig.workspace.json",
|
||||
"include": ["src", "test"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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" },
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user