diff --git a/.gitignore b/.gitignore index b02b27bec..74c509945 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ dist .DS_Store pw.txt __pycache__/ +integrations/pi-pocket/office-v2-client/world/ +integrations/pi-pocket/office-v2-client/office-document.json diff --git a/README.md b/README.md index fac9441a5..43f63e2e5 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,18 @@ curl -fsSL https://raw.githubusercontent.com/AgentSystemLabs/agent-office/main/i There's a lot more (a rooftop bar, an office dog, an arcade, supercars in the garage to drive round a scenic loop past a farm, pines, mountains and a beach): see [docs/features.md](docs/features.md). +## Office v2 with the Pi Pocket runtime + +A separate extension runs the **desktop 3D office in Pi Pocket**, with a root chat per floor, a master desk, and real linked-session workers at regular desks instead of CLI workers. Its fullscreen host also contains native Pocket worker conversations, so messages, tools, approvals, and follow-ups do not require leaving the office. The Agent Office Node backend does not run in this mode. + +**This redesign requires Pocket's experimental conversation-list and conversation-view SDK additions; stock 0.19.0 is not sufficient.** The office remains an extension, but those generic mechanisms require a separately reviewed Pocket update. + +Build with `PI_POCKET_ROOT=/path/to/updated/pi-pocket npm run build:pocket`. Test against that same updated checkout using isolated data and a scripted model. Review the core and extension changes before installing either. No credentials are copied; the required core SDK update needs an owner-managed restart. + +The elevator switches authorized chat floors without moving their agents or teammates. Office fills the computer window, retains its original desktop HUD, and requires at least 1024 × 600. It never switches to an iPhone-style layout. Desktop dialogs stay within the window with reachable close controls. Worker inspection uses core-owned native chat rather than copied streams or private APIs, and never automatically sends a draft. GitHub is intentionally excluded for now. Legacy shell, worktree, voice, game-simulation, and automation workflows require their own adapters; the scene alone does not imply feature parity. The complete fullscreen workflow must pass end-to-end acceptance before this redesign replaces a live installation. + +See [Pi Pocket integration](docs/pi-pocket.md) for architecture, installation, permissions, recovery, and limitations. The ordinary CLI setup below is a different runtime; do not start it for Pocket mode. + ## Requirements On the machine that runs the office: diff --git a/docs/pi-pocket.md b/docs/pi-pocket.md new file mode 100644 index 000000000..a8cddeeaa --- /dev/null +++ b/docs/pi-pocket.md @@ -0,0 +1,136 @@ +# Office v2: chat-backed 3D floors in Pi Pocket + +Back to the [README](../README.md). + +Office v2 is a Pocket extension. Its primary interface is the original **desktop** 3D office. Its own page fills the window and can also enter browser fullscreen. It does not use a phone-style extension panel, bottom sheet, or mobile fallback. A computer window of at least 1024 × 600 is required. There is no Office Node backend, PTY service, provider CLI, separate login, or credential copy. + +**This redesign requires the experimental Pocket conversation SDK additions ([Pocket PR #129](https://github.com/TannerMidd/pi-pocket/pull/129)). Stock Pocket 0.19.0 is not sufficient.** The extension checks capabilities rather than pretending that a larger window provides missing runtime APIs. Do not replace a live installation until the Pocket SDK update and the complete extension workflow have passed isolated tests. + +## Room model + +- Each ordinary root Pocket chat is a floor. Its title is the floor's name. +- The elevator lists authorized, unarchived root chats. Search and refresh stay in the 3D elevator. +- The current floor's root chat is its **master**, represented separately from hired children at a raised desk. +- Regular desks bind real child Pocket conversations to that root chat. +- Switching floors changes your room. It does not move another person or move/delete/restart agents. +- Teammates who choose the same chat floor share its presence, desks, and office chat. +- A child's model, thinking, tools, instructions, working folder, and team budget inherit through Pocket's linked-session mechanism. +- A child does **not** inherit the root transcript or another child's context. Shared files and explicit team messages are not automatic shared memory. + +GitHub and repository-per-floor workflows are outside this redesign's scope. A floor is a chat, not a repository clone. + +## Fullscreen and worker inspection + +The fullscreen element belongs to the extension and contains both: + +1. the trusted Office world iframe; +2. the worker-inspector overlay, rendered with public `pocket.Conversation`. + +Fullscreen does not apply to the world iframe alone. Otherwise native chat would appear outside the fullscreen element and become unreachable. + +On detected software WebGL, Office disables multisample antialiasing, shadows, and outlines, lowers pixel resolution, and draws at most 5 frames per second. It budgets idle time after submission and pauses expensive drawing behind dialogs. Simulation, network synchronization, and real session activity continue. Normal hardware keeps the original rendering settings. This fallback is not a phone layout or a substitute renderer. + +Clicking a worker or master first refreshes its authorized binding through the extension route. The inspector then embeds the **native Pocket conversation**, with its own document, store, connection, and conversation ID. It does not navigate or retarget the parent chat. Messages, thinking, tools, approvals, the human composer, and stopping work remain core-owned controls. Drafts are never automatically sent. Opening a worker never approves a tool, takes the turn, changes a model, or starts new work. + +Closing the inspector disposes its embedded connection and restores the world's input lifecycle. A parent-to-world control message pauses movement and clears held keys while the inspector is open. Its source window, origin, channel, and boolean payload are checked. + +## Core mechanism versus extension workflow + +```text +Office v2 extension page / fullscreen host + ├─ trusted Office 3D iframe + │ └─ bounded origin/window/channel-checked bridge + ├─ native Pocket conversation component in the inspector + │ └─ core-owned transcript, composer, tools, approvals, authorization + └─ public SDK + module-owned routes + ├─ conversations.list → authorized root chat floors + ├─ ephemeral per-floor presence and avatar events + ├─ durable source-scoped desk bindings and office chat + └─ linkedSessions.start/list → real Pocket children +``` + +The reusable mechanisms belong to Pocket core: + +- `WebRequest.conversations.list({ rootsOnly, includeArchived })` returns current actor-authorized metadata. It requires no model request or durable write. +- `pocket.Conversation({ conversationId, draft?, title?, onClose? })` renders a scoped native conversation. Each embed remains independent of the parent's selection. + +Both additions are experimental for unreleased Pocket 0.20.0. They require an explicit Pocket update and, for server changes, an owner-managed restart. The workflow, 3D assets, floor selection, master desk, worker desks, and presentation stay in the extension. + +Production extension code uses only public SDK methods, its own routes, and its own packaged assets. It does not import Pocket internals, read core documents, call private HTTP APIs, copy sign-ins, or bypass approvals. Tests can use Pocket's internal helpers only in isolated temporary data. + +## Identity and bootstrap + +The parent establishes the signed-in Pocket person, account-owned avatar preferences, authorized root chat list, and authorized source snapshot **before** starting the world. It injects `{ person, profile, snapshot, floors }`; the source must occur in that floor list. + +Names always come from Pocket. Defaults are deterministic per account. The server stores avatar preferences by the authenticated person, not in the standalone `agent-office.profile` browser key. Customization requires no source turn or model work. Its overlay is inside the fullscreen host. + +The Pocket branch skips standalone login, character selection, saved standalone position, repository/floor creation, and dog-loading prerequisites. Its readiness uses a source welcome and first rendered frame. Audio defaults to muted and notifications to off. Per-user browser preferences use `x.office-v2..*` keys. A remembered floor grants no authority: access is checked again. + +The iframe is **trusted same-origin code, not a security sandbox**. Review the complete bundle before enabling it. Bridge channels bind messages to a frame; server authorization remains independent. Legacy Office API calls fail locally rather than falling back to a CLI backend. + +## Presence and safety + +Presence is a 15-second ephemeral lease, separate from durable sessions and desks. Movement is coalesced and polled, not an Office WebSocket. Account profiles remain authoritative. + +Avatar `act`, emotes, seating, and activity/reading presence use bounded ephemeral state/events. A fieldless `act` is a one-shot reach. It is not an unsupported worker command. Events have sequence numbers, finite retention, and replay suppression. Seat conflicts produce private refusals. + +Do not imply full legacy feature parity from the scene alone. GitHub is intentionally excluded. Voice/RTC, issue-card carrying, shared game simulation, shell workers, CLI provider settings, Office-managed worktrees, and automation without an implemented Pocket adapter remain unverified or unavailable. UI controls must not advertise a successful operation that the runtime did not perform. + +Idle is not task success. Unknown activity/model/cost is not invented. Exact worker activity comes from the native Pocket inspector. Actor and audience checks filter bindings on every fresh request. + +## Build + +Use a Pocket checkout containing the experimental SDK additions: + +```sh +npm ci --include=dev --ignore-scripts +npm run typecheck +node --import tsx --import=#tests/css --test --test-concurrency=4 tests/*.test.ts +npm run build +PI_POCKET_ROOT=/path/to/updated/pi-pocket npm run build:pocket +``` + +`build:pocket` packages assets under `integrations/pi-pocket/office-v2-client/world/` and writes `office-document.json`. It copies public types from `PI_POCKET_ROOT` (default `/opt/pi-pocket`). These are build-tool paths, not production imports. Never package credentials or temporary test data. + +Local GLB files receive `.glb.png` suffixes for Pocket's extension asset allowlist; their binary data is unchanged. Assets are local, not CDN dependencies, and each must fit Pocket's 8 MiB limit. The complete client folder and `office-v2.ts` form the deployment. + +## Isolated verification + +```sh +PI_POCKET_ROOT=/path/to/updated/pi-pocket npm run test:pocket +PI_POCKET_ROOT=/path/to/updated/pi-pocket node integrations/pi-pocket/preview.mjs +node integrations/pi-pocket/responsive-check.mjs +PI_POCKET_ROOT=/path/to/updated/pi-pocket node integrations/pi-pocket/browser-check.mjs +``` + +The preview and fullscreen acceptance harness use temporary Pocket helpers and a scripted model, never live conversations or subscriptions. The acceptance harness starts and stops its own preview. Its tool probe runs only `printf` in the temporary folder; its approval probe exercises the native approval UI in isolated data. It prints isolated sign-in links on `127.0.0.2`. Do not publish those links. Follow its printed stop command when finished. + +The layout fixture opens the real desktop Office DOM and styles without a renderer or backend. It checks dialogs, close targets, HUD, menu, and overflow at 1280×800, 1440×900, 1024×768, and 1920×1080. It verifies layout, not WebGL rendering. Small windows show a desktop-size requirement instead of switching to an iPhone interface. + +End-to-end acceptance must separately verify the real 3D world, two users, elevator switching between two chats, the master desk, child hiring, live inspector activity, human follow-ups, approvals, correct source/target isolation, reconnect/recovery, and desktop screenshots. The native inspector must also retain a computer-width viewport. Do not report readiness from a layout fixture alone. + +### Verified desktop browser workflow + +The complete `browser-check.mjs` run has passed in isolated data with real software WebGL and a scripted model: + +- Two authenticated users, real WASD movement, required-task rejection, and exactly one linked child at a shared desk. +- Native child chat, human follow-up, an unsent review draft, explicit Send, and a real isolated `bash`/`printf` result. +- The native Allow approval, unchanged parent URL, and a continuously fullscreen host. +- Master-chat isolation and elevator switching without transferring workers or teammates. +- Account-owned appearance in a fresh browser, desktop inspector bounds, small-window rejection, and Escape cleanup. +- No browser exceptions and no Office WebSocket backend. + +Screenshots are written to `dist/screenshots/office-v2-*.png`. The CPU-only WebGL run can take several minutes. This acceptance result does not install the extension, update live Pocket, or replace the owner-approved live smoke check. Receipt recovery and authorization edge cases also have integration coverage. + +## Install and recovery + +1. Review the Pocket SDK and extension changes separately. Test both together in isolated data. +2. Apply the Pocket update only with owner approval. The owner performs the required server restart through Pocket; an agent must not restart while its tool call runs. +3. With approval, install the complete extension package as regular files in the data `extensions/` folder. Keep the existing Office extension separate. +4. The owner enables Office v2 through **Menu → Extensions**. +5. Open a root chat and choose its model. Open Office v2 from the rail, then **Fullscreen**. +6. Verify one small task and its native approval path before inviting collaborators. + +A failed bridge never falls back to a CLI worker. Releasing a desk does not stop or delete its conversation. A pending hire may already have admitted model work: recover its original actor-bound receipt rather than starting a new task. Public receipt retention is finite. Cancellation after admission does not undo work. + +Disable the extension through Pocket's Extensions UI. If browser code fails, use `?noext=1` and disable it. Disabling the extension does not delete its linked conversations. diff --git a/integrations/pi-pocket/_pocket.d.ts b/integrations/pi-pocket/_pocket.d.ts new file mode 100644 index 000000000..05d2072fb --- /dev/null +++ b/integrations/pi-pocket/_pocket.d.ts @@ -0,0 +1,1471 @@ +/** + * Pi Pocket's public extension API: what a module gets on the server (`Host`, `Web`, `CommandContext`), and what its + * browser module gets in the app (`Pocket`). + * + * Import these types with `import type`. Node erases that import, so it has no effect when the module runs. Do not use + * `import { type … }`: the gallery's check reads it as an import that runs. + * + * ```ts + * import type { Host, Web } from "./_pocket.d.ts"; + * ``` + * + * `@since` gives the first Pi Pocket release that has a member. `@experimental` marks a member that can change in any + * release. A member without it is stable. The reference is in `docs/extensions/reference/`. `test/public-types.ts` + * fails when this file and Pi Pocket's code do not agree. + */ +import type { Context } from "@earendil-works/chord"; +import type { Api, Model, ModelThinkingLevel } from "@earendil-works/pi-ai"; +import type { Skill } from "@earendil-works/pi-coding-agent"; +import type { + ConversationId, + DocumentReader, + Extension, + ModelRef, + Tx, +} from "@earendil-works/pi-durable"; +import type { ComponentType, Fragment, FunctionComponent, h, VNode } from "preact"; +import type { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useReducer, + useRef, + useState, +} from "preact/hooks"; + +// ─── Common ────────────────────────────────────────────────────────────────── + +/** + * A person's role in Pi Pocket. + * @since 0.14.0 + */ +export type Role = "owner" | "guest" | "viewer"; + +/** + * A person: who sent a request, ran a command, or uses a tab. + * @since 0.14.0 + */ +export interface Person { + /** + * The person's id. + * @since 0.14.0 + */ + readonly id: string; + /** + * The person's name, as the app shows it. + * @since 0.14.0 + */ + readonly name: string; + /** + * The person's role. + * @since 0.14.0 + */ + readonly role: Role; +} + +/** + * How important a notice is. + * @since 0.1.0 + */ +export type NoticeLevel = "info" | "warning" | "error"; + +// ─── The module ────────────────────────────────────────────────────────────── + +/** + * A module: one `.ts` file in the drop-in folder (or `src/server/extensions/`) that default-exports a setup function. + * @since 0.1.0 + */ +export interface Module { + /** + * Sets up the module. Returns its extensions: one, a list, or nothing when it only adds to the app. It can be async. + * @since 0.1.0 + */ + default( + host: Host, + web: Web, + ): Extension | readonly Extension[] | void | Promise; + /** + * The version of the extension API that the module needs. An older Pi Pocket does not set it up. + * @since 0.14.0 + */ + pocketApi?: number; +} + +// ─── The host ──────────────────────────────────────────────────────────────── + +/** + * What the app gives a module: the first argument of its default export. The host stays the same while modules reload. + * It changes only when the server restarts. + * @since 0.1.0 + */ +export interface Host { + /** + * Pi Pocket's data folder. + * @since 0.1.0 + */ + readonly dataDir: string; + /** + * The running server's Pi Pocket package version, not the extension API level. + * @since 0.16.0 + */ + readonly pocketVersion: string; + /** + * Local metadata for an exact provider/modelId, without network or sign-in information. + * @since 0.16.0 + */ + modelInfo( + spec: string, + ): { model: ModelRef; name: string; thinkingLevels: ModelThinkingLevel[] } | undefined; + /** + * Pi's own folder, usually `~/.pi/agent`. + * @since 0.1.0 + */ + readonly agentDir: string; + /** + * The extra skill folders in Pi's settings. + * @since 0.1.0 + */ + skillPaths(): string[]; + /** + * Pi's skills for a session that works in `cwd`, from all the places where Pi looks. + * @since 0.12.0 + */ + skills(cwd: string): Skill[]; + /** + * Finds a model by `provider/modelId` (or a model id alone) that is signed in and whose sign-in works. Throws an + * error when there is none. The error names no other models. + * @since 0.1.0 + */ + resolveModel(spec: string): ModelRef; + /** + * The models that the owner lets subagents use, as `provider/modelId`. Empty by default. + * @since 0.13.1 + */ + subagentModels(): readonly string[]; + /** + * Why Pi cannot use a model now (not known, not signed in, or the sign-in expired), or undefined when it can. With + * `fresh`, it asks the provider again first. + * @since 0.13.1 + */ + modelProblem(ref: ModelRef, fresh?: boolean): Promise; + /** + * Shapes the request every model call sends: `shape` returns a replacement payload, or undefined to keep it. It runs + * after Pi and the caller shaped the request, closest to the wire. The return value stops the shaping. + * @since 0.18.0 + * @experimental + */ + shapeModelRequests( + shape: (payload: unknown, model: Model) => unknown | undefined, + ): () => void; + /** + * The id of the person that Pi works for in a session now: the person whose message started its current work. + * @since 0.3.0 + */ + requesterOf(conversationId: ConversationId): string | undefined; + /** + * Reports something to the server log and to the app. With `conversationId` (since 0.13.1), only that session's + * tabs show it. + * @since 0.1.0 + */ + notice(level: NoticeLevel, message: string, conversationId?: ConversationId): void; + /** + * Why Pi cannot start work in a session when nobody asked for it (a spend limit), or undefined when it can. + * @since 0.12.0 + */ + heldBack(conversationId: ConversationId): string | undefined; + /** + * Calls `listener` when the owner changes a spend limit. Returns a function that stops it. + * @since 0.12.0 + */ + onLimitsChanged(listener: () => void): () => void; + /** + * Sessions that a session starts and sends messages to, as Agent Teams does. + * @since 0.14.0 + * @experimental + */ + readonly linkedSessions: LinkedSessions; + /** + * Makes text from other places safe for Pi to read. + * @since 0.14.0 + */ + readonly text: SafeText; + /** + * The thinking levels, lowest first, with the names that models and sessions use. + * @since 0.14.0 + */ + readonly thinkingLevels: readonly ModelThinkingLevel[]; +} + +/** + * Makes text from other places safe for Pi to read. Tell Pi in the same place that the text is data. + * @since 0.14.0 + */ +export interface SafeText { + /** + * The text as one line in quotes that it cannot close, cut to `max` characters, without invisible characters or + * tags. + * @since 0.14.0 + */ + quoted(text: string, max?: number): string; + /** + * The text with its lines, but with no closing tag left in it: for text between tags of your own. + * @since 0.14.0 + */ + defang(text: string): string; +} + +/** + * Sessions that a session starts and sends messages to. Each call acts as the person that Pi works for in `source`, + * with that person's checks. A call again with the same `key` acts once, and returns what the first call returned. + * @since 0.14.0 + * @experimental + */ +export interface LinkedSessions { + /** + * Starts a session linked to `source`, in `cwd` or the source's folder, and sends it `message` as its first + * message. `model` is `provider/modelId`; `thinking` is a level no higher than the source's. `cwd` is since 0.19.0: + * an older Pi Pocket ignores it, and starts the session in the source's folder. A blank `cwd` counts as none; a + * relative one resolves against the server process's folder, not the source's. + * @since 0.14.0 + * @experimental + */ + start( + source: ConversationId, + request: { + title?: string; + message: string; + model?: string; + thinking?: string; + cwd?: string; + }, + key: string, + ): Promise<{ session: ConversationId; address: string }>; + /** + * Sends `message` to the linked session at the address `to`: as a follow-up, or with `steer` at its next pause. + * @since 0.14.0 + * @experimental + */ + send( + source: ConversationId, + request: { to: string; message: string; steer?: boolean }, + key: string, + ): Promise; + /** + * The sessions linked to `source`, at any distance, that the person Pi works for there can see. + * @since 0.14.0 + * @experimental + */ + list(source: ConversationId): Promise; +} + +/** + * A linked session, as `list` gives it to the session that asks. + * @since 0.14.0 + * @experimental + */ +export interface LinkedSession { + /** + * The asking session's address for it. It is never the session's id. + * @since 0.14.0 + * @experimental + */ + address: string; + /** + * Its title: only when everyone who sees the asking session also sees this one. + * @since 0.14.0 + * @experimental + */ + title?: string; + /** + * Its folder, with the same condition as `title`. + * @since 0.14.0 + * @experimental + */ + cwd?: string; + /** + * Whether Pi works in it now, with the same condition as `title`. + * @since 0.14.0 + * @experimental + */ + busy?: boolean; + /** + * Whether it is archived, with the same condition as `title`. + * @since 0.14.0 + * @experimental + */ + archived?: boolean; +} + +/** + * A human-facing linked session that the authenticated person can see. + * @since 0.16.0 + */ +export interface HumanLinkedSession extends LinkedSession { + /** + * Its id, for pocket.openConversation. Other metadata keeps LinkedSession's audience rules. + * @since 0.16.0 + */ + conversationId: ConversationId; +} + +/** + * Linked sessions bound to the authenticated person and source of one route request. + * @since 0.16.0 + */ +export interface HumanLinkedSessions { + /** + * Starts once for this key, as the clicking person. Optional `cwd` (since 0.19.0) selects an existing folder: blank + * counts as none, and a relative one resolves against the server process's folder. + * Explicit thinking must be supported and no higher than source. + * @since 0.16.0 + */ + start( + request: { + title?: string; + message: string; + model?: string; + thinking?: ModelThinkingLevel; + cwd?: string; + }, + key: string, + ): Promise<{ session: ConversationId; address: string }>; + /** + * Visible linked sessions, with ids for human navigation. Valid only during this request. + * @since 0.16.0 + */ + list(): Promise; +} + +// ─── web: the module's part of the app ─────────────────────────────────────── + +/** + * Who can use a route or a browser module: the owner alone, people who can steer every session, or people who can see + * every session. People invited to one session never can. + * @since 0.14.0 + */ +export type WebAccess = "owner" | "steer" | "view"; + +/** + * The HTTP methods of routes. + * @since 0.14.0 + */ +export type WebMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + +/** + * Minimal metadata for a conversation visible to the requesting person. + * @since 0.20.0 + * @experimental + */ +export interface ConversationSummary { + conversationId: number; + title: string; + cwd: string; + busy: boolean; + archived: boolean; + linkedFrom?: number; + linkedOrigin?: true; + parentId?: number; +} + +/** + * Read-only conversation discovery, bound to the current person and request lifetime. + * @since 0.20.0 + * @experimental + */ +export interface RequestConversations { + list(options?: { + rootsOnly?: boolean; + includeArchived?: boolean; + }): Promise; +} + +/** + * A request to a route, as its handler gets it. + * @since 0.14.0 + */ +export interface WebRequest { + /** + * The request's method. + * @since 0.14.0 + */ + readonly method: WebMethod; + /** + * The values of the path's `:name` parts, by name. + * @since 0.14.0 + */ + readonly params: Readonly>; + /** + * The query string. + * @since 0.14.0 + */ + readonly query: URLSearchParams; + /** + * The JSON object that the browser sent. It is empty for GET and DELETE. + * @since 0.14.0 + */ + readonly body: Readonly>; + /** + * The person who sent the request. + * @since 0.14.0 + */ + readonly user: Person; + /** + * Aborts when the browser stops waiting for the answer. + * @since 0.14.0 + */ + readonly signal: AbortSignal; + /** + * Fresh committed document reads; use this request's context. + * @since 0.16.0 + */ + readonly snapshot: DocumentReader["snapshot"]; + /** + * Request-scoped context, cancelled on disconnect or handler return. + * @since 0.16.0 + */ + readonly context: Context; + /** + * Read-only authorized catalogue; unavailable after disconnect or handler return. + * @since 0.20.0 + * @experimental + */ + readonly conversations: RequestConversations; + /** + * Changes the module's documents atomically. Refused for viewers and after this request ends. + * @since 0.16.0 + */ + commit(change: (tx: Tx) => T | Promise): Promise; +} + +/** + * A request whose named path parameter resolved to an authorized conversation. + * @since 0.16.0 + */ +export interface ConversationWebRequest extends WebRequest { + /** + * Authoritative source settings for this request; omitted model means none is selected. + * @since 0.16.0 + */ + readonly conversation: { + readonly id: ConversationId; + readonly title: string; + readonly cwd: string; + readonly model?: ModelRef; + readonly thinkingLevel: ModelThinkingLevel; + }; + /** + * Starts and lists linked sessions as the authenticated person, never the source's last requester. + * @since 0.16.0 + */ + readonly linkedSessions: HumanLinkedSessions; +} + +/** + * Answers a route's requests. The value it returns goes back as JSON (`undefined` as `{}`). + * @since 0.14.0 + */ +export type WebHandler = (request: WebRequest) => unknown; + +/** + * What a module adds to the app: the second argument of its default export. Register routes, the browser module, and + * commands while the module loads. They go when this version of the module goes. + * @since 0.14.0 + */ +export interface Web { + /** + * The version of the extension API. + * @since 0.14.0 + */ + readonly api: number; + /** + * The module's name: its file's name without `.ts`. + * @since 0.14.0 + */ + readonly module: string; + /** + * Adds a route at `/api/x//`. A part of `path` written `:name` matches any one part. + * @since 0.14.0 + */ + route( + method: WebMethod, + path: string, + handle: WebHandler, + options?: { access?: WebAccess }, + ): void; + /** + * Binds the path parameter named by options.conversation to a visible source before calling the handler. + * GET requires seeing; all other methods also require steering and the turn. + * @since 0.16.0 + */ + conversationRoute( + method: WebMethod, + path: string, + handle: (request: ConversationWebRequest) => unknown, + options: { conversation: string; access?: WebAccess }, + ): void; + /** + * Sets the browser module: a `.js` file beside the module, or a folder with an `index.js`. + * @since 0.14.0 + */ + client(entry: string, options?: { access?: WebAccess }): void; + /** + * Adds the command `/name` to the message box. + * @since 0.14.0 + */ + command(name: string, command: Command): void; + /** + * Sends an event to the browser module: to everyone it loads for, or to the person `to`. It never throws. + * @since 0.14.0 + */ + send(event: string, data?: unknown, options?: { to?: string }): void; + /** + * Makes an error for a route or command to throw. The person who asked gets `status` and `message` as they are. + * @since 0.14.0 + */ + error(status: number, message: string): Error; + /** + * Runs `dispose` when this version of the module goes. It can return a promise. + * @since 0.14.0 + */ + onDispose(dispose: () => unknown): void; +} + +// ─── Commands ──────────────────────────────────────────────────────────────── + +/** + * A command that people run from the message box, without asking Pi. + * @since 0.14.0 + */ +export interface Command { + /** + * What the command does, for the message box's suggestions. + * @since 0.14.0 + */ + description: string; + /** + * A hint for the arguments, in words, such as `"[zone]"`. + * @since 0.14.0 + */ + args?: string; + /** + * Who can run it: `"conversation"`, a person who can steer the session; `"global"`, the owner alone. + * @since 0.14.0 + */ + scope: "conversation" | "global"; + /** + * Runs the command. `args` is all the text after the name. + * @since 0.14.0 + */ + handler(args: string, context: CommandContext): CommandResult | Promise; +} + +/** + * Who ran a command and where, with the durable task that runs it. + * @since 0.14.0 + */ +export interface CommandContext { + /** + * The person who ran the command. + * @since 0.14.0 + */ + readonly user: Person; + /** + * The session where the person ran it. + * @since 0.14.0 + */ + readonly conversationId: ConversationId; + /** + * The session's folder. + * @since 0.14.0 + */ + readonly cwd: string; + /** + * Aborts when a person stops the command. + * @since 0.14.0 + */ + readonly signal: AbortSignal; + /** + * Reads a document: `ctx.snapshot(Doc, ctx.conversationId, ctx.context)`. + * @since 0.14.0 + */ + readonly snapshot: DocumentReader["snapshot"]; + /** + * The task's context, for `snapshot` and other calls to Pi Durable. + * @since 0.14.0 + */ + readonly context: Context; + /** + * Changes documents in one commit, and returns what `change` returns. + * @since 0.14.0 + */ + commit(change: (tx: Tx) => T | Promise): Promise; +} + +/** + * What a command returns: a notice for the person who ran it, or a card of plain text in the session. + * @since 0.14.0 + */ +export type CommandResult = + { type: "toast"; level: NoticeLevel; message: string } | { type: "card"; output: string }; + +// ─── pocket: the browser module's API ──────────────────────────────────────── + +/** + * A browser module: the `.js` file that the app loads in the tabs of the people it is for. + * @since 0.14.0 + */ +export interface BrowserModule { + /** + * Sets up the browser module. A function that it returns runs when this version goes. + * @since 0.14.0 + */ + default(pocket: Pocket): void | (() => void) | Promise void)>; + /** + * The version of the extension API that the browser module needs. + * @since 0.14.0 + */ + pocketApi?: number; +} + +/** + * What a rail button, menu row, or launcher action does when a person uses it: exactly one of these. `panel` (since + * 0.19.0) opens one of the module's panels. + * @since 0.14.0 + */ +export type Action = + | { page: string; sheet?: never; panel?: never; run?: never } + | { sheet: string; page?: never; panel?: never; run?: never } + | { panel: string; page?: never; sheet?: never; run?: never } + | { run: () => unknown; page?: never; sheet?: never; panel?: never }; + +/** + * A button on the rail. + * @since 0.14.0 + */ +export type RailSpec = Action & { + /** + * The button's id in the module. + * @since 0.14.0 + */ + id: string; + /** + * The button's label. + * @since 0.14.0 + */ + title: string; + /** + * The name of one of the app's icons. + * @since 0.14.0 + */ + icon?: string; + /** + * An icon of your own: a stroked path in a 24 × 24 box. + * @since 0.14.0 + */ + iconPath?: string; + /** + * A short count or word on the button. + * @since 0.14.0 + */ + badge?: number | string | null | false; +}; + +/** + * A row in the menu's App group. + * @since 0.14.0 + */ +export type MenuSpec = Action & { + /** + * The row's id in the module. + * @since 0.14.0 + */ + id: string; + /** + * The row's label. + * @since 0.14.0 + */ + label: string; + /** + * A short hint on the right of the row. + * @since 0.14.0 + */ + hint?: string; +}; + +/** + * An action in the launcher. + * @since 0.14.0 + */ +export type LauncherSpec = Action & { + /** + * The action's id in the module. + * @since 0.14.0 + */ + id: string; + /** + * The action's label. + * @since 0.14.0 + */ + label: string; + /** + * A line under the label. By default, the module's title. + * @since 0.14.0 + */ + detail?: string; + /** + * The name of one of the app's icons. + * @since 0.14.0 + */ + icon?: string; + /** + * An icon of your own: a stroked path in a 24 × 24 box. + * @since 0.14.0 + */ + iconPath?: string; + /** + * More words that find the action when a person types them. + * @since 0.14.0 + */ + keywords?: string[]; +}; + +/** + * What the component of a page or sheet gets. + * @since 0.14.0 + */ +export interface PageProps { + /** + * The `pocket` of the module's version that added the page or sheet. + * @since 0.14.0 + */ + pocket: Pocket; + /** + * Closes this page or sheet while it shows. Later, it closes nothing. + * @since 0.14.0 + */ + close(): void; +} + +/** + * What a sheet's component gets: the props that `openSheet` gave, and `pocket` and `close`. + * @since 0.14.0 + */ +export type SheetProps

= P & PageProps; + +/** + * A page in the place of the session. + * @since 0.14.0 + */ +export interface PageSpec { + /** + * The page's id in the module. + * @since 0.14.0 + */ + id: string; + /** + * The page's title. + * @since 0.14.0 + */ + title: string; + /** + * What the page shows. + * @since 0.14.0 + */ + component: ComponentType; + /** + * With `false`, Esc does not close the page. + * @since 0.14.0 + */ + escape?: boolean; + /** + * With `false`, no swipe on the page goes back. + * @since 0.14.0 + */ + swipe?: boolean; +} + +/** + * A sheet, as the app's own sheets. + * @since 0.14.0 + */ +export interface SheetSpec

{ + /** + * The sheet's id in the module. + * @since 0.14.0 + */ + id: string; + /** + * The sheet's title. + * @since 0.14.0 + */ + title: string; + /** + * What the sheet shows. + * @since 0.14.0 + */ + component: ComponentType>; + /** + * With `true`, the sheet is wider. + * @since 0.14.0 + */ + wide?: boolean; +} + +/** + * A panel beside the conversation, where the Browser and Files panels dock; over the conversation on a narrow screen. + * The top bar and the places get a button that opens it. + * @since 0.19.0 + * @experimental + */ +export interface PanelSpec { + /** + * The panel's id in the module. + * @since 0.19.0 + * @experimental + */ + id: string; + /** + * The panel's title, and its buttons' label. + * @since 0.19.0 + * @experimental + */ + title: string; + /** + * What the panel shows. + * @since 0.19.0 + * @experimental + */ + component: ComponentType; + /** + * The name of one of the app's icons, for its buttons. + * @since 0.19.0 + * @experimental + */ + icon?: string; + /** + * An icon of your own: a stroked path in a 24 × 24 box. + * @since 0.19.0 + * @experimental + */ + iconPath?: string; + /** + * A short count or word on its buttons, as on a rail button. + * @since 0.19.0 + * @experimental + */ + badge?: number | string | null | false; + /** + * With `true`, the panel is wider beside the conversation. + * @since 0.19.0 + * @experimental + */ + wide?: boolean; +} + +/** + * An item in the status line under the message box, before the spend. With an action, it is a button. + * @since 0.19.0 + * @experimental + */ +export type StatusSpec = (Action | { page?: never; sheet?: never; panel?: never; run?: never }) & { + /** + * The item's id in the module. + * @since 0.19.0 + * @experimental + */ + id: string; + /** + * What the item says: `"46W · 74° · 95%"`. + * @since 0.19.0 + * @experimental + */ + text: string; + /** + * What it says on a line too narrow for `text`: `"46W"`. + * @since 0.19.0 + * @experimental + */ + short?: string; + /** + * Its tooltip, and what a tap on the line says on a touch screen. + * @since 0.19.0 + * @experimental + */ + title?: string; + /** + * Its color: `"warn"`, `"error"`, or `"muted"` (greyed, as for a service that is out of reach). + * @since 0.19.0 + * @experimental + */ + tone?: "warn" | "error" | "muted"; + /** + * Its name in Appearance → Layout. By default, the module's title. + * @since 0.19.0 + * @experimental + */ + label?: string; +} & Placement; + +/** + * Where an item goes in its slot: just before or just after another item, such as `"core:spend"`. Without either, it + * goes in the place the slot keeps for extensions. A person's own order wins. + * @since 0.19.0 + * @experimental + */ +export type Placement = { before?: string; after?: never } | { after?: string; before?: never }; + +/** + * A button in the top bar, after `core:browser` and before `core:artifacts`, `core:people`, and the menu. + * @since 0.19.0 + * @experimental + */ +export type TopbarSpec = Action & + Placement & { + /** + * The button's id in the module. + * @since 0.19.0 + * @experimental + */ + id: string; + /** + * The button's label. + * @since 0.19.0 + * @experimental + */ + title: string; + /** + * The name of one of the app's icons. + * @since 0.19.0 + * @experimental + */ + icon?: string; + /** + * An icon of your own: a stroked path in a 24 × 24 box. + * @since 0.19.0 + * @experimental + */ + iconPath?: string; + /** + * A short count or word on the button. + * @since 0.19.0 + * @experimental + */ + badge?: number | string | null | false; + }; + +/** + * A button on the left of the message box's row, after the app's own (Attach, the model, Plan). + * @since 0.19.0 + * @experimental + */ +export type ComposerButtonSpec = TopbarSpec; + +/** + * One of Pi's answers, as an action under it gets it. + * @since 0.19.0 + * @experimental + */ +export interface Reply { + /** + * The answer's id in its session. + * @since 0.19.0 + * @experimental + */ + readonly id: string; + /** + * Its words, as Markdown. + * @since 0.19.0 + * @experimental + */ + readonly text: string; + /** + * The session it is in. + * @since 0.19.0 + * @experimental + */ + readonly conversationId: number; +} + +/** + * An action under Pi's answers, before More. `run` gets the answer; a sheet gets it as the prop `reply`. + * @since 0.19.0 + * @experimental + */ +export type ReplyActionSpec = ( + { run: (reply: Reply) => unknown; sheet?: never } | { sheet: string; run?: never } +) & + Placement & { + /** + * The action's id in the module. + * @since 0.19.0 + * @experimental + */ + id: string; + /** + * The action's words. + * @since 0.19.0 + * @experimental + */ + label: string; + /** + * The name of one of the app's icons, before the words. + * @since 0.19.0 + * @experimental + */ + icon?: string; + /** + * An icon of your own: a stroked path in a 24 × 24 box. + * @since 0.19.0 + * @experimental + */ + iconPath?: string; + }; + +/** + * A place in the app built from named items: `"topbar"` (the buttons after the session's title), `"status"` (the + * status line under the message box), `"places"` (the tiles of the places and the menu), `"composer"` (the buttons on + * the left of the message box's row), or `"reply"` (the actions under Pi's answers). + * @since 0.19.0 + * @experimental + */ +export type SlotName = "topbar" | "status" | "places" | "composer" | "reply"; + +/** + * How a module arranges a slot: items to hide, and an order for some of them. The named items take the places those + * items had, in this order; the rest stay. The app's items are `core:…`, extensions' `ext::`. + * @since 0.19.0 + * @experimental + */ +export interface Arrangement { + /** + * Ids of items to hide. Fixed items (the menu, Lancet Guard, the connection's warning) always show. + * @since 0.19.0 + * @experimental + */ + hide?: string[]; + /** + * Ids of items, in the order they show. + * @since 0.19.0 + * @experimental + */ + order?: string[]; +} + +/** + * How a group of tool calls counts a call: "messaged 2 sessions". + * @since 0.14.0 + */ +export interface CallSummary { + /** + * The verb: `"messaged"`. + * @since 0.14.0 + */ + verb: string; + /** + * The noun that the calls count: `"session"`. + * @since 0.14.0 + */ + noun?: string; + /** + * The argument whose values the calls count by: `"to"`. + * @since 0.14.0 + */ + by?: string; +} + +/** + * How the calls of one of the module's tools show in transcripts and peek tiles. Each function gets a copy of the + * call's arguments. + * @since 0.14.0 + */ +export interface ToolCallSpec> { + /** + * The tool's name, as Pi calls it. It must be a tool of the module's own extensions. + * @since 0.14.0 + */ + tool: string; + /** + * The name of one of the app's icons, for peek tiles. + * @since 0.14.0 + */ + icon?: string; + /** + * An icon of your own, for peek tiles: a stroked path in a 24 × 24 box. + * @since 0.14.0 + */ + iconPath?: string; + /** + * The words of the call's line: `"start session"`. + * @since 0.14.0 + */ + label: (args: A) => string; + /** + * What the call acts on. + * @since 0.14.0 + */ + subject?: (args: A) => string; + /** + * How a group of calls counts this call. + * @since 0.14.0 + */ + summary?: (args: A) => CallSummary; +} + +/** + * What `rail`, `menu`, `launcher`, `page`, and `sheet` return. + * @since 0.14.0 + */ +export interface Added { + /** + * Changes some of its fields. + * @since 0.14.0 + */ + update(patch: Partial): void; + /** + * Removes it. + * @since 0.14.0 + */ + remove(): void; +} + +/** + * The props of `pocket.Icon`. + * @since 0.14.0 + */ +export interface IconProps { + /** + * The name of one of the app's icons. + * @since 0.14.0 + */ + name?: string; + /** + * An icon of your own: a stroked path in a 24 × 24 box. + * @since 0.14.0 + */ + path?: string; + /** + * Its width and height, in pixels. + * @since 0.14.0 + */ + size?: number; +} + +/** + * The props of `pocket.Loader` and `pocket.Spinner`. + * @since 0.14.0 + */ +export interface LoaderProps { + /** + * What is loading, in words. + * @since 0.14.0 + */ + label?: string; +} + +/** + * The props of the native `pocket.Conversation` view. + * @since 0.20.0 + * @experimental + */ +export interface ConversationProps { + conversationId: number; + draft?: string; + title?: string; + onClose?: () => void; +} + +/** + * The props of `pocket.Markdown`. + * @since 0.14.0 + */ +export interface MarkdownProps { + /** + * The Markdown to show. + * @since 0.14.0 + */ + text: string; +} + +/** + * The props of `pocket.Switch`. + * @since 0.14.0 + */ +export interface SwitchProps { + /** + * Whether it is on. + * @since 0.14.0 + */ + on: boolean; + /** + * Its name, for screen readers. + * @since 0.14.0 + */ + label: string; + /** + * Runs when a person turns it. + * @since 0.14.0 + */ + onChange: () => void; + /** + * Whether it cannot be turned now. + * @since 0.14.0 + */ + disabled?: boolean; +} + +/** + * The app's API for a browser module: the argument of its default export. + * @since 0.14.0 + */ +export interface Pocket { + /** + * The version of the extension API. + * @since 0.14.0 + */ + readonly version: number; + /** + * The connected server's Pi Pocket package version; undefined with a server older than 0.16.0. + * @since 0.16.0 + */ + readonly pocketVersion: string | undefined; + /** + * The module's name. + * @since 0.14.0 + */ + readonly module: string; + /** + * The module's title. + * @since 0.14.0 + */ + readonly title: string; + /** + * The app's Preact `h`. + * @since 0.14.0 + */ + readonly h: typeof h; + /** + * The app's htm, bound to its Preact. + * @since 0.14.0 + */ + html(strings: TemplateStringsArray, ...values: unknown[]): VNode | VNode[]; + /** + * The app's Preact `Fragment`. + * @since 0.14.0 + */ + readonly Fragment: typeof Fragment; + /** + * Preact's `useState`. + * @since 0.14.0 + */ + readonly useState: typeof useState; + /** + * Preact's `useEffect`. + * @since 0.14.0 + */ + readonly useEffect: typeof useEffect; + /** + * Preact's `useLayoutEffect`. + * @since 0.14.0 + */ + readonly useLayoutEffect: typeof useLayoutEffect; + /** + * Preact's `useRef`. + * @since 0.14.0 + */ + readonly useRef: typeof useRef; + /** + * Preact's `useMemo`. + * @since 0.14.0 + */ + readonly useMemo: typeof useMemo; + /** + * Preact's `useCallback`. + * @since 0.14.0 + */ + readonly useCallback: typeof useCallback; + /** + * Preact's `useReducer`. + * @since 0.14.0 + */ + readonly useReducer: typeof useReducer; + /** + * One of the app's icons, or an icon of your own. + * @since 0.14.0 + */ + readonly Icon: FunctionComponent; + /** + * The app's spinner. + * @since 0.14.0 + */ + readonly Spinner: FunctionComponent; + /** + * The app's spinner with a line of text. + * @since 0.14.0 + */ + readonly Loader: FunctionComponent; + /** + * Markdown, cleaned as the app cleans Pi's replies. + * @since 0.14.0 + */ + readonly Markdown: FunctionComponent; + /** + * The native conversation view. Uses normal server authorization and never sends a draft automatically. + * @since 0.20.0 + * @experimental + */ + readonly Conversation: FunctionComponent; + /** + * The app's on and off switch. + * @since 0.14.0 + */ + readonly Switch: FunctionComponent; + /** + * The person who uses this tab, or null before sign-in. + * @since 0.14.0 + */ + me(): Person | null; + /** + * The id of the session that this tab shows, a number as in its address (`/s/12`), or null. + * @since 0.14.0 + */ + conversationId(): number | null; + /** + * Opens a conversation through normal navigation. The server still checks access. + * @since 0.16.0 + */ + openConversation(id: number | string): void; + /** + * Calls one of the module's routes: a GET without `body`, a POST with it. + * @since 0.14.0 + */ + api( + path: string, + body?: object, + options?: { method?: WebMethod; signal?: AbortSignal }, + ): Promise; + /** + * Listens for an event that the module sends. Returns a function that stops it. + * @since 0.14.0 + */ + on(event: string, run: (data: T) => void): () => void; + /** + * Shows a notice, as the app's own. + * @since 0.14.0 + */ + notify(level: NoticeLevel, message: string): void; + /** + * Adds a button on the rail. + * @since 0.14.0 + */ + rail(spec: RailSpec): Added; + /** + * Adds a row in the menu. + * @since 0.14.0 + */ + menu(spec: MenuSpec): Added; + /** + * Adds an action in the launcher. + * @since 0.14.0 + */ + launcher(spec: LauncherSpec): Added; + /** + * Adds a page. + * @since 0.14.0 + */ + page(spec: PageSpec): Added; + /** + * Adds a sheet. + * @since 0.14.0 + */ + sheet

(spec: SheetSpec

): Added>; + /** + * Adds a panel beside the conversation, with a button in the top bar and in the places. + * @since 0.19.0 + * @experimental + */ + panel(spec: PanelSpec): Added; + /** + * Adds an item in the status line under the message box. + * @since 0.19.0 + * @experimental + */ + status(spec: StatusSpec): Added; + /** + * Adds a button in the top bar. + * @since 0.19.0 + * @experimental + */ + topbar(spec: TopbarSpec): Added; + /** + * Adds a button on the left of the message box's row. + * @since 0.19.0 + * @experimental + */ + composerButton(spec: ComposerButtonSpec): Added; + /** + * Adds an action under Pi's answers. + * @since 0.19.0 + * @experimental + */ + replyAction(spec: ReplyActionSpec): Added; + /** + * Puts text into the message box, after what is there, for the person to send. It never sends. + * @since 0.19.0 + * @experimental + */ + insertText(text: string, options?: { inline?: boolean }): void; + /** + * Hides or reorders a slot's items, the app's own too. A person's own choices in Appearance → Layout win. + * @since 0.19.0 + * @experimental + */ + arrange(slot: SlotName, arrangement: Arrangement): { remove(): void }; + /** + * Sets how the calls of one of the module's tools show. + * @since 0.14.0 + */ + toolCall>(spec: ToolCallSpec): { remove(): void }; + /** + * Opens one of the module's pages. + * @since 0.14.0 + */ + openPage(id: string): void; + /** + * Closes the module's page that shows. + * @since 0.14.0 + */ + closePage(): void; + /** + * Opens one of the module's sheets. Its component gets `props`. + * @since 0.14.0 + */ + openSheet(id: string, props?: object): void; + /** + * Closes the module's sheet that shows. + * @since 0.14.0 + */ + closeSheet(): void; + /** + * Opens one of the module's panels. It takes the place of the panel that shows. + * @since 0.19.0 + * @experimental + */ + openPanel(id: string): void; + /** + * Closes the module's panel that shows. + * @since 0.19.0 + * @experimental + */ + closePanel(): void; + /** + * Loads a stylesheet from the browser module's folder. + * @since 0.14.0 + */ + style(path: string): void; + /** + * Runs `dispose` when this version of the browser module goes. + * @since 0.14.0 + */ + onDispose(dispose: () => void): void; +} diff --git a/integrations/pi-pocket/browser-check.mjs b/integrations/pi-pocket/browser-check.mjs new file mode 100644 index 000000000..b915548d9 --- /dev/null +++ b/integrations/pi-pocket/browser-check.mjs @@ -0,0 +1,198 @@ +#!/usr/bin/env node +// Fullscreen acceptance in temporary Pocket data, with real WebGL and a scripted model. No live server. +import assert from 'node:assert/strict'; +import { mkdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import { chromium } from 'playwright-core'; +process.env.OFFICE_POCKET_PREVIEW_PORT ??= '0'; +const {app,base,session,other,guest,context,closePreview}=await import('./preview.mjs'); +const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH??'/usr/bin/google-chrome-stable',headless:true,args:['--no-sandbox','--enable-unsafe-swiftshader']}); +const screenshots=join(import.meta.dirname,'../../dist/screenshots'); +await mkdir(screenshots,{recursive:true}); +const errors=[]; +const pages=[]; +let sockets=0; +async function until(check,label,timeout=30000){const start=Date.now();while(Date.now()-startsetTimeout(r,100));}throw Error(`Timed out: ${label}`);} +async function frame(page,selector){const handle=await page.locator(selector).elementHandle();const result=await handle.contentFrame();if(!result)throw Error(`Missing frame ${selector}`);return result;} +async function world(page){await page.locator('.office-v2-worldframe').waitFor();const game=await frame(page,'.office-v2-worldframe');await game.locator('html.pocket-office').waitFor({state:'attached',timeout:45000});await game.locator('#loading').waitFor({state:'detached',timeout:45000});return game;} +async function boot(game){return game.evaluate(()=>JSON.parse(decodeURIComponent(document.querySelector('meta[name="pocket-bootstrap"]').content)));} +async function state(page,source,client){const res=await page.request.get(`${base}/api/x/office-v2/state/${source}?clientId=${client}`,{headers:{'X-Pocket':'1'}});assert.equal(res.status(),200);return res.json();} +async function self(page,source,identity){return (await state(page,source,identity.slice(-36))).peers.find(peer=>peer.id===identity);} +async function walk(actor,identity,key,reached,label){ + let pose; + for(let attempt=0;attempt<90;attempt++){ + pose=await self(actor.page,session,identity); + if(attempt%10===0)console.log('Walking',label,attempt,pose?.x,pose?.z,await actor.game.evaluate(()=>({focused:document.hasFocus(),active:document.activeElement?.tagName,modals:document.querySelector('#modal-root')?.children.length,locked:!!document.pointerLockElement}))); + if(pose&&reached(pose))return; + await actor.game.locator('#scene').focus(); + await actor.page.keyboard.down(key); + try{await actor.page.waitForTimeout(100);}finally{await actor.page.keyboard.up(key);} + // Release the key before reading network feedback, so round-trip latency cannot cause overshoot. + await actor.page.waitForTimeout(250); + } + throw Error(`${label}: stopped at ${JSON.stringify(pose)}`); +} +async function open(userToken){ + const ctx=await browser.newContext({viewport:{width:1280,height:800}}); + const page=await ctx.newPage();pages.push(page);page.setDefaultTimeout(30000); + page.on('pageerror',error=>errors.push(error.message));page.on('websocket',()=>sockets++); + page.on('console',message=>{if(message.type()==='warning'&&message.text().includes('with nothing happening'))errors.push(message.text());}); + await page.goto(`${base}/login?token=${encodeURIComponent(userToken)}&next=/s/${session}`); + await page.locator('.sidebar .rail-ext[title="Office v2"]').click(); + return {ctx,page,game:await world(page)}; +} +// Equivalent to releasing mouse-look with the browser's Escape control, not a scene-state mutation. +async function freeMouse(game){await game.evaluate(()=>document.exitPointerLock());await until(()=>game.evaluate(()=>!document.pointerLockElement),'released mouse-look');} +async function inspector(page){await page.locator('.office-v2-inspector-view iframe').waitFor();const view=await frame(page,'.office-v2-inspector-view iframe');await view.locator('.composer textarea').waitFor();assert.equal(await view.locator('.sidebar,.ext-page').count(),0);return view;} +async function send(view,text){await view.locator('.composer textarea').fill(text);await view.getByRole('button',{name:'Send',exact:true}).click();await view.getByText(`echo: ${text}`,{exact:true}).waitFor();} +async function closeInspector(page){await page.getByRole('button',{name:'Close inspector',exact:true}).press('Enter');await page.locator('.office-v2-inspector').waitFor({state:'detached'});const game=await frame(page,'.office-v2-worldframe');await game.locator('#modal-root .backdrop').waitFor({state:'detached'});} +async function menu(game,page,label){ + const toggle=game.getByRole('button',{name:'Menu',exact:true}); + // Parent focus restoration and pointer-lock changes can land after inspector DOM removal. + for(let attempt=0;attempt<4&&await toggle.getAttribute('aria-expanded')!=='true';attempt++){ + await toggle.focus();await page.keyboard.press('Tab');await page.waitForTimeout(500); + } + await game.locator('.menu-item').filter({hasText:label}).first().press('Enter'); +} +async function fullscreen(page){if(!await page.evaluate(()=>!!document.fullscreenElement))await page.getByRole('button',{name:'Fullscreen',exact:true}).press('Enter');assert.equal(await page.evaluate(()=>document.fullscreenElement?.classList.contains('office-v2-world')),true);} +async function bounds(page){assert.equal(await page.getByRole('button',{name:'Close inspector'}).evaluate(el=>{const r=el.getBoundingClientRect();return r.width>=32&&r.height>=32&&r.x>=0&&r.y>=0&&r.right<=innerWidth+1&&r.bottom<=innerHeight+1;}),true);} +try{ + const owner=await open(app.config.ownerToken); + console.log('Real WebGL:',await owner.game.evaluate(()=>{const canvas=document.querySelector('#scene');const gl=canvas.getContext('webgl2');const debug=gl.getExtension('WEBGL_debug_renderer_info');return{width:canvas.width,height:canvas.height,antialias:gl.getContextAttributes().antialias,renderer:debug?gl.getParameter(debug.UNMASKED_RENDERER_WEBGL):'masked'};})); + // A reader's native inspector pauses drawing, not the real world, simulation, or presence. + // Keep only one software-rendered world drawing at a time on this CPU-only acceptance host. + await owner.page.getByRole('button',{name:'Master chat',exact:true}).press('Enter'); + const desktopMaster=await inspector(owner.page); + const teammate=await open(guest.token); + const originalUrl=owner.page.url(); + const initial=await boot(owner.game); + assert.equal(initial.person.name,'Owner');assert.equal(initial.snapshot.source,Number(session));assert.equal(initial.floors.length,2); + await owner.game.locator('#people').getByText('Office teammate',{exact:false}).waitFor(); + await teammate.game.locator('#people').getByText('Owner',{exact:false}).waitFor(); + console.log('PASS: real 3D boot, authenticated appearance, and two people on the same chat floor.'); + await freeMouse(teammate.game); + await teammate.page.getByRole('button',{name:'Master chat',exact:true}).press('Enter'); + await inspector(teammate.page); + await owner.page.bringToFront(); + assert.ok(await desktopMaster.evaluate(()=>innerWidth)>=960,'native Master inspector must use its desktop layout'); + assert.ok(await owner.game.evaluate(()=>innerWidth)>=1024,'3D world must use its desktop layout'); + await owner.page.screenshot({path:join(screenshots,'office-v2-desktop-master-inspector.png'),timeout:10000}); + await closeInspector(owner.page); + console.log('PASS: full-window desktop 3D and computer-width native Master inspector.'); + + // Use the actual movement controls to leave the elevator and approach a regular desk. + await owner.page.bringToFront(); + // Closing the inspector restores pointer lock. Keyboard activation avoids pointer-lock mouse clicks. + await menu(owner.game,owner.page,'Settings'); + await freeMouse(owner.game); + await owner.game.getByRole('radio',{name:'Third person',exact:true}).press('Enter'); + await owner.game.getByRole('dialog',{name:'Pocket room settings',exact:true}).getByRole('button',{name:'Close',exact:true}).press('Enter'); + await walk(owner,initial.snapshot.you,'w',p=>p.z>-10,'walking out of the elevator'); + await walk(owner,initial.snapshot.you,'d',p=>p.x<5.5,'walking around the board kiosk'); + await walk(owner,initial.snapshot.you,'w',p=>p.z>-7.5,'passing the board kiosk'); + await walk(owner,initial.snapshot.you,'d',p=>p.x<0.8,'approaching a regular desk'); + await walk(owner,initial.snapshot.you,'w',p=>p.z>-6.2,'entering the desk interaction circle'); + await owner.game.locator('#hint').filter({hasText:/Hire/}).waitFor(); + await owner.page.keyboard.press('e'); + await owner.game.getByRole('textbox',{name:'Prompt',exact:true}).waitFor(); + const hire=owner.game.getByRole('dialog').filter({has:owner.game.getByRole('textbox',{name:'Prompt',exact:true})}); + await hire.getByRole('button',{name:'Hire & start',exact:true}).press('Enter'); + assert.equal((await state(owner.page,session,initial.snapshot.you.slice(-36))).workers.length,0,'empty hiring never admits work'); + await owner.game.getByRole('textbox',{name:'Prompt',exact:true}).fill('Office worker initial task'); + await hire.getByRole('button',{name:'Hire & start',exact:true}).press('Enter'); + await until(async()=>(await state(owner.page,session,initial.snapshot.you.slice(-36))).bindings.length===1,'real child binding'); + const hired=await state(owner.page,session,initial.snapshot.you.slice(-36)); + const child=hired.bindings[0].conversationId; + await teammate.game.locator('#workers li:not(.empty)').first().waitFor(); + await owner.game.locator('#hint').filter({hasText:'Open worker conversation'}).waitFor(); + assert.equal(app.sessions().length,3,'exactly one child, separate from both master floors'); + console.log('PASS: movement, required task at E/hire, inherited real child, and shared worker desk.'); + + await owner.page.keyboard.press('e'); + let view=await inspector(owner.page); + assert.equal(new URL(view.url()).pathname,`/s/${child}`); + // Linked-session initial tasks include Pocket's native sender and safety wrapper. + await view.getByText(/^echo: .*Office worker initial task$/s).waitFor(); + assert.equal(owner.page.url(),originalUrl); + await fullscreen(owner.page); + assert.equal(await owner.page.locator('.office-v2-inspector-view iframe').evaluate(el=>document.fullscreenElement.contains(el)),true); + await send(view,'Human follow-up inside the office'); + console.log('PASS: native child response and human follow-up remain inside fullscreen.'); + assert.equal(owner.page.url(),originalUrl); + await until(async()=>(await self(owner.page,session,initial.snapshot.you))?.reading===true,'inspector reading presence'); + await closeInspector(owner.page); + await until(async()=>(await self(owner.page,session,initial.snapshot.you))?.reading!==true,'restored game focus'); + await owner.game.locator('#scene').focus();await owner.page.keyboard.press('p'); + await owner.game.getByRole('textbox',{name:'Prompt',exact:true}).fill('Review-only follow-up draft'); + await owner.game.getByRole('button',{name:/Review/}).press('Enter'); + view=await inspector(owner.page); + await until(async()=>await view.locator('.composer textarea').inputValue()==='Review-only follow-up draft','unsent native draft'); + assert.equal(await view.getByText('echo: Review-only follow-up draft',{exact:true}).count(),0); + await view.getByRole('button',{name:'Send',exact:true}).click();await view.getByText('echo: Review-only follow-up draft',{exact:true}).waitFor(); + console.log('PASS: native follow-up draft stays unsent until explicit Send.'); + await view.locator('.composer textarea').fill('office tool check');await view.getByRole('button',{name:'Send',exact:true}).click(); + await view.getByText('echo: office-tool-ok',{exact:true}).waitFor(); + console.log('PASS: scripted model ran the real isolated bash printf tool.'); + const decision=app.approvals.request({id:'office-inspector-approval',conversationId:child,taskId:999,tool:'bash',subject:'printf isolated-approval',reason:'Isolated Office inspector approval test',createdAt:Date.now()},context).catch(error=>({error})); + await view.getByRole('button',{name:'Allow',exact:true}).waitFor(); + await bounds(owner.page);await owner.page.screenshot({path:join(screenshots,'office-v2-fullscreen-approval.png')}); + await view.getByRole('button',{name:'Allow',exact:true}).click();assert.equal((await decision).allow,true); + assert.equal(await owner.page.evaluate(()=>!!document.fullscreenElement),true); + assert.equal(owner.page.url(),originalUrl); + console.log('PASS: fullscreen native child conversation, live tool result, human follow-ups, unsent drafts, and explicit approval.'); + + await closeInspector(owner.page); + await owner.page.getByRole('button',{name:'Master chat',exact:true}).press('Enter'); + view=await inspector(owner.page); + assert.equal(new URL(view.url()).pathname,`/s/${session}`); + assert.equal(await view.getByText('Human follow-up inside the office',{exact:true}).count(),0,'child history was not copied into the master'); + await closeInspector(owner.page); + await menu(owner.game,owner.page,'Switch workspace'); + await owner.game.getByRole('dialog',{name:'Elevator',exact:true}).getByRole('button',{name:/Workspace switch test project/}).press('Enter'); + await until(async()=>{const candidate=await world(owner.page);if((await boot(candidate)).snapshot.source!==Number(other))return false;owner.game=candidate;return true;},'replacement scene for the selected chat floor',60000); + assert.equal(await owner.page.evaluate(()=>!!document.fullscreenElement),true,'scene replacement keeps the fullscreen host'); + await teammate.game.locator('#workers li:not(.empty)').first().waitFor(); + await until(async()=>!(await teammate.game.locator('#people').innerText()).includes('Owner'),'old-floor presence removed'); + assert.equal((await state(owner.page,other,initial.snapshot.you.slice(-36))).workers.length,0); + await owner.page.getByRole('button',{name:'Master chat',exact:true}).press('Enter');view=await inspector(owner.page); + assert.equal(new URL(view.url()).pathname,`/s/${other}`);await send(view,'Second floor master message'); + assert.equal(owner.page.url(),originalUrl); + await closeInspector(owner.page); + await owner.page.screenshot({path:join(screenshots,'office-v2-world-fullscreen.png')}); + console.log('PASS: elevator switches real master chats without moving workers or teammates, without exiting fullscreen.'); + + await menu(owner.game,owner.page,'Settings'); + await owner.game.getByRole('button',{name:'Change avatar',exact:true}).press('Enter'); + await owner.page.getByLabel('Shirt color',{exact:true}).selectOption('#06d6a0'); + await owner.page.getByRole('button',{name:'Save my appearance',exact:true}).press('Enter'); + await until(async()=>{const candidate=await world(owner.page);if((await boot(candidate)).profile.color!=='#06d6a0')return false;owner.game=candidate;return true;},'appearance bootstrap after edit',60000); + const fresh=await open(app.config.ownerToken); + assert.equal((await boot(fresh.game)).profile.color,'#06d6a0','appearance follows the account into a fresh desktop browser'); + await fresh.page.getByRole('button',{name:'Master chat',exact:true}).press('Enter'); + const freshView=await inspector(fresh.page);await fullscreen(fresh.page);await bounds(fresh.page); + assert.ok(await freshView.evaluate(()=>innerWidth)>=960,'native inspector must retain the computer layout'); + assert.ok(await fresh.game.evaluate(()=>innerWidth)>=1024,'world must retain the computer layout'); + await fresh.page.screenshot({path:join(screenshots,'office-v2-desktop-inspector.png')}); + await fresh.page.setViewportSize({width:1024,height:768});await bounds(fresh.page); + assert.ok(await freshView.evaluate(()=>innerWidth)>=960); + await fresh.page.screenshot({path:join(screenshots,'office-v2-compact-desktop-inspector.png')}); + await fresh.page.getByRole('button',{name:'Exit fullscreen',exact:true}).press('Enter'); + await freshView.locator('.composer textarea').focus();await fresh.page.keyboard.press('Escape'); + await fresh.page.locator('.office-v2-inspector').waitFor({state:'detached'}); + assert.equal(await fresh.game.locator('#modal-root .backdrop').count(),0); + await fresh.page.setViewportSize({width:900,height:650}); + await fresh.page.getByText('Desktop Office',{exact:true}).waitFor(); + assert.equal(await fresh.page.locator('.office-v2-worldframe').count(),0,'a small window must not silently switch to phone UI'); + assert.deepEqual(errors,[]);assert.equal(sockets,0,'no Office WebSocket backend'); + console.log('PASS: account-owned appearance, forced desktop world/inspector, reachable close targets, no phone fallback, and Escape/focus cleanup.'); + console.log(`PASS: all fullscreen acceptance checks. Screenshots: ${screenshots}`); +}catch(error){ + console.error('Browser exceptions:',errors); + for(const [index,page] of pages.entries())try{ + await page.screenshot({path:join(screenshots,`office-v2-failure-${index}.png`),timeout:10000}); + const game=await frame(page,'.office-v2-worldframe');const data=await boot(game); + console.error('World diagnostic',index,await game.locator('#hint').innerText(),await self(page,data.snapshot.source,data.snapshot.you)); + console.error('Hit-test diagnostic',await page.evaluate(()=>({frame:document.querySelector('.office-v2-worldframe')?.getBoundingClientRect().toJSON(),top:document.elementFromPoint(innerWidth-36,80)?.outerHTML.slice(0,200)})),await game.evaluate(()=>{const r=document.querySelector('.dock-menu')?.getBoundingClientRect();return{menu:r?.toJSON(),hit:r?document.elementFromPoint(r.x+r.width/2,r.y+r.height/2)?.outerHTML.slice(0,200):null};})); + }catch{/* Preserve the original failure. */} + throw error; +}finally{await browser.close();await closePreview();} diff --git a/integrations/pi-pocket/office-v2-client/AGENT-OFFICE-LICENSE.txt b/integrations/pi-pocket/office-v2-client/AGENT-OFFICE-LICENSE.txt new file mode 100644 index 000000000..36f6eaf56 --- /dev/null +++ b/integrations/pi-pocket/office-v2-client/AGENT-OFFICE-LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 AgentSystemLabs + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/integrations/pi-pocket/office-v2-client/bridge.js b/integrations/pi-pocket/office-v2-client/bridge.js new file mode 100644 index 000000000..5fdc160f2 --- /dev/null +++ b/integrations/pi-pocket/office-v2-client/bridge.js @@ -0,0 +1,103 @@ +import { uuid } from './uuid.js'; +// This bridge exposes only this module's public routes and scoped native conversation inspection. +export function validRequest(event, frameWindow, origin, channel) { + const m = event.data; + if (event.source !== frameWindow || event.origin !== origin || !m || typeof m !== 'object') return false; + if (m.type !== 'office-v2:request' || m.channel !== channel) return false; + if (typeof m.id !== 'string' || !/^[a-zA-Z0-9._:-]{1,120}$/.test(m.id)) return false; + if (!['connect', 'poll', 'move', 'chat', 'hire', 'open', 'dismiss', 'customize', 'workspace', 'presence', 'floors', 'switch'].includes(m.op)) return false; + if (m.payload !== undefined && (!m.payload || typeof m.payload !== 'object' || Array.isArray(m.payload))) return false; + try { return JSON.stringify(m).length <= 32000; } catch { return false; } +} + +export function createBridge({ pocket, source, clientId, signal, onSnapshot = () => {}, customize = () => {}, workspace = () => {}, inspect, switchFloor }) { + let presence = {}; + const ensureOpen = () => { if (signal.aborted) throw new Error('The office is closed.'); }; + const observe = (snapshot) => { ensureOpen(); onSnapshot(snapshot); return snapshot; }; + const state = (heartbeat = true) => { + const query = new URLSearchParams({ clientId }); + if (heartbeat) query.set('presence', JSON.stringify(presence)); + return pocket.api(`state/${source}?${query}`, undefined, { signal }).then(observe); + }; + return async (op, payload = {}) => { + if (signal.aborted) throw new Error('The office is closed.'); + switch (op) { + case 'connect': + presence = { ...presence, ...(payload.profile ?? payload) }; + return state(); + case 'poll': return state(); + case 'customize': customize(); return { opened: true }; + case 'workspace': workspace(); return { opened: true }; + case 'floors': return pocket.api('floors', undefined, { signal }); + case 'switch': { + if (!Number.isSafeInteger(payload.source) || payload.source < 1 || typeof switchFloor !== 'function') throw new Error('Select an available chat floor.'); + await switchFloor(payload.source); + ensureOpen(); + return { switching: true, source: payload.source }; + } + case 'presence': + return pocket.api(`presence/${source}`, { clientId, action: payload.action }, { signal }).then(observe); + case 'move': + presence = { ...presence, ...(payload.position ?? payload) }; + return state(); + case 'hire': + return pocket.api(`hire/${source}`, { deskId: payload.deskId, prompt: payload.prompt, requestId: payload.key, clientId }, { signal }).then(observe); + case 'chat': + return pocket.api(`state/${source}`, { action: 'chat', text: payload.text, requestId: payload.key ?? uuid(), clientId }, { signal }).then(observe); + case 'dismiss': + return pocket.api(`dismiss/${source}`, { workerId: payload.workerId, clientId }, { signal }).then(observe); + case 'open': { + if (typeof payload.workerId !== 'string' || !payload.workerId || payload.workerId.length > 120) throw new Error('Select a worker or the master desk.'); + const snapshot = await state(false); + ensureOpen(); + const binding = snapshot.master?.workerId === payload.workerId + ? { conversationId: snapshot.source } + : snapshot.bindings?.find((b) => b.workerId === payload.workerId); + if (!binding) throw new Error('This desk has no session you can open. Refresh the office.'); + if (payload.prompt !== undefined && (typeof payload.prompt !== 'string' || payload.prompt.length > 20000)) throw new Error('The task draft is too long. Nothing was sent.'); + if (typeof inspect !== 'function') throw new Error('The Pocket conversation-view SDK update is required to inspect this worker.'); + const master = snapshot.master?.workerId === payload.workerId; + const worker = snapshot.workers?.find((item) => item.id === payload.workerId); + ensureOpen(); + await inspect({ source, workerId: payload.workerId, conversationId: binding.conversationId, + title: master ? snapshot.master.title : worker?.name ?? 'Worker conversation', + master, draft: payload.prompt }); + ensureOpen(); + return { opened: true, draft: !!payload.prompt, sent: false }; + } + default: throw new Error('This action is not supported by the Pocket runtime.'); + } + }; +} + +export function listenToFrame({ frame, origin, channel, handle, onError = () => {} }) { + const replies = new Map(); + let closed = false; + const listener = async (event) => { + if (closed || !validRequest(event, frame.contentWindow, origin, channel)) return; + const { id, op, payload } = event.data; + const fingerprint = JSON.stringify({ op, payload }); + const send = (body) => { + if (!closed) frame.contentWindow?.postMessage({ type: 'office-v2:reply', channel, id, ...body }, origin); + }; + const previous = replies.get(id); + if (previous) { + if (previous.fingerprint !== fingerprint) return send({ error: 'Request id was reused for a different action.' }); + return send(await previous.reply); + } + if (replies.size >= 128) { + const settled = [...replies].find(([, entry]) => entry.settled); + if (settled) replies.delete(settled[0]); + else return send({ error: 'Too many pending office requests.' }); + } + const entry = { fingerprint, settled: false, reply: null }; + entry.reply = Promise.resolve().then(() => handle(op, payload)).then( + (result) => ({ result }), + (error) => { onError(error.message); return { error: error.message || 'The office request failed.' }; }, + ).finally(() => { entry.settled = true; }); + replies.set(id, entry); + send(await entry.reply); + }; + window.addEventListener('message', listener); + return () => { closed = true; replies.clear(); window.removeEventListener('message', listener); }; +} diff --git a/integrations/pi-pocket/office-v2-client/frame-errors.js b/integrations/pi-pocket/office-v2-client/frame-errors.js new file mode 100644 index 000000000..d38638ce6 --- /dev/null +++ b/integrations/pi-pocket/office-v2-client/frame-errors.js @@ -0,0 +1,20 @@ +// Runs only inside the Office frame. Keep failures visible if the bundle cannot boot. +function showOfficeFailure(message) { + const show = () => { + let box = document.getElementById('pocket-frame-error'); + if (!box) { + box = document.createElement('div'); + box.id = 'pocket-frame-error'; + box.setAttribute('role', 'alert'); + box.style.cssText = 'position:fixed;inset:12px 12px auto;z-index:99999;padding:16px;border:2px solid #e88;background:#241923;color:#fff;font:14px/1.5 system-ui;white-space:pre-wrap'; + document.body.append(box); + } + box.textContent = `Office v2 could not finish loading. ${message}\nUse Reconnect above. If the error continues, close the panel and check the extension package.`; + }; + if (document.body) show(); else document.addEventListener('DOMContentLoaded', show, { once: true }); +} +window.addEventListener('error', (event) => { + if (event.message) showOfficeFailure(event.message); + else if (event.target instanceof HTMLScriptElement) showOfficeFailure('A packaged script could not load.'); +}, true); +window.addEventListener('unhandledrejection', (event) => showOfficeFailure(event.reason?.message ?? 'An unexpected loading error occurred.')); diff --git a/integrations/pi-pocket/office-v2-client/index.js b/integrations/pi-pocket/office-v2-client/index.js new file mode 100644 index 000000000..037194dfe --- /dev/null +++ b/integrations/pi-pocket/office-v2-client/index.js @@ -0,0 +1,209 @@ +import { uuid } from './uuid.js'; +import { createBridge, listenToFrame } from './bridge.js'; +import { roomViews } from './views.js'; +import { rememberWorkspace } from './workspace.js'; +import { worldDocument } from './world-document.js'; +export const pocketApi = 1; + +export default function setup(pocket) { + const { html, useState, useRef, useEffect, useLayoutEffect } = pocket; + const listeners = new Set(); + const changed = (event) => { for (const listener of listeners) listener(event); }; + const { Avatar } = roomViews(pocket, changed); + pocket.style('style.css'); + + function Office() { + const [identity, setIdentity] = useState(null); + const [floors, setFloors] = useState([]); + const [source, setSource] = useState(null); + const [snapshot, setSnapshot] = useState(null); + const [scene, setScene] = useState(null); + const [overlay, setOverlay] = useState(null); + const [error, setError] = useState(''); + const [generation, setGeneration] = useState(0); + const [fullscreen, setFullscreen] = useState(false); + const [recovering, setRecovering] = useState(null); + const [recoveryError, setRecoveryError] = useState(''); + const [smallWindow, setSmallWindow] = useState(false); + const documentHtml = !smallWindow && scene?.source === source ? scene.html : null; + const host = useRef(null); + const frame = useRef(null); + const clientId = useRef(uuid()); + const liveBridge = useRef(null); + const currentOverlay = useRef(null); + currentOverlay.current = overlay; + const asset = useRef(null); + const latest = useRef(null); + latest.current = snapshot; + + useEffect(() => { + const stop = new AbortController(); + async function bootstrap() { + if (typeof pocket.Conversation !== 'function') throw new Error('This 3D office requires the Pocket conversation-view SDK update. The installed stock 0.19 SDK cannot embed worker conversations.'); + const [person, rooms, document] = await Promise.all([ + pocket.api('profile', undefined, { signal: stop.signal }), + pocket.api('floors', undefined, { signal: stop.signal }), + fetch(new URL('./office-document.json', import.meta.url), { signal: stop.signal }).then(async (res) => { + if (!res.ok) throw new Error('The packaged Office world is missing. Build the extension first.'); + return res.json(); + }), + ]); + if (stop.signal.aborted) return; + const current = pocket.conversationId(); + let remembered; + try { remembered = Number(localStorage.getItem(`x.office-v2.${encodeURIComponent(person.person.id)}.workspace`)); } catch { /* Optional preference. */ } + const selected = rooms.find((room) => room.id === remembered) ?? rooms.find((room) => room.id === current) ?? rooms[0]; + asset.current = document.html; + setIdentity(person); setFloors(rooms); setSource(selected?.id ?? null); + } + bootstrap().catch((failure) => { if (!stop.signal.aborted) setError(failure.message); }); + return () => stop.abort(); + }, []); + + useEffect(() => { + if (!source || !identity || !asset.current || smallWindow) return; + const stop = new AbortController(); + setScene(null); setOverlay(null); setError(''); setRecovering(null); setRecoveryError(''); + async function enter() { + const rooms = await pocket.api('floors', undefined, { signal: stop.signal }); + if (!rooms.some((room) => room.id === source)) throw new Error('This chat floor is archived or unavailable. Return to Pocket and choose an accessible root chat.'); + const self = latest.current?.source === source ? latest.current.peers.find((peer) => peer.id === latest.current.you) : null; + const presence = { ...identity.profile, lite: false, ...(self ? { x:self.x,y:self.y,z:self.z,rotY:self.rotY,moving:false } : {}) }; + const state = await pocket.api(`state/${source}?${new URLSearchParams({ clientId:clientId.current, presence:JSON.stringify(presence) })}`, undefined, { signal:stop.signal }); + if (stop.signal.aborted) return; + const channel = uuid(); + const boot = { person:state.person, profile:state.profile, snapshot:state, floors:rooms }; + const document = worldDocument(asset.current, new URL('./world/', import.meta.url).href, channel, boot, new URL('./frame-errors.js', import.meta.url).href); + setFloors(rooms); setSnapshot(state); setScene({source,channel,html:document}); + rememberWorkspace(identity.person.id, source); + } + enter().catch((failure) => { if (!stop.signal.aborted) setError(failure.message); }); + return () => stop.abort(); + }, [source, identity, generation, smallWindow]); + + useEffect(() => { + const stop = new AbortController(); + const update = async (event) => { + if (event !== 'profile') return; + try { const person = await pocket.api('profile', undefined, { signal:stop.signal }); if (!stop.signal.aborted) setIdentity(person); } + catch (failure) { if (!stop.signal.aborted) setError(failure.message); } + }; + listeners.add(update); + return () => { stop.abort(); listeners.delete(update); }; + }, []); + + useLayoutEffect(() => { + if (!documentHtml || !frame.current || !source || scene.source !== source) return; + const boundFrame = frame.current; + const boundChannel = scene.channel; + const stop = new AbortController(); + const timers = new Set(); + const bridge = createBridge({ pocket, source, clientId:clientId.current, signal:stop.signal, + customize: () => setOverlay({ kind:'avatar' }), + workspace: () => setOverlay({ kind:'floors' }), + inspect: (worker) => { if (!stop.signal.aborted) setOverlay({ kind:'worker', ...worker, key:uuid() }); }, + switchFloor: async (target) => { + const rooms = await pocket.api('floors', undefined, { signal:stop.signal }); + if (!rooms.some((room) => room.id === target)) throw new Error('That chat floor is not available to your account.'); + await pocket.api(`state/${target}?${new URLSearchParams({ clientId:clientId.current })}`, undefined, { signal:stop.signal }); + if (stop.signal.aborted) throw new Error('The office is closed.'); + // Acknowledge the old scene before replacing it. Never move its workers or other participants. + const timer = setTimeout(() => { + timers.delete(timer); + if (!stop.signal.aborted) { setOverlay(null); setFloors(rooms); setSource(target); if (target === source) setGeneration((g) => g + 1); } + }, 0); + timers.add(timer); + }, + onSnapshot: (state) => { + if (stop.signal.aborted || state.source !== source) return; + setSnapshot(state); + // Also synchronize after connect, when an inspector opened before the world was ready. + boundFrame.contentWindow?.postMessage({type:'office-v2:overlay',channel:boundChannel,open:!!currentOverlay.current},location.origin); + }, + }); + liveBridge.current = bridge; + const unlisten = listenToFrame({ frame:boundFrame, origin:location.origin, channel:boundChannel, handle:bridge, + onError:(message) => { if (!stop.signal.aborted) setError(message); }, + }); + return () => { stop.abort(); unlisten(); if (liveBridge.current === bridge) liveBridge.current = null; for (const timer of timers) clearTimeout(timer); }; + }, [scene, source, smallWindow]); + + useEffect(() => { + if (!documentHtml || scene.source !== source) return; + frame.current?.contentWindow?.postMessage({ type:'office-v2:overlay', channel:scene.channel, open:!!overlay }, location.origin); + if (!overlay) frame.current?.focus(); + }, [overlay, scene, source]); + useEffect(() => { + const key = (event) => { if (event.key === 'Escape' && overlay) { event.stopPropagation(); setOverlay(null); } }; + window.addEventListener('keydown', key); + return () => window.removeEventListener('keydown', key); + }, [overlay]); + useEffect(() => { + const update = () => setSmallWindow(window.innerWidth < 1024 || window.innerHeight < 600); + update(); window.addEventListener('resize', update); + return () => window.removeEventListener('resize', update); + }, []); + useEffect(() => { + const update = () => setFullscreen(document.fullscreenElement === host.current); + document.addEventListener('fullscreenchange', update); + return () => document.removeEventListener('fullscreenchange', update); + }, []); + + async function toggleFullscreen() { + try { + if (document.fullscreenElement === host.current) await document.exitFullscreen(); + else await host.current?.requestFullscreen(); + } catch { setError('Fullscreen is unavailable in this browser. The office still fills its Pocket page.'); } + } + async function leave() { + if (document.fullscreenElement === host.current) await document.exitFullscreen(); + if (source) pocket.openConversation(source); + pocket.closePage(); + } + function openMaster() { + if (!snapshot?.master) return; + const bridge = liveBridge.current; + if (!bridge) return; + bridge('open', { workerId:snapshot.master.workerId }).catch((failure) => { if (liveBridge.current === bridge) setError(failure.message); }); + } + + async function recoverPending(pending, release = false) { + const bridge = liveBridge.current; + if (!bridge || recovering) return; + const view = overlay; + setRecovering(pending.workerId); setRecoveryError(''); + try { + if (release) await bridge('dismiss', {workerId:pending.workerId,key:uuid()}); + else await bridge('hire', {deskId:pending.deskId,prompt:pending.prompt,key:pending.requestId}); + if (liveBridge.current === bridge && currentOverlay.current === view) setOverlay(null); + } catch (failure) { if (liveBridge.current === bridge && currentOverlay.current === view) setRecoveryError(failure.message); } + finally { if (liveBridge.current === bridge) setRecovering(null); } + } + + return html`

+
${snapshot?.title ?? 'Office v2'} + + ${snapshot?.pending?.length ? html`` : null} + + +
+ ${smallWindow ? html`` : null} + ${error ? html`` : null} + ${documentHtml ? html`` : !error ? html`
<${pocket.Loader} label=${identity && !floors.length ? 'No root chats. Create a chat in Pocket first.' : 'Entering your chat floor…'} />
` : null} + ${overlay ? html`
` : null} +
`; + } + + pocket.page({ id:'office-v2-world', title:'Office v2', icon:'grid', component:Office }); + pocket.rail({ id:'office-v2', title:'Office v2', icon:'grid', page:'office-v2-world' }); + pocket.launcher({ id:'office-v2', label:'Open 3D Office', detail:'Chat floors, master desk, and real Pocket workers', icon:'grid', page:'office-v2-world' }); +} diff --git a/integrations/pi-pocket/office-v2-client/style.css b/integrations/pi-pocket/office-v2-client/style.css new file mode 100644 index 000000000..895bdf8c6 --- /dev/null +++ b/integrations/pi-pocket/office-v2-client/style.css @@ -0,0 +1,24 @@ +.office-v2-world{position:fixed;inset:0;z-index:50;display:flex;flex-direction:column;width:100vw;height:100dvh;min-width:1024px;min-height:600px;overflow:hidden;color:var(--text);background:#171a25} +.office-v2-world:fullscreen{width:100vw;height:100dvh;background:#171a25} +.office-v2-worldbar{display:flex;align-items:center;gap:8px;padding:5px 12px;flex:none;border-bottom:1px solid var(--line);background:var(--bg)} +.office-v2-worldbar strong{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:14px} +.office-v2-worldbar .button{min-height:32px;flex:none;font-size:13px;padding:5px 12px} +.office-v2-world-stage{position:relative;display:flex;flex-direction:column;flex:1;min-height:0;overflow:hidden} +.office-v2-worldframe{display:block;width:100%;min-height:0;flex:1;border:0;background:#171a25} +.office-v2-world-loading{flex:1;display:grid;place-items:center;padding:24px;text-align:center} +.office-v2-world-error{position:absolute;z-index:30;top:12px;left:20px;right:20px;max-height:50%;overflow:auto;padding:12px;border:1px solid var(--err);border-radius:8px;background:var(--bg);overflow-wrap:anywhere} +.office-v2-world-error .button{margin:8px 8px 0 0;min-height:32px} +.office-v2-inspector-scrim{position:absolute;inset:0;z-index:40;background:#1119;display:grid;place-items:center;padding:16px} +.office-v2-inspector{display:flex;flex-direction:column;width:min(1180px,100%);height:min(850px,100%);min-height:0;overflow:hidden;border:1px solid var(--line);border-radius:10px;background:var(--bg);box-shadow:0 12px 48px #0007} +.office-v2-inspector>header{display:flex;align-items:center;gap:8px;padding:5px 10px;flex:none;border-bottom:1px solid var(--line)} +.office-v2-inspector>header>strong{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:14px} +.office-v2-inspector>header>.button{min-width:32px;min-height:32px;flex:none;padding:4px 8px} +.office-v2-inspector-view{flex:1;width:100%;min-height:0;overflow:hidden} +.office-v2-avatar{padding:16px;display:flex;flex-direction:column;gap:14px;min-height:0;overflow:auto} +.office-v2-avatar p{margin:0;line-height:1.5} +.office-v2-avatar label{display:flex;flex-direction:column;gap:6px;max-width:480px} +.office-v2-avatar .button{min-height:32px} +.office-v2-avatar select,.office-v2-avatar textarea,.office-v2-avatar input[type=text]{min-height:34px;padding:7px 9px;border:1px solid var(--line);border-radius:6px;color:var(--text);background:var(--card);font:inherit;width:100%;box-sizing:border-box} +.office-v2-desktop-required{position:fixed;inset:0;z-index:100;display:flex;flex-direction:column;justify-content:center;align-items:center;gap:12px;padding:24px;text-align:center;background:#171a25;color:var(--text)} +.office-v2-desktop-required p{max-width:480px;line-height:1.5} +.office-v2-desktop-required .button{min-height:32px} diff --git a/integrations/pi-pocket/office-v2-client/uuid.js b/integrations/pi-pocket/office-v2-client/uuid.js new file mode 100644 index 000000000..3fd73b396 --- /dev/null +++ b/integrations/pi-pocket/office-v2-client/uuid.js @@ -0,0 +1,10 @@ +// Keep this module valid JavaScript: the Pocket packager copies it to its standalone browser host. +export function uuid() { + if (typeof globalThis.crypto?.randomUUID === 'function') return globalThis.crypto.randomUUID(); + if (!globalThis.crypto?.getRandomValues) throw new Error('Secure random values are unavailable in this browser.'); + const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16)); + bytes[6] = (bytes[6] & 15) | 64; + bytes[8] = (bytes[8] & 63) | 128; + const hex = Array.from(bytes, (value) => value.toString(16).padStart(2, '0')).join(''); + return [hex.slice(0,8),hex.slice(8,12),hex.slice(12,16),hex.slice(16,20),hex.slice(20)].join('-'); +} diff --git a/integrations/pi-pocket/office-v2-client/views.js b/integrations/pi-pocket/office-v2-client/views.js new file mode 100644 index 000000000..8e9606d52 --- /dev/null +++ b/integrations/pi-pocket/office-v2-client/views.js @@ -0,0 +1,74 @@ +import { uuid } from './uuid.js'; +const COLORS = ['#ff8a5b', '#4f86f7', '#06d6a0', '#ef476f', '#ffd166', '#9d4edd', '#00b4d8', '#f77f00']; +const HAIR = ['Short', 'Long', 'Bun', 'Spiky', 'Curly', 'Ponytail', 'Bald']; + +// Presence is per browser instance. The native room lists accounts, not duplicate tabs. +export function roomPeople(peers) { + const people = new Map(); + for (const peer of peers) { + const boundary = peer.id.lastIndexOf(':'); + people.set(boundary < 0 ? peer.id : peer.id.slice(0, boundary), peer); + } + return [...people.values()]; +} +export function workerStatus(status) { + return status === 'working' ? 'Working' : status === 'idle' ? 'Idle' : 'Status unavailable'; +} + +export function roomViews(pocket, changed) { + const { html, useState, useRef, useEffect } = pocket; + function Avatar({ close }) { + const [profile, setProfile] = useState(null); + const [error, setError] = useState(''); + const [saving, setSaving] = useState(false); + useEffect(() => { + const stop = new AbortController(); + pocket.api('profile', undefined, { signal: stop.signal }).then((data) => setProfile(data.profile), (failure) => { if (!stop.signal.aborted) setError(failure.message); }); + return () => stop.abort(); + }, []); + const look = (field, value) => setProfile((p) => ({ ...p, look: { ...p.look, [field]: Number(value) } })); + async function save() { + setSaving(true); setError(''); + try { + await pocket.api('profile', { color: profile.color, look: profile.look }); + changed('profile'); close(); + } catch (failure) { setError(failure.message); } + finally { setSaving(false); } + } + return html`
+

Your identity comes from Pocket. Your appearance belongs to your account, across browsers and conversations.

+ ${error ? html`` : null} + ${profile ? html`

${profile.name}

+ + + + + ` : html`<${pocket.Loader} label="Loading your appearance…" />`} +
`; + } + function Hire({ close, source, deskId, clientId }) { + const [prompt, setPrompt] = useState(''); + const [error, setError] = useState(''); + const [saving, setSaving] = useState(false); + const key = useRef(uuid()); + const submitted = useRef(null); + async function hire() { + setSaving(true); setError(''); + // Once attempted, retain the original task and key. An uncertain admission must not become a new hire. + submitted.current ??= prompt.trim(); + try { + const snapshot = await pocket.api(`hire/${source}`, { clientId, deskId, prompt: submitted.current, requestId: key.current }); + changed('state', snapshot); close(); + } catch (failure) { setError(`${failure.message} Retry uses this same task and hire key. A pending hire can also be recovered from the room.`); } + finally { setSaving(false); } + } + return html`
+

Hire a real Pocket agent at ${deskId}. It inherits workspace #${source}’s model, thinking, folder, tools, and team budget.

+ + ${error ? html`` : null} + +

This starts model work. Follow-ups and approvals happen in the agent’s native Pocket conversation.

+
`; + } + return { Avatar, Hire }; +} diff --git a/integrations/pi-pocket/office-v2-client/workspace.js b/integrations/pi-pocket/office-v2-client/workspace.js new file mode 100644 index 000000000..660dc652b --- /dev/null +++ b/integrations/pi-pocket/office-v2-client/workspace.js @@ -0,0 +1,85 @@ +import { uuid } from './uuid.js'; +const prefix = (personId) => `x.office-v2.${encodeURIComponent(personId)}.`; +export function recentWorkspaces(personId, storage) { + try { + storage ??= globalThis.localStorage; + const ids = JSON.parse(storage.getItem(`${prefix(personId)}workspaces`) ?? '[]'); + return Array.isArray(ids) ? [...new Set(ids.filter((id) => Number.isSafeInteger(id) && id > 0))].slice(0, 8) : []; + } catch { return []; } +} +export function rememberWorkspace(personId, source, storage) { + if (!Number.isSafeInteger(source) || source < 1) return; + try { + storage ??= globalThis.localStorage; + storage.setItem(`${prefix(personId)}workspace`, String(source)); + storage.setItem(`${prefix(personId)}workspaces`, JSON.stringify([source, ...recentWorkspaces(personId, storage).filter((id) => id !== source)].slice(0, 8))); + } catch { /* Account-owned browser history is optional and does not grant access. */ } +} +export function workspaceFromLink(value, origin) { + const input = value.trim(); + if (/^#?[1-9]\d*$/.test(input)) { + const id = Number(input.replace(/^#/, '')); + if (Number.isSafeInteger(id)) return id; + } + try { + const url = new URL(input, origin); + const match = url.pathname.match(/^\/s\/([1-9]\d*)\/?$/); + if (url.origin === origin && !url.username && !url.password && match && Number.isSafeInteger(Number(match[1]))) return Number(match[1]); + } catch { /* Report one clear input error below. */ } + throw new Error('Paste a chat link from this Pocket app, such as /s/123, or a conversation number.'); +} + +export function workspaceView(pocket, changed) { + const { html, useState, useRef, useEffect } = pocket; + return function Workspace({ close, source, personId }) { + const openChat = pocket.conversationId(); + const [rooms, setRooms] = useState([]); + const [link, setLink] = useState(''); + const [error, setError] = useState(''); + const [busy, setBusy] = useState(false); + const clientId = useRef(uuid()); + const lifetime = useRef(null); + const ids = [...new Set([openChat, source, ...recentWorkspaces(personId)].filter((id) => Number.isSafeInteger(id) && id > 0))]; + const load = (id, signal) => pocket.api(`state/${id}?${new URLSearchParams({ clientId: clientId.current })}`, undefined, { signal }); + useEffect(() => { + const stop = new AbortController(); + lifetime.current = stop; + Promise.all(ids.map(async (id) => { + try { const room = await load(id, stop.signal); return { id, title: room.title, model: room.model }; } + catch { return { id, unavailable: true }; } + })).then((items) => { if (!stop.signal.aborted) setRooms(items); }); + return () => stop.abort(); + }, []); + async function choose(id) { + const stop = lifetime.current; + if (!stop || stop.signal.aborted) return; + setBusy(true); setError(''); + try { + // Recheck access at selection time. No heartbeat, agent start, or conversation mutation. + const room = await load(id, stop.signal); + if (stop.signal.aborted) return; + rememberWorkspace(personId, room.source); + changed('workspace', room); + pocket.openConversation(room.source); + close(); + } catch (failure) { if (!stop.signal.aborted) setError(failure.message); } + finally { if (!stop.signal.aborted) setBusy(false); } + } + function paste() { + try { void choose(workspaceFromLink(link, location.origin)); } + catch (failure) { setError(failure.message); } + } + return html`
+

Choose the chat whose office you want to join. Open a chat in Pocket, paste its link, or choose a recent workspace.

+

People and desks belong to each workspace. Switching joins that room; it does not move teammates or agents. New agents inherit the selected root chat’s model and settings. Linked agent chats cannot hire other agents.

+ + + ${error ? html`` : null} +
${rooms.length ? rooms.map((room) => html`
+ ${room.unavailable ? `Conversation #${room.id} is unavailable` : room.title} +

#${room.id}${room.id === source ? ' · Current office' : ''}${room.id === openChat ? ' · Open chat' : ''}

+ ${!room.unavailable ? html`

${room.model ?? 'No model selected'}

` : null} +
`) : html`<${pocket.Loader} label="Checking your conversations…" />`}
+
`; + }; +} diff --git a/integrations/pi-pocket/office-v2-client/world-document.js b/integrations/pi-pocket/office-v2-client/world-document.js new file mode 100644 index 000000000..236be0416 --- /dev/null +++ b/integrations/pi-pocket/office-v2-client/world-document.js @@ -0,0 +1,6 @@ +export function worldDocument(html, assetRoot, channel, bootstrap, errorScript) { + if (!Array.isArray(bootstrap.floors) || !bootstrap.floors.some((floor) => floor.id === bootstrap.snapshot.source)) throw new Error('The selected chat floor is no longer available.'); + const meta = ``; + // Pocket deliberately forbids . Resolve only trusted packaged relative asset references. + return html.replace(/\b(src|href)="(\.\/[^\"]+)"/g, (_all, attribute, path) => `${attribute}="${new URL(path, assetRoot).href}"`).replace('', `${meta}`); +} diff --git a/integrations/pi-pocket/office-v2.test.ts b/integrations/pi-pocket/office-v2.test.ts new file mode 100644 index 000000000..8d7318ab6 --- /dev/null +++ b/integrations/pi-pocket/office-v2.test.ts @@ -0,0 +1,413 @@ +// Helpers must load first. These tests use temporary data and a scripted model, never the live server. +import assert from 'node:assert/strict'; +import { copyFileSync, mkdirSync, writeFileSync } from 'node:fs'; +import { createServer } from 'node:http'; +import { join } from 'node:path'; +import { after, before, test } from 'node:test'; +import { pathToFileURL } from 'node:url'; +const pocketRoot = process.env.PI_POCKET_ROOT ?? '/opt/pi-pocket'; +const { cleanUp, context, newSession, openApp, owner, root, scriptedModel } = await import(pathToFileURL(join(pocketRoot, 'test/helpers.ts')).href); +const { createHandler } = await import(pathToFileURL(join(pocketRoot, 'src/server/http.ts')).href); +const { PlanDoc, TurnsDoc } = await import(pathToFileURL(join(pocketRoot, 'src/server/docs.ts')).href); +const { defineDocFamily } = await import(pathToFileURL(join(pocketRoot, 'node_modules/@earendil-works/pi-durable/dist/index.js')).href); +import { SKIN_TONES, HAIR_COLORS, HAIR_STYLES } from '../../src/shared/avatar.ts'; +const TestProfiles = defineDocFamily({ kind: 'x.office-v2.profiles', version: 1, scope: 'session', family: true, + initial: (_seed: null) => ({}) }); + +let app: Awaited>; +let server: ReturnType; +let base: string; +const clientId = 'a0b1c2d3-0000-4000-8000-000000000001'; +const tokens: Record = {}; +let guestId: string; +before(async () => { + const drop = join(root, 'data', 'extensions'); + mkdirSync(join(drop, 'office-v2-client'), { recursive: true }); + writeFileSync(join(drop, 'office-v2-client', 'index.js'), 'export default () => {};'); + copyFileSync(join(import.meta.dirname, 'office-v2.ts'), join(drop, '_office-v2.ts')); + // Test-only public capability decorator: fail the binding commit after real initial-message admission. + writeFileSync(join(drop, 'office-v2.ts'), ` +import setup from './_office-v2.ts'; +export const pocketApi = 2; +export default (host, web) => setup(host, new Proxy({ ...web }, { get(target, key) { + if (key !== 'conversationRoute') return target[key]; + return (method, path, handler, options) => target.conversationRoute(method, path, req => { + let commits = 0; + return handler(new Proxy({ ...req }, { get(request, member) { + if (member !== 'commit') return request[member]; + return callback => { + if (++commits === 2 && path === 'hire/:source' && globalThis.officeFailBinding) { + globalThis.officeFailBinding = false; + return Promise.reject(new Error('Test interrupted binding commit')); + } + return request.commit(callback); + }; + }})); + }, options); +}})); +`); + app = await openApp(scriptedModel()); + tokens.owner = app.config.ownerToken; + const guest = app.config.addUser('Guest', 'guest'); + tokens.guest = guest.token; + guestId = guest.user.id; + tokens.viewer = app.config.addUser('Viewer', 'viewer').token; + tokens.scoped = app.config.addUser('Scoped', 'guest', ['1']).token; + await app.setExtensionEnabled(owner(app), 'office-v2.ts', true); + server = createServer(createHandler({ app, listen: { host: '127.0.0.1', port: 0 }, restart: () => {} })); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + base = `http://127.0.0.1:${(server.address() as { port: number }).port}`; +}); +after(async () => { server?.closeAllConnections(); server?.close(); await app?.close(); cleanUp(); }); +async function call(path: string, body?: object, actor = 'owner', header = true) { + const response = await fetch(`${base}/api/x/office-v2/${path}`, { + method: body ? 'POST' : 'GET', headers: { + authorization: `Bearer ${tokens[actor]}`, ...(header ? { 'X-Pocket': '1' } : {}), + ...(body ? { 'content-type': 'application/json' } : {}), + }, ...(body ? { body: JSON.stringify(body) } : {}), + }); + return { status: response.status, data: await response.json() as any }; +} +const hire = (deskId = 'desk-1', requestId = 'hire-one') => ({ clientId, deskId, requestId, prompt: 'Say hello.' }); +const state = (source: unknown) => `state/${source}?clientId=${clientId}`; + +test('profile bootstrap computes deterministic defaults without model, source, or durable GET writes', async () => { + assert.equal(app.sessions().length, 0, 'bootstrap does not need any source session'); + const first = await call('profile'); + assert.equal(first.status, 200, JSON.stringify(first.data)); + assert.deepEqual(first.data.person, { id: owner(app).id, name: owner(app).name, role: 'owner' }); + assert.equal(first.data.profile.name, owner(app).name); + assert.match(first.data.profile.color, /^#[0-9a-f]{6}$/); + assert.deepEqual((await call(`profile?clientId=${clientId}`)).data, first.data); + assert.deepEqual((await call('profile?clientId=bbbbbbbb-0000-4000-8000-000000000002')).data, first.data); + assert.equal(await app.harness.snapshot(TestProfiles, owner(app).id, context), undefined); + assert.equal((await call('profile', undefined, 'viewer')).status, 404); + assert.equal((await call('profile', undefined, 'scoped')).status, 404); + assert.equal((await call('profile', undefined, 'owner', false)).status, 403); +}); + +test('per-person avatar edits persist across tabs and reload; Pocket display name remains authoritative', async () => { + const guestDefault = (await call('profile', undefined, 'guest')).data; + assert.deepEqual([SKIN_TONES.length, HAIR_COLORS.length, HAIR_STYLES.length], [8, 10, 7]); + const edited = await call('profile', { color: '#A1B2C3', look: { + skin: SKIN_TONES.length - 1, hair: HAIR_COLORS.length - 1, style: HAIR_STYLES.length - 1, + } }); + assert.equal(edited.status, 200, JSON.stringify(edited.data)); + assert.equal(edited.data.profile.color, '#a1b2c3'); + assert.deepEqual((await call('profile', undefined, 'guest')).data, guestDefault); + assert.equal((await call('profile', { color: '#123456' }, 'guest')).status, 200); + assert.deepEqual((await call(`profile?clientId=${clientId}`)).data, edited.data); + await app.setExtensionEnabled(owner(app), 'office-v2.ts', false); + await app.setExtensionEnabled(owner(app), 'office-v2.ts', true); + assert.deepEqual((await call('profile')).data, edited.data); + assert.equal((await call('profile', undefined, 'guest')).data.profile.color, '#123456'); + const oldName = owner(app).name; + try { + app.config.updateUser(owner(app).id, { name: 'Pocket renamed person' }); + const current = (await call('profile')).data; + assert.equal(current.person.name, 'Pocket renamed person'); + assert.equal(current.profile.name, current.person.name); + assert.deepEqual(current.profile.look, edited.data.profile.look); + assert.equal(current.profile.color, edited.data.profile.color); + } finally { app.config.updateUser(owner(app).id, { name: oldName }); } +}); + +test('profile edits reject identity, provider, credentials, and out-of-range avatar values', async () => { + const before = (await call('profile')).data; + for (const body of [ + { name: 'Impersonation' }, { id: guestId }, { person: { id: guestId } }, { role: 'owner' }, + { provider: 'other' }, { credentials: 'secret' }, { token: 'secret' }, { model: 'other/model' }, + { color: 'red' }, { color: null }, { look: null }, { look: { skin: 8, hair: 0, style: 0 } }, + { look: { skin: 0, hair: 10, style: 0 } }, { look: { skin: 0, hair: 0, style: 7 } }, + { look: { skin: -1, hair: 0, style: 0 } }, { look: { skin: 0.5, hair: 0, style: 0 } }, + { look: { skin: 0, hair: 0 } }, { look: { skin: 0, hair: 0, style: 0, name: 'Other' } }, {}, + ]) assert.equal((await call('profile', body)).status, 400, JSON.stringify(body)); + assert.deepEqual((await call('profile')).data, before, 'invalid requests never partially save'); +}); + +test('profile customization is independent of source turn and plan; state uses the saved avatar', async () => { + const source = await newSession(app); + await app.harness.commit(async tx => { + Object.assign(await tx.doc(TurnsDoc, source), { on: true, driver: guestId, asks: [] }); + (await tx.doc(PlanDoc, source)).on = true; + }, context); + const saved = await call('profile', { color: '#bada55', look: { skin: 2, hair: 3, style: 4 } }); + assert.equal(saved.status, 200); + const presence = encodeURIComponent(JSON.stringify({ name: 'Spoof', color: '#000000', look: { skin: 0, hair: 0, style: 0 } })); + const a = await call(`${state(source)}&presence=${presence}`); + const secondClient = 'bbbbbbbb-0000-4000-8000-000000000002'; + const b = await call(`state/${source}?clientId=${secondClient}&presence=${presence}`); + assert.equal(a.status, 200); + assert.deepEqual(a.data.person, saved.data.person); + assert.deepEqual(a.data.profile, saved.data.profile); + assert.deepEqual(b.data.profile, saved.data.profile); + assert.notEqual(a.data.you, b.data.you); + for (const peer of b.data.peers) { + assert.equal(peer.name, saved.data.person.name); + assert.equal(peer.color, saved.data.profile.color); + assert.deepEqual(peer.look, saved.data.profile.look); + } +}); + +test('authenticated GET presence has per-tab identity and no durable write; invalid fields fail', async () => { + const source = await newSession(app); + const presence = encodeURIComponent(JSON.stringify({ name: 'Avatar', x: 1, z: 2, moving: true })); + const result = await call(`${state(source)}&presence=${presence}`); + assert.equal(result.status, 200, JSON.stringify(result.data)); + assert.equal(result.data.you, `${owner(app).id}:${clientId}`); + assert.equal(result.data.peers[0].floor, 'pocket'); + assert.equal(result.data.peers[0].name, owner(app).name, 'profile cannot impersonate another actor'); + assert.deepEqual(result.data.bindings, []); + assert.equal((await call(state(source), undefined, 'viewer')).status, 404); + assert.equal((await call(state(source), undefined, 'scoped')).status, 404); + assert.equal((await call(state(source), undefined, 'owner', false)).status, 403); + assert.equal((await call(`${state(source)}&presence=${encodeURIComponent('{"x":99999}')}`)).status, 400); + assert.equal((await call(`hire/${source}`, { ...hire(), model: 'other/model' })).status, 400); + assert.equal((await call(`hire/${source}`, hire('../../secret'))).status, 400); + assert.equal((await call(`hire/${source}`, hire('desk-17'))).status, 400, 'unbuilt expansion desk'); + assert.equal((await call(`hire/${source}`, hire('meeting-1'))).status, 400, 'meeting seats are deferred'); +}); + +test('native heartbeat marks lite presence and a 3D heartbeat clears it for the same tab', async () => { + const source = await newSession(app); + const heartbeat = presence => call(`${state(source)}&presence=${encodeURIComponent(JSON.stringify(presence))}`); + const native = await heartbeat({ lite: true }); + assert.equal(native.status, 200); + assert.equal(native.data.peers.find(p => p.id === native.data.you).lite, true); + const rendered = await heartbeat({ x: 2, z: 3 }); + assert.equal(rendered.status, 200); + assert.equal(rendered.data.peers.find(p => p.id === rendered.data.you).lite, false); + assert.equal(rendered.data.peers.find(p => p.id === rendered.data.you).x, 2); + assert.equal((await heartbeat({ lite: false })).data.peers[0].lite, false); + for (const lite of ['true', 1, null, {}, []]) assert.equal((await heartbeat({ lite })).status, 400); +}); + +test('hire is idempotent, binds an ordinary inherited session, and refuses another actor', async () => { + const source = await newSession(app); + const first = await call(`hire/${source}`, hire()); + assert.equal(first.status, 200, JSON.stringify(first.data)); + assert.equal(first.data.bindings.length, 1); + const binding = first.data.bindings[0]; + assert.match(binding.workerId, /^pocket-[0-9a-f]{24}$/); + assert.equal(app.cwdOf(binding.conversationId), app.cwdOf(source)); + const count = app.sessions().length; + assert.deepEqual((await call(`hire/${source}`, hire())).data.bindings, [binding]); + assert.equal(app.sessions().length, count); + assert.equal((await call(`hire/${source}`, hire(), 'guest')).status, 409); + const dismissed = await call(`dismiss/${source}`, { clientId, workerId: binding.workerId }); + assert.equal(dismissed.status, 200); + assert.deepEqual(dismissed.data.bindings, []); + assert.equal(app.sessions().length, count, 'dismiss never deletes a session'); + assert.equal((await call(state(source))).data.workers.length, 0); + const second = await call(`hire/${source}`, hire('desk-1', 'new-generation')); + assert.equal(second.status, 200); + assert.notEqual(second.data.bindings[0].workerId, binding.workerId); + assert.equal((await call(`dismiss/${source}`, { clientId, workerId: binding.workerId })).status, 409); + assert.equal((await call(state(source))).data.bindings[0].workerId, second.data.bindings[0].workerId); +}); + +test('concurrent hires reserve one desk atomically', async () => { + const source = await newSession(app); + const count = app.sessions().length; + const results = await Promise.all([ + call(`hire/${source}`, hire('desk-2', 'first')), + call(`hire/${source}`, hire('desk-2', 'second')), + ]); + assert.deepEqual(results.map(r => r.status).sort(), [200, 409]); + assert.equal(app.sessions().length, count + 1); + assert.equal((await call(state(source))).data.workers.length, 1); +}); + +test('failed plan admission permits original-key recovery and explicit unseat', async () => { + const source = await newSession(app); + await app.harness.commit(async tx => { (await tx.doc(PlanDoc, source)).on = true; }, context); + const count = app.sessions().length; + const failed = await call(`hire/${source}`, hire('desk-3')); + assert.notEqual(failed.status, 200); + assert.equal(app.sessions().length, count); + const pending = await call(state(source)); + assert.equal(pending.status, 200, 'GET is not model work'); + assert.equal(pending.data.pending[0].requestId, 'hire-one'); + assert.equal(pending.data.pending[0].recoverable, true); + assert.deepEqual((await call(state(source), undefined, 'guest')).data.pending, []); + await app.harness.commit(async tx => { (await tx.doc(PlanDoc, source)).on = false; }, context); + const retry = await call(`hire/${source}`, hire('desk-3')); + assert.equal(retry.status, 200, JSON.stringify(retry.data)); + assert.equal(retry.data.bindings.length, 1); + assert.equal(app.sessions().length, count + 1); +}); + +test('GET remains available without the turn; chat, hire, and dismiss need the turn', async () => { + const source = await newSession(app); + await app.harness.commit(async tx => { + Object.assign(await tx.doc(TurnsDoc, source), { on: true, driver: guestId, asks: [] }); + }, context); + assert.equal((await call(state(source))).status, 200); + for (const [route, body] of [ + ['hire', hire()], + ['dismiss', { clientId, workerId: 'pocket-000000000000000000000000' }], + ['state', { clientId, action: 'chat', text: 'Hello', requestId: 'chat-1' }], + ] as const) assert.equal((await call(`${route}/${source}`, body)).status, 409); + const chat = { clientId, action: 'chat', text: 'Hello', requestId: 'chat-1' }; + assert.equal((await call(`state/${source}`, chat, 'guest')).status, 200); + const repeated = await call(`state/${source}`, chat, 'guest'); + assert.equal(repeated.data.chat.length, 1); + assert.equal(repeated.data.chat[0].from, `${guestId}:${clientId}`); +}); + +test('interrupted binding commit recovers the original receipt across extension reload', async () => { + const source = await newSession(app); + const count = app.sessions().length; + (globalThis as any).officeFailBinding = true; + assert.equal((await call(`hire/${source}`, hire('desk-4', 'interrupted'))).status, 500); + assert.equal(app.sessions().length, count + 1, 'initial message was already admitted'); + const pending = (await call(state(source))).data.pending[0]; + assert.equal(pending.recoverable, true); + await app.setExtensionEnabled(owner(app), 'office-v2.ts', false); + await app.setExtensionEnabled(owner(app), 'office-v2.ts', true); + const recovered = await call(`hire/${source}`, { clientId, deskId: pending.deskId, prompt: pending.prompt, requestId: pending.requestId }); + assert.equal(recovered.status, 200, JSON.stringify(recovered.data)); + assert.equal(recovered.data.bindings[0].workerId, pending.workerId); + assert.equal(recovered.data.model, 'faux/faux-1'); + assert.deepEqual(recovered.data.pending, []); + assert.equal(app.sessions().length, count + 1, 'receipt recovery never starts another session'); +}); + +test('dismiss of uncertain admission retains durable exclusion until receipt recovery', async () => { + const source = await newSession(app); + (globalThis as any).officeFailBinding = true; + assert.equal((await call(`hire/${source}`, hire('desk-5', 'uncertain'))).status, 500); + const count = app.sessions().length; + const pending = (await call(state(source))).data.pending[0]; + assert.equal((await call(`dismiss/${source}`, { clientId, workerId: pending.workerId })).status, 200); + assert.equal((await call(`hire/${source}`, hire('desk-5', 'different'))).status, 409); + const recovered = await call(`hire/${source}`, hire('desk-5', 'uncertain')); + assert.equal(recovered.status, 200); + assert.deepEqual(recovered.data.bindings, [], 'dismissed admitted session stays unseated'); + assert.deepEqual(recovered.data.pending, []); + assert.equal(app.sessions().length, count, 'uncertain session is neither duplicated nor deleted'); + assert.equal((await call(`hire/${source}`, hire('desk-5', 'different'))).status, 200); +}); + +test('avatar actions deliver bounded source-scoped one-shots and heartbeat preserves state without model work', async () => { + const source = await newSession(app); + const other = await newSession(app); + const sessions = app.sessions().length; + const action = value => call(`presence/${source}`, { clientId, action: value }); + const reach = await action({ t: 'act' }); + assert.equal(reach.status, 200, JSON.stringify(reach.data)); + assert.equal(reach.data.events.length, 1); + assert.deepEqual(reach.data.events[0].message, { t: 'peer.act', id: reach.data.you }); + const held = await action({ t: 'act', smoke: true, golf: true, drink: 'water', throwing: 'darts' }); + const emote = await action({ t: 'emote', emote: 'wave' }); + assert.ok(emote.data.events.at(-1).seq > held.data.events.at(-1).seq); + assert.equal(emote.data.events.at(-1).message.t, 'peer.emote'); + await action({ t: 'doing', what: 'Reading', reading: true }); + const heartbeat = await call(`${state(source)}&presence=${encodeURIComponent('{"x":2}')}`); + const p = heartbeat.data.peers.find(p => p.id === reach.data.you); + assert.deepEqual([p.x, p.smoking, p.golfing, p.drink, p.throwing, p.doing, p.reading], [2, true, true, 'water', 'darts', 'Reading', true]); + assert.equal((await call(state(source), undefined, 'guest')).data.events.length, 3); + assert.deepEqual((await call(state(other))).data.events, []); + await action({ t: 'act', drink: null, throwing: null, smoke: false, golf: false }); + await action({ t: 'doing' }); + const cleared = (await call(state(source))).data.peers.find(p => p.id === reach.data.you); + assert.equal(cleared.drink, undefined); + assert.equal(cleared.throwing, undefined); + assert.equal(cleared.doing, undefined); + assert.equal(cleared.reading, false); + assert.equal(app.sessions().length, sessions); + assert.equal(await app.harness.snapshot(PlanDoc, source, context), undefined); +}); + +test('avatar actions reject identity overrides and invalid values; seats use current leases with private refusal', async () => { + const source = await newSession(app); + const action = (value, actor = 'owner') => call(`presence/${source}`, { clientId, action: value }, actor); + for (const value of [{ t: 'act', id: guestId }, { t: 'act', smoke: 'yes' }, { t: 'act', drink: 'fuel' }, + { t: 'act', throwing: 'bomb' }, { t: 'emote', emote: 'hack' }, { t: 'doing', reading: 1 }, + { t: 'doing', what: 'a'.repeat(161) }, { t: 'carry', issue: 1 }, { t: '__proto__' }]) { + assert.equal((await action(value)).status, 400, JSON.stringify(value)); + } + assert.equal((await action({ t: 'act' }, 'viewer')).status, 404); + assert.equal((await action({ t: 'act' }, 'scoped')).status, 404); + await action({ t: 'sit', seat: 'couch:1' }); + const refused = await action({ t: 'sit', seat: 'couch:1' }, 'guest'); + assert.equal(refused.status, 200); + assert.equal(refused.data.events.at(-1).message.t, 'sit.refused'); + assert.equal(refused.data.peers.find(p => p.id === refused.data.you).seat, undefined); + assert.equal((await call(state(source))).data.events.some(e => e.message.t === 'sit.refused'), false); + await action({ t: 'sit' }); + assert.equal((await action({ t: 'sit', seat: 'couch:1' }, 'guest')).data.peers.find(p => p.id === refused.data.you).seat, 'couch:1'); +}); + +test('avatar events expire and bursts remain bounded', async () => { + const source = await newSession(app); + const action = () => call(`presence/${source}`, { clientId, action: { t: 'act' } }); + const first = await action(); + const realNow = Date.now; + try { + for (let i = 1; i <= 40; i++) { + Date.now = () => realNow() + i * 1100; + assert.equal((await action()).status, 200); + } + const current = (await call(state(source))).data; + assert.ok(current.events.length <= 32); + assert.ok(current.events.every(e => e.seq > first.data.events[0].seq)); + Date.now = () => realNow() + 70_000; + const expired = (await call(state(source))).data; + assert.deepEqual(expired.events, []); + assert.deepEqual(expired.peers, []); + } finally { Date.now = realNow; } + for (let i = 0; i < 12; i++) assert.equal((await action()).status, 200); + assert.equal((await action()).status, 429); +}); + +test('chat floors list only authorized root conversations and keep the master separate from children', async () => { + const source = await newSession(app); + const before = app.sessions().length; + const empty = await call(state(source)); + assert.equal(empty.data.master.workerId, `pocket-master-${source}`); + assert.equal(empty.data.master.title, empty.data.title); + assert.equal(empty.data.master.busy, false); + assert.deepEqual(empty.data.workers, []); + assert.deepEqual(empty.data.bindings, [], 'the master is not a hired child or releasable seat'); + const hired = await call(`hire/${source}`, hire('desk-16', 'floor-child')); + const child = hired.data.bindings[0].conversationId; + const floors = await call('floors'); + assert.equal(floors.status, 200, JSON.stringify(floors.data)); + assert.ok(floors.data.some((floor: any) => floor.id === Number(source))); + assert.ok(!floors.data.some((floor: any) => floor.id === child), 'children do not become root floors'); + assert.equal((await call('floors', undefined, 'viewer')).status, 404); + assert.equal((await call('floors', undefined, 'scoped')).status, 404); + assert.equal((await call('floors', undefined, 'owner', false)).status, 403); + assert.equal(app.sessions().length, before + 1, 'reading floors starts no model work'); +}); + +test('a client changes chat floors immediately without moving its other tabs', async () => { + const a = await newSession(app); + const b = await newSession(app); + const otherClient = 'eeeeeeee-0000-4000-8000-000000000005'; + const joinFloor = (source: unknown, id: string) => call(`state/${source}?${new URLSearchParams({clientId:id,presence:JSON.stringify({x:1,y:0,z:1,rotY:0,moving:false})})}`); + await joinFloor(a, clientId); + await joinFloor(a, otherClient); + await joinFloor(b, clientId); + const old = (await call(state(a))).data; + const next = (await call(state(b))).data; + assert.ok(!old.peers.some((peer: any) => peer.id.endsWith(clientId))); + assert.ok(old.peers.some((peer: any) => peer.id.endsWith(otherClient))); + assert.ok(next.peers.some((peer: any) => peer.id.endsWith(clientId))); +}); + +test('each read suppresses bindings when the source audience cannot see worker metadata', async () => { + const source = await newSession(app); + const first = await call(`hire/${source}`, hire('desk-6', 'filtering')); + assert.equal(first.status, 200); + assert.equal(first.data.bindings.length, 1); + const restricted = app.config.addUser('Source-only reader', 'viewer', [String(source)]); + try { + const filtered = await call(state(source)); + assert.equal(filtered.status, 200); + assert.deepEqual(filtered.data.bindings, []); + assert.deepEqual(filtered.data.workers, []); + assert.deepEqual(filtered.data.pending, [], 'bound hidden sessions do not enter recovery metadata'); + } finally { app.config.removeUser(restricted.user.id); } +}); diff --git a/integrations/pi-pocket/office-v2.ts b/integrations/pi-pocket/office-v2.ts new file mode 100644 index 000000000..07c8e9587 --- /dev/null +++ b/integrations/pi-pocket/office-v2.ts @@ -0,0 +1,348 @@ +/** Agent Office: source-scoped desks, shared chat, and ephemeral presence. Dismiss only unseats a worker; it never stops or deletes a session. No Office server, shell, or provider access. */ +import { randomUUID } from 'node:crypto'; +import { defineDoc, defineDocFamily } from '@earendil-works/pi-durable'; +import type { Host, Web, WebRequest, Person, ConversationWebRequest } from './_pocket.d.ts'; +import type { PresenceServerMsg, PeerInfo, ChatLine } from '../../src/shared/protocol/presence.js'; +import type { WorkerInfo } from '../../src/shared/protocol/workers.js'; + +export const pocketApi = 2; +// Mirrors builtDesks(0) in src/shared/layout.ts: 16 initial desks. No expansion, overflow, stations, or meetings. +export const DESK_IDS = Array.from({ length: 16 }, (_, n) => `desk-${n + 1}`); +type Seat = { + workerId: string; deskId: string; actor: string; requestId: string; key: string; prompt: string; + createdAt: number; conversationId?: number; state: 'reserved' | 'retry' | 'bound' | 'dismissed'; +}; +// A mapped JSON record keeps the Office interface compatible with Durable's JsonObject constraint. +type StoredChat = { [K in keyof ChatLine]: ChatLine[K] }; +type OfficeData = { seats: Record; chat: StoredChat[]; chatKeys: string[] }; +const Office = defineDoc({ + kind: 'x.office-v2.office', version: 1, scope: 'conversation', history: 'latest', fork: 'initial', + initial: () => ({ seats: {}, chat: [], chatKeys: [] }), +}); +// Session scope means app-wide, not conversation-owned. Each person's family key is server-selected. +type Avatar = { color: string; look: { skin: number; hair: number; style: number } }; +const Profiles = defineDocFamily<{ color?: string; look?: Avatar['look'] }, null>({ + kind: 'x.office-v2.profiles', version: 1, scope: 'session', family: true, initial: () => ({}), +}); +// Mirrors avatar.ts: SKIN_TONES.length=8, HAIR_COLORS.length=10, HAIR_STYLES.length=7. +const AVATAR_COUNTS = { skin: 8, hair: 10, style: 7 } as const; +function defaultAvatar(id: string): Avatar { + let hash = 2166136261; + for (let i = 0; i < id.length; i++) hash = Math.imul(hash ^ id.charCodeAt(i), 16777619); + hash >>>= 0; + const colors = ['#7c83ff', '#52b788', '#e9c46a', '#e76f51', '#4ea8de', '#c77dff']; + return { color: colors[hash % colors.length], look: { + skin: hash % AVATAR_COUNTS.skin, hair: (hash >>> 3) % AVATAR_COUNTS.hair, style: (hash >>> 7) % AVATAR_COUNTS.style, + } }; +} +export interface OfficeProfileResponse { + person: Person; + profile: Avatar & { name: string }; +} +type AvatarEvent = { seq: number; at: number; message: Extract }; +// Standalone extension mirrors shared/emotes.ts, rooftop.ts and bargames.ts; tests enforce parity. +const EMOTES = ['wave', 'thumbs', 'clap', 'dance', 'point', 'facepalm']; +const DRINKS = ['beer', 'wine', 'martini', 'maitai', 'shot', 'mojito', 'water']; +const BAR_GAMES = ['darts', 'axe']; +export interface OfficeSnapshot extends OfficeProfileResponse { + master: { workerId: string; title: string; busy?: boolean }; + events: AvatarEvent[]; + source: number; title: string; cwd: string; model?: string; you: string; + peers: PeerInfo[]; chat: ChatLine[]; workers: WorkerInfo[]; + bindings: { workerId: string; conversationId: number }[]; + pending: { workerId: string; deskId: string; requestId: string; prompt: string; recoverable: boolean }[]; +} + +export default function officeV2(_host: Host, web: Web) { + if (typeof web.conversationRoute !== 'function') throw new Error('Agent Office needs Pi Pocket 0.19.0 or later.'); + web.client('office-v2-client', { access: 'steer' }); + // Explicitly ephemeral. Nothing here is an authority for a desk or a session. + const presence = new Map(); + let sequence = Date.now() * 1000; + const active = new Set(); // Local duplicate suppression; durable reservations survive reloads. + const prune = () => { + const now = Date.now(); + for (const [key, entry] of presence) { + if (entry.expires <= now) presence.delete(key); + else entry.events = entry.events.filter(event => event.at > now - 15_000); + } + }; + const timer = setInterval(prune, 5_000); + timer.unref?.(); + web.onDispose(() => { clearInterval(timer); presence.clear(); }); + const fail = (message: string): never => { throw web.error(400, message); }; + const text = (value: unknown, max: number, label: string) => { + if (typeof value !== 'string' || !value.trim() || value.length > max || /[\u0000-\u0008\u000b\u000c\u000e-\u001f]/.test(value)) return fail(`Invalid ${label}`); + return value.trim(); + }; + const client = (value: unknown) => { + if (typeof value !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value)) return fail('clientId must be a UUID'); + return value.toLowerCase(); + }; + const fields = (body: Readonly>, allowed: string[]) => { + if (Object.keys(body).some(key => !allowed.includes(key))) fail('Unsupported request field'); + }; + const validateColor = (value: unknown): string => { + if (typeof value !== 'string' || !/^#[0-9a-f]{6}$/i.test(value)) return fail('Invalid color'); + return value.toLowerCase(); + }; + const validateLook = (value: unknown): Avatar['look'] => { + if (!value || typeof value !== 'object' || Array.isArray(value)) return fail('Invalid look'); + const look = value as Record; + fields(look, ['skin', 'hair', 'style']); + for (const key of ['skin', 'hair', 'style'] as const) { + if (!Number.isInteger(look[key]) || Number(look[key]) < 0 || Number(look[key]) >= AVATAR_COUNTS[key]) fail('Invalid look'); + } + return { skin: Number(look.skin), hair: Number(look.hair), style: Number(look.style) }; + }; + const profile = async (req: WebRequest): Promise => { + const saved = await req.snapshot(Profiles, req.user.id, req.context); + const defaults = defaultAvatar(req.user.id); + return { person: { id: req.user.id, name: req.user.name, role: req.user.role }, + profile: { name: req.user.name, color: saved?.color ?? defaults.color, look: saved?.look ?? defaults.look } }; + }; + // Ordinary routes deliberately have no conversation binding, source model, turn, or spend admission requirement. + web.route('GET', 'profile', req => profile(req), { access: 'steer' }); + web.route('POST', 'profile', async req => { + fields(req.body, ['color', 'look']); + if (!Object.keys(req.body).length) fail('Provide color or look'); + const color = 'color' in req.body ? validateColor(req.body.color) : undefined; + const look = 'look' in req.body ? validateLook(req.body.look) : undefined; + await req.commit(async tx => { + const saved = await tx.doc(Profiles, req.user.id, null); + if (color !== undefined) saved.color = color; + if (look !== undefined) saved.look = look; + }); + const result = await profile(req); + // Existing tabs share this user's saved avatar. Presence remains ephemeral and does not become an authority. + for (const entry of presence.values()) if (entry.peer.id.startsWith(`${req.user.id}:`)) { + entry.peer = { ...entry.peer, ...result.profile }; + } + return result; + }, { access: 'steer' }); + const identity = (req: ConversationWebRequest, id: string) => `${req.user.id}:${id}`; + const read = async (req: ConversationWebRequest) => await req.snapshot(Office, req.conversation.id, req.context) ?? { seats: {}, chat: [], chatKeys: [] }; + const snapshot = async (req: ConversationWebRequest, id: string): Promise => { + prune(); + const data = await read(req); + const visible = new Map((await req.linkedSessions.list()).filter(s => + typeof s.title === 'string' && typeof s.cwd === 'string' && typeof s.busy === 'boolean', + ).map(s => [Number(s.conversationId), s])); + const workers: WorkerInfo[] = []; + const bindings: OfficeSnapshot['bindings'] = []; + const pending: OfficeSnapshot['pending'] = []; + for (const seat of Object.values(data.seats)) { + if (seat.conversationId === undefined && seat.actor === req.user.id) pending.push({ + workerId: seat.workerId, deskId: seat.deskId, requestId: seat.requestId, prompt: seat.prompt, + recoverable: !active.has(`${req.conversation.id}:${seat.deskId}`), + }); + const linked = seat.conversationId === undefined ? undefined : visible.get(seat.conversationId); + if (!linked) continue; + const workerId = seat.workerId; + workers.push({ id: workerId, kind: 'agent', deskId: seat.deskId, name: linked.title!, + title: linked.title, color: '#7c83ff', status: linked.busy ? 'working' : 'idle', acked: true, + createdBy: seat.actor, createdAt: seat.createdAt, cols: 80, rows: 24, viewers: [], viewerIds: [], + }); + bindings.push({ workerId, conversationId: seat.conversationId! }); + } + const model = req.conversation.model; + const roots = typeof req.conversations?.list === 'function' ? await req.conversations.list({ rootsOnly: true }) : []; + const root = roots.find(room => Number(room.conversationId) === Number(req.conversation.id)); + const master = { workerId: `pocket-master-${req.conversation.id}`, title: req.conversation.title, + ...(root ? { busy: root.busy } : {}) }; + // The master is the authorized source itself, not a hired child or a releasable seat. + return { master, ...await profile(req), source: Number(req.conversation.id), title: req.conversation.title, cwd: req.conversation.cwd, + ...(model ? { model: `${model.provider}/${model.modelId}` } : {}), you: identity(req, id), + peers: [...presence.values()].filter(p => p.source === Number(req.conversation.id)).map(p => p.peer), + events: [...presence.values()].filter(p => p.source === Number(req.conversation.id)).flatMap(p => + p.events.filter(e => e.message.t !== 'sit.refused' || p.peer.id === identity(req, id))).sort((a, b) => a.seq - b.seq), + chat: data.chat, workers, bindings, pending }; + }; + const options = { conversation: 'source', access: 'steer' as const }; + web.route('GET', 'floors', async req => { + if (typeof req.conversations?.list !== 'function') throw web.error(501, 'Chat-backed floors require the Pocket conversation-list SDK update.'); + const rooms = await req.conversations.list({ rootsOnly: true, includeArchived: false }); + return rooms.map(room => ({ id: Number(room.conversationId), title: room.title, cwd: room.cwd, + busy: room.busy, archived: room.archived })); + }, { access: 'steer' }); + web.conversationRoute('GET', 'state/:source', async req => { + const id = client(req.query.get('clientId')); + const raw = req.query.get('presence'); + if (raw !== null) { + if (raw.length > 2048) fail('Presence is too large'); + let p: Record; + try { p = JSON.parse(raw); } catch { return fail('Invalid presence JSON'); } + if (!p || typeof p !== 'object' || Array.isArray(p)) fail('Invalid presence'); + fields(p, ['name', 'color', 'look', 'x', 'y', 'z', 'rotY', 'moving', 'lite']); + const coordinate = (key: string, max: number) => { + const value = p[key] ?? 0; + if (typeof value !== 'number' || !Number.isFinite(value) || Math.abs(value) > max) return fail(`Invalid ${key}`); + return value; + }; + // Compatibility profile fields are validated but never override the saved per-person profile. + if (p.color !== undefined) validateColor(p.color); + if (p.look !== undefined) validateLook(p.look); + const avatar = (await profile(req)).profile; + if (p.moving !== undefined && typeof p.moving !== 'boolean') fail('Invalid moving'); + if (p.lite !== undefined && typeof p.lite !== 'boolean') fail('Invalid lite'); + prune(); + const key = `${req.conversation.id}:${identity(req, id)}`; + if (!presence.has(key) && presence.size >= 1000) throw web.error(429, 'Presence capacity reached'); + const position = { x: coordinate('x', 200), y: coordinate('y', 100), z: coordinate('z', 200), rotY: coordinate('rotY', 100) }; + const previous = presence.get(key); + // One Office client occupies one chat floor. Other tabs keep their independent leases. + for (const [otherKey, entry] of presence) if (entry.peer.id === identity(req, id) && entry.source !== Number(req.conversation.id)) presence.delete(otherKey); + presence.set(key, { events: previous?.events ?? [], source: Number(req.conversation.id), expires: Date.now() + 15_000, peer: { + ...previous?.peer, id: identity(req, id), ...avatar, + ...position, + moving: p.moving === true, lite: p.lite === true, voice: false, muted: true, sharing: false, floor: 'pocket', + } }); + } + return snapshot(req, id); + }, options); + // Avatar actions are process-local leases and one-shot events, never model work or durable history. + web.conversationRoute('POST', 'presence/:source', async req => { + fields(req.body, ['clientId', 'action']); + const id = client(req.body.clientId); + const raw = req.body.action; + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) fail('Invalid avatar action'); + const action = raw as Record; + const allowed: Record = { + act: ['t', 'smoke', 'golf', 'drink', 'throwing'], emote: ['t', 'emote'], + sit: ['t', 'seat'], doing: ['t', 'what', 'reading'], + }; + if (typeof action.t !== 'string' || !Object.hasOwn(allowed, action.t)) fail('Unsupported avatar action'); + fields(action, allowed[action.t as string]); + const bool = (key: string) => { if (action[key] !== undefined && typeof action[key] !== 'boolean') fail(`Invalid ${key}`); }; + bool('smoke'); bool('golf'); bool('reading'); + if (action.drink !== undefined && action.drink !== null && !DRINKS.includes(action.drink as string)) fail('Invalid drink'); + if (action.throwing !== undefined && action.throwing !== null && !BAR_GAMES.includes(action.throwing as string)) fail('Invalid throwing'); + if (action.t === 'emote' && !EMOTES.includes(action.emote as string)) fail('Invalid emote'); + const seat = action.seat === undefined ? undefined : text(action.seat, 80, 'seat'); + const what = action.what === undefined ? undefined : text(action.what, 160, 'doing'); + prune(); + const key = `${req.conversation.id}:${identity(req, id)}`; + // Resolve the profile before the lease check. Do not yield between the seat check and update. + const avatar = (await profile(req)).profile; + prune(); + let entry = presence.get(key); + if (!entry) { + if (presence.size >= 1000) throw web.error(429, 'Presence capacity reached'); + entry = { source: Number(req.conversation.id), expires: 0, events: [], peer: { + id: identity(req, id), ...avatar, x: 0, y: 0, z: 0, rotY: 0, moving: false, + voice: false, muted: true, sharing: false, floor: 'pocket', + } }; + presence.set(key, entry); + } + // Bound bursts as well as retention, before changing state. + if (entry.events.filter(e => e.at > Date.now() - 1000).length >= 12) throw web.error(429, 'Avatar actions are too frequent'); + entry.expires = Date.now() + 15_000; + const event = (message: AvatarEvent['message']) => { + entry!.events.push({ seq: ++sequence, at: Date.now(), message }); + entry!.events = entry!.events.slice(-32); + }; + if (action.t === 'act') { + if (action.smoke !== undefined) entry.peer.smoking = action.smoke as boolean; + if (action.golf !== undefined) entry.peer.golfing = action.golf as boolean; + if (action.drink !== undefined) entry.peer.drink = action.drink === null ? undefined : action.drink as PeerInfo['drink']; + if (action.throwing !== undefined) entry.peer.throwing = action.throwing === null ? undefined : action.throwing as PeerInfo['throwing']; + event({ ...action, t: 'peer.act', id: entry.peer.id } as AvatarEvent['message']); + } else if (action.t === 'emote') { + event({ t: 'peer.emote', id: entry.peer.id, emote: action.emote } as AvatarEvent['message']); + } else if (action.t === 'doing') { + entry.peer.doing = what; + entry.peer.reading = action.reading === true; + } else if (action.t === 'sit') { + const occupied = seat && [...presence.values()].find(p => p !== entry && p.source === entry!.source && p.peer.seat === seat); + if (occupied) event({ t: 'sit.refused', seat, by: occupied.peer.name }); + else entry.peer.seat = seat; + } + return snapshot(req, id); + }, options); + // Office chat is durable shared data, not a human message to a model. The source turn is required. + web.conversationRoute('POST', 'state/:source', async req => { + fields(req.body, ['clientId', 'action', 'text', 'requestId']); + const id = client(req.body.clientId); + if (req.body.action !== 'chat') fail('Only chat is a durable state action'); + const message = text(req.body.text, 2000, 'chat'); + const key = `${req.user.id}:${text(req.body.requestId, 128, 'requestId')}`; + const avatar = (await profile(req)).profile; + await req.commit(async tx => { + const data = await tx.doc(Office, req.conversation.id); + if (data.chatKeys.includes(key)) return; + data.chat.push({ from: identity(req, id), name: req.user.name, color: avatar.color, text: message, at: Date.now(), account: true }); + data.chat = data.chat.slice(-100); + data.chatKeys = [...data.chatKeys, key].slice(-200); + }); + return snapshot(req, id); + }, options); + web.conversationRoute('POST', 'hire/:source', async req => { + fields(req.body, ['clientId', 'deskId', 'prompt', 'requestId']); + const id = client(req.body.clientId); + const deskId = text(req.body.deskId, 24, 'deskId'); + if (!DESK_IDS.includes(deskId)) fail('Unknown desk'); + const prompt = text(req.body.prompt, 8000, 'prompt'); + const requestId = text(req.body.requestId, 128, 'requestId'); + const lock = `${req.conversation.id}:${deskId}`; + if (active.has(lock)) throw web.error(409, 'This desk hire is in progress; retry the same request'); + active.add(lock); + try { + const seat = await req.commit(async tx => { + const data = await tx.doc(Office, req.conversation.id); + const existing = data.seats[deskId]; + if (existing) { + if (existing.actor !== req.user.id || existing.requestId !== requestId || existing.prompt !== prompt) throw web.error(409, 'Desk is occupied or reserved; recover the original hire or dismiss it'); + return { ...existing }; + } + // Reserve atomically before starting work. Keep this key through failure and process interruption. + const value: Seat = { workerId: `pocket-${randomUUID().replaceAll('-', '').slice(0, 24)}`, deskId, actor: req.user.id, requestId, prompt, + key: `x.office-v2.hire:${req.conversation.id}:${req.user.id}:${deskId}:${requestId}`, + state: 'reserved', createdAt: Date.now() }; + data.seats[deskId] = value; + return { ...value }; + }); + if (seat.conversationId === undefined) { + try { + // No model, thinking, cwd, shell, or worktree override. Pocket inherits and authorizes all settings. + const started = await req.linkedSessions.start({ title: `Office ${deskId}`, message: seat.prompt }, seat.key); + await req.commit(async tx => { + const current = (await tx.doc(Office, req.conversation.id)).seats[deskId]; + if (current?.workerId !== seat.workerId) throw web.error(409, 'Desk reservation changed'); + // A dismiss during uncertain admission retains the reservation. Receipt recovery releases it, + // leaving the admitted session ordinary and unseated, never stopped or deleted. + if (current.state === 'dismissed') delete (await tx.doc(Office, req.conversation.id)).seats[deskId]; + else { + current.conversationId = Number(started.session); + current.state = 'bound'; + } + }); + } catch (error) { + // A failed/uncertain start is retryable with its original receipt, and dismissible. Never invent a new key. + try { await req.commit(async tx => { + const current = (await tx.doc(Office, req.conversation.id)).seats[deskId]; + if (current?.workerId === seat.workerId && current.conversationId === undefined && current.state !== 'dismissed') current.state = 'retry'; + }); } catch { /* Request capabilities can already have expired. The reservation still permits recovery. */ } + throw error; + } + } + return snapshot(req, id); + } finally { active.delete(lock); } + }, options); + web.conversationRoute('POST', 'dismiss/:source', async req => { + fields(req.body, ['clientId', 'workerId']); + const id = client(req.body.clientId); + const workerId = text(req.body.workerId, 31, 'workerId'); + if (!/^pocket-[0-9a-f]{24}$/.test(workerId)) fail('Invalid workerId'); + await req.commit(async tx => { + const data = await tx.doc(Office, req.conversation.id); + const seat = Object.values(data.seats).find(s => s.workerId === workerId); + if (!seat) throw web.error(409, 'Worker generation no longer occupies a desk'); + if (seat.conversationId === undefined) { + // An admission can have succeeded even when no binding commit is visible. Do not free this desk + // until the original actor recovers that receipt. Never expose another actor\'s prompt or key. + seat.state = 'dismissed'; + } else delete data.seats[seat.deskId]; + }); + return snapshot(req, id); + }, options); +} diff --git a/integrations/pi-pocket/package.mjs b/integrations/pi-pocket/package.mjs new file mode 100644 index 000000000..777e40fb7 --- /dev/null +++ b/integrations/pi-pocket/package.mjs @@ -0,0 +1,56 @@ +#!/usr/bin/env node +// Package a self-contained Office browser client for Pi Pocket's extension asset server. +// Run `npm run build:pocket` first. No live Pocket files are read or changed. +import { readFile, writeFile, mkdir, readdir, cp, stat, rm } from 'node:fs/promises'; +import { resolve, join, dirname, relative } from 'node:path'; + +const root = resolve(import.meta.dirname, '../..'); +const input = join(root, 'dist/pocket'); +const output = join(root, 'integrations/pi-pocket/office-v2-client'); +const world = join(output, 'world'); +// Only this generated, ignored output folder is replaced. Source files remain untouched. +await rm(world, { recursive: true, force: true }); +await mkdir(world, { recursive: true }); + +async function files(dir) { + const all = []; + for (const entry of await readdir(dir, { withFileTypes: true })) { + const file = join(dir, entry.name); + if (entry.isDirectory()) all.push(...await files(file)); + else if (entry.isFile()) all.push(file); + else throw new Error(`Refusing non-regular build asset: ${file}`); + } + return all; +} + +const allowed = new Set(['js', 'mjs', 'css', 'json', 'svg', 'png', 'jpg', 'jpeg', 'gif', 'webp', 'woff2']); +for (const source of await files(input)) { + const rel = relative(input, source); + if (rel.endsWith('.html')) continue; + // The extension server permits images, not .glb. GLTFLoader reads binary buffers + // by their GLB header, so the local image suffix changes no model content. + const target = join(world, rel.replace(/\.glb$/, '.glb.png')); + const ext = target.split('.').at(-1); + if (!allowed.has(ext)) continue; + await mkdir(dirname(target), { recursive: true }); + if (ext === 'js' || ext === 'css') { + const original = await readFile(source, 'utf8'); + const rewritten = original.replace(/\.glb(?=["'`?])/g, '.glb.png'); + await writeFile(target, rewritten); + } else { + await cp(source, target); + } + if ((await stat(target)).size > 8 * 1024 * 1024) throw new Error(`Asset exceeds Pocket's 8 MiB limit: ${rel}`); +} + +const html = (await readFile(join(input, 'index.html'), 'utf8')) + .replace(/]*>/g, '') + .replace(/[^<]*<\/title>/, '<title>Office v2 · Pocket runtime'); +if (/]*\bsrc=)[^>]*>/i.test(html)) throw new Error('The Office document must not contain inline scripts.'); +await writeFile(join(output, 'office-document.json'), JSON.stringify({ html })); +await cp(join(root, 'src/shared/uuid.ts'), join(output, 'uuid.js')); +await cp(join(root, 'LICENSE'), join(output, 'AGENT-OFFICE-LICENSE.txt')); +const pocketRoot = process.env.PI_POCKET_ROOT ?? '/opt/pi-pocket'; +await cp(join(pocketRoot, 'types/pocket.d.ts'), join(root, 'integrations/pi-pocket/_pocket.d.ts')); +console.log(`Pocket extension package: ${relative(root, dirname(output))}`); +console.log('Review the complete package before installing. No live server was changed.'); diff --git a/integrations/pi-pocket/preview.mjs b/integrations/pi-pocket/preview.mjs new file mode 100644 index 000000000..069f53449 --- /dev/null +++ b/integrations/pi-pocket/preview.mjs @@ -0,0 +1,55 @@ +#!/usr/bin/env node +// Isolated Pocket preview with a scripted model. Never reads or changes live Pocket data. +import { createServer } from 'node:http'; +import { cp, mkdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const pocketRoot = process.env.PI_POCKET_ROOT ?? '/opt/pi-pocket'; +// Import helpers FIRST: they point Pocket at temporary data and fake auth. +const helpers = await import(pathToFileURL(join(pocketRoot, 'test/helpers.ts')).href); +const { cleanUp, newSession, openApp, root, say, scriptedModel, owner, lastText, context } = helpers; +const { fauxAssistantMessage, fauxToolCall, fauxText } = await import(pathToFileURL(join(pocketRoot, 'node_modules/@earendil-works/pi-ai/dist/providers/faux.js')).href); +const { createHandler } = await import(pathToFileURL(join(pocketRoot, 'src/server/http.ts')).href); +const dropIns = join(root, 'data/extensions'); +await mkdir(dropIns, { recursive: true }); +await cp(join(import.meta.dirname, 'office-v2.ts'), join(dropIns, 'office-v2.ts')); +await cp(join(import.meta.dirname, 'office-v2-client'), join(dropIns, 'office-v2-client'), { recursive: true }); +const app = await openApp(scriptedModel((request) => { + const last = lastText(request); + // Pocket adds authenticated sender labels to model-visible human messages. + // Normalize only that label; keep linked-session safety text in the scripted echo. + const text = last.role === 'user' ? last.text.replace(/^\[from: [^\]\r\n]+\]\s*/, '') : last.text; + return last.role === 'user' && text === 'office tool check' + ? fauxAssistantMessage([fauxToolCall('bash', {command:'printf office-tool-ok'})], {stopReason:'toolUse'}) + : fauxAssistantMessage([fauxText(`echo: ${text}`)]); +})); +await app.setExtensionEnabled(owner(app), 'office-v2.ts', true); +app.loader.watch(); +const session = await newSession(app); +await say(app, session, 'Office v2 isolated runtime preview'); +const other = await newSession(app); +await say(app, other, 'Workspace switch test project'); +const guest = app.config.addUser('Office teammate', 'guest'); +const requestedPort = Number(process.env.OFFICE_POCKET_PREVIEW_PORT ?? 8899); +const host = '127.0.0.2'; +const server = createServer(createHandler({ app, listen: { host, port:requestedPort }, restart: () => {} })); +await new Promise((resolve) => server.listen(requestedPort, host, resolve)); +const port = server.address().port; +const base = `http://${host}:${port}`; +console.log(`Office v2 isolated Pocket preview. Scripted model only. Data: ${root}`); +console.log(`Owner: http://${host}:${port}/login?token=${encodeURIComponent(app.config.ownerToken)}&next=/s/${session}`); +console.log(`Guest: http://${host}:${port}/login?token=${encodeURIComponent(guest.token)}&next=/s/${session}`); +console.log(`Other workspace: http://${host}:${port}/s/${other}`); +console.log(`Source session: ${session}. Stop and remove temporary data with kill ${process.pid}.`); +let stopping = false; +async function closePreview() { + if (stopping) return; + stopping = true; + server.closeAllConnections(); server.close(); + await app.close(); cleanUp(); +} +async function stop() { await closePreview(); process.exit(0); } +process.on('SIGINT', stop); +process.on('SIGTERM', stop); +export { app, base, root, session, other, guest, owner, context, closePreview }; diff --git a/integrations/pi-pocket/responsive-check.mjs b/integrations/pi-pocket/responsive-check.mjs new file mode 100644 index 000000000..ae78ebe97 --- /dev/null +++ b/integrations/pi-pocket/responsive-check.mjs @@ -0,0 +1,114 @@ +#!/usr/bin/env node +// Check the original desktop Office DOM and bounded dialogs. No renderer, live server, or model. +import assert from 'node:assert/strict'; +import { readFile, mkdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import { createServer } from 'vite'; +import { chromium } from 'playwright-core'; + +const root = join(import.meta.dirname, '../..'); +const source = await readFile(join(root, 'src/client/index.html'), 'utf8'); +const fixture = source.replace('', ``); +const host = ''; +const server = await createServer({ + configFile: join(root, 'vite.config.ts'), + server: { host: '127.0.0.1', port: 5193, strictPort: true, open: false }, + plugins: [{ name:'desktop-layout-fixture', configureServer(app) { + app.middlewares.use(async (req,res,next) => { + if (req.url === '/__desktop-host') { res.setHeader('content-type','text/html'); res.end(host); return; } + if (req.url !== '/__desktop-world') return next(); + try { res.setHeader('content-type','text/html'); res.end(await app.transformIndexHtml('/__desktop-world', fixture)); } + catch(error) { next(error); } + }); + }}], +}); +let browser; +const output = join(root,'dist/screenshots'); +await mkdir(output,{recursive:true}); +try { + await server.listen(); + browser = await chromium.launch({executablePath:process.env.CHROMIUM_PATH??'/usr/bin/google-chrome-stable',headless:true,args:['--no-sandbox']}); + const page = await browser.newPage(); + const errors=[]; + page.on('pageerror',error=>errors.push(error.message)); + await page.route('**/api/**',route=>route.fulfill({status:200,contentType:'application/json',body:'{}'})); + const world = page.frameLocator('iframe'); + const bounds = selector => world.locator(selector).evaluate(element=>{ + const r=element.getBoundingClientRect(); + return {x:r.x,y:r.y,width:r.width,height:r.height,right:r.right,bottom:r.bottom,vw:innerWidth,vh:innerHeight,scrollWidth:element.scrollWidth,clientWidth:element.clientWidth}; + }); + function inside(box,label) { + assert.ok(box.x>=-1 && box.y>=-1 && box.right<=box.vw+1 && box.bottom<=box.vh+1,`${label} leaves ${box.vw}×${box.vh}: ${JSON.stringify(box)}`); + } + for(const [width,height] of [[1280,800],[1440,900],[1024,768],[1920,1080]]) { + await page.setViewportSize({width,height}); + await page.goto('http://127.0.0.1:5193/__desktop-host'); + await world.locator('html.pocket-office').waitFor(); + await page.waitForTimeout(200); + assert.deepEqual(await world.locator('body').evaluate(()=>[innerWidth,innerHeight]),[width,height]); + inside(await bounds('.topbar'),'Original desktop HUD bar'); + inside(await bounds('.side'),'Original participant and worker panels'); + await page.screenshot({path:join(output,`office-desktop-${width}x${height}-hud.png`)}); + for(const kind of ['prompt','help','settings','menu']) { + const open = () => world.locator('body').evaluate((_,name)=>window.__desktop[name](),kind); + const selector = kind==='menu'?'.hud-menu':'.modal'; + await open(); + await world.locator(selector).waitFor(); + await page.waitForTimeout(200); + const box=await bounds(selector); + inside(box,kind); + assert.ok(box.scrollWidth<=box.clientWidth+1,`${kind} has horizontal overflow at ${width}×${height}`); + if(kind!=='menu') { + const before=await bounds('.modal .close'); + inside(before,`${kind} close`); + assert.equal(Math.round(before.width),34,'Keep the original desktop close-button width'); + assert.equal(Math.round(before.height),34,'Keep the original desktop close-button height'); + if(kind==='prompt') { + inside(await bounds('.modal footer'),'Initial task actions'); + await world.locator('.modal > .body').evaluate(body=>{ body.scrollTop=body.scrollHeight; }); + const after=await bounds('.modal .close'); + assert.equal(after.y,before.y,'The close button stays outside scrolling task content'); + } + await page.screenshot({path:join(output,`office-desktop-${width}x${height}-${kind}.png`)}); + await world.getByRole('button',{name:'Close',exact:true}).click(); + assert.equal(await world.locator(selector).count(),0,'X closes the desktop dialog'); + await open(); + await world.locator(selector).waitFor(); + } + await world.locator('body').press('Escape'); + assert.equal(await world.locator(selector).count(),0,'Esc closes the desktop popup'); + } + console.log(`PASS: desktop ${width}×${height}: original HUD, task, controls, settings, menu, X, Esc, and bounded scrolling.`); + } + assert.deepEqual(errors,[],'No browser exceptions'); +} finally { + await browser?.close(); + await server.close(); +} diff --git a/package.json b/package.json index 66e7bd2e9..752183bed 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,8 @@ "dev": "concurrently -k \"vite\" \"tsx watch src/server/cli.ts --port 4600 --password dev --no-open\"", "build": "npm run build:client && npm run build:server", "build:client": "vite build", + "build:pocket": "vite build --base ./ --outDir ../../dist/pocket && node integrations/pi-pocket/package.mjs", + "test:pocket": "node --test integrations/pi-pocket/office-v2.test.ts", "build:server": "tsc -p tsconfig.server.json", "typecheck": "tsc -p tsconfig.server.json --noEmit && tsc -p tsconfig.client.json --noEmit", "start": "node bin/agent-office.js", diff --git a/src/client/core/loop.ts b/src/client/core/loop.ts index d78dfad8d..5bf4d6ce0 100644 --- a/src/client/core/loop.ts +++ b/src/client/core/loop.ts @@ -20,6 +20,10 @@ const QUIET_FEET = 1.2; export interface LoopDeps { /** Offers the 2D view (/lite), where the 3D is hard going (see main.ts). */ offer2d(why: 'slow'): void; + /** A host can pause expensive drawing while its external conversation view has focus. */ + shouldDraw?(frame: Frame): boolean; + /** Lets a host finish presentation before it budgets the next expensive frame. */ + afterDraw?(): void; } /** Registers the office's own ticks: install it before anything else registers one. */ @@ -183,7 +187,9 @@ export function installLoop(ctx: Ctx, core: CoreState, parts: Pick> = (() => { try { - const r = JSON.parse(localStorage.getItem(THROW_KEY) ?? '{}') as Record; + const r = JSON.parse(localStorage.getItem(storageKey(THROW_KEY)) ?? '{}') as Record; const out: Partial> = {}; for (const g of ['darts', 'axe'] as BarGame[]) if (typeof r[g] === 'number') out[g] = r[g] as number; return out; @@ -47,7 +48,7 @@ export function installBarGames(ctx: Ctx, deps: BarGamesDeps) { })(); function saveThrowBests() { try { - localStorage.setItem(THROW_KEY, JSON.stringify(throwBests)); + localStorage.setItem(storageKey(THROW_KEY), JSON.stringify(throwBests)); } catch { // private window: it's only for this visit then } diff --git a/src/client/features/bookshelf/ui.ts b/src/client/features/bookshelf/ui.ts index 091bebec1..42d92a646 100644 --- a/src/client/features/bookshelf/ui.ts +++ b/src/client/features/bookshelf/ui.ts @@ -1,3 +1,4 @@ +import { storageKey } from '../../state/persist'; import './ui.css'; import { isDocPath, resolveDocLink, type DocFile, type DocList, type DocText } from '../../../shared/docs'; import { clip, h, openModal, setDoing, timeAgo, toast } from '../../ui/dom'; @@ -127,7 +128,7 @@ function size(bytes: number): string { function lastRead(floor: string): string | undefined { try { - return JSON.parse(localStorage.getItem(LAST_KEY) ?? '{}')[floor]; + return JSON.parse(localStorage.getItem(storageKey(LAST_KEY)) ?? '{}')[floor]; } catch { return undefined; } @@ -135,9 +136,9 @@ function lastRead(floor: string): string | undefined { function rememberRead(floor: string, path: string) { try { - const all = JSON.parse(localStorage.getItem(LAST_KEY) ?? '{}'); + const all = JSON.parse(localStorage.getItem(storageKey(LAST_KEY)) ?? '{}'); all[floor] = path; - localStorage.setItem(LAST_KEY, JSON.stringify(all)); + localStorage.setItem(storageKey(LAST_KEY), JSON.stringify(all)); } catch { // Private mode, or storage is full: it just won't reopen where you were. } diff --git a/src/client/features/cars/index.ts b/src/client/features/cars/index.ts index 86475f565..eaa775f6c 100644 --- a/src/client/features/cars/index.ts +++ b/src/client/features/cars/index.ts @@ -3,6 +3,7 @@ * horn, laps of the scenic loop, and a car shoving you out of its way. Placing you anywhere gets you * out first: see the driver's activity, and placeAt in core/place.ts. */ +import { storageKey } from '../../state/persist'; import { CARS, SEAT_HIPS, type CarSeat } from '../../../shared/garage'; import { PLACES, placeAt as loopPlace } from '../../../shared/scenic'; import type { Ctx, Hint } from '../../core/context'; @@ -150,7 +151,7 @@ export function installCars(ctx: Ctx, deps: CarsDeps) { const laps = new LapTimer( (() => { try { - const best = Number(localStorage.getItem(LAP_KEY)); + const best = Number(localStorage.getItem(storageKey(LAP_KEY))); return best > 0 ? best : null; } catch { return null; @@ -161,7 +162,7 @@ export function installCars(ctx: Ctx, deps: CarsDeps) { const done = laps.done; if (done?.best) { try { - localStorage.setItem(LAP_KEY, String(time)); + localStorage.setItem(storageKey(LAP_KEY), String(time)); } catch { // private window: it's only for this visit then } diff --git a/src/client/features/golf/index.ts b/src/client/features/golf/index.ts index 483d0e185..f7df19d8b 100644 --- a/src/client/features/golf/index.ts +++ b/src/client/features/golf/index.ts @@ -2,6 +2,7 @@ * Golf off the balcony: the tee (a club out, a swing, the camera following the ball), everyone's balls * in the air or lying where they stopped, and your records, kept in this browser. */ +import { storageKey } from '../../state/persist'; import { GOLF_HOLE } from '../../../shared/layout'; import type { Ctx } from '../../core/context'; import { aside, hintTitle, key, onE } from '../../core/hint'; @@ -38,7 +39,7 @@ export function installGolf(ctx: Ctx, deps: GolfDeps) { const GOLF_KEY = 'agent-office.golf'; function golfRecord(): { best: number | null; holes: number } { try { - const r = JSON.parse(localStorage.getItem(GOLF_KEY) ?? '{}') as { best?: unknown; holes?: unknown }; + const r = JSON.parse(localStorage.getItem(storageKey(GOLF_KEY)) ?? '{}') as { best?: unknown; holes?: unknown }; return { best: typeof r.best === 'number' ? r.best : null, holes: typeof r.holes === 'number' ? r.holes : 0 }; } catch { return { best: null, holes: 0 }; @@ -46,7 +47,7 @@ export function installGolf(ctx: Ctx, deps: GolfDeps) { } function saveGolfRecord(r: { best: number | null; holes: number }) { try { - localStorage.setItem(GOLF_KEY, JSON.stringify(r)); + localStorage.setItem(storageKey(GOLF_KEY), JSON.stringify(r)); } catch { // private window: it's only for this visit then } diff --git a/src/client/features/hanging/controller.ts b/src/client/features/hanging/controller.ts index 998991c53..460f813d4 100644 --- a/src/client/features/hanging/controller.ts +++ b/src/client/features/hanging/controller.ts @@ -1,3 +1,4 @@ +import { storageKey } from '../../state/persist'; import * as THREE from 'three'; import { PICTURE_MAX, PICTURE_MIN, clampToWall, frameRect, overlaps, pictureSize, type WallId } from '../../../shared/decor'; import type { Net } from '../../net'; @@ -37,7 +38,7 @@ export interface Spot { const SIZE_KEY = 'agent-office.picture-size'; function lastSize(): number { try { - const n = Number(localStorage.getItem(SIZE_KEY)); + const n = Number(localStorage.getItem(storageKey(SIZE_KEY))); return n >= PICTURE_MIN && n <= PICTURE_MAX ? n : 1.2; } catch { return 1.2; @@ -183,7 +184,7 @@ export class Hanger { this.net.send({ t: 'decor.add', decor: { url: cur.url, title: cur.title || undefined, frame: cur.frame, ...spot } }); } try { - localStorage.setItem(SIZE_KEY, String(cur.size)); + localStorage.setItem(storageKey(SIZE_KEY), String(cur.size)); } catch { // storage blocked } diff --git a/src/client/features/hanging/ui.ts b/src/client/features/hanging/ui.ts index 726970ea5..38b5c4c26 100644 --- a/src/client/features/hanging/ui.ts +++ b/src/client/features/hanging/ui.ts @@ -1,3 +1,4 @@ +import { storageKey } from '../../state/persist'; import './ui.css'; import { FRAMES, checkImageUrl, type Decoration } from '../../../shared/decor'; import { store } from '../../state'; @@ -14,7 +15,7 @@ export interface HangChoice { const FRAME_KEY = 'agent-office.frame'; function lastFrame(): number { try { - const n = Number(localStorage.getItem(FRAME_KEY)); + const n = Number(localStorage.getItem(storageKey(FRAME_KEY))); return Number.isInteger(n) && n >= 0 && n < FRAMES.length ? n : 0; } catch { return 0; @@ -147,7 +148,7 @@ export function openHangDialog(opts: { initial?: Decoration; onDone(choice: Hang const finish = () => { if (!pic) return; try { - localStorage.setItem(FRAME_KEY, String(frame)); + localStorage.setItem(storageKey(FRAME_KEY), String(frame)); } catch { // storage blocked } diff --git a/src/client/features/hud/index.ts b/src/client/features/hud/index.ts index a54730173..15a53ebbf 100644 --- a/src/client/features/hud/index.ts +++ b/src/client/features/hud/index.ts @@ -4,6 +4,7 @@ * character. */ import { ROOF } from '../../../shared/rooftop'; +import { pocketRuntime } from '../../shared/pocket/runtime'; import type { Ctx } from '../../core/context'; import type { CoreState } from '../../core/ctx'; import { builtFloors } from '../../core/floors'; @@ -68,7 +69,7 @@ export function installHud(ctx: Ctx, core: CoreState, parts: HudParts) { { id: 'search', icon: '🔎', label: 'Search', section: 'Open', key: '/', title: () => 'Search the chat and every terminal', run: waiting.showSearch }, // The office has its bookshelf for them; a map of its own may not. { id: 'docs', icon: '📚', label: 'Docs', section: 'Open', shown: () => !inOffice(), title: () => 'Read the project’s docs', run: parts.bookshelf.showBookshelf }, - { id: 'elevator', icon: '🛗', label: () => (inOffice() ? 'Elevator' : 'Floors'), section: 'Open', count: () => store.floors.reduce((n, f) => n + (f.id === store.floor ? 0 : f.waiting), 0), title: () => (inOffice() ? 'Ride to another project' : 'Go to another project, or add one'), run: travel.showElevator }, + { id: 'elevator', icon: '🛗', label: () => pocketRuntime() ? 'Switch workspace' : (inOffice() ? 'Elevator' : 'Floors'), section: 'Open', count: () => store.floors.reduce((n, f) => n + (f.id === store.floor ? 0 : f.waiting), 0), title: () => pocketRuntime() ? 'Join the office for another Pocket conversation' : (inOffice() ? 'Ride to another project' : 'Go to another project, or add one'), run: travel.showElevator }, { id: 'roof', icon: '🍸', label: 'Rooftop bar', section: 'Open', shown: () => !core.upTop && inOffice() && builtFloors().length > 0, title: () => 'Ride the elevator up to the roof: a DJ, drinks and the city', run: () => travel.ride(ROOF) }, // In voice, V is push to talk, so leaving is only from here. { id: 'voice', icon: '🎙️', label: () => (voice.inVoice ? 'Leave voice' : 'Join voice'), section: 'Together', key: () => (voice.inVoice ? undefined : 'V'), on: () => voice.inVoice, blocked: noMedia, run: () => void talk.toggleVoice() }, diff --git a/src/client/features/pocket-master/index.ts b/src/client/features/pocket-master/index.ts new file mode 100644 index 000000000..b336c4c21 --- /dev/null +++ b/src/client/features/pocket-master/index.ts @@ -0,0 +1,119 @@ +/** Pocket's root conversation owns the existing elevated loft desk, never a regular worker seat. */ +import * as THREE from 'three'; +import { LOFT } from '../../../shared/layout'; +import type { Ctx } from '../../core/context'; +import { hintTitle, key, onE } from '../../core/hint'; +import { readPocketBootstrap } from '../../shared/pocket/bootstrap'; +import { onPocketSnapshot } from '../../shared/pocket/native'; +import { validPocketMaster, type PocketSnapshot } from '../../shared/pocket/wire'; +import { mesh, textPlane, toonUnique } from '../../world/toon'; +import type { Interactable } from '../../world/types'; + +// The kind owns only E/click. It has no hire, dismiss, model, or standalone terminal actions. +declare module '../../world/types' { + interface InteractKinds { + pocketmaster: true; + } +} + +export const MASTER_LOFT = { + x: (LOFT.minX + LOFT.maxX) / 2 + 0.5, + y: LOFT.y, + z: (LOFT.minZ + LOFT.maxZ) / 2 - 0.3, +} as const; + +interface Options { + initial?: PocketSnapshot | null; + text?: typeof textPlane; + watch?: typeof onPocketSnapshot; +} + +export function installPocketMaster(ctx: Ctx, options: Options = {}): { dispose(): void } { + let snapshot = options.initial === undefined ? readPocketBootstrap()?.snapshot : options.initial; + const source = snapshot?.source; + const text = options.text ?? textPlane; + const decorations = new THREE.Group(); + decorations.name = 'pocket-master-loft'; + const it: Interactable = { kind: 'pocketmaster', ...MASTER_LOFT, radius: 2.7, off: true }; + const offInteraction = ctx.interactions.define('pocketmaster', { + reach: 3.5, + hint: () => ({ k: `${snapshot?.master?.title}:${snapshot?.master?.busy}`, parts: [ + hintTitle(`Master · ${snapshot?.master?.title ?? 'Root conversation'}`), key('E', 'Open root conversation'), + ] }), + use: onE(() => { if (!it.off && snapshot?.master) ctx.net.openPocketSession(snapshot.master.workerId); }), + }); + if (source === undefined) return { dispose: offInteraction }; + + const office = ctx.office; + const desk = office.bossScreen.parent!; + const oldSeat = office.interactables.find(i => i.seatId === 'boss-chair'); + const oldSeatOff = oldSeat?.off; + const oldLabels = ['loft-desk-label', 'loft-inside-label', 'loft-outside-label'] + .map(name => office.group.getObjectByName(name)).filter((o): o is THREE.Object3D => !!o); + const previousInteractions = new Map(); + desk.traverse(o => { + if (o.userData.interact) previousInteractions.set(o, o.userData.interact); + }); + previousInteractions.set(desk, desk.userData.interact); + office.group.add(decorations); + const offUsable = ctx.usables.add({ usable: () => it.off ? [] : [it] }); + + const clear = () => { + decorations.traverse(o => { + if (!(o instanceof THREE.Mesh)) return; + o.geometry.dispose(); + for (const material of Array.isArray(o.material) ? o.material : [o.material]) { + const map = (material as THREE.MeshBasicMaterial).map; + map?.dispose(); + material.dispose(); + } + }); + decorations.clear(); + }; + const update = (next: PocketSnapshot) => { + if (next.source !== source || !validPocketMaster(next)) return; + const before = JSON.stringify(snapshot?.master); + snapshot = next; + const master = next.master; + if (!it.off && before === JSON.stringify(master)) return; + it.off = !master; + for (const label of oldLabels) label.visible = !master; + if (oldSeat) oldSeat.off = master ? true : oldSeatOff; + for (const [object, previous] of previousInteractions) object.userData.interact = master ? it : previous; + clear(); + ctx.hint.invalidate(); + if (!master) return; + const addText = (label: string, x: number, y: number, z: number, scale: number, bg = '#ffd166') => { + const plane = text(label.slice(0, 160), { bg, color: '#242638', size: 64 }); + plane.scale.multiplyScalar(Math.min(scale, 7 / plane.geometry.parameters.width)); + plane.position.set(x, y, z); + plane.rotation.y = Math.PI; + plane.userData.interact = it; + decorations.add(plane); + }; + // The existing loft slab and stair colliders are the raised platform. No lower desk is moved. + addText('MASTER · ROOT CHAT', (LOFT.minX + LOFT.maxX) / 2, LOFT.y + LOFT.height + 0.2, LOFT.minZ - 0.09, 1.3); + addText('MASTER', MASTER_LOFT.x, LOFT.y + 0.5, MASTER_LOFT.z - 0.56, 0.55); + addText(master.title, LOFT.maxX - 3, LOFT.y + 2.35, LOFT.maxZ - 0.05, 0.8, '#fffaf3'); + const status = master.busy === undefined ? 'STATUS UNKNOWN' : master.busy ? 'WORKING' : 'IDLE'; + addText(`MASTER · ${status}`, MASTER_LOFT.x, LOFT.y + 1.65, MASTER_LOFT.z - 0.3, 0.55, + master.busy === undefined ? '#d8dce2' : master.busy ? '#a7e5b0' : '#f6df9d'); + const indicator = mesh(new THREE.SphereGeometry(0.1, 12, 8), toonUnique(master.busy === undefined ? '#8f99a6' : master.busy ? '#28b463' : '#e9b949'), + MASTER_LOFT.x + 0.62, LOFT.y + 1.2, MASTER_LOFT.z - 0.22); + indicator.userData.interact = it; + decorations.add(indicator); + ctx.hint.invalidate(); + }; + update(snapshot!); + const offSnapshot = (options.watch ?? onPocketSnapshot)(update); + const dispose = () => { + offSnapshot(); offUsable(); offInteraction(); + for (const label of oldLabels) label.visible = true; + if (oldSeat) oldSeat.off = oldSeatOff; + for (const [object, previous] of previousInteractions) object.userData.interact = previous; + decorations.removeFromParent(); clear(); + window.removeEventListener('pagehide', dispose); + }; + window.addEventListener('pagehide', dispose, { once: true }); + return { dispose }; +} diff --git a/src/client/features/workers/actions.ts b/src/client/features/workers/actions.ts index a409561ee..a1d389683 100644 --- a/src/client/features/workers/actions.ts +++ b/src/client/features/workers/actions.ts @@ -28,6 +28,8 @@ import { openPull } from '../../ui/pull'; import { openRepoPulls, workerRepos } from '../../ui/repos'; import { openTerminal } from '../../ui/terminal'; import { hiringPaused, usageLabel, usageTitle } from '../../ui/usage'; +import { createWorkerPolicy, workerKeyAllowed, workerPolicyHint, type WorkerActionPolicy } from './policy'; +import { registerInteractionPolicy } from '../../shared/interaction-policy'; // The kinds of thing you can use that this defines (see InteractKinds in world/types.ts). declare module '../../world/types' { @@ -40,11 +42,19 @@ declare module '../../world/types' { export type WorkerActionsParts = Pick; /** Registers the worktree answer (worker.worktree), and defines what's done at a desk and at a board agent. */ -export function installWorkerActions(ctx: Ctx, core: CoreState, parts: WorkerActionsParts) { +export function installWorkerActions(ctx: Ctx, core: CoreState, parts: WorkerActionsParts, policy: WorkerActionPolicy = {}) { const { player, net, me, settings } = ctx; const { plan, inOffice } = parts.worlds; - const openWorkerTerminal = (id: string) => parts.waiting.openWorkerTerminal(id); - const openWorkerChanges = (id: string, repo?: string) => parts.waiting.openWorkerChanges(id, repo); + const offDeskPolicy = registerInteractionPolicy('desk', (it, key) => workerKeyAllowed(policy, key) + && !(policy.boardAgents === false && plan().byId.get(it.deskId ?? '')?.room)); + const offStationPolicy = registerInteractionPolicy('station', () => policy.boardAgents !== false); + const openWorkerTerminal = (id: string) => policy.conversationLabel ? net.openPocketSession(id) : parts.waiting.openWorkerTerminal(id); + const openWorkerChanges = (id: string, repo?: string) => policy.repositories === false ? policyActions.unavailable() : parts.waiting.openWorkerChanges(id, repo); + const policyActions = createWorkerPolicy(policy, { + workerAtDesk: id => store.workerAtDesk(id), worker: id => store.workers.get(id), desk: id => plan().byId.get(id), + canHire: () => !officeIsFull(), hire, send: msg => net.send(msg), openConversation: id => { net.openPocketSession(id); }, + prompt: openPrompt, confirm: confirmDialog, notify: message => { toast(message, 'warn'); }, + }); function freeDesk(): string | null { // Prefer the empty desk nearest to you; when they're all taken, the bean bag that's out. @@ -78,6 +88,7 @@ export function installWorkerActions(ctx: Ctx, core: CoreState, parts: WorkerAct } function hire(deskId: string, prompt?: string, worktree = false, provider?: AgentProvider, model?: string, effort?: AgentEffort, issue?: number, repos?: string[], via?: 'herald') { + if (!policyActions.allowHire(deskId, prompt, worktree, provider, model, effort, issue, repos, via)) return; net.send({ t: 'worker.spawn', deskId, prompt, worktree, provider, model, effort, issue, repos: repos?.length ? repos : undefined, via }); // The moment notifications start to matter: ask once (it has to come from a key press or click). if (settings.notify && notifyPermission() === 'default' && !askedToNotify) { @@ -87,11 +98,13 @@ export function installWorkerActions(ctx: Ctx, core: CoreState, parts: WorkerAct } function openShell(deskId: string) { + if (policy.shell === false) return policyActions.unavailable(); if (officeIsFull()) return; net.send({ t: 'worker.spawn', deskId, kind: 'shell' }); } function promptAtDesk(deskId: string) { + if (policyActions.desk(deskId, 'P')) return; const w = store.workerAtDesk(deskId); const desk = plan().byId.get(deskId)!; if (!w) { @@ -128,6 +141,7 @@ export function installWorkerActions(ctx: Ctx, core: CoreState, parts: WorkerAct /** Direct hire from an empty desk, with an optional first prompt and provider choice. */ function hireAtDesk(deskId: string) { + if (policyActions.desk(deskId, 'E')) return; const desk = plan().byId.get(deskId)!; if (officeIsFull()) return; openPrompt({ @@ -146,6 +160,7 @@ export function installWorkerActions(ctx: Ctx, core: CoreState, parts: WorkerAct ctx.messages.on('worker.worktree', routeWorktreeMessage); function killWorker(id: string) { + if (policyActions.release(id)) return; const w = store.workers.get(id); if (!w) return; const where = plan().byId.get(w.deskId)?.label ?? 'the desk'; @@ -178,6 +193,7 @@ export function installWorkerActions(ctx: Ctx, core: CoreState, parts: WorkerAct /** E at a board agent: type it a request. It's hired with it when nobody is there yet. */ function askStation(deskId: string) { + if (policyActions.blockBoard()) return; const kind = plan().byId.get(deskId)?.station; if (!kind) return; const w = store.workerAtDesk(deskId); @@ -208,6 +224,7 @@ export function installWorkerActions(ctx: Ctx, core: CoreState, parts: WorkerAct } function resumeWorker(w: WorkerInfo) { + if (policy.conversationLabel) return openWorkerTerminal(w.id); if (w.lost) return fixLostWorktree(w); if (!w.sessionId && w.kind !== 'shell') toast(`${w.name} has no saved Claude session — starting a fresh one`, 'warn'); net.send({ t: 'worker.resume', workerId: w.id }); @@ -219,6 +236,7 @@ export function installWorkerActions(ctx: Ctx, core: CoreState, parts: WorkerAct * more are lost, or to send it home. */ function fixLostWorktree(w: WorkerInfo) { + if (policy.repositories === false) return policyActions.unavailable(); if (!w.lost || !w.worktree) return; const others = [...store.workers.values()].filter((o) => o.lost && o.id !== w.id); lostWorktreeDialog({ @@ -243,6 +261,7 @@ export function installWorkerActions(ctx: Ctx, core: CoreState, parts: WorkerAct /** O at a desk: see the worker's pull request, or push its branch and open one. */ function pullRequestFor(w: WorkerInfo) { + if (policy.repositories === false) return policyActions.unavailable(); if (w.repos?.length) return pullRequestsFor(w); if (w.pr) { const it = store.pulls.items.find((p) => p.number === w.pr!.number); @@ -344,7 +363,10 @@ export function installWorkerActions(ctx: Ctx, core: CoreState, parts: WorkerAct } function deskHint(deskId: string): Hint { + if (policy.boardAgents === false && plan().byId.get(deskId)?.room) return { k: 'unconnected', parts: [aside('Meetings are not connected in this runtime')] }; const w = store.workerAtDesk(deskId); + const runtimeHint = workerPolicyHint(policy, w, plan().byId.get(deskId)?.label ?? 'Desk', w ? STATUS_LABEL[w.status] : ''); + if (runtimeHint) return { k: JSON.stringify(runtimeHint), parts: [h('span.title', {}, runtimeHint.title), ...runtimeHint.keys.map(([code, label]) => key(code, label))] }; if (!w && plan().byId.get(deskId)?.room) return { k: 'room', parts: [h('span.title', {}, `🤝 ${plan().byId.get(deskId)!.label} · free`), key('E', 'Call a meeting')] }; // The sign over it, if it has one, and L to hang one (or change it). const sign = store.floorPlan.labels[deskId]?.text; @@ -411,6 +433,7 @@ export function installWorkerActions(ctx: Ctx, core: CoreState, parts: WorkerAct } function stationHint(deskId: string): Hint { + if (policy.boardAgents === false) return { k: 'unconnected', parts: [aside('GitHub boards and board agents are not connected')] }; const kind = plan().byId.get(deskId)?.station; if (!kind) return { k: '', parts: [] }; const w = store.workerAtDesk(deskId); @@ -447,7 +470,7 @@ export function installWorkerActions(ctx: Ctx, core: CoreState, parts: WorkerAct reach: 4.5, hint: (it) => (it.deskId ? deskHint(it.deskId) : { k: '', parts: [] }), use: (it, key) => { - if (!it.deskId) return; + if (!it.deskId || policyActions.desk(it.deskId, key)) return; if (key === 'L') return openDeskLabel(net, it.deskId); const w = store.workerAtDesk(it.deskId); // Nobody is hired at the meeting table: a meeting seats its own workers there. @@ -465,7 +488,7 @@ export function installWorkerActions(ctx: Ctx, core: CoreState, parts: WorkerAct reach: 4.5, hint: (it) => (it.deskId ? stationHint(it.deskId) : { k: '', parts: [] }), use: (it, key) => { - if (!it.deskId) return; + if (!it.deskId || policyActions.blockBoard()) return; const w = store.workerAtDesk(it.deskId); if (key === 'E' || key === 'P') return askStation(it.deskId); if (key === 'O' && w) return openWorkerTerminal(w.id); @@ -475,6 +498,7 @@ export function installWorkerActions(ctx: Ctx, core: CoreState, parts: WorkerAct /** A prompt from the boards goes to a new worker at a free desk, or to one already at a desk. With `issue`, that worker takes the issue. */ function sendToWorker(title: string, text: { context?: string; initial?: string }, issue?: number) { + if (policyActions.blockBoard()) return; const desk = freeDesk(); const awake = [...store.workers.values()].filter((w) => w.kind === 'agent' && !isAsleep(w.status)); if (!desk && !awake.length) { @@ -499,14 +523,15 @@ export function installWorkerActions(ctx: Ctx, core: CoreState, parts: WorkerAct /** What the boards' buttons do: hand an issue to a worker, queue it, call a meeting about it, go to a desk, take its card. */ function boardActions() { return { - queue: (prompt: string, title: string, issue: number, provider?: AgentProvider, model?: string, effort?: AgentEffort) => net.send({ t: 'queue.add', prompt, title, issue, provider, model, effort }), + queue: (prompt: string, title: string, issue: number, provider?: AgentProvider, model?: string, effort?: AgentEffort) => { if (!policyActions.blockBoard()) net.send({ t: 'queue.add', prompt, title, issue, provider, model, effort }); }, assign: (prompt: string, title: string, issue?: number) => sendToWorker(`🤖 ${title}`, { initial: prompt }, issue), ask: (context: string, title: string) => sendToWorker(`✍️ ${title}`, { context }), - meeting: (preset: MeetingPreset) => parts.meeting.showMeeting(preset), + meeting: (preset: MeetingPreset) => { if (!policyActions.blockBoard()) parts.meeting.showMeeting(preset); }, goToDesk, - pickUp: parts.cards.pickUp, + pickUp: (...args: Parameters) => { if (!policyActions.blockBoard()) return parts.cards.pickUp(...args); }, }; } - return { officeIsFull, firstFreeSeat, hire, hireAtDesk, resumeWorker, fixLostWorktree, pullRequestFor, standAt, boardActions }; + return { officeIsFull, firstFreeSeat, hire, hireAtDesk, resumeWorker, fixLostWorktree, pullRequestFor, standAt, boardActions, + disposePolicy: () => { offDeskPolicy(); offStationPolicy(); } }; } diff --git a/src/client/features/workers/policy.ts b/src/client/features/workers/policy.ts new file mode 100644 index 000000000..3ad62823c --- /dev/null +++ b/src/client/features/workers/policy.ts @@ -0,0 +1,134 @@ +import type { AgentEffort, AgentProvider, ClientMsg, WorkerInfo } from '../../../shared/protocol'; +import type { PromptOptions } from '../../ui/prompt'; +import type { DeskKey } from '../../interaction'; + +/** Runtime capabilities, not a provider choice. An empty policy keeps standalone behavior. */ +export interface WorkerActionPolicy { + requireInitialTask?: boolean; + conversationLabel?: string; + reviewFollowupDrafts?: boolean; + releaseOnly?: boolean; + shell?: boolean; + repositories?: boolean; + deskLabels?: boolean; + boardAgents?: boolean; + providerChoices?: boolean; +} + +export function workerKeyAllowed(policy: WorkerActionPolicy, key: string): boolean { + if (key === 'B') return policy.shell !== false; + if (key === 'C' || key === 'O') return policy.repositories !== false; + if (key === 'L') return policy.deskLabels !== false; + return true; +} + +export function workerPromptOptions(policy: WorkerActionPolicy, options: PromptOptions, hiring: boolean): PromptOptions { + return { + ...options, + ...(hiring && policy.requireInitialTask ? { + subtitle: 'An initial task is required. The new conversation inherits this floor’s settings.', + placeholder: 'Required initial task…', allowEmpty: false, + } : {}), + ...(policy.providerChoices === false ? { providerOption: false } : {}), + ...(policy.repositories === false ? { worktreeOption: false, repoOptions: undefined } : {}), + ...(!hiring && policy.reviewFollowupDrafts ? { + title: 'Review a follow-up draft', + subtitle: `Review this draft in the ${policy.conversationLabel ?? 'worker conversation'}. Nothing is sent or queued automatically.`, + submitLabel: 'Review draft in worker conversation', enterLabel: 'Enter to review draft', allowEmpty: false, + } : {}), + }; +} + +export function releaseWorkerCopy(name: string, where: string) { + return { + title: `Release ${name}’s desk?`, + body: `This releases ${where} only. The conversation and any model work continue. Use Stop in the native conversation to stop work.`, + submitLabel: 'Release desk', + }; +} + +interface PolicyDeps { + workerAtDesk(deskId: string): WorkerInfo | undefined; + worker(id: string): WorkerInfo | undefined; + desk(deskId: string): { label: string; station?: unknown; room?: boolean } | undefined; + canHire(): boolean; + hire(deskId: string, task: string): void; + send(message: ClientMsg): void; + openConversation(id: string): void; + prompt(options: PromptOptions): void; + confirm(title: string, body: string, submit: string, yes: () => void): void; + notify(message: string): void; +} + +/** Pure action boundary, shared by hotkeys and direct feature callbacks. It does not select a runtime. */ +export function createWorkerPolicy(policy: WorkerActionPolicy, deps: PolicyDeps) { + const unavailable = () => deps.notify('This action is not connected in this runtime. Use the worker conversation.'); + const blockBoard = () => { + if (policy.boardAgents !== false) return false; + deps.notify('GitHub boards and board agents are not connected in this runtime.'); + return true; + }; + const requestTask = (deskId: string) => { + if (!deps.canHire()) return; + deps.prompt(workerPromptOptions(policy, { + title: `✨ New task at ${deps.desk(deskId)?.label ?? 'the desk'}`, + submitLabel: 'Hire & start', + onSubmit: task => { if (task.trim()) deps.hire(deskId, task.trim()); }, + }, true)); + }; + const release = (id: string) => { + if (!policy.releaseOnly) return false; + const w = deps.worker(id); + if (w) { + const copy = releaseWorkerCopy(w.name, deps.desk(w.deskId)?.label ?? 'the desk'); + deps.confirm(copy.title, copy.body, copy.submitLabel, () => deps.send({ t: 'worker.kill', workerId: id })); + } + return true; + }; + return { + unavailable, blockBoard, release, + desk(deskId: string, key: DeskKey): boolean { + if (!workerKeyAllowed(policy, key)) { unavailable(); return true; } + const desk = deps.desk(deskId); + if ((desk?.station || desk?.room) && blockBoard()) return true; + const worker = deps.workerAtDesk(deskId); + if (!worker && policy.requireInitialTask && (key === 'E' || key === 'P')) { requestTask(deskId); return true; } + if (worker && policy.conversationLabel && (key === 'E' || key === 'R')) { deps.openConversation(worker.id); return true; } + if (worker && key === 'P' && policy.reviewFollowupDrafts) { + deps.prompt(workerPromptOptions(policy, { + title: `Prompt ${worker.name}`, + onSubmit: task => { if (task.trim()) deps.send({ t: 'worker.prompt', workerId: worker.id, prompt: task.trim() }); }, + }, false)); + return true; + } + return !!worker && key === 'X' && release(worker.id); + }, + allowHire(deskId: string, prompt?: string, worktree = false, provider?: AgentProvider, model?: string, effort?: AgentEffort, + issue?: number, repos?: string[], via?: string): boolean { + if ((deps.desk(deskId)?.station || issue !== undefined || via) && blockBoard()) return false; + if ((policy.repositories === false && (worktree || repos?.length)) || + (policy.providerChoices === false && (provider !== undefined || model !== undefined || effort !== undefined))) { + unavailable(); return false; + } + if (policy.requireInitialTask && !prompt?.trim()) { requestTask(deskId); return false; } + return true; + }, + }; +} + +export function workerPolicyHint(policy: WorkerActionPolicy, worker: WorkerInfo | undefined, deskLabel: string, status: string) { + if (!Object.keys(policy).length) return null; + const keys: [DeskKey, string][] = []; + if (!worker) { + keys.push(['E', policy.requireInitialTask ? 'Hire with required task' : 'Hire a worker'], ['P', 'Hire with a task']); + if (policy.shell !== false) keys.push(['B', 'Shell']); + } else { + keys.push(['E', policy.conversationLabel ? 'Open worker conversation' : 'Open terminal']); + if (policy.repositories !== false) keys.push(['C', 'Changes']); + keys.push(['P', policy.reviewFollowupDrafts ? 'Review follow-up draft' : 'Prompt']); + if (policy.repositories !== false && worker.pr) keys.push(['O', `PR #${worker.pr.number}`]); + keys.push(['X', policy.releaseOnly ? 'Release desk' : 'Send home']); + } + if (policy.deskLabels !== false) keys.push(['L', 'Label']); + return { title: worker ? `${worker.name} · ${status}` : `${deskLabel} · empty`, keys }; +} diff --git a/src/client/interaction.ts b/src/client/interaction.ts index a4ab6ce0b..551c99965 100644 --- a/src/client/interaction.ts +++ b/src/client/interaction.ts @@ -2,6 +2,7 @@ import { canLabel } from '../shared/floorplan'; import type { GhIssue, WorkerInfo } from '../shared/protocol'; import { isAsleep } from '../shared/status'; import type { Interactable } from './world/types'; +import { interactionPermitted } from './shared/interaction-policy'; export const DESK_KEYS = { KeyE: 'E', KeyP: 'P', KeyR: 'R', KeyX: 'X', KeyB: 'B', KeyC: 'C', KeyO: 'O', KeyL: 'L' } as const; export type DeskKey = (typeof DESK_KEYS)[keyof typeof DESK_KEYS]; @@ -15,7 +16,7 @@ export interface InteractionState { /** Whether a desk key has an action at the interaction currently in reach. */ export function interactionAvailable(it: Interactable | null, key: DeskKey, state: InteractionState): boolean { - if (!it) return false; + if (!it || !interactionPermitted(it, key)) return false; // A carried issue can be handed to any desk, queued, returned/swapped, or taken into a meeting. if (key === 'E' && state.carrying && (it.kind === 'desk' || it.kind === 'queue' || it.kind === 'issues' || it.kind === 'meeting')) { diff --git a/src/client/main.ts b/src/client/main.ts index 7cc7da7d8..f3cd1aa67 100644 --- a/src/client/main.ts +++ b/src/client/main.ts @@ -1,4 +1,6 @@ import './style.css'; +import { pocketNoWebGL } from './shared/pocket/runtime'; +import { initPocketBootstrap } from './shared/pocket/bootstrap'; import { Net } from './net'; import { DesktopNotifier } from './notify'; import { store, loadProfile, loadSettings } from './state'; @@ -44,6 +46,8 @@ import { installDog } from './features/dog'; import { installEmotes } from './features/emotes'; import { installGolf } from './features/golf'; import { installGong } from './features/gong'; +import { installPocketMaster } from './features/pocket-master'; +import { pocketPresentation } from './shared/pocket/presentation'; import { installGallery, installHanging } from './features/hanging'; import { installHerald } from './features/herald'; import { installHud } from './features/hud'; @@ -63,17 +67,20 @@ import { installWaiting } from './features/waiting'; import { installWalking } from './features/walking'; import { installWhiteboard } from './features/whiteboard'; import { installWorkerActions } from './features/workers/actions'; +import { pocketWorkerActions } from './shared/pocket/controls'; import { installWorkerViews } from './features/workers/views'; -// The loading screen stays up until there's an office to see (see boot and whoami at the end). +// Optional Pocket 3D starts from authenticated native-room data, before any scene or avatar. +const pocket = initPocketBootstrap(store); +// The standalone loading screen remains independent of the native Pocket room. const loading = loadingScreen(onModelsProgress); // Came here from the 2D view's 🏢 3D button: it isn't offered straight back. -const chose3d = new URLSearchParams(location.search).has('3d'); +const chose3d = !pocket && new URLSearchParams(location.search).has('3d'); if (chose3d) history.replaceState(null, '', location.pathname); /** Offers the 2D view (/lite) where the 3D is hard going. */ -const offer2d = (why: 'touch' | 'slow') => chose3d || offerLite(why); +const offer2d = (why: 'touch' | 'slow') => !!pocket || chose3d || offerLite(why); // A phone can't walk around the office: the 2D view is made for it. -if (touchOnly()) offer2d('touch'); +if (!pocket && touchOnly()) offer2d('touch'); // The models made in Blender, loaded before the world they're in is built (see world/models.ts). await preloadModels(); @@ -85,11 +92,14 @@ await preloadModels(); const parts = {} as Parts; const { ctx, core } = createCtx(parts); // The office's own parts of each frame, before anything else's. -installLoop(ctx, core, parts, { offer2d }); +const presentation = pocket ? pocketPresentation() : undefined; +installLoop(ctx, core, parts, { offer2d, shouldDraw: presentation?.shouldDraw, afterDraw: presentation?.afterDraw }); // ---- Renderer & scene --------------------------------------------------------------------------- const canvas = $('scene') as HTMLCanvasElement; -parts.stage = createScene(canvas, makeRenderer(canvas) ?? (await noWebGL())); +presentation?.prepare(canvas); +parts.stage = createScene(canvas, makeRenderer(canvas) ?? (await (pocket ? pocketNoWebGL() : noWebGL()))); +presentation?.setup(parts.stage); parts.worlds = createWorlds(ctx); installSky(ctx); @@ -142,7 +152,7 @@ parts.maps = installMaps(ctx, core, parts); parts.peers = installPeers(ctx, core, parts); parts.walking = installWalking(ctx, core, parts); parts.views = installWorkerViews(ctx, core, parts); -parts.actions = installWorkerActions(ctx, core, parts); +parts.actions = installWorkerActions(ctx, core, parts, pocketWorkerActions()); parts.waiting = installWaiting(ctx, core, parts); parts.needsYou = installNeedsYou(ctx, parts); installPalette(ctx, parts); @@ -169,6 +179,7 @@ parts.cards = installCarrying(ctx, { }); parts.seating = installSeating(ctx, { shares: () => parts.talk.currentShares(), watchShare: () => parts.talk.watchShare(), arcade: parts.arcade, showBar: parts.bar.showBar, usable: () => parts.pointer.usable() }); installGong(ctx, { burstOver: parts.views.burstOver, workerViews: parts.views.workerViews, court: () => parts.worlds.court(), idleAgents: () => parts.worlds.idleAgents() }); +installPocketMaster(ctx); parts.hintbar = installHintBar(ctx, core, parts); parts.emotes = installEmotes(ctx, { personOf }); @@ -182,7 +193,7 @@ parts.hud = installHud(ctx, core, parts); // ---- Main loop --------------------------------------------------------------------------------------- fitWindow(ctx); -const frame = frameLoop(ctx, loading); +const frame = frameLoop(ctx, { drew() { loading.drew(); pocket?.drew(); } }); // ---- Boot ------------------------------------------------------------------------------------------ function boot() { @@ -202,7 +213,9 @@ async function whoami() { } } -void whoami().then(() => { +if (pocket) { + void pocket.start({ net: parts.net, loading, startFrames: () => requestAnimationFrame(frame) }); +} else void whoami().then(() => { const saved = loadProfile(); if (saved && store.me.account) saved.name = store.me.account.name; if (store.me.account) store.profile.name = store.me.account.name; diff --git a/src/client/net.ts b/src/client/net.ts index 4cfab43ac..0390da4a3 100644 --- a/src/client/net.ts +++ b/src/client/net.ts @@ -1,6 +1,12 @@ import type { ClientMsg, ServerMsg } from '../shared/protocol'; import { lastFloor, store, type Profile, type Spot } from './state'; +import { installPocketBoundary, pocketRuntime } from './shared/pocket/runtime'; +import { PocketTransport } from './shared/pocket/transport'; +import { readPocketBootstrap } from './shared/pocket/bootstrap'; + +installPocketBoundary(); + type Handler = (msg: ServerMsg) => void; /** The sign-in page, coming back to the 2D view afterwards if that's where you are (see login.ts). */ @@ -10,6 +16,14 @@ export function loginUrl(): string { export class Net { private ws: WebSocket | null = null; + private pocket?: PocketTransport; + + /** Native Pocket chat replaces the terminal in the embedded client. */ + openPocketSession(workerId: string): boolean { + if (!pocketRuntime()) return false; + this.send({ t: 'worker.attach', workerId }); + return true; + } private handlers: Handler[] = []; private statusHandlers: ((up: boolean) => void)[] = []; private retry = 0; @@ -28,6 +42,7 @@ export class Net { onMessage(h: Handler) { this.handlers.push(h); + return () => { this.handlers = this.handlers.filter((handler) => handler !== h); }; } onStatus(h: (up: boolean) => void) { @@ -35,6 +50,14 @@ export class Net { } connect() { + if (pocketRuntime()) { + this.pocket ??= new PocketTransport( + (msg) => this.handlers.forEach((h) => h(msg)), + (up) => { this.up = up; this.statusHandlers.forEach((h) => h(up)); }, + ); + this.pocket.connect(this.profile(), readPocketBootstrap() ?? undefined); + return; + } const { name, color, look } = this.profile(); const proto = location.protocol === 'https:' ? 'wss' : 'ws'; const q = new URLSearchParams({ name, color, skin: String(look.skin), hair: String(look.hair), style: String(look.style) }); @@ -88,6 +111,11 @@ export class Net { } send(msg: ClientMsg) { + if (pocketRuntime()) { + if (this.pocket) this.pocket.send(msg); + else this.handlers.forEach((h) => h({ t: 'toast', level: 'warn', text: 'Pocket is not connected yet.' })); + return; + } if (this.ws && this.ws.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(msg)); } } diff --git a/src/client/shared/interaction-policy.ts b/src/client/shared/interaction-policy.ts new file mode 100644 index 000000000..57af86ac6 --- /dev/null +++ b/src/client/shared/interaction-policy.ts @@ -0,0 +1,15 @@ +import type { DeskKey } from '../interaction'; +import type { Interactable, InteractKind } from '../world/types'; + +/** Runtime policies can remove an action before the pointer starts its reach animation or sends an event. */ +type Availability = (target: Interactable, key: DeskKey) => boolean; +const policies = new Map(); + +export function registerInteractionPolicy(kind: InteractKind, allowed: Availability): () => void { + policies.set(kind, allowed); + return () => { if (policies.get(kind) === allowed) policies.delete(kind); }; +} + +export function interactionPermitted(target: Interactable, key: DeskKey): boolean { + return policies.get(target.kind)?.(target, key) ?? true; +} diff --git a/src/client/shared/pocket/bootstrap.ts b/src/client/shared/pocket/bootstrap.ts new file mode 100644 index 000000000..17c2816bf --- /dev/null +++ b/src/client/shared/pocket/bootstrap.ts @@ -0,0 +1,138 @@ +import './responsive.css'; +import type { Me, PeerInfo, WorkerInfo } from '../../../shared/protocol'; +import { HAIR_COLORS, HAIR_STYLES, SKIN_TONES } from '../../../shared/avatar'; +import type { Profile } from '../../state/persist'; +import { validPocketMaster, type PocketSnapshot } from './wire'; +import { pocketRuntime } from './runtime'; +import { parsePocketFloors, type PocketFloor } from './floors'; + +export interface PocketBootstrap { + person: { id: string; name: string; role: 'owner' | 'guest' | 'viewer' }; + profile: Profile; + snapshot: PocketSnapshot; + floors: PocketFloor[]; +} + +type Obj = Record; +const object = (v: unknown): v is Obj => !!v && typeof v === 'object' && !Array.isArray(v); +const text = (v: unknown): v is string => typeof v === 'string' && v.length > 0; +const finite = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v); +function look(v: unknown): boolean { + if (!object(v)) return false; + return [v.skin, v.hair, v.style].every((n, i) => Number.isInteger(n) && Number(n) >= 0 && Number(n) < [SKIN_TONES.length, HAIR_COLORS.length, HAIR_STYLES.length][i]); +} +function peer(v: unknown): v is PeerInfo { + return object(v) && text(v.id) && text(v.name) && typeof v.color === 'string' && look(v.look) + && [v.x, v.y, v.z, v.rotY].every(finite) && [v.moving, v.voice, v.muted, v.sharing].every((n) => typeof n === 'boolean'); +} +function worker(v: unknown): v is WorkerInfo { + return object(v) && text(v.id) && text(v.deskId) && text(v.name) && typeof v.color === 'string' + && v.kind === 'agent' && ['working', 'idle', 'offline'].includes(String(v.status)) && finite(v.createdAt) + && typeof v.createdBy === 'string' && typeof v.acked === 'boolean' && finite(v.cols) && finite(v.rows) + && Array.isArray(v.viewers) && v.viewers.every((n) => typeof n === 'string') + && Array.isArray(v.viewerIds) && v.viewerIds.every((n) => typeof n === 'string'); +} + +/** Reject invalid startup data. Never fall back to standalone login or a random character. */ +export function parsePocketBootstrap(encoded: string): PocketBootstrap { + let b: unknown; + try { b = JSON.parse(decodeURIComponent(encoded)); } + catch { throw new Error('Invalid Pocket bootstrap encoding'); } + if (!object(b) || !object(b.person) || !text(b.person.id) || !text(b.person.name) + || !['owner', 'guest', 'viewer'].includes(String(b.person.role)) || !object(b.profile) + || !text(b.profile.name) || typeof b.profile.color !== 'string' || !/^#[0-9a-f]{6}$/i.test(b.profile.color) || !look(b.profile.look) + || !object(b.snapshot)) throw new Error('Invalid Pocket bootstrap identity or profile'); + const s = b.snapshot; + if (!Number.isSafeInteger(s.source) || Number(s.source) <= 0 || typeof s.title !== 'string' || !text(s.cwd) || !text(s.you) + || (s.model !== undefined && typeof s.model !== 'string') || !Array.isArray(s.peers) || !s.peers.every(peer) + || !s.peers.some((p) => p.id === s.you) || !Array.isArray(s.workers) || !s.workers.every(worker) + || !Array.isArray(s.chat) || !s.chat.every((c) => object(c) && text(c.from) && typeof c.name === 'string' + && typeof c.color === 'string' && typeof c.text === 'string' && finite(c.at))) throw new Error('Invalid Pocket bootstrap source snapshot'); + if (!validPocketMaster(s as unknown as PocketSnapshot)) throw new Error('Invalid Pocket bootstrap master'); + b.floors = parsePocketFloors(b.floors); + if (!(b.floors as PocketFloor[]).some(f => f.id === s.source)) throw new Error('Invalid Pocket bootstrap floor selection'); + // The authenticated person's name, not a browser's old display name, is authoritative. + b.profile.name = b.person.name; + return b as unknown as PocketBootstrap; +} + +const documents = new WeakMap(); +export function readPocketBootstrap(): PocketBootstrap | null { + if (!pocketRuntime()) return null; + const saved = documents.get(document); + if (saved instanceof Error) throw saved; + if (saved) return saved; + try { + const encoded = document.querySelector('meta[name="pocket-bootstrap"]')?.getAttribute('content'); + if (!encoded) throw new Error('Pocket bootstrap is missing. Reopen the Office panel.'); + const b = parsePocketBootstrap(encoded); + documents.set(document, b); + return b; + } catch (e) { + const error = e instanceof Error ? e : new Error(String(e)); + documents.set(document, error); + throw error; + } +} + +export function pocketMe(b: PocketBootstrap): Me { + // Office account administration is never enabled, including for the Pocket owner. + return { admin: false, account: { name: b.person.name, role: 'member' } }; +} + +interface BootDeps { + net: { onMessage(fn: (msg: { t: string }) => void): () => void; connect(): void }; + loading: { until(steps: { say: string; done: Promise }[]): void }; + startFrames(): void; +} + +/** One startup lifecycle. Readiness means a source welcome was applied and a frame was rendered. */ +export class PocketLifecycle { + readonly ready: Promise; + private sourceReady: Promise; + private sourceDone!: () => void; + private frameDone!: () => void; + private welcomed = false; + private started = false; + constructor(readonly data: PocketBootstrap) { + this.sourceReady = new Promise((resolve) => { this.sourceDone = resolve; }); + const frameReady = new Promise((resolve) => { this.frameDone = resolve; }); + this.ready = Promise.all([this.sourceReady, frameReady]).then(() => {}); + } + drew() { if (this.welcomed) this.frameDone(); } + start(deps: BootDeps): Promise { + if (this.started) return this.ready; + this.started = true; + const off = deps.net.onMessage((msg) => { + if (msg.t !== 'welcome') return; + off(); + this.welcomed = true; + this.sourceDone(); + }); + deps.loading.until([{ say: 'Opening Pocket source', done: this.sourceReady }]); + deps.net.connect(); + deps.startFrames(); + return this.ready; + } +} + +/** Called before createCtx/createScene/makeMe. Does not read standalone profile or position. */ +export function initPocketBootstrap(target: { profile: Profile; me: Me }): PocketLifecycle | null { + try { + const b = readPocketBootstrap(); + if (!b) return null; + document.documentElement.classList.add('pocket-office'); + target.profile = { ...b.profile, look: { ...b.profile.look } }; + target.me = pocketMe(b); + return new PocketLifecycle(b); + } catch (error) { + if (typeof document !== 'undefined' && document.body) { + const alert = document.createElement('div'); + alert.setAttribute('role', 'alert'); + alert.style.cssText = 'position:fixed;inset:0;z-index:100000;background:#18202b;color:white;padding:2rem;font:18px system-ui'; + alert.textContent = `${error instanceof Error ? error.message : error}. Reopen the Office panel.`; + document.body.append(alert); + } + throw error; + } +} diff --git a/src/client/shared/pocket/controls.ts b/src/client/shared/pocket/controls.ts new file mode 100644 index 000000000..1ca6cc5dc --- /dev/null +++ b/src/client/shared/pocket/controls.ts @@ -0,0 +1,23 @@ +import { pocketRuntime } from './runtime'; +import type { WorkerActionPolicy } from '../../features/workers/policy'; + +/** Source settings and session lifetime belong to the host, not the Office CLI. */ +export function pocketWorkerActions(embedded = pocketRuntime()): WorkerActionPolicy { + return embedded ? { + requireInitialTask: true, conversationLabel: 'worker conversation', reviewFollowupDrafts: true, + releaseOnly: true, shell: false, repositories: false, deskLabels: false, boardAgents: false, providerChoices: false, + } : {}; +} + +const ACTIONS = new Set(['elevator', 'settings', 'help']); +const PANELS = new Set(['workers', 'people', 'chat', 'floor']); + +/** Only advertise actions backed by the Pocket transport or local room controls. */ +export function pocketHudActions(actions: T[], embedded = pocketRuntime()): T[] { + return embedded ? actions.filter((action) => ACTIONS.has(action.id)) : actions; +} + +/** CLI spend and provider-limit data do not exist in Pocket. Keep supported panel toggles. */ +export function pocketHudPanel(id: string, embedded = pocketRuntime()): boolean { + return !embedded || PANELS.has(id); +} diff --git a/src/client/shared/pocket/floors.ts b/src/client/shared/pocket/floors.ts new file mode 100644 index 000000000..aaabb778b --- /dev/null +++ b/src/client/shared/pocket/floors.ts @@ -0,0 +1,41 @@ +import type { FloorInfo } from '../../../shared/protocol'; +import type { PocketSnapshot } from './wire'; + +export interface PocketFloor { + id: number; + title: string; + cwd: string; + busy: boolean; + archived: boolean; +} + +export function pocketFloorId(source: number): string { return `chat-${source}`; } +export function pocketFloorSource(id: string): number | null { + if (!/^chat-[1-9]\d*$/.test(id)) return null; + const source = Number(id.slice(5)); + return Number.isSafeInteger(source) ? source : null; +} + +/** Only the public parent response supplies the catalog. Children are not synthesized from workers. */ +export function parsePocketFloors(value: unknown): PocketFloor[] { + if (!Array.isArray(value) || value.length > 10_000) throw new Error('Invalid Pocket floor list'); + const ids = new Set(); + return value.map(f => { + if (!f || typeof f !== 'object' || !Number.isSafeInteger(f.id) || f.id <= 0 || ids.has(f.id) + || typeof f.title !== 'string' || typeof f.cwd !== 'string' || typeof f.busy !== 'boolean' + || typeof f.archived !== 'boolean') throw new Error('Invalid Pocket floor'); + ids.add(f.id); + return { id: f.id, title: f.title, cwd: f.cwd, busy: f.busy, archived: f.archived }; + }).filter(f => !f.archived); +} + +export function pocketFloorViews(floors: PocketFloor[], current: PocketSnapshot): FloorInfo[] { + return floors.map(f => ({ + id: pocketFloorId(f.id), name: f.title, dir: f.cwd, palette: f.id, wing: 0, + addedBy: 'Pocket', addedAt: 0, + // A root's busy flag is not a count of its workers. Only this floor's snapshot supplies counts. + workers: f.id === current.source ? current.workers.length : 0, + busy: f.id === current.source ? current.workers.filter(w => w.status === 'working').length : 0, + people: f.id === current.source ? current.peers.length : 0, waiting: 0, + })); +} diff --git a/src/client/shared/pocket/native.ts b/src/client/shared/pocket/native.ts new file mode 100644 index 000000000..f5d441e59 --- /dev/null +++ b/src/client/shared/pocket/native.ts @@ -0,0 +1,58 @@ +import { pocketRuntime } from './runtime'; +import type { PocketFloor } from './floors'; +import type { PocketSnapshot } from './wire'; +export { pocketOverlay } from './overlay'; + +const snapshotListeners = new Set<(snapshot: PocketSnapshot) => void>(); +export function onPocketSnapshot(fn: (snapshot: PocketSnapshot) => void): () => void { + snapshotListeners.add(fn); + return () => { snapshotListeners.delete(fn); }; +} +export function publishPocketSnapshot(snapshot: PocketSnapshot): void { + for (const fn of snapshotListeners) fn(snapshot); +} + +let floorCallbacks: { list(): Promise; switch(source: number): Promise } | undefined; + +export function registerPocketFloors(callbacks: NonNullable): () => void { + floorCallbacks = callbacks; + return () => { if (floorCallbacks === callbacks) floorCallbacks = undefined; }; +} +export function listPocketFloors(): Promise { + if (!floorCallbacks) return Promise.reject(new Error('Pocket floor connection is unavailable. Reopen the Office.')); + return floorCallbacks.list(); +} +export function switchPocketFloor(source: number): Promise { + if (!floorCallbacks) return Promise.reject(new Error('Pocket floor connection is unavailable. Reopen the Office.')); + return floorCallbacks.switch(source); +} + +let customize: (() => void) | undefined; +let workspace: (() => void) | undefined; + +/** The transport owns the callback and removes it when its iframe closes. */ +export function registerPocketCustomize(open: () => void): () => void { + customize = open; + return () => { if (customize === open) customize = undefined; }; +} +export function registerPocketWorkspace(open: () => void): () => void { + workspace = open; + return () => { if (workspace === open) workspace = undefined; }; +} + +/** True means the native flow owns this action; never render the standalone form. */ +export function openPocketCustomize(): boolean { + if (!pocketRuntime()) return false; + if (!customize) throw new Error('Pocket is not connected. Reopen the Office panel to customize your avatar.'); + customize(); + return true; +} + +/** The project's elevator is a workspace picker in Pocket, not a repository clone dialog. */ +export function openPocketWorkspace(): boolean { + if (!pocketRuntime()) return false; + if (!workspace) throw new Error('Pocket is not connected. Reopen the Office panel to switch workspaces.'); + document.exitPointerLock?.(); + workspace(); + return true; +} diff --git a/src/client/shared/pocket/overlay.ts b/src/client/shared/pocket/overlay.ts new file mode 100644 index 000000000..0f57144f0 --- /dev/null +++ b/src/client/shared/pocket/overlay.ts @@ -0,0 +1,29 @@ +import { h, openModal, type Modal } from '../../ui/dom'; + +/** The parent owns the visible inspector. This sentinel only uses the normal world-focus lifecycle. */ +function openSentinel(): Modal { + const modal = openModal(h('div', { 'aria-hidden': 'true' }), { + doing: 'reading a Pocket conversation', reading: true, + escCloses: false, closeButton: false, backdropCloses: false, + }); + modal.backdrop.style.display = 'none'; + modal.backdrop.style.pointerEvents = 'none'; + return modal; +} + +export class PocketOverlay { + private modal?: Pick; + get open(): boolean { return !!this.modal; } + constructor(private create: () => Pick = openSentinel) {} + set(open: boolean): void { + if (open) this.modal ??= this.create(); + else this.close(); + } + close(): void { + const modal = this.modal; + this.modal = undefined; + modal?.close(); + } +} + +export const pocketOverlay = new PocketOverlay(); diff --git a/src/client/shared/pocket/presentation.ts b/src/client/shared/pocket/presentation.ts new file mode 100644 index 000000000..d40929c21 --- /dev/null +++ b/src/client/shared/pocket/presentation.ts @@ -0,0 +1,52 @@ +import type { Frame } from '../../core/registry'; +import type { Stage } from '../../core/scene'; +import { pocketOverlay } from './overlay'; +import { modalOpen } from '../../ui/dom'; + +export function softwareRenderer(name: string): boolean { + return /swiftshader|llvmpipe|softpipe|software (?:rasterizer|renderer)|microsoft basic render/i.test(name); +} + +/** Keep the native inspector responsive. Software WebGL still draws the real scene, at a bounded cost. */ +export function pocketPresentation(overlay: { readonly open: boolean } = pocketOverlay, localModal = modalOpen) { + let software = false; + let lastDraw = -Infinity; + return { + prepare(canvas: HTMLCanvasElement) { + const probe = document.createElement('canvas'); + let gl: WebGL2RenderingContext | null; + try { gl = probe.getContext('webgl2', { antialias: false }); } catch { return; } + if (!gl) return; + try { + const debug = gl.getExtension('WEBGL_debug_renderer_info'); + if (debug && softwareRenderer(String(gl.getParameter(debug.UNMASKED_RENDERER_WEBGL)))) { + // Context attributes are fixed at creation. Keep hardware AA; avoid software MSAA's raster cost. + canvas.getContext('webgl2', { alpha: false, antialias: false }); + } + } catch { /* The normal renderer still decides whether WebGL is available. */ } + finally { try { gl.getExtension('WEBGL_lose_context')?.loseContext(); } catch { /* Already lost. */ } } + }, + setup(stage: Stage) { + try { + const gl = stage.renderer.getContext(); + const debug = gl.getExtension('WEBGL_debug_renderer_info'); + software = !!debug && softwareRenderer(String(gl.getParameter(debug.UNMASKED_RENDERER_WEBGL))); + } catch { /* An unavailable renderer description must not prevent entry. */ } + if (software) { + stage.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 0.6)); + stage.renderer.shadowMap.enabled = false; + stage.effect.enabled = false; + } + }, + afterDraw() { + if (!software) return; + // Start idle time after slow submission, rather than immediately drawing another overdue frame. + lastDraw = performance.now(); + }, + shouldDraw({ now }: Pick): boolean { + if (overlay.open || (software && (localModal() || now - lastDraw < 200))) return false; + lastDraw = now; + return true; + }, + }; +} diff --git a/src/client/shared/pocket/responsive.css b/src/client/shared/pocket/responsive.css new file mode 100644 index 000000000..5b7421027 --- /dev/null +++ b/src/client/shared/pocket/responsive.css @@ -0,0 +1,48 @@ +/* Desktop Office only. The host supplies a full viewport of at least 1024×600. + Keep the original desktop HUD, panel layout, typography, and control sizes. + These Pocket-scoped rules only bound dialogs inside the available desktop viewport. */ +html.pocket-office .backdrop:not(.clear) { padding: 16px; } +html.pocket-office .modal { + position: relative; + max-width: calc(100vw - 32px); + max-height: calc(100dvh - 38px); /* Leave room for the original bottom shadow. */ + min-width: 0; + min-height: 0; +} +/* Headers and close buttons stay outside the scrolling body. Original desktop close size: 34px. */ +html.pocket-office .modal > header { + position: relative; + flex: none; + padding-right: 62px; +} +html.pocket-office .modal > header .close { + position: absolute; + top: 12px; + right: 16px; + left: auto; + margin: 0; + z-index: 3; +} +html.pocket-office .modal > .close.corner { + top: 10px; + right: 10px; + left: auto; + z-index: 3; +} +html.pocket-office .modal:not(:has(> header)) { padding-top: 50px; } +html.pocket-office .modal > :is(.body, .settings-body, .changes-body, .term-host) { + min-height: 0; + min-width: 0; +} +html.pocket-office .modal > .body { overflow: auto; overscroll-behavior: contain; } +/* Long action text can wrap without moving the close button or extending beyond the desktop. */ +html.pocket-office .modal > footer { + flex: none; + flex-wrap: wrap; + max-height: min(180px, 30dvh); + overflow: auto; + overscroll-behavior: contain; +} +html.pocket-office .modal > footer > * { min-width: 0; max-width: 100%; } +html.pocket-office .modal > footer .grow { flex: 1 1 180px; } +html.pocket-office .modal :is(input, textarea, select, img, video) { max-width: 100%; } diff --git a/src/client/shared/pocket/runtime.ts b/src/client/shared/pocket/runtime.ts new file mode 100644 index 000000000..96d0fcf35 --- /dev/null +++ b/src/client/shared/pocket/runtime.ts @@ -0,0 +1,35 @@ +/** srcdoc keeps the parent's origin; it does not have its own URL or hash. */ +export function pocketRuntime(): boolean { + return typeof document !== 'undefined' && document.querySelector('meta[name="pocket-runtime"]')?.getAttribute('content') === '1'; +} + +export function pocketChannel(): string { + return document.querySelector('meta[name="pocket-channel"]')?.getAttribute('content') ?? ''; +} + +/** The regular fallback navigates to /lite, which is not a Pocket extension route. */ +export function pocketNoWebGL(): Promise { + const error = document.createElement('div'); + error.setAttribute('role', 'alert'); + error.style.cssText = 'position:fixed;inset:0;z-index:100000;background:#18202b;color:white;padding:2rem;font:18px system-ui'; + error.textContent = 'This device cannot start the 3D Office. WebGL is required. Use native Pocket chat instead.'; + document.body.append(error); + return new Promise(() => {}); +} + +/** Keep legacy UI requests away from Pocket APIs and external services. */ +export function installPocketBoundary() { + if (!pocketRuntime()) return; + const original = window.fetch.bind(window); + const origin = new URL(document.baseURI).origin; + window.fetch = (input, init) => { + const url = new URL(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url, document.baseURI); + if (url.origin !== origin || !['http:', 'https:'].includes(url.protocol) || url.pathname === '/api' || url.pathname.startsWith('/api/')) { + return Promise.resolve(new Response(JSON.stringify({ error: 'This Office action is unsupported in Pocket. Use native Pocket chat.' }), { + status: 501, headers: { 'content-type': 'application/json' }, + })); + } + // An allowed same-origin asset must not redirect to another origin or an API. + return original(input, { ...init, mode: 'same-origin', redirect: 'error' }); + }; +} diff --git a/src/client/shared/pocket/settings.ts b/src/client/shared/pocket/settings.ts new file mode 100644 index 000000000..371751427 --- /dev/null +++ b/src/client/shared/pocket/settings.ts @@ -0,0 +1,53 @@ +import type { Settings, ViewMode } from '../../state'; +import { h, openModal } from '../../ui/dom'; +import { openPocketElevator } from '../../ui/pocket-elevator'; +import { pocketRuntime } from './runtime'; + +/** Pocket settings are local preferences. Opening this dialog never sends a runtime request. */ +export function openPocketSettings(settings: Settings, onChange: (settings: Settings) => void, onCharacter: () => void): boolean { + if (!pocketRuntime()) return false; + const change = (patch: Partial) => { + settings = { ...settings, ...patch }; + onChange(settings); + }; + const camera = h('div.seg', { role: 'radiogroup', 'aria-label': 'Camera view' }); + const paintCamera = () => camera.replaceChildren(...([['first', 'First person'], ['third', 'Third person']] as [ViewMode, string][]).map(([view, label]) => + h('button.btn', { type: 'button', role: 'radio', 'aria-checked': String(settings.view === view), class: settings.view === view ? 'on' : '', onclick: () => { change({ view }); paintCamera(); } }, label), + )); + paintCamera(); + const volume = (label: string, level: 'volume' | 'music', muted: 'muted' | 'musicMuted') => { + const slider = h('input', { type: 'range', min: 0, max: 100, step: 1, 'aria-label': label }); + const mute = h('button.btn', { type: 'button' }); + const percent = h('span.vol-pct'); + const paint = () => { + const value = Math.round(settings[level] * 100); + slider.value = String(value); + slider.style.setProperty('--fill', `${value}%`); + percent.textContent = settings[muted] ? 'Muted' : `${value}%`; + mute.textContent = settings[muted] ? 'Unmute' : 'Mute'; + mute.setAttribute('aria-label', `${settings[muted] ? 'Unmute' : 'Mute'} ${label}`); + mute.setAttribute('aria-pressed', String(settings[muted])); + }; + slider.addEventListener('input', () => { change({ [level]: Number(slider.value) / 100, [muted]: false }); paint(); }); + mute.addEventListener('click', () => { change({ [muted]: !settings[muted] }); paint(); }); + paint(); + return h('div.volume', {}, mute, slider, percent); + }; + const section = (title: string, ...children: Node[]) => h('section.setting', {}, h('div.setting-head', {}, h('h4', {}, title)), ...children); + const character = h('button.btn', { type: 'button' }, 'Change avatar'); + const workspace = h('button.btn', { type: 'button' }, 'Switch workspace'); + const el = h('div.modal.pocket-settings', { role: 'dialog', 'aria-label': 'Pocket room settings' }, + h('header', {}, h('h2', {}, 'Room settings')), + h('div.body', {}, + section('Camera', camera), + section('Office sounds', volume('Office sounds volume', 'volume', 'muted')), + section('Music preference', volume('Music volume', 'music', 'musicMuted'), h('p.setting-note', {}, 'This is a local playback preference. Shared jukebox controls are not connected in Pocket.')), + section('Your room', h('div.seg', {}, character, workspace)), + h('p.setting-note', {}, 'Click a worker or the Master desk for its live Pocket conversation, prompts, tools, and approvals inside the office. GitHub is not connected.'), + ), + ); + const modal = openModal(el); + character.addEventListener('click', () => { modal.close(); onCharacter(); }); + workspace.addEventListener('click', () => { modal.close(); openPocketElevator(); }); + return true; +} diff --git a/src/client/shared/pocket/transport.ts b/src/client/shared/pocket/transport.ts new file mode 100644 index 000000000..014657260 --- /dev/null +++ b/src/client/shared/pocket/transport.ts @@ -0,0 +1,244 @@ +import { uuid } from '../../../shared/uuid'; +import type { ClientMsg, ServerMsg } from '../../../shared/protocol'; +import { pocketChannel } from './runtime'; +import { pocketMe, type PocketBootstrap } from './bootstrap'; +import { registerPocketCustomize, registerPocketWorkspace, registerPocketFloors, pocketOverlay, publishPocketSnapshot } from './native'; +import { parsePocketFloors, pocketFloorSource, pocketFloorViews, type PocketFloor } from './floors'; +import { validPocketMaster, pocketEventCursor, pocketUpdates, pocketWelcome, type PocketSnapshot } from './wire'; + +export type PocketOp = 'connect' | 'poll' | 'move' | 'chat' | 'hire' | 'open' | 'dismiss' | 'customize' | 'presence' | 'workspace' | 'floors' | 'switch'; +export interface PocketRequest { + type: 'office-v2:request'; channel: string; id: string; op: PocketOp; payload: Record; +} +export interface PocketReply { + type: 'office-v2:reply'; channel: string; id: string; result?: unknown; error?: string; +} + +export function verifiedReply(event: MessageEvent, parent: Window, origin: string, channel: string): event is MessageEvent { + const m = event.data; + return event.source === parent && event.origin === origin && !!channel && m?.type === 'office-v2:reply' + && m.channel === channel && typeof m.id === 'string'; +} + +export function verifiedOverlay(event: MessageEvent, parent: Window, origin: string, channel: string): event is MessageEvent<{ type: 'office-v2:overlay'; channel: string; open: boolean }> { + const m = event.data; + return event.source === parent && event.origin === origin && !!channel && m?.type === 'office-v2:overlay' + && m.channel === channel && typeof m.open === 'boolean'; +} + +/** The iframe has no Office socket, credentials, or runtime authority. */ +export class PocketTransport { + private pending = new Map }>(); + private snapshot?: PocketSnapshot; + private polling?: ReturnType; + private moving?: ReturnType; + private move?: Extract; + private active = false; + private syncing = false; + private channel: string; + private origin: string; + private bootstrap?: PocketBootstrap; + private stopCustomize?: () => void; + private stopWorkspace?: () => void; + private stopFloors?: () => void; + private floors: PocketFloor[] = []; + private switching = false; + private eventCursor = 0; + + constructor(private emit: (msg: ServerMsg) => void, private status: (up: boolean) => void, + private overlay: Pick = pocketOverlay) { + this.channel = pocketChannel(); + // about:srcdoc can report a null location origin; baseURI inherits the parent URL. + this.origin = new URL(document.baseURI).origin; + } + + private receive = (event: MessageEvent) => { + if (verifiedOverlay(event, window.parent, this.origin, this.channel)) { + if (this.active && !this.switching) this.overlay.set(event.data.open); + return; + } + if (!verifiedReply(event, window.parent, this.origin, this.channel)) return; + const waiting = this.pending.get(event.data.id); + if (!waiting) return; + this.pending.delete(event.data.id); + clearTimeout(waiting.timer); + if (typeof event.data.error === 'string') waiting.reject(new Error(event.data.error)); + else waiting.resolve(event.data.result); + }; + + private request(op: PocketOp, payload: Record = {}): Promise { + if (!this.active || (this.switching && op !== 'switch')) return Promise.reject(new Error('Pocket transport is not connected')); + if (!this.channel || window.parent === window) return Promise.reject(new Error('Pocket parent channel is unavailable')); + const id = uuid(); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`Pocket ${op} request timed out`)); + }, 15_000); + this.pending.set(id, { resolve, reject, timer }); + const message: PocketRequest = { type: 'office-v2:request', channel: this.channel, id, op, payload }; + window.parent.postMessage(message, this.origin); + }); + } + + private failure(error: unknown) { + this.emit({ t: 'toast', level: 'error', text: error instanceof Error ? error.message : String(error) }); + } + + private apply(value: unknown) { + const s = value as PocketSnapshot; + if (!s || !Number.isSafeInteger(s.source) || typeof s.you !== 'string' || typeof s.title !== 'string' || typeof s.cwd !== 'string' + || !Array.isArray(s.peers) || !Array.isArray(s.workers) || !Array.isArray(s.chat) || !validPocketMaster(s)) throw new Error('Invalid Pocket snapshot'); + if (this.snapshot && s.source !== this.snapshot.source) throw new Error('Pocket source changed; reopen the Office panel'); + if (!this.active || this.switching) return; + if (!this.floors.length) this.floors = [{ id: s.source, title: s.title, cwd: s.cwd, busy: false, archived: false }]; + const welcome = () => ({ ...pocketWelcome(s, this.floors), ...(this.bootstrap ? { me: pocketMe(this.bootstrap) } : {}) }); + const messages = this.snapshot ? pocketUpdates(this.snapshot, s, this.eventCursor) : [welcome()]; + this.eventCursor = Math.max(this.eventCursor, pocketEventCursor(s)); + this.snapshot = s; + publishPocketSnapshot(s); + for (const message of messages) this.emit(message); + this.status(true); + } + + connect(profile: unknown, bootstrap?: PocketBootstrap) { + if (this.active) return; + this.active = true; + this.bootstrap = bootstrap; + this.floors = bootstrap?.floors ?? []; + this.switching = false; + this.stopFloors = registerPocketFloors({ list: () => this.listFloors(), switch: source => this.switchFloor(source) }); + this.stopCustomize = registerPocketCustomize(() => { void this.action('customize', {}); }); + this.stopWorkspace = registerPocketWorkspace(() => { void this.action('workspace', {}); }); + window.addEventListener('message', this.receive); + window.addEventListener('pagehide', this.dispose, { once: true }); + if (bootstrap) this.apply(bootstrap.snapshot); + void this.request('connect', { profile }).then((s) => this.apply(s)).catch((e) => { + if (this.active && !this.switching) { this.status(false); this.failure(e); } + }); + this.polling = setInterval(() => { void this.poll(); }, 1000); + } + + private async poll() { + if (this.syncing || !this.active || this.switching) return; + this.syncing = true; + try { this.apply(await this.request('poll')); } + catch (e) { if (this.active && !this.switching) { this.status(false); this.failure(e); } } + finally { this.syncing = false; } + } + + private async action(op: PocketOp, payload: Record) { + try { + const result = await this.request(op, payload); + if (!this.active || this.switching) return; + if (op === 'presence') this.apply(result); + if (op === 'open' && this.active && !this.switching) this.emit({ t: 'toast', level: 'info', text: payload.prompt ? 'Worker inspector opened. Review the unsent draft there.' : 'Opened worker inspector.' }); + if (op === 'dismiss') this.emit({ t: 'toast', level: 'info', text: 'Released the desk. The Pocket session continues.' }); + if (!['open', 'move', 'customize', 'presence', 'workspace'].includes(op)) await this.poll(); + } catch (e) { if (this.active && !this.switching) this.failure(e); } + } + + private async listFloors(): Promise { + const floors = parsePocketFloors(await this.request('floors')); + if (!this.active || this.switching) throw new Error('Pocket floor request was cancelled'); + this.floors = floors; + if (this.snapshot) this.emit({ t: 'floors', floors: pocketFloorViews(floors, this.snapshot) }); + return floors; + } + + private async switchFloor(source: number): Promise { + if (!this.active || this.switching) throw new Error('Pocket floor switch is unavailable'); + if (!this.floors.some(f => f.id === source)) throw new Error('This chat is not an available floor'); + if (source === this.snapshot?.source) return; + this.switching = true; + clearTimeout(this.moving); + this.moving = undefined; + this.move = undefined; + this.cancelPending('Pocket floor changed'); + try { + const reply = await this.request('switch', { source }) as { switching?: boolean; source?: number }; + if (reply?.switching !== true || reply.source !== source) throw new Error('Pocket did not confirm the floor switch'); + // The parent replaces this iframe. Never apply another floor's snapshot to the old scene. + } catch (error) { + if (this.active) this.switching = false; + throw error; + } + } + + private cancelPending(message: string) { + for (const p of this.pending.values()) { clearTimeout(p.timer); p.reject(new Error(message)); } + this.pending.clear(); + } + + send(msg: ClientMsg | { t: 'floor.switch'; floor: string }) { + if (!this.active || this.switching) return; + if (msg.t === 'floor.go' || msg.t === 'floor.switch') { + const source = pocketFloorSource(msg.floor); + if (source === null) return this.unsupported('Non-chat floors'); + void this.switchFloor(source).catch(e => { if (this.active) this.failure(e); }); + return; + } + if (msg.t === 'move') { + this.move = msg; + this.moving ??= setTimeout(() => { + this.moving = undefined; + const latest = this.move; + this.move = undefined; + if (latest) { + const { t: _t, ...position } = latest; + void this.action('move', position); + } + }, 250); + return; + } + if (msg.t === 'ping') { this.emit({ t: 'pong', at: msg.at, now: Date.now() }); return; } + if (msg.t === 'act' || msg.t === 'emote' || msg.t === 'sit' || msg.t === 'doing') { + void this.action('presence', { action: msg }); + return; + } + // These are local view notifications, not runtime operations. + if (msg.t === 'worker.detach' || msg.t === 'term.typing') return; + if (msg.t === 'chat') { void this.action('chat', { text: msg.text }); return; } + if (msg.t === 'worker.spawn') { + if (msg.deskId?.startsWith('pocket-master-')) return this.unsupported('Hiring at the Master desk'); + if (msg.kind === 'shell') return this.unsupported('Shell workers'); + if (msg.worktree || msg.repos?.length) return this.unsupported('Office-managed worktrees and repositories'); + if ((msg.provider !== undefined && msg.provider !== 'pi') || msg.model !== undefined || msg.effort !== undefined) return this.unsupported('CLI provider, model and effort choices; Pocket inherits the source settings'); + if (!msg.prompt?.trim()) return this.unsupported('Hiring without an initial task'); + void this.action('hire', { deskId: msg.deskId, prompt: msg.prompt, key: uuid() }); + return; + } + if (msg.t === 'worker.prompt' || msg.t === 'worker.attach' || msg.t === 'worker.resume') { + void this.action('open', { workerId: msg.workerId, ...(msg.t === 'worker.prompt' ? { prompt: msg.prompt } : {}) }); + return; + } + if (msg.t === 'worker.kill') { + if (msg.workerId.startsWith('pocket-master-')) return this.unsupported('Dismissing the root conversation'); + void this.action('dismiss', { workerId: msg.workerId, key: uuid() }); + return; + } + this.unsupported(msg.t); + } + + private unsupported(action: string) { + this.failure(new Error(`${action} is unsupported in Pocket. Use native Pocket chat.`)); + } + + dispose = () => { + this.active = false; + this.overlay.close(); + this.stopCustomize?.(); + this.stopCustomize = undefined; + this.stopWorkspace?.(); + this.stopWorkspace = undefined; + this.stopFloors?.(); + this.stopFloors = undefined; + clearInterval(this.polling); + clearTimeout(this.moving); + window.removeEventListener('message', this.receive); + window.removeEventListener('pagehide', this.dispose); + this.move = undefined; + this.cancelPending('Pocket transport closed'); + this.status(false); + }; +} diff --git a/src/client/shared/pocket/wire.ts b/src/client/shared/pocket/wire.ts new file mode 100644 index 000000000..c5a05fe61 --- /dev/null +++ b/src/client/shared/pocket/wire.ts @@ -0,0 +1,93 @@ +import type { ChatLine, PeerInfo, ServerMsg, WorkerInfo } from '../../../shared/protocol'; +import { EMPTY_PLAN } from '../../../shared/floorplan'; +import { parked } from '../../../shared/garage'; +import { JUKEBOX_TUNES } from '../../../shared/jukebox'; +import { pocketFloorId, pocketFloorViews, type PocketFloor } from './floors'; + +/** The parent authorizes every field. Extra parent-only binding fields are ignored. */ +export interface PocketSnapshot { + source: number; + title: string; + cwd: string; + model?: string; + you: string; + peers: PeerInfo[]; + chat: ChatLine[]; + workers: WorkerInfo[]; + /** The root inspector is separate from all regular desks and worker counts. */ + master?: { workerId: string; title: string; busy?: boolean }; + events?: { seq: number; at: number; message: Extract }[]; +} + +export function validPocketMaster(s: PocketSnapshot): boolean { + const master = s.master; + return master === undefined || (!!master && master.workerId === `pocket-master-${s.source}` + && typeof master.title === 'string' && (master.busy === undefined || typeof master.busy === 'boolean')); +} + +type Welcome = Extract; +const machine = (s: PocketSnapshot): Welcome['machine'] => ({cpu:0,cores:0,memUsed:0,memTotal:0,history:[],workers:s.workers.length,limit:16}); + +function workers(s: PocketSnapshot): WorkerInfo[] { + return s.workers.map((w) => ({ + ...w, + status: w.status === 'working' ? 'working' : w.status === 'idle' ? 'idle' : 'offline', + // The public list does not expose each child's current model. Never label it with the source's current model. + model: undefined, + usage: undefined, // Office has no authoritative Pocket usage or pricing. + })); +} + +export function pocketWelcome(s: PocketSnapshot, floors: PocketFloor[] = [{ id: s.source, title: s.title, cwd: s.cwd, busy: false, archived: false }]): Welcome { + const unknownUsage = () => ({ input: 0, output: 0, cacheWrite: 0, cacheRead: 0, cost: 0, calls: 0, costKnown: false, callsKnown: false, incomplete: true }); + return { + t: 'welcome', you: s.you, peers: s.peers.map((p) => ({ ...p, floor: pocketFloorId(s.source) })), chat: s.chat, + floors: pocketFloorViews(floors, s), + projectsDir: { dir: s.cwd, custom: false }, ice: [], invites: false, version: 'pocket', + upgrade: { available: false, phase: 'idle' }, + usage: { total: unknownUsage(), today: unknownUsage(), day: '', pauseHiring: false }, + limits: { windows: [], at: 0 }, me: { admin: false }, notify: {}, + machine: machine(s), + sky: { lat: 0, lon: 0, utcOffset: 0, weather: 'clear', intensity: 0, realTime: true }, + theme: { pick: 'off', active: null }, map: { pick: 'office', custom: [] }, + prompts: { custom: {} }, leaveOnMerge: { on: false }, floor: pocketFloorId(s.source), + project: { name: s.title, dir: s.cwd, agentCmd: 'Pocket · inherited model', defaultProvider: 'pi', agentProviders: ['pi'] }, + workers: workers(s), issues: { items: [], loading: false, fetchedAt: 0 }, pulls: { items: [], loading: false, fetchedAt: 0 }, + queue: { tasks: [], maxWorkers: 0 }, decor: [], plan: { ...EMPTY_PLAN, labels: {} }, + services: { items: [], port: 0 }, dog: null, + jukebox: { on: false, track: JUKEBOX_TUNES[0].id, startedAt: 0, elapsed: 0 }, + cabinet: { player: null, scores: [], frame: null }, whiteboard: { elements: [], people: [] }, + meeting: { current: null, past: [] }, ball: {}, cars: parked(), jail: { prisoners: [], bones: 0 }, + }; +} + +/** Polls are deltas, not welcomes: they must not reset the player or rebuild the floor. */ +export function pocketUpdates(before: PocketSnapshot, next: PocketSnapshot, eventCursor = pocketEventCursor(before)): ServerMsg[] { + const messages: ServerMsg[] = []; + if (before.workers.length !== next.workers.length) messages.push({t:'machine',state:machine(next)}); + const peers = new Map(before.peers.map((p) => [p.id, p])); + for (const p of next.peers) { + messages.push({ t: peers.has(p.id) ? 'peer.update' : 'peer.join', peer: { ...p, floor: pocketFloorId(next.source) } }); + peers.delete(p.id); + } + for (const id of peers.keys()) messages.push({ t: 'peer.leave', id }); + const old = new Map(workers(before).map((w) => [w.id, w])); + for (const worker of workers(next)) { + if (JSON.stringify(old.get(worker.id)) !== JSON.stringify(worker)) messages.push({ t: 'worker.update', worker }); + old.delete(worker.id); + } + for (const workerId of old.keys()) messages.push({ t: 'worker.remove', workerId }); + const seen = new Set(before.chat.map((line) => JSON.stringify(line))); + for (const line of next.chat) if (!seen.has(JSON.stringify(line))) messages.push({ t: 'chat', ...line }); + for (const event of next.events ?? []) { + if (event.seq <= eventCursor || event.at <= Date.now() - 15_000) continue; + // Local actions already animate the player. Refusals must reach that player only. + if (event.message.t !== 'sit.refused' && event.message.id === next.you) continue; + messages.push(event.message); + } + return messages; +} + +export function pocketEventCursor(snapshot: PocketSnapshot): number { + return (snapshot.events ?? []).reduce((seq, event) => Math.max(seq, event.seq), 0); +} diff --git a/src/client/state/persist.ts b/src/client/state/persist.ts index 7d204d7d0..c377f4a0a 100644 --- a/src/client/state/persist.ts +++ b/src/client/state/persist.ts @@ -2,6 +2,13 @@ // you were last on and the spot you were standing in. Every read and write shrugs off blocked storage. import { randomLook, sanitizeLook, type Look } from '../../shared/avatar'; +import { readPocketBootstrap } from '../shared/pocket/bootstrap'; + +/** Explicit per-user namespacing; never replace the browser's storage implementation. */ +export function storageKey(standaloneKey: string): string { + const b = readPocketBootstrap(); + return b ? `x.office-v2.${encodeURIComponent(b.person.id)}.${standaloneKey.replace(/^agent-office\./, '')}` : standaloneKey; +} export interface Profile { name: string; @@ -14,8 +21,10 @@ export const AVATAR_COLORS = ['#ff8a5b', '#4f86f7', '#06d6a0', '#ef476f', '#ffd1 /** Your saved profile. `look` is missing if you joined before there was a character select screen. */ export function loadProfile(): (Omit & { look?: Look }) | null { + const b = readPocketBootstrap(); + if (b) return { ...b.profile, look: { ...b.profile.look } }; try { - const p = JSON.parse(localStorage.getItem(PROFILE_KEY) ?? 'null'); + const p = JSON.parse(localStorage.getItem(storageKey(PROFILE_KEY)) ?? 'null'); if (p && typeof p.name === 'string' && typeof p.color === 'string') { return { name: p.name, color: p.color, look: p.look ? sanitizeLook(p.look, randomLook()) : undefined }; } @@ -27,8 +36,9 @@ export function loadProfile(): (Omit & { look?: Look }) | null /** Without a look, the 3D office still has you pick a character (the 2D view saves only a name). */ export function saveProfile(p: Omit & { look?: Look }) { + if (readPocketBootstrap()) return; // Native Pocket owns avatar persistence. try { - localStorage.setItem(PROFILE_KEY, JSON.stringify(p)); + localStorage.setItem(storageKey(PROFILE_KEY), JSON.stringify(p)); } catch { // storage blocked } @@ -72,8 +82,10 @@ const FLOOR_KEY = 'agent-office.floor'; /** The floor you were last on, to come back to it after a reload. */ export function lastFloor(): string | null { + const pocket = readPocketBootstrap(); + if (pocket) return `chat-${pocket.snapshot.source}`; try { - return localStorage.getItem(FLOOR_KEY); + return localStorage.getItem(storageKey(FLOOR_KEY)); } catch { return null; } @@ -81,7 +93,7 @@ export function lastFloor(): string | null { export function rememberFloor(id: string | null) { try { - if (id) localStorage.setItem(FLOOR_KEY, id); + if (id) localStorage.setItem(storageKey(FLOOR_KEY), id); } catch { // storage blocked } @@ -106,8 +118,9 @@ export interface Spot { /** The spot you were last in, if this browser has one. */ export function lastSpot(): Spot | null { + if (readPocketBootstrap()) return null; try { - const s = JSON.parse(localStorage.getItem(SPOT_KEY) ?? 'null'); + const s = JSON.parse(localStorage.getItem(storageKey(SPOT_KEY)) ?? 'null'); const finite = (v: unknown) => typeof v === 'number' && Number.isFinite(v); if (s && typeof s.floor === 'string' && s.floor && finite(s.x) && finite(s.y) && finite(s.z) && finite(s.facing)) { return { floor: s.floor, name: typeof s.name === 'string' ? s.name : '', ...(typeof s.map === 'string' ? { map: s.map } : {}), ...(s.throne === true ? { throne: true } : {}), x: s.x, y: s.y, z: s.z, facing: s.facing }; @@ -119,8 +132,9 @@ export function lastSpot(): Spot | null { } export function rememberSpot(s: Spot) { + if (readPocketBootstrap()) return; try { - localStorage.setItem(SPOT_KEY, JSON.stringify(s)); + localStorage.setItem(storageKey(SPOT_KEY), JSON.stringify(s)); } catch { // storage blocked } @@ -128,8 +142,9 @@ export function rememberSpot(s: Spot) { export function loadSettings(): Settings { const s: Settings = { view: 'first', volume: 0.7, muted: false, music: 0.5, musicMuted: false, pageTurns: true, pushToTalk: false, notify: true, needsYouSound: 'once', hud: { ...HUD_DEFAULTS }, pins: [] }; + if (readPocketBootstrap()) Object.assign(s, { muted: true, musicMuted: true, notify: false, pushToTalk: true, needsYouSound: 'off', hud: { ...HUD_DEFAULTS, workers: true, people: true } }); try { - const saved = JSON.parse(localStorage.getItem(SETTINGS_KEY) ?? 'null'); + const saved = JSON.parse(localStorage.getItem(storageKey(SETTINGS_KEY)) ?? 'null'); if (saved?.view === 'first' || saved?.view === 'third') s.view = saved.view; if (typeof saved?.volume === 'number' && Number.isFinite(saved.volume)) s.volume = Math.max(0, Math.min(1, saved.volume)); if (typeof saved?.muted === 'boolean') s.muted = saved.muted; @@ -149,7 +164,7 @@ export function loadSettings(): Settings { export function saveSettings(s: Settings) { try { - localStorage.setItem(SETTINGS_KEY, JSON.stringify(s)); + localStorage.setItem(storageKey(SETTINGS_KEY), JSON.stringify(s)); } catch { // storage blocked } diff --git a/src/client/ui/ask.ts b/src/client/ui/ask.ts index b8f82b7db..96f685756 100644 --- a/src/client/ui/ask.ts +++ b/src/client/ui/ask.ts @@ -1,3 +1,4 @@ +import { storageKey } from '../state/persist'; import './ask.css'; import type { AgentEffort, AgentProvider, WorkerStatus } from '../../shared/protocol'; import { h, openModal, STATUS_LABEL } from './dom'; @@ -45,7 +46,7 @@ export function openAsk(opts: AskOptions) { ta.value = opts.initial ?? ''; const wtBox = h('input', { type: 'checkbox', id: 'ask-wt' }) as HTMLInputElement; try { - wtBox.checked = localStorage.getItem(WT_KEY) === '1'; + wtBox.checked = localStorage.getItem(storageKey(WT_KEY)) === '1'; } catch { // storage blocked } @@ -102,7 +103,7 @@ export function openAsk(opts: AskOptions) { modal.close(); if (!to && opts.worktreeOption) { try { - localStorage.setItem(WT_KEY, wtBox.checked ? '1' : '0'); + localStorage.setItem(storageKey(WT_KEY), wtBox.checked ? '1' : '0'); } catch { // storage blocked } diff --git a/src/client/ui/boards.ts b/src/client/ui/boards.ts index 6ca5a8707..044b5782a 100644 --- a/src/client/ui/boards.ts +++ b/src/client/ui/boards.ts @@ -1,3 +1,4 @@ +import { storageKey } from '../state/persist'; import './boards.css'; import type { GhIssue, GhLabel, GhPull, WorkerInfo } from '../../shared/protocol'; import type { Net } from '../net'; @@ -61,7 +62,7 @@ function filtersKey(kind: 'issues' | 'pulls'): string { function loadFilters(kind: 'issues' | 'pulls'): LabelFilters { const out: LabelFilters = {}; try { - const saved = JSON.parse(localStorage.getItem(filtersKey(kind)) ?? 'null'); + const saved = JSON.parse(localStorage.getItem(storageKey(filtersKey(kind))) ?? 'null'); if (saved && typeof saved === 'object') { for (const [k, v] of Object.entries(saved)) if (Array.isArray(v) && v.length) out[k] = v.filter((x): x is string => typeof x === 'string'); } @@ -73,7 +74,7 @@ function loadFilters(kind: 'issues' | 'pulls'): LabelFilters { function saveFilters(kind: 'issues' | 'pulls', filters: LabelFilters) { try { - localStorage.setItem(filtersKey(kind), JSON.stringify(filters)); + localStorage.setItem(storageKey(filtersKey(kind)), JSON.stringify(filters)); } catch { // storage blocked } diff --git a/src/client/ui/character.ts b/src/client/ui/character.ts index 0c84b55aa..14cb14763 100644 --- a/src/client/ui/character.ts +++ b/src/client/ui/character.ts @@ -1,4 +1,5 @@ import './character.css'; +import { openPocketCustomize } from '../shared/pocket/native'; import * as THREE from 'three'; import { OutlineEffect } from 'three/examples/jsm/effects/OutlineEffect.js'; import { HAIR_COLOR_NAMES, HAIR_COLORS, HAIR_STYLES, SKIN_TONES, randomLook, randomName, type Look } from '../../shared/avatar'; @@ -132,6 +133,7 @@ class Preview { * `first` is the one you see when you join: closing it goes in as whoever's picked so far. */ export function openCharacter(first: boolean, onSave: (p: Profile) => void) { + if (openPocketCustomize()) return; const pick: Profile = { ...store.profile, look: { ...store.profile.look } }; const canvas = h('canvas', { 'aria-label': 'Your character, drag to spin' }) as HTMLCanvasElement; const preview = new Preview(canvas, pick); diff --git a/src/client/ui/elevator.ts b/src/client/ui/elevator.ts index 36f6c92a9..284d378e1 100644 --- a/src/client/ui/elevator.ts +++ b/src/client/ui/elevator.ts @@ -1,4 +1,6 @@ import './elevator.css'; +import { pocketRuntime } from '../shared/pocket/runtime'; +import { openPocketElevator, pocketElevatorOpen } from './pocket-elevator'; import type { CloneProgress, FloorInfo, RepoChoice, ServerMsg } from '../../shared/protocol'; import { cloneLabel, cloneStep, floorPalette, normalizeRepo, sameRepo } from '../../shared/floors'; import { ROOF, ROOF_NAME } from '../../shared/rooftop'; @@ -54,10 +56,11 @@ function cloneBar(p: CloneProgress | undefined): HTMLElement | null { let current: Modal | null = null; export function elevatorPanelOpen(): boolean { - return !!current; + return !!current || pocketElevatorOpen(); } export function openElevator(opts: ElevatorOptions): void { + if (pocketRuntime()) return openPocketElevator(); if (current) return; // Nowhere to go yet: the panel greets you. It closes like any other; the elevator (or the floor // name in the corner) opens it again. diff --git a/src/client/ui/floormenu.ts b/src/client/ui/floormenu.ts index f35d9030f..9bac1e347 100644 --- a/src/client/ui/floormenu.ts +++ b/src/client/ui/floormenu.ts @@ -1,4 +1,6 @@ import './floormenu.css'; +import { pocketRuntime } from '../shared/pocket/runtime'; +import { openPocketElevator } from './pocket-elevator'; import { cloneLabel, floorPalette } from '../../shared/floors'; import { ROOF, ROOF_NAME } from '../../shared/rooftop'; import type { FloorInfo } from '../../shared/protocol'; @@ -32,6 +34,7 @@ export function closeFloorMenu() { /** Opens the floor list under `anchor`, or closes it if it's open. */ export function toggleFloorMenu(anchor: HTMLElement, opts: FloorMenuOptions): void { + if (pocketRuntime()) return openPocketElevator(); if (current) return current.close(); const el = h('div.floor-menu.panel', { role: 'menu', 'aria-label': 'Floors' }); diff --git a/src/client/ui/floorplan.ts b/src/client/ui/floorplan.ts index 527dac2e1..67f618ece 100644 --- a/src/client/ui/floorplan.ts +++ b/src/client/ui/floorplan.ts @@ -1,3 +1,4 @@ +import { storageKey } from '../state/persist'; import './floorplan.css'; import { LABEL_IDEAS, MAX_LABEL, SIGN_COLORS, cleanLabel, rowDesks, signColor, signInk } from '../../shared/floorplan'; import { DESK_BY_ID, WING } from '../../shared/layout'; @@ -8,7 +9,7 @@ import { h, openModal } from './dom'; const COLOR_KEY = 'agent-office.signColor'; function lastColor(): string { try { - return signColor(localStorage.getItem(COLOR_KEY)); + return signColor(localStorage.getItem(storageKey(COLOR_KEY))); } catch { return SIGN_COLORS[0].color; } @@ -60,7 +61,7 @@ export function openDeskLabel(net: Net, deskId: string) { onclick: () => { color = c.color; try { - localStorage.setItem(COLOR_KEY, color); + localStorage.setItem(storageKey(COLOR_KEY), color); } catch { // private mode: the color just isn't remembered } diff --git a/src/client/ui/github/comment-box.ts b/src/client/ui/github/comment-box.ts index 4e9d8f9e2..9f4756e33 100644 --- a/src/client/ui/github/comment-box.ts +++ b/src/client/ui/github/comment-box.ts @@ -1,3 +1,4 @@ +import { storageKey } from '../../state/persist'; import type { GhComment } from '../../../shared/protocol'; import type { Net } from '../../net'; import { h } from '../dom'; @@ -51,7 +52,7 @@ export function commentBox(kind: 'issue' | 'pull', number: number, itemUrl: stri if (ta.value) savePref(draftKey, ta.value); else try { - localStorage.removeItem(draftKey); + localStorage.removeItem(storageKey(draftKey)); } catch { // storage blocked } diff --git a/src/client/ui/github/prefs.ts b/src/client/ui/github/prefs.ts index b3d253265..1cf88f306 100644 --- a/src/client/ui/github/prefs.ts +++ b/src/client/ui/github/prefs.ts @@ -1,3 +1,4 @@ +import { storageKey } from '../../state/persist'; import type { GhMergeMethod } from '../../../shared/protocol'; // What the windows remember in this browser: how you last merged, the Files tab's layout, which tab @@ -5,7 +6,7 @@ import type { GhMergeMethod } from '../../../shared/protocol'; export function pref(key: string, fallback: T): T { try { - return (JSON.parse(localStorage.getItem(key) ?? 'null') as T) ?? fallback; + return (JSON.parse(localStorage.getItem(storageKey(key)) ?? 'null') as T) ?? fallback; } catch { return fallback; } @@ -13,7 +14,7 @@ export function pref(key: string, fallback: T): T { export function savePref(key: string, v: unknown) { try { - localStorage.setItem(key, JSON.stringify(v)); + localStorage.setItem(storageKey(key), JSON.stringify(v)); } catch { // storage blocked } diff --git a/src/client/ui/github/pulldiff.ts b/src/client/ui/github/pulldiff.ts index 96df6f7fd..cfbfa416f 100644 --- a/src/client/ui/github/pulldiff.ts +++ b/src/client/ui/github/pulldiff.ts @@ -1,3 +1,4 @@ +import { storageKey } from '../../state/persist'; import './pulldiff.css'; import type { GhReviewComment } from '../../../shared/protocol'; import { h, timeAgo } from '../dom'; @@ -107,7 +108,7 @@ type ReviewedStore = Record function loadReviewed(): ReviewedStore { try { - const v = JSON.parse(localStorage.getItem(REVIEWED_KEY) ?? '{}'); + const v = JSON.parse(localStorage.getItem(storageKey(REVIEWED_KEY)) ?? '{}'); return v && typeof v === 'object' ? v : {}; } catch { return {}; @@ -139,7 +140,7 @@ export class Reviewed { const keys = Object.keys(all).sort((a, b) => all[b].at - all[a].at); for (const k of keys.slice(KEEP_PRS)) delete all[k]; try { - localStorage.setItem(REVIEWED_KEY, JSON.stringify(all)); + localStorage.setItem(storageKey(REVIEWED_KEY), JSON.stringify(all)); } catch { // storage blocked or full: the mark lasts until the window closes } diff --git a/src/client/ui/litesuggest.ts b/src/client/ui/litesuggest.ts index 625efd7e2..fa0c15fe5 100644 --- a/src/client/ui/litesuggest.ts +++ b/src/client/ui/litesuggest.ts @@ -1,3 +1,4 @@ +import { storageKey } from '../state/persist'; import './litesuggest.css'; // Offering the 2D view (/lite) where the 3D office is hard going: on a phone, with no keys to walk // with, or on a computer where frames come slowly (see framerate.ts). @@ -14,7 +15,7 @@ export function touchOnly(): boolean { function declined(): boolean { try { - return localStorage.getItem(DECLINED_KEY) === '1'; + return localStorage.getItem(storageKey(DECLINED_KEY)) === '1'; } catch { return false; } @@ -39,7 +40,7 @@ export function offerLite(why: 'touch' | 'slow') { ); stay.addEventListener('click', () => { try { - localStorage.setItem(DECLINED_KEY, '1'); + localStorage.setItem(storageKey(DECLINED_KEY), '1'); } catch { // storage blocked: it's only this page then } diff --git a/src/client/ui/menu.ts b/src/client/ui/menu.ts index 0fcf6d0d8..8b8bc5dc5 100644 --- a/src/client/ui/menu.ts +++ b/src/client/ui/menu.ts @@ -1,4 +1,5 @@ import './menu.css'; +import { pocketHudActions, pocketHudPanel } from '../shared/pocket/controls'; import { store, type HudPanel, type Settings, type Topic } from '../state'; import { waitingOnSomeone } from '../notify'; import { DESK_BY_ID } from '../../shared/layout'; @@ -61,6 +62,7 @@ export interface Hud { * and the panels you choose to show. Everything else waits in the menu, so the office stays in view. */ export function mountHud(actions: HudAction[], settings: Settings, save: () => void): Hud { + actions = pocketHudActions(actions); const dock = $('dock'); const labelOf = (a: HudAction) => (typeof a.label === 'string' ? a.label : a.label()); const iconOf = (a: HudAction) => (typeof a.icon === 'string' ? a.icon : a.icon()); @@ -74,7 +76,7 @@ export function mountHud(actions: HudAction[], settings: Settings, save: () => v menuBtn.addEventListener('click', () => toggleMenu()); function applyPanels() { - for (const p of PANELS) $(PANEL_EL[p.id]).classList.toggle('hud-off', !settings.hud[p.id]); + for (const p of PANELS) $(PANEL_EL[p.id]).classList.toggle('hud-off', !pocketHudPanel(p.id) || !settings.hud[p.id]); } function setPanel(id: HudPanel, on: boolean) { @@ -202,7 +204,7 @@ export function mountHud(actions: HudAction[], settings: Settings, save: () => v 'div.hud-menu', { role: 'menu', 'aria-label': 'Menu' }, h('div.menu-col', {}, ...section('Open', rows('Open')), ...section('Together', rows('Together'))), - h('div.menu-col', {}, ...section('Show on screen', PANELS.map(toggle)), ...section('Office', rows('Office'))), + h('div.menu-col', {}, ...section('Show on screen', PANELS.filter((p) => pocketHudPanel(p.id)).map(toggle)), ...section('Office', rows('Office'))), h('p.menu-foot', {}, 'Pin what you use most to keep it on the top bar. ', h('kbd', {}, 'Tab'), ' opens and closes this menu.'), ); // On the window, so the keys work wherever focus is while the menu is up. diff --git a/src/client/ui/pocket-elevator.ts b/src/client/ui/pocket-elevator.ts new file mode 100644 index 000000000..6becf2a5d --- /dev/null +++ b/src/client/ui/pocket-elevator.ts @@ -0,0 +1,82 @@ +import { floorPalette } from '../../shared/floors'; +import { pocketFloorId, type PocketFloor } from '../shared/pocket/floors'; +import { listPocketFloors, switchPocketFloor } from '../shared/pocket/native'; +import { store } from '../state'; +import { h, openModal, type Modal } from './dom'; + +let current: Modal | null = null; +export function pocketElevatorOpen(): boolean { return !!current; } + +/** The original elevator appearance, with authorized root chats instead of repository actions. */ +export function openPocketElevator(): void { + if (current) return; + let closed = false; + let loading = false; + let switching = false; + let floors: PocketFloor[] = []; + const list = h('div.floors'); + const status = h('p.note', { role: 'status', 'aria-live': 'polite' }, 'Loading chat floors…'); + const search = h('input', { type: 'search', placeholder: 'Search chat titles', 'aria-label': 'Search chat floors' }); + const refresh = h('button.btn', { type: 'button' }, '↻ Refresh'); + const close = h('button.btn.close', { type: 'button', 'aria-label': 'Close', title: 'Close (Esc)' }, '✕'); + + const render = () => { + const query = search.value.trim().toLocaleLowerCase(); + list.replaceChildren(...floors.filter(f => f.title.toLocaleLowerCase().includes(query)).map((f) => { + const here = store.floor === pocketFloorId(f.id); + const row = h('button.floor-btn', { + type: 'button', class: here ? 'here' : '', disabled: here || switching, + title: here ? 'You are on this chat floor' : `Ride to ${f.title}`, + }, + h('span.floor-no', { style: `background:${floorPalette(f.id).trim}` }, String(floors.indexOf(f) + 1)), + h('span.floor-text', {}, + h('span.floor-name', { style: 'white-space:normal;overflow:visible;text-overflow:clip' }, f.title, + here ? h('span.here-tag', {}, 'you are here') : null), + h('span.floor-sub', {}, f.cwd)), + h('span.floor-stats', {}, f.busy ? h('span', { title: 'Root chat is busy' }, 'Working') : '')); + row.addEventListener('click', () => { + if (here || switching) return; + switching = true; + status.textContent = `Opening ${f.title}…`; + render(); + void switchPocketFloor(f.id).then(() => { + // A successful switch replaces the iframe, not the scene inside this old iframe. + if (!closed) modal.close(); + }).catch(error => { + if (closed) return; + switching = false; + status.textContent = error instanceof Error ? error.message : String(error); + render(); + }); + }); + return row; + }).reverse()); + if (!list.children.length && !loading) list.append(h('p.empty', {}, floors.length ? 'No chat titles match.' : 'No root chat floors are available.')); + refresh.disabled = loading || switching; + search.disabled = switching; + }; + const load = async () => { + if (loading || switching) return; + loading = true; + status.textContent = 'Loading chat floors…'; + render(); + try { + const next = await listPocketFloors(); + if (closed) return; + floors = next; + status.textContent = 'Each floor is a Pocket root chat. Workers stay on their own floor.'; + } catch (error) { + if (!closed) status.textContent = error instanceof Error ? error.message : String(error); + } finally { loading = false; if (!closed) render(); } + }; + const el = h('div.modal.elevator', { role: 'dialog', 'aria-label': 'Elevator' }, + h('header', {}, h('h2', {}, '🛗 Elevator'), close), + h('div.body', {}, h('div.repo-search', {}, search, refresh), status, list), + h('footer', {}, h('span.grow', {}, 'Pick a chat floor · Esc to stay here'))); + const modal = openModal(el, { doing: '🛗 at the elevator', onClose: () => { closed = true; current = null; } }); + current = modal; + close.addEventListener('click', () => modal.close()); + refresh.addEventListener('click', () => { void load(); }); + search.addEventListener('input', render); + void load(); +} diff --git a/src/client/ui/prompt.ts b/src/client/ui/prompt.ts index 0bf860541..2420cfc97 100644 --- a/src/client/ui/prompt.ts +++ b/src/client/ui/prompt.ts @@ -1,3 +1,4 @@ +import { storageKey } from '../state/persist'; import './prompt.css'; import type { AgentEffort, AgentProvider, LostBranch, ServerMsg, WorktreeCleanup, WorktreeState } from '../../shared/protocol'; import { h, openModal } from './dom'; @@ -13,6 +14,8 @@ export interface PromptOptions { placeholder?: string; initial?: string; submitLabel?: string; + /** The Enter shortcut's action, when submit reviews a draft rather than sending it. */ + enterLabel?: string; /** Allow hiring a worker without an initial prompt (the direct hire flow). */ allowEmpty?: boolean; /** Offer the "own git worktree" option (only when hiring a new worker). */ @@ -28,7 +31,7 @@ const WT_KEY = 'agent-office.worktree'; /** Whether the last hire asked for its own git worktree (the Ask window shares the choice). */ export function worktreePref(): boolean { try { - return localStorage.getItem(WT_KEY) === '1'; + return localStorage.getItem(storageKey(WT_KEY)) === '1'; } catch { return false; } @@ -76,7 +79,7 @@ export function openPrompt(opts: PromptOptions) { { role: 'dialog', 'aria-label': opts.title }, h('header', {}, h('h2', {}, opts.title)), h('div.body', {}, opts.warning ? h('p.setting-note.bad', { style: 'margin:0 0 10px', role: 'alert' }, opts.warning) : null, opts.subtitle ? h('p', { style: 'margin:0 0 10px;font-weight:700;color:var(--muted)' }, opts.subtitle) : null, dictateField(ta), provider?.element ?? null, wtRow, repos.element), - h('footer', {}, h('span.grow', {}, 'Enter to send · Shift+Enter for a new line'), cancel, submit), + h('footer', {}, h('span.grow', {}, `${opts.enterLabel ?? 'Enter to send'} · Shift+Enter for a new line`), cancel, submit), ) as HTMLFormElement; form.noValidate = true; @@ -92,7 +95,7 @@ export function openPrompt(opts: PromptOptions) { modal.close(); if (opts.worktreeOption) { try { - localStorage.setItem(WT_KEY, wtBox.checked ? '1' : '0'); + localStorage.setItem(storageKey(WT_KEY), wtBox.checked ? '1' : '0'); } catch { // storage blocked } diff --git a/src/client/ui/provider.ts b/src/client/ui/provider.ts index 94b0d7aa2..75751ad75 100644 --- a/src/client/ui/provider.ts +++ b/src/client/ui/provider.ts @@ -104,6 +104,8 @@ export function choiceLabel(choice: AgentChoice): string { return badge ? `${PROVIDER_LABEL[choice.provider]} · ${badge}` : PROVIDER_LABEL[choice.provider]; } +import { pocketRuntime } from '../shared/pocket/runtime'; + export interface ProviderPicker { element: HTMLElement; value(): AgentProvider; @@ -128,6 +130,7 @@ const CATALOGUE_TTL_MS = 60_000; /** Asks for a provider's models, unless what's here is fresh: then there's nothing to wait for. */ function loadCatalogue(provider: AgentProvider): Promise | undefined { + if (pocketRuntime()) return undefined; const c = catalogues.get(provider) ?? { at: 0 }; catalogues.set(provider, c); if ((c.list || c.failed) && Date.now() - c.at < CATALOGUE_TTL_MS) return undefined; @@ -159,6 +162,11 @@ function loadCatalogue(provider: AgentProvider): Promise | undefined { * a provider added there gets its fields here. */ export function agentFields(project: ProjectInfo | null, id: string, initial: AgentChoice, label = 'Provider'): AgentFields { + if (pocketRuntime()) return { + element: h('small.provider-note', {}, 'Pocket inherits the source model and thinking. Change them in native Pocket chat.'), + value: () => 'pi', model: () => undefined, effort: () => undefined, valid: () => true, + set: () => {}, choice: () => ({ provider: 'pi' }), + }; const options = supportedProviders(project); const fallback = resolvedProvider(project?.defaultProvider, project); const select = h('select.provider-select', { id, 'aria-label': 'Worker provider' }) as HTMLSelectElement; diff --git a/src/client/ui/settings.ts b/src/client/ui/settings.ts index ba8dae5c2..cdca2f29f 100644 --- a/src/client/ui/settings.ts +++ b/src/client/ui/settings.ts @@ -1,4 +1,5 @@ import './settings.css'; +import { openPocketSettings } from '../shared/pocket/settings'; import type { Net } from '../net'; import type { OfficeSound } from '../sound'; import { store, type NeedsYouSound, type Settings, type ViewMode } from '../state'; @@ -50,6 +51,7 @@ let lastPane: SettingsPane = 'you'; /** `outside` describes the sky over the office (see describeSky), once the server has said. `first` opens on that category instead of the last one. */ export function openSettings(net: Net, settings: Settings, onChange: (s: Settings) => void, onCharacter: () => void, sound: Pick, notifier: DesktopNotifier, onSignOut: () => void, outside?: { now: string; live: boolean }, first?: SettingsPane) { + if (openPocketSettings(settings, onChange, onCharacter)) return; const seg = h('div.seg', { role: 'radiogroup', 'aria-label': 'Camera view' }); const note = h('p.setting-note'); const paint = () => { diff --git a/src/client/ui/terminal.ts b/src/client/ui/terminal.ts index b7124ec77..500eb3f90 100644 --- a/src/client/ui/terminal.ts +++ b/src/client/ui/terminal.ts @@ -99,6 +99,7 @@ export function openTerminalFor(): string | null { } export function openTerminal(net: Net, workerId: string, onChanges?: () => void, find?: TerminalFind, opts: TerminalOptions = {}) { + if (net.openPocketSession(workerId)) return; if (current?.workerId === workerId) { if (find) current.find(find); return; diff --git a/src/client/world/office/loft.ts b/src/client/world/office/loft.ts index a3c988f74..a9daaa379 100644 --- a/src/client/world/office/loft.ts +++ b/src/client/world/office/loft.ts @@ -127,6 +127,7 @@ export function buildLoft(group: THREE.Group, colliders: Collider[], interactabl desk.add(screen); desk.add(mesh(new THREE.CylinderGeometry(0.06, 0.05, 0.12, 10), toon('#ffd166'), 0.9, 0.89, 0.15)); const plate = textPlane('👑 BOSS', { bg: '#ffd166', size: 48 }); + plate.name = 'loft-desk-label'; plate.scale.multiplyScalar(0.55); plate.position.set(0, 0.5, -0.55); plate.rotation.y = Math.PI; @@ -196,11 +197,13 @@ export function buildLoft(group: THREE.Group, colliders: Collider[], interactabl // Signs: one on the back wall inside, one over the glass for everyone downstairs. const inside = textPlane('👑 Boss Office', { bg: '#fffaf3', size: 64 }); + inside.name = 'loft-inside-label'; inside.scale.multiplyScalar(0.8); inside.position.set(maxX - 3, floorY + 1.9, maxZ - 0.04); inside.rotation.y = Math.PI; group.add(inside); const outside = textPlane('👑 Boss Office', { bg: '#2b2d42', color: '#fffaf3', size: 64, border: '#fffaf3' }); + outside.name = 'loft-outside-label'; outside.scale.multiplyScalar(1.4); // In front of the roof's trim (minZ - 0.04 to minZ), or the trim hides the sign's lower half. outside.position.set(cx, roofY + 0.2, minZ - 0.07); diff --git a/src/shared/uuid.ts b/src/shared/uuid.ts new file mode 100644 index 000000000..3fd73b396 --- /dev/null +++ b/src/shared/uuid.ts @@ -0,0 +1,10 @@ +// Keep this module valid JavaScript: the Pocket packager copies it to its standalone browser host. +export function uuid() { + if (typeof globalThis.crypto?.randomUUID === 'function') return globalThis.crypto.randomUUID(); + if (!globalThis.crypto?.getRandomValues) throw new Error('Secure random values are unavailable in this browser.'); + const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16)); + bytes[6] = (bytes[6] & 15) | 64; + bytes[8] = (bytes[8] & 63) | 128; + const hex = Array.from(bytes, (value) => value.toString(16).padStart(2, '0')).join(''); + return [hex.slice(0,8),hex.slice(8,12),hex.slice(12,16),hex.slice(16,20),hex.slice(20)].join('-'); +} diff --git a/tests/pocket-bridge.test.ts b/tests/pocket-bridge.test.ts new file mode 100644 index 000000000..12cd5a8c7 --- /dev/null +++ b/tests/pocket-bridge.test.ts @@ -0,0 +1,72 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +// @ts-ignore Browser module is deliberately plain JavaScript for Pocket. +import { validRequest, createBridge, listenToFrame } from '../integrations/pi-pocket/office-v2-client/bridge.js'; + +const frameWindow = {}; +const origin = 'https://pocket.example'; +const channel = 'test-channel'; +const request = { type: 'office-v2:request', channel, id: 'request-1', op: 'hire', payload: { deskId: 'desk-1', prompt: 'test' } }; + +test('Pocket bridge validates the exact source, origin, channel, operation, and bounded schema', () => { + const good = { source: frameWindow, origin, data: request }; + assert.equal(validRequest(good, frameWindow, origin, channel), true); + assert.equal(validRequest({ ...good, source: {} }, frameWindow, origin, channel), false); + assert.equal(validRequest({ ...good, origin: 'https://attacker.example' }, frameWindow, origin, channel), false); + for (const data of [ + { ...request, channel: 'wrong' }, { ...request, type: 'other' }, { ...request, id: 1 }, + { ...request, op: 'shell' }, { ...request, payload: [] }, { ...request, payload: { text: 'x'.repeat(32000) } }, + ]) assert.equal(validRequest({ ...good, data }, frameWindow, origin, channel), false); +}); + +test('Pocket bridge fixes the source and translates hire keys without accepting browser model overrides', async () => { + const calls: unknown[] = []; + const pocket = { api: async (...args: unknown[]) => { calls.push(args); return {}; } }; + const handle = createBridge({ pocket, source: 42, clientId: 'client', signal: new AbortController().signal }); + await handle('hire', { source: 99, deskId: 'desk-1', prompt: 'task', key: 'stable', model: 'forbidden' }); + assert.deepEqual((calls[0] as unknown[]).slice(0, 2), ['hire/42', { deskId: 'desk-1', prompt: 'task', requestId: 'stable', clientId: 'client' }]); + await assert.rejects(handle('shell', {}), /not supported/); +}); + +test('Opening a desk refreshes authorization and opens a scoped inspector without navigating or sending', async () => { + const actions: unknown[] = []; + const pocket = { + api: async () => ({ source:42, bindings:[{workerId:'pocket-1',conversationId:73}], workers:[{id:'pocket-1',name:'Agent one'}] }), + openConversation: () => { throw new Error('Must not navigate the parent'); }, + insertText: () => { throw new Error('Must not write to the parent composer'); }, + }; + const handle = createBridge({ pocket, source:42, clientId:'client', signal:new AbortController().signal, inspect:(worker: unknown) => actions.push(worker) }); + assert.deepEqual(await handle('open', {workerId:'pocket-1',prompt:'follow up'}), {opened:true,draft:true,sent:false}); + assert.deepEqual(actions, [{source:42,workerId:'pocket-1',conversationId:73,title:'Agent one',master:false,draft:'follow up'}]); + await assert.rejects(handle('open', {workerId:'hidden-session'}), /no session you can open/); +}); + +test('Closing the bridge stops operations and never falls back to another runtime', async () => { + const stop = new AbortController(); stop.abort(); + const handle = createBridge({ pocket: { api: () => { throw new Error('must not call'); } }, source: 42, clientId: 'client', signal: stop.signal }); + await assert.rejects(handle('hire', {}), /closed/); +}); + +test('Duplicate frame requests reuse one result; changed payloads and messages after disposal cannot act', async () => { + let listener: ((event: unknown) => Promise) | undefined; + const old = (globalThis as any).window; + const replies: unknown[] = []; + const frame = { contentWindow: { postMessage: (reply: unknown) => replies.push(reply) } }; + (globalThis as any).window = { + addEventListener: (_: string, fn: typeof listener) => { listener = fn; }, + removeEventListener: () => {}, + }; + try { + let calls = 0; + const dispose = listenToFrame({ frame, origin, channel, handle: async () => { calls++; return { ok: true }; } }); + const event = { source: frame.contentWindow, origin, data: request }; + await listener!(event); await listener!(event); + assert.equal(calls, 1); + assert.equal(replies.length, 2); + await listener!({ ...event, data: { ...request, payload: { prompt: 'different' } } }); + assert.equal(calls, 1); + assert.match(JSON.stringify(replies.at(-1)), /reused/); + dispose(); await listener!(event); + assert.equal(replies.length, 3); + } finally { (globalThis as any).window = old; } +}); diff --git a/tests/pocket-client.test.ts b/tests/pocket-client.test.ts new file mode 100644 index 000000000..ef3055f14 --- /dev/null +++ b/tests/pocket-client.test.ts @@ -0,0 +1,528 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { pocketWelcome, pocketUpdates, type PocketSnapshot } from '../src/client/shared/pocket/wire'; +import { PocketTransport, verifiedReply, verifiedOverlay, type PocketRequest } from '../src/client/shared/pocket/transport'; +import { installPocketBoundary, pocketRuntime } from '../src/client/shared/pocket/runtime'; +import type { PeerInfo, ServerMsg, WorkerInfo } from '../src/shared/protocol'; +import { initPocketBootstrap, parsePocketBootstrap, readPocketBootstrap, type PocketBootstrap } from '../src/client/shared/pocket/bootstrap'; +import { loadProfile, loadSettings, saveSettings, lastSpot, lastFloor, rememberSpot, storageKey } from '../src/client/state/persist'; +import { openPocketCustomize, openPocketWorkspace, listPocketFloors, switchPocketFloor } from '../src/client/shared/pocket/native'; +import { parsePocketFloors, pocketFloorId, pocketFloorSource, pocketFloorViews } from '../src/client/shared/pocket/floors'; +import { EMOTES } from '../src/shared/emotes'; +import { DRINKS } from '../src/shared/rooftop'; +import { ROUND } from '../src/shared/bargames'; + +const peer: PeerInfo = { id: 'me', name: 'Sam', color: '#ffffff', look: { skin: 0, hair: 0, style: 0 }, x: 0, y: 0, z: 0, rotY: 0, moving: false, voice: false, muted: true, sharing: false }; +const worker: WorkerInfo = { id: 'w1', kind: 'agent', provider: 'pi', name: 'Worker', deskId: 'desk-1', color: '#ffffff', status: 'working', acked: true, createdBy: 'Sam', createdAt: 0, cols: 80, rows: 24, viewers: [], viewerIds: [] }; +const snapshot = (): PocketSnapshot => ({ source: 10, title: 'Project', cwd: '/project', model: 'provider/model', you: 'me', peers: [peer], chat: [], workers: [worker] }); +const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); + +const bootstrapValue = (): PocketBootstrap => ({ person: { id: 'person-1', name: 'Sam', role: 'owner' }, profile: { name: 'Sam', color: '#ffffff', look: { skin: 0, hair: 0, style: 0 } }, snapshot: snapshot(), floors: [{ id: 10, title: 'Project', cwd: '/project', busy: false, archived: false }] }); +const encoded = (b: unknown) => encodeURIComponent(JSON.stringify(b)); + +function harness(bootstrap: PocketBootstrap = bootstrapValue(), storage = new Map()) { + const saved = ['window', 'document', 'localStorage'].map((key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)] as const); + const listeners = new Map void>>(); + const requests: PocketRequest[] = []; + const fetches: { input: RequestInfo | URL; init?: RequestInit }[] = []; + const rootClasses = new Set(); + const parent = { postMessage(message: PocketRequest, origin: string) { assert.equal(origin, 'https://pocket.test'); requests.push(message); } }; + const fake = { + parent, location: { origin: 'null' }, fetch: async (input: RequestInfo | URL, init?: RequestInit) => { + fetches.push({ input, init }); + return new Response('asset'); + }, + addEventListener(name: string, fn: (e: MessageEvent) => void) { if (!listeners.has(name)) listeners.set(name, new Set()); listeners.get(name)!.add(fn); }, + removeEventListener(name: string, fn: (e: MessageEvent) => void) { listeners.get(name)?.delete(fn); }, + }; + Object.defineProperty(globalThis, 'window', { configurable: true, value: fake }); + Object.defineProperty(globalThis, 'document', { configurable: true, value: { + baseURI: 'https://pocket.test/s/3', + documentElement: { classList: { add(name: string) { rootClasses.add(name); } } }, + querySelector(selector: string) { return { getAttribute() { + return selector.includes('pocket-runtime') ? '1' : selector.includes('pocket-bootstrap') ? encoded(bootstrap) : 'channel-1'; + } }; }, + } }); + Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: { + getItem(key: string) { return storage.get(key) ?? null; }, + setItem(key: string, value: string) { storage.set(key, value); }, + } }); + function reply(request: PocketRequest, result: unknown, error?: string, overrides = {}) { + const event = { source: parent, origin: 'https://pocket.test', data: { type: 'office-v2:reply', channel: 'channel-1', id: request.id, result, error }, ...overrides } as unknown as MessageEvent; + for (const fn of listeners.get('message') ?? []) fn(event); + } + return { requests, reply, fake, fetches, rootClasses, restore() { + for (const [key, descriptor] of saved) { + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else Reflect.deleteProperty(globalThis, key); + } + } }; +} + +test('welcome supplies one Pocket floor without inventing child model, costs, or detailed status', () => { + const s = snapshot(); + s.workers = [{ ...worker, status: 'needs_input' }]; + const w = pocketWelcome(s); + assert.equal(w.floor, 'chat-10'); + assert.equal(w.floors.length, 1); + assert.equal(w.project?.branch, undefined); + assert.deepEqual(w.project?.agentProviders, ['pi']); + assert.equal(w.workers[0].model, undefined); + assert.equal(w.workers[0].status, 'offline'); + assert.equal(w.workers[0].usage, undefined); + assert.equal(w.usage.total.costKnown, false); + assert.equal(w.usage.total.incomplete, true); + assert.equal(w.me.admin, false); + assert.equal(w.upgrade.available, false); + assert.equal(w.dog, null); + assert.equal(w.cars.length > 0, true); +}); + +test('poll deltas preserve the floor, remove departed workers/peers and do not repeat chat', () => { + const before = snapshot(); + before.chat = [{ from: 'me', name: 'Sam', color: '#fff', text: 'old', at: 1 }]; + const after = { ...before, peers: [{ ...peer, id: 'other' }], workers: [], chat: [...before.chat, { ...before.chat[0], text: 'new', at: 2 }] }; + const messages = pocketUpdates(before, after); + assert.deepEqual(messages.map((m) => m.t), ['machine', 'peer.join', 'peer.leave', 'worker.remove', 'chat']); + assert.equal(messages[0].t === 'machine' && messages[0].state.workers, 0); + assert.equal(messages[0].t === 'machine' && messages[0].state.limit, 16); + assert.equal(messages.some((m) => m.t === 'welcome' || m.t === 'floor.enter'), false); + assert.equal(pocketUpdates(before, before).some((m) => m.t === 'worker.update' || m.t === 'chat'), false); +}); + +test('overlay controls require exact source, origin, channel and a boolean, separate from request replies', async () => { + const h = harness(); + const changes: boolean[] = []; + let closes = 0; + const transport = new PocketTransport(() => {}, () => {}, { set: open => changes.push(open), close: () => { closes++; } }); + try { + transport.connect({ name: 'Sam' }); + const data = { type: 'office-v2:overlay', channel: 'channel-1', open: true }; + const event = { source: h.fake.parent, origin: 'https://pocket.test', data } as unknown as MessageEvent; + assert.equal(verifiedOverlay(event, h.fake.parent as Window, event.origin, 'channel-1'), true); + for (const override of [{ source: {} }, { origin: 'https://evil.test' }, { data: { ...data, channel: 'wrong' } }, + { data: { ...data, open: 'true' } }, { data: { ...data, type: 'office-v2:reply' } }]) { + const invalid = { ...event, ...override } as MessageEvent; + assert.equal(verifiedOverlay(invalid, h.fake.parent as Window, event.origin, 'channel-1'), false); + h.reply(h.requests[0], {}, undefined, invalid); + } + assert.deepEqual(changes, []); + h.reply(h.requests[0], {}, undefined, { data }); + h.reply(h.requests[0], {}, undefined, { data: { ...data, open: false } }); + assert.deepEqual(changes, [true, false]); + h.reply(h.requests[0], snapshot()); // Overlay controls do not consume pending request replies. + await tick(); + transport.dispose(); + assert.equal(closes, 1); + } finally { transport.dispose(); await tick(); h.restore(); } +}); + +test('reply validation requires parent source, exact origin, channel and envelope', () => { + const parent = {} as Window; + const event = { source: parent, origin: 'https://pocket.test', data: { type: 'office-v2:reply', channel: 'nonce', id: 'id' } } as MessageEvent; + assert.equal(verifiedReply(event, parent, event.origin, 'nonce'), true); + assert.equal(verifiedReply({ ...event, source: {} } as MessageEvent, parent, event.origin, 'nonce'), false); + assert.equal(verifiedReply(event, parent, 'https://other.test', 'nonce'), false); + assert.equal(verifiedReply(event, parent, event.origin, 'wrong'), false); + assert.equal(verifiedReply(event, parent, event.origin, ''), false); +}); + +test('transport connects, opens native chat, requires hire prompt, refuses shell and admin actions', async () => { + const h = harness(); + const messages: ServerMsg[] = []; + const transport = new PocketTransport((m) => messages.push(m), () => {}); + try { + transport.connect({ name: 'Sam' }); + assert.equal(h.requests[0].op, 'connect'); + assert.deepEqual(h.requests[0].payload, { profile: { name: 'Sam' } }); + h.reply(h.requests[0], snapshot(), undefined, { origin: 'https://evil.test' }); + await tick(); + assert.equal(messages.length, 0); + h.reply(h.requests[0], snapshot()); + await tick(); + assert.equal(messages[0].t, 'welcome'); + transport.send({ t: 'worker.prompt', workerId: 'w1', prompt: 'Draft only' }); + assert.equal(h.requests[1].op, 'open'); + assert.deepEqual(h.requests[1].payload, { workerId: 'w1', prompt: 'Draft only' }); + h.reply(h.requests[1], {}); + for (const t of ['worker.attach', 'worker.resume'] as const) { + transport.send({ t, workerId: 'w1' }); + assert.equal(h.requests.at(-1)?.op, 'open'); + h.reply(h.requests.at(-1)!, {}); + } + const n = h.requests.length; + transport.send({ t: 'worker.spawn', deskId: 'desk-1' }); + transport.send({ t: 'worker.spawn', deskId: 'desk-1', kind: 'shell', prompt: 'echo unsafe' }); + transport.send({ t: 'upgrade.start' }); + transport.send({ t: 'term.input', workerId: 'w1', data: 'unsafe' }); + assert.equal(h.requests.length, n); + assert.equal(messages.filter((m) => m.t === 'toast').length, 4); + transport.send({ t: 'worker.spawn', deskId: 'desk-1', prompt: 'Task', provider: 'claude', model: 'ignored' }); + transport.send({ t: 'worker.spawn', deskId: 'desk-1', prompt: 'Task', worktree: true }); + transport.send({ t: 'worker.spawn', deskId: 'desk-1', prompt: 'Task', repos: ['other'] }); + assert.equal(h.requests.length, n); + transport.send({ t: 'worker.spawn', deskId: 'desk-1', prompt: 'Task', provider: 'pi' }); + assert.equal(h.requests.at(-1)?.op, 'hire'); + assert.deepEqual(Object.keys(h.requests.at(-1)!.payload).sort(), ['deskId', 'key', 'prompt']); + h.reply(h.requests.at(-1)!, undefined, 'Access denied'); + await tick(); + assert.ok(messages.some((m) => m.t === 'toast' && m.text === 'Access denied')); + } finally { transport.dispose(); await tick(); h.restore(); } +}); + +test('transport coalesces moves for 250 ms and polls without rebuilding welcome', async () => { + const h = harness(); + const messages: ServerMsg[] = []; + const transport = new PocketTransport((m) => messages.push(m), () => {}); + try { + transport.connect({ name: 'Sam' }); + h.reply(h.requests[0], snapshot()); + await tick(); + transport.send({ t: 'move', x: 1, y: 0, z: 0, rotY: 0, moving: true }); + transport.send({ t: 'move', x: 2, y: 0, z: 0, rotY: 0, moving: false }); + assert.equal(h.requests.length, 1); + await new Promise((resolve) => setTimeout(resolve, 280)); + assert.equal(h.requests[1].op, 'move'); + assert.equal(h.requests[1].payload.x, 2); + h.reply(h.requests[1], {}); + await new Promise((resolve) => setTimeout(resolve, 750)); + assert.equal(h.requests[2].op, 'poll'); + h.reply(h.requests[2], snapshot()); + await tick(); + assert.equal(messages.filter((m) => m.t === 'welcome').length, 1); + } finally { transport.dispose(); await tick(); h.restore(); } +}); + +test('Pocket fetch boundary blocks APIs and external fetches but permits same-origin assets without redirects', async () => { + const h = harness(); + try { + assert.equal(pocketRuntime(), true); + installPocketBoundary(); + for (const url of ['/api/whoami', '/api/models', '/api/agents/pi/models', '/api/github', '/api/term/drop', 'https://external.test/asset', '//external.test/asset', 'data:text/plain,external']) { + const response = await h.fake.fetch(url); + assert.equal(response.status, 501); + assert.match((await response.json()).error, /unsupported/); + } + const request = new Request('https://external.test/request'); + assert.equal((await h.fake.fetch(request)).status, 501); + assert.equal(h.fetches.length, 0); + const asset = 'https://pocket.test/x/office/client/assets/model.glb.png'; + assert.equal((await h.fake.fetch(asset)).status, 200); + assert.deepEqual(h.fetches, [{ input: asset, init: { mode: 'same-origin', redirect: 'error' } }]); + } finally { h.restore(); } +}); + +test('Pocket boot is separate from standalone identity, saved profile, character and dog gates', () => { + const read = (path: string) => readFileSync(new URL(`../src/client/${path}`, import.meta.url), 'utf8'); + const main = read('main.ts'); + assert.ok(main.indexOf('const pocket = initPocketBootstrap(store)') < main.indexOf('parts.stage = createScene')); + assert.ok(main.indexOf('const pocket = initPocketBootstrap(store)') < main.indexOf('parts.me = makeMe')); + const branch = main.slice(main.indexOf('if (pocket) {'), main.indexOf('} else void whoami()')); + assert.match(branch, /pocket.start\(/); + assert.doesNotMatch(branch, /whoami|loadProfile|openCharacter|firstReady|lastSpot|showElevator/); + assert.match(main, /!!pocket \|\| chose3d \|\| offerLite/); + assert.match(read('ui/character.ts'), /if \(openPocketCustomize\(\)\) return;/); + assert.match(read('ui/elevator.ts'), /if \(pocketRuntime\(\)\) return openPocketElevator\(\);/); + assert.doesNotMatch(read('ui/pocket-elevator.ts'), /openPocketWorkspace|floor\.repos|floor\.add|openConversation/); + assert.match(read('ui/terminal.ts'), /if \(net.openPocketSession\(workerId\)\) return;/); + assert.match(read('ui/provider.ts'), /if \(pocketRuntime\(\)\) return undefined;/); +}); + +test('fresh Pocket user initializes authenticated profile before scene and needs only welcome plus rendered frame', async () => { + const h = harness(); + const messages: ServerMsg[] = []; + const transport = new PocketTransport((m) => { messages.push(m); listeners.forEach((fn) => fn(m)); }, () => {}); + const listeners = new Set<(msg: { t: string }) => void>(); + try { + const target = { profile: { name: 'Old standalone name', color: '#000000', look: { skin: 1, hair: 1, style: 1 } }, me: { admin: false } }; + const lifecycle = initPocketBootstrap(target)!; + assert.equal(h.rootClasses.has('pocket-office'), true); + assert.deepEqual(target.profile, bootstrapValue().profile); + assert.deepEqual(target.me, { admin: false, account: { name: 'Sam', role: 'member' } }); + assert.equal(readPocketBootstrap(), lifecycle.data); // Parsed once per document. + const settings = loadSettings(); + assert.equal(settings.hud.workers, true); + assert.equal(settings.hud.people, true); + assert.equal(settings.muted, true); + assert.equal(settings.musicMuted, true); + assert.equal(settings.notify, false); + let frameStarted = false; + let ready = false; + const steps: string[] = []; + const readyPromise = lifecycle.start({ + net: { onMessage(fn) { listeners.add(fn); return () => { listeners.delete(fn); }; }, connect() { transport.connect(target.profile, lifecycle.data); } }, + loading: { until(items) { steps.push(...items.map((item) => item.say)); } }, + startFrames() { frameStarted = true; }, + }).then(() => { ready = true; }); + assert.equal(frameStarted, true); + assert.deepEqual(steps, ['Opening Pocket source']); + assert.equal(messages[0].t, 'welcome'); // No wait for a parent request/reply round trip. + assert.deepEqual(messages[0].t === 'welcome' && messages[0].me, target.me); + assert.equal(listeners.size, 0); + await tick(); + assert.equal(ready, false); + lifecycle.drew(); + await readyPromise; + assert.equal(ready, true); + assert.equal(h.requests[0].op, 'connect'); // Parent still refreshes authorization. + h.reply(h.requests[0], snapshot()); + await tick(); + assert.equal(messages.filter((m) => m.t === 'welcome').length, 1); + } finally { transport.dispose(); await tick(); h.restore(); } +}); + +test('Pocket persistence isolates two users in one browser and never reads standalone profile or position', () => { + const storage = new Map([ + ['agent-office.profile', JSON.stringify({ name: 'Wrong user', color: '#000000' })], + ['agent-office.settings', JSON.stringify({ notify: true, muted: false, hud: { workers: false } })], + ['agent-office.spot', JSON.stringify({ floor: 'old', name: 'Old', x: 99, y: 0, z: 99, facing: 0 })], + ]); + const h1 = harness(bootstrapValue(), storage); + try { + assert.equal(loadProfile()?.name, 'Sam'); + assert.equal(lastSpot(), null); + assert.equal(lastFloor(), 'chat-10'); + rememberSpot({ floor: 'pocket', name: 'Room', x: 2, y: 0, z: 2, facing: 0 }); + const settings = loadSettings(); + assert.equal(settings.notify, false); + settings.hud.workers = false; + saveSettings(settings); + assert.equal(storageKey('agent-office.settings'), 'x.office-v2.person-1.settings'); + assert.equal(storage.has('x.office-v2.person-1.settings'), true); + assert.equal(storage.has('x.office-v2.person-1.spot'), false); + } finally { h1.restore(); } + const other = bootstrapValue(); + other.person = { id: 'person-2', name: 'Alex', role: 'guest' }; + const h2 = harness(other, storage); + try { + assert.equal(loadProfile()?.name, 'Alex'); + assert.equal(loadSettings().hud.workers, true); + assert.equal(storageKey('agent-office.golf'), 'x.office-v2.person-2.golf'); + saveSettings(loadSettings()); + assert.equal(storage.has('x.office-v2.person-2.settings'), true); + assert.equal(storage.has('agent-office.settings'), true); + } finally { h2.restore(); } +}); + +test('malformed bootstrap fails closed instead of falling back to standalone onboarding', () => { + for (const value of ['', '%invalid', encoded(null), encoded({}), encoded({ ...bootstrapValue(), person: { id: '', name: 'Sam', role: 'owner' } }), + encoded({ ...bootstrapValue(), profile: { name: 'Sam', color: 'invalid', look: {} } }), + encoded({ ...bootstrapValue(), snapshot: { ...snapshot(), peers: [] } })]) { + assert.throws(() => parsePocketBootstrap(value), /Invalid Pocket bootstrap/); + } + const h = harness({ ...bootstrapValue(), snapshot: { ...snapshot(), source: -1 } }); + try { + assert.throws(() => readPocketBootstrap(), /Invalid Pocket bootstrap source/); + assert.throws(() => readPocketBootstrap(), /Invalid Pocket bootstrap source/); + } finally { h.restore(); } +}); + +test('root chat floors retain full titles and stable scene IDs without mixing workers or inventing counts', () => { + const floors = parsePocketFloors([ + { id: 20, title: 'Second root — full long chat title', cwd: '/two', busy: true, archived: false }, + { id: 10, title: 'Project', cwd: '/project', busy: false, archived: false }, + { id: 30, title: 'Archived root', cwd: '/old', busy: false, archived: true }, + ]); + const welcome = pocketWelcome(snapshot(), floors); + assert.deepEqual(welcome.floors.map(f => f.id), ['chat-20', 'chat-10']); + assert.equal(welcome.floors[0].name, floors[0].title); + assert.equal(welcome.floors[0].workers, 0); + assert.equal(welcome.floors[0].people, 0); + assert.equal(welcome.floors[0].busy, 0); + assert.equal(welcome.floors[1].workers, 1); + assert.equal(welcome.floors[1].people, 1); + assert.equal(welcome.floors[1].wing, 0); + assert.equal(welcome.peers[0].floor, 'chat-10'); + assert.equal(welcome.floor, 'chat-10'); + assert.equal(pocketFloorViews([...floors].reverse(), snapshot())[1].palette, welcome.floors[0].palette); + assert.equal(pocketFloorId(20), 'chat-20'); + assert.equal(pocketFloorSource('chat-20'), 20); + for (const id of ['pocket', '@roof', 'chat-0', 'chat-01', 'chat-1e2', 'chat-9007199254740992']) assert.equal(pocketFloorSource(id), null); + for (const bad of [null, {}, [{ id: 0 }], [floors[0], floors[0]]]) assert.throws(() => parsePocketFloors(bad), /Invalid Pocket floor/); + assert.throws(() => parsePocketBootstrap(encoded({ ...bootstrapValue(), floors: [] })), /floor selection/); + assert.throws(() => parsePocketBootstrap(encoded({ ...bootstrapValue(), floors: undefined })), /floor list/); +}); + +test('floor catalog refresh and switching use the parent, cancel old actions, and never apply a new floor to the old scene', async () => { + const h = harness(); + const messages: ServerMsg[] = []; + const transport = new PocketTransport(m => messages.push(m), () => {}); + try { + transport.connect(bootstrapValue().profile, readPocketBootstrap()!); + h.reply(h.requests[0], snapshot()); + await tick(); + const catalog = [...bootstrapValue().floors, { id: 20, title: 'Another chat', cwd: '/two', busy: true, archived: false }]; + const listed = listPocketFloors(); + assert.equal(h.requests.at(-1)?.op, 'floors'); + assert.deepEqual(h.requests.at(-1)?.payload, {}); + h.reply(h.requests.at(-1)!, catalog); + assert.deepEqual(await listed, catalog); + assert.ok(messages.some(m => m.t === 'floors' && m.floors[1].id === 'chat-20')); + transport.send({ t: 'chat', text: 'Old pending message' }); + const old = h.requests.at(-1)!; + transport.send({ t: 'move', x: 2, y: 0, z: 0, moving: true, rotY: 0 }); + transport.send({ t: 'floor.go', floor: 'chat-20' }); + const switching = h.requests.at(-1)!; + assert.equal(switching.op, 'switch'); + assert.deepEqual(switching.payload, { source: 20 }); + h.reply(old, { ...snapshot(), source: 20 }); + h.reply(switching, { switching: true, source: 20 }); + await tick(); + const count = h.requests.length; + transport.send({ t: 'act' }); + await new Promise(resolve => setTimeout(resolve, 280)); + assert.equal(h.requests.length, count); + assert.equal(messages.filter(m => m.t === 'welcome').length, 1); + assert.equal(messages.filter(m => m.t === 'toast').length, 0); + transport.dispose(); + await assert.rejects(listPocketFloors(), /unavailable/); + await assert.rejects(switchPocketFloor(10), /unavailable/); + } finally { transport.dispose(); await tick(); h.restore(); } +}); + +test('refused floor switch resumes the old floor and unsupported standalone floor actions never cross the bridge', async () => { + const h = harness(); + const messages: ServerMsg[] = []; + const boot = bootstrapValue(); + boot.floors.push({ id: 20, title: 'Other', cwd: '/two', busy: false, archived: false }); + const transport = new PocketTransport(m => messages.push(m), () => {}); + try { + transport.connect(boot.profile, boot); + h.reply(h.requests[0], snapshot()); + await tick(); + transport.send({ t: 'floor.switch', floor: 'chat-20' }); + h.reply(h.requests.at(-1)!, undefined, 'No longer authorized'); + await tick(); + assert.ok(messages.some(m => m.t === 'toast' && m.text === 'No longer authorized')); + transport.send({ t: 'act' }); + assert.equal(h.requests.at(-1)?.op, 'presence'); + h.reply(h.requests.at(-1)!, snapshot()); + await tick(); + const count = h.requests.length; + transport.send({ t: 'floor.go', floor: '@roof' }); + transport.send({ t: 'floor.add', repo: 'owner/repo' }); + transport.send({ t: 'floor.repos' }); + transport.send({ t: 'floor.remove', floor: 'chat-20' }); + assert.equal(h.requests.length, count); + } finally { transport.dispose(); await tick(); h.restore(); } +}); + +test('Master snapshots never enter regular worker counts, and transport refuses root hire or dismiss', async () => { + const h = harness(); + const messages: ServerMsg[] = []; + const transport = new PocketTransport(m => messages.push(m), () => {}); + const s = { ...snapshot(), master: { workerId: 'pocket-master-10', title: 'Root', busy: true } }; + try { + const boot = { ...bootstrapValue(), snapshot: s }; + assert.deepEqual(parsePocketBootstrap(encoded(boot)).snapshot.master, s.master); + assert.throws(() => parsePocketBootstrap(encoded({ ...boot, snapshot: { ...s, master: { ...s.master, workerId: 'pocket-master-20' } } })), /master/); + const welcome = pocketWelcome(s); + assert.equal(welcome.workers.length, 1); + assert.equal(welcome.floors[0].workers, 1); + transport.connect(boot.profile, boot); + h.reply(h.requests[0], s); + await tick(); + const count = h.requests.length; + transport.send({ t: 'worker.kill', workerId: s.master.workerId }); + transport.send({ t: 'worker.spawn', deskId: s.master.workerId, prompt: 'Must not start' }); + assert.equal(h.requests.length, count); + assert.equal(messages.filter(m => m.t === 'toast').length, 2); + transport.send({ t: 'worker.attach', workerId: s.master.workerId }); + assert.equal(h.requests.at(-1)?.op, 'open'); + h.reply(h.requests.at(-1)!, { opened: true }); + await tick(); + } finally { transport.dispose(); await tick(); h.restore(); } +}); + +test('standalone avatar allowlists match the shared scene constants', () => { + const code = readFileSync(new URL('../integrations/pi-pocket/office-v2.ts', import.meta.url), 'utf8'); + for (const [name, values] of [['EMOTES', EMOTES.map(e => e.id)], ['DRINKS', DRINKS.map(d => d.id)], ['BAR_GAMES', Object.keys(ROUND)]] as const) { + const literal = code.split(`const ${name} = `)[1].split(';')[0]; + assert.deepEqual(JSON.parse(literal.replaceAll("'", '"')), values); + } +}); + +test('avatar deltas deliver reach and emote once, ignore expired and own animations, and retain private refusal', () => { + const now = Date.now(); + const before = snapshot(); + before.events = [{ seq: 1, at: now, message: { t: 'peer.act', id: 'other' } }]; + const after = snapshot(); + after.events = [before.events[0], + { seq: 2, at: now, message: { t: 'peer.act', id: 'other' } }, + { seq: 3, at: now, message: { t: 'peer.emote', id: 'other', emote: 'wave' } }, + { seq: 4, at: now - 15_001, message: { t: 'peer.act', id: 'other' } }, + { seq: 5, at: now, message: { t: 'peer.act', id: 'me' } }, + { seq: 6, at: now, message: { t: 'sit.refused', seat: 'couch:1', by: 'Other' } }]; + assert.deepEqual(pocketUpdates(before, after).filter(m => m.t !== 'peer.update'), [ + { t: 'peer.act', id: 'other' }, { t: 'peer.emote', id: 'other', emote: 'wave' }, + { t: 'sit.refused', seat: 'couch:1', by: 'Other' }, + ]); + assert.deepEqual(pocketUpdates(after, after).filter(m => m.t !== 'peer.update'), []); + assert.deepEqual(pocketUpdates(snapshot(), after, 6).filter(m => m.t !== 'peer.update'), []); +}); + +test('transport sends real avatar actions, applies refusal, and keeps the event cursor through late replies', async () => { + const h = harness(); + const messages: ServerMsg[] = []; + const transport = new PocketTransport(m => messages.push(m), () => {}); + try { + transport.connect({ name: 'Sam' }); + h.reply(h.requests[0], snapshot()); + await tick(); + const actions = [{ t: 'act' }, { t: 'act', smoke: true, drink: 'water' }, { t: 'emote', emote: 'wave' }, + { t: 'doing', what: 'Reading', reading: true }, { t: 'sit', seat: 'couch:1' }] as const; + for (const action of actions) { + transport.send(action); + assert.equal(h.requests.at(-1)?.op, 'presence'); + assert.deepEqual(h.requests.at(-1)?.payload, { action }); + h.reply(h.requests.at(-1)!, { ...snapshot(), events: [{ seq: 10, at: Date.now(), message: { t: 'peer.act', id: 'other' } }, + { seq: 11, at: Date.now(), message: { t: 'sit.refused', seat: 'couch:1', by: 'Other' } }] }); + await tick(); + } + assert.equal(messages.filter(m => m.t === 'peer.act').length, 1); + assert.equal(messages.filter(m => m.t === 'sit.refused').length, 1); + transport.send({ t: 'act' }); + h.reply(h.requests.at(-1)!, snapshot()); // A response made before the event arrived. + await tick(); + transport.send({ t: 'act' }); + h.reply(h.requests.at(-1)!, { ...snapshot(), events: [{ seq: 10, at: Date.now(), message: { t: 'peer.act', id: 'other' } }] }); + await tick(); + assert.equal(messages.filter(m => m.t === 'peer.act').length, 1); + assert.equal(messages.filter(m => m.t === 'toast').length, 0); + } finally { transport.dispose(); await tick(); h.restore(); } +}); + +test('native workspace callback uses the parent sheet without polling and removes itself on disposal', async () => { + const h = harness(); + const transport = new PocketTransport(() => {}, () => {}); + try { + transport.connect(bootstrapValue().profile, readPocketBootstrap()!); + h.reply(h.requests[0], snapshot()); + assert.equal(openPocketWorkspace(), true); + assert.equal(h.requests[1].op, 'workspace'); + assert.deepEqual(h.requests[1].payload, {}); + h.reply(h.requests[1], { opened: true }); + await tick(); + assert.equal(h.requests.length, 2); + transport.dispose(); + assert.throws(() => openPocketWorkspace(), /not connected/); + } finally { transport.dispose(); await tick(); h.restore(); } +}); + +test('native customization uses the live transport and removes its callback on disposal', async () => { + const h = harness(); + const transport = new PocketTransport(() => {}, () => {}); + try { + transport.connect(bootstrapValue().profile, readPocketBootstrap()!); + h.reply(h.requests[0], snapshot()); + assert.equal(openPocketCustomize(), true); + assert.equal(h.requests[1].op, 'customize'); + assert.deepEqual(h.requests[1].payload, {}); + h.reply(h.requests[1], { opened: true }); + await tick(); + assert.equal(h.requests.length, 2); + transport.dispose(); + assert.throws(() => openPocketCustomize(), /not connected/); + } finally { transport.dispose(); await tick(); h.restore(); } +}); diff --git a/tests/pocket-controls.test.ts b/tests/pocket-controls.test.ts new file mode 100644 index 000000000..ad42093ab --- /dev/null +++ b/tests/pocket-controls.test.ts @@ -0,0 +1,41 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { pocketHudActions, pocketHudPanel } from '../src/client/shared/pocket/controls'; +import { openPocketSettings } from '../src/client/shared/pocket/settings'; +import { loadSettings } from '../src/client/state/persist'; + +const read = (path: string) => readFileSync(new URL(`../src/client/${path}`, import.meta.url), 'utf8'); + +test('Pocket HUD allows only workspace, settings, and controls actions', () => { + const actions = ['signins', 'lite', 'meeting', 'shell', 'repos', 'queue', 'voice', 'share', 'roof', 'whiteboard', 'elevator', 'settings', 'help'].map((id) => ({ id })); + assert.deepEqual(pocketHudActions(actions, true).map((a) => a.id), ['elevator', 'settings', 'help']); + assert.equal(pocketHudActions(actions, false), actions); + assert.deepEqual(actions.slice(-3), pocketHudActions(actions, true)); +}); + +test('Pocket HUD retains supported panel toggles but excludes CLI usage panels', () => { + for (const id of ['workers', 'people', 'chat', 'floor']) assert.equal(pocketHudPanel(id, true), true); + for (const id of ['spend', 'limits', 'unknown']) { + assert.equal(pocketHudPanel(id, true), false); + assert.equal(pocketHudPanel(id, false), true); + } +}); + +test('Pocket local settings do not intercept standalone or call a runtime on open', () => { + let changed = false; + assert.equal(openPocketSettings(loadSettings(), () => { changed = true; }, () => { changed = true; }), false); + assert.equal(changed, false); + const settings = read('ui/settings.ts'); + const guard = settings.indexOf('if (openPocketSettings(settings, onChange, onCharacter)) return;'); + assert.ok(guard > 0); + assert.ok(guard < settings.indexOf("net.send({ t: 'map.set' })")); + assert.ok(guard < settings.indexOf("const seg = h(")); + const pocket = read('shared/pocket/settings.ts'); + assert.doesNotMatch(pocket, /\b(?:net|fetch|XMLHttpRequest|send|request)\s*\(/); + assert.doesNotMatch(pocket, /from ['"].*(?:transport|net)['"]/); + assert.match(pocket, /const modal = openModal\(el\)/); + assert.match(pocket, /workspace.addEventListener\('click', .*openPocketElevator\(\)/); + assert.match(pocket, /character.addEventListener\('click', .*onCharacter\(\)/); + assert.match(read('ui/menu.ts'), /actions = pocketHudActions\(actions\)/); +}); diff --git a/tests/pocket-fullscreen.test.ts b/tests/pocket-fullscreen.test.ts new file mode 100644 index 000000000..260730f11 --- /dev/null +++ b/tests/pocket-fullscreen.test.ts @@ -0,0 +1,42 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +// @ts-ignore Pocket browser modules are deliberately plain JavaScript. +import { createBridge, validRequest } from '../integrations/pi-pocket/office-v2-client/bridge.js'; +// @ts-ignore Pocket browser modules are deliberately plain JavaScript. +import { worldDocument } from '../integrations/pi-pocket/office-v2-client/world-document.js'; + +const signal = () => new AbortController().signal; +test('master desk resolves only to the freshly authorized source, not a hired child', async () => { + const inspected: unknown[] = []; + const pocket = { api: async () => ({ source:42, master:{workerId:'pocket-master-42',title:'Master chat',busy:false}, bindings:[],workers:[] }) }; + const handle = createBridge({pocket,source:42,clientId:'client',signal:signal(),inspect:(view: unknown) => inspected.push(view)}); + await handle('open',{workerId:'pocket-master-42'}); + assert.deepEqual(inspected,[{source:42,workerId:'pocket-master-42',conversationId:42,title:'Master chat',master:true,draft:undefined}]); + await assert.rejects(handle('open',{workerId:'pocket-master-99'}),/no session you can open/); + await assert.rejects(handle('open',{}),/Select a worker/); +}); + +test('floor list is public metadata from the extension route and switch validates its target', async () => { + const rooms = [{id:42,title:'Master',cwd:'/work',busy:false,archived:false}]; + const requested: string[] = []; + const switched: number[] = []; + const handle = createBridge({pocket:{api:async (path: string) => {requested.push(path);return rooms;}},source:42,clientId:'client',signal:signal(),switchFloor:(source: number) => switched.push(source)}); + assert.deepEqual(await handle('floors'),rooms); + assert.deepEqual(requested,['floors']); + assert.deepEqual(await handle('switch',{source:43}),{switching:true,source:43}); + assert.deepEqual(switched,[43]); + for(const source of [0,-1,1.5,'43',null]) await assert.rejects(handle('switch',{source}),/available chat floor/); + const frame={}; + for(const op of ['floors','switch']) assert.equal(validRequest({source:frame,origin:'https://pocket.test',data:{type:'office-v2:request',channel:'channel',id:op,op,payload:{}}},frame,'https://pocket.test','channel'),true); +}); + +test('world document establishes floor bootstrap before scripts without an inline script or forbidden base', () => { + const boot={person:{id:'owner',name:'Owner'},profile:{},snapshot:{source:42,title:''},floors:[{id:42}]}; + const html=worldDocument('','https://pocket.test/x/office-v2/world/','channel',boot,'https://pocket.test/x/office-v2/frame-errors.js'); + assert.ok(html.indexOf('pocket-bootstrap')/); + const encoded=html.match(/name="pocket-bootstrap" content="([^"]+)"/)![1]; + assert.deepEqual(JSON.parse(decodeURIComponent(encoded)),boot); + assert.throws(()=>worldDocument('','https://pocket.test/','channel',{...boot,floors:[]},'errors.js'),/no longer available/); +}); diff --git a/tests/pocket-master.test.ts b/tests/pocket-master.test.ts new file mode 100644 index 000000000..2fa6ae90d --- /dev/null +++ b/tests/pocket-master.test.ts @@ -0,0 +1,110 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as THREE from 'three'; +import { DESKS, DESK_SIZE, LOFT, STAIRS } from '../src/shared/layout'; +import { Interactions, Usables } from '../src/client/core/registry'; +import type { Ctx, OfficeInteraction } from '../src/client/core/context'; +import { installPocketMaster, MASTER_LOFT } from '../src/client/features/pocket-master'; +import { PocketOverlay } from '../src/client/shared/pocket/overlay'; +import { validPocketMaster, pocketWelcome, type PocketSnapshot } from '../src/client/shared/pocket/wire'; +import type { textPlane } from '../src/client/world/toon'; +import type { Interactable } from '../src/client/world/types'; + +const snapshot = (busy = false): PocketSnapshot => ({ source: 10, title: 'Root', cwd: '/project', you: 'me', peers: [], chat: [], workers: [], + master: { workerId: 'pocket-master-10', title: 'Root master title', busy } }); + +test('Master stays separate from all 16 regular desks and uses the existing elevated platform and stairs', () => { + assert.equal(DESKS.length, 16); + assert.ok(MASTER_LOFT.y > DESK_SIZE.height + 2); + assert.equal(MASTER_LOFT.y, LOFT.y); + assert.ok(MASTER_LOFT.x - 1.3 > LOFT.minX); + assert.ok(MASTER_LOFT.x + 1.3 < LOFT.maxX); + assert.ok(MASTER_LOFT.z - 0.6 > LOFT.minZ); + assert.ok(MASTER_LOFT.z + 0.6 < LOFT.maxZ); + assert.equal(STAIRS.toX, LOFT.minX); + const welcome = pocketWelcome(snapshot()); + assert.equal(welcome.workers.length, 0); + assert.equal(welcome.floors[0].workers, 0); + assert.equal(validPocketMaster(snapshot()), true); + assert.equal(validPocketMaster({ ...snapshot(), master: undefined }), true); + assert.equal(validPocketMaster({ ...snapshot(), master: { workerId: 'pocket-master-10', title: 'Old SDK' } }), true); + assert.equal(validPocketMaster({ ...snapshot(), master: { ...snapshot().master!, workerId: 'pocket-master-20' } }), false); +}); + +test('Master registry opens only the root inspector, updates live status, and restores the original loft on disposal', () => { + const savedWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); + Object.defineProperty(globalThis, 'window', { configurable: true, value: new EventTarget() }); + try { + const group = new THREE.Group(); + const desk = new THREE.Group(); + const screen = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), new THREE.MeshBasicMaterial()); + const oldSeat: Interactable = { kind: 'seat', seatId: 'boss-chair', x: MASTER_LOFT.x, y: LOFT.y, z: MASTER_LOFT.z + 1, radius: 1.2 }; + screen.userData.interact = oldSeat; + desk.add(screen); group.add(desk); + for (const name of ['loft-desk-label', 'loft-inside-label', 'loft-outside-label']) { + const label = new THREE.Group(); label.name = name; group.add(label); + } + const interactions = new Interactions(); + const usables = new Usables(); + const calls: string[] = []; + let watch: ((s: PocketSnapshot) => void) | undefined; + let stopped = false; + const ctx = { office: { group, bossScreen: screen, interactables: [oldSeat] }, interactions, usables, + net: { openPocketSession: (id: string) => calls.push(id) }, hint: { invalidate() {} } } as unknown as Ctx; + const feature = installPocketMaster(ctx, { initial: snapshot(), + text: ((label: string) => { + const plane = new THREE.Mesh(new THREE.PlaneGeometry(3, 0.5), new THREE.MeshBasicMaterial()); + plane.name = label; return plane; + }) as typeof textPlane, + watch: fn => { watch = fn; return () => { stopped = true; }; }, + }); + assert.deepEqual(calls, [], 'rendering must not open or start model work'); + assert.equal(oldSeat.off, true); + assert.equal(group.getObjectByName('loft-desk-label')!.visible, false); + assert.ok(group.getObjectByName('MASTER · IDLE')); + const it = usables.lists()[0][0]; + assert.equal(it.kind, 'pocketmaster'); + assert.equal(it.deskId, undefined); + assert.equal(screen.userData.interact, it); + interactions.use(it, 'X', null); + interactions.use(it, 'P', null); + assert.deepEqual(calls, []); + interactions.use(it, 'E', null); + assert.deepEqual(calls, ['pocket-master-10']); + watch!(snapshot(true)); + assert.ok(group.getObjectByName('MASTER · WORKING')); + assert.equal(group.getObjectByName('MASTER · IDLE'), undefined); + watch!({ ...snapshot(), source: 20, master: { workerId: 'pocket-master-20', title: 'Other root', busy: false } }); + interactions.use(it, 'E', null); + assert.deepEqual(calls, ['pocket-master-10', 'pocket-master-10']); + watch!({ ...snapshot(), master: { workerId: 'pocket-master-10', title: 'Old SDK' } }); + assert.ok(group.getObjectByName('MASTER · STATUS UNKNOWN')); + assert.equal(group.getObjectByName('MASTER · IDLE'), undefined); + watch!({ ...snapshot(), master: undefined }); + assert.equal(usables.lists()[0].length, 0); + assert.equal(oldSeat.off, undefined); + assert.equal(screen.userData.interact, oldSeat); + feature.dispose(); + assert.equal(stopped, true); + assert.equal(interactions.has('pocketmaster'), false); + assert.equal(group.getObjectByName('pocket-master-loft'), undefined); + assert.equal(group.getObjectByName('loft-desk-label')!.visible, true); + } finally { + if (savedWindow) Object.defineProperty(globalThis, 'window', savedWindow); + else Reflect.deleteProperty(globalThis, 'window'); + } +}); + +test('parent overlay sentinel has an idempotent modal lifecycle and closes on disposal', () => { + let opens = 0; + let closes = 0; + const overlay = new PocketOverlay(() => { opens++; return { close: () => { closes++; } }; }); + overlay.set(false); + overlay.set(true); overlay.set(true); + assert.equal(opens, 1); + overlay.set(false); overlay.set(false); + assert.equal(closes, 1); + overlay.set(true); overlay.close(); overlay.close(); + assert.equal(opens, 2); + assert.equal(closes, 2); +}); diff --git a/tests/pocket-presentation.test.ts b/tests/pocket-presentation.test.ts new file mode 100644 index 000000000..d396fd0ba --- /dev/null +++ b/tests/pocket-presentation.test.ts @@ -0,0 +1,63 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import type { Stage } from '../src/client/core/scene'; +import { pocketPresentation, softwareRenderer } from '../src/client/shared/pocket/presentation'; + +test('software graphics detection does not classify ordinary GPUs as software', () => { + for(const name of ['ANGLE (Google, SwiftShader Device)','llvmpipe','Microsoft Basic Render Driver','Software Rasterizer']) assert.equal(softwareRenderer(name),true); + for(const name of ['Apple M2','ANGLE (NVIDIA GeForce RTX)','Intel Iris Xe','AMD Radeon']) assert.equal(softwareRenderer(name),false); +}); + +test('software preparation disables MSAA only on software GPUs and frees its temporary probe', () => { + const before=Object.getOwnPropertyDescriptor(globalThis,'document'); + try { + for (const name of ['ANGLE SwiftShader', 'Apple M2']) { + let released=0; + const options:unknown[]=[]; + Object.defineProperty(globalThis,'document',{configurable:true,value:{createElement:()=>({getContext:()=>({getExtension:(extension:string)=>extension==='WEBGL_debug_renderer_info'?{UNMASKED_RENDERER_WEBGL:1}:{loseContext:()=>released++},getParameter:()=>name})})}}); + const canvas={getContext:(_kind:string,attributes:unknown)=>{options.push(attributes);}} as unknown as HTMLCanvasElement; + pocketPresentation().prepare(canvas); + assert.deepEqual(options,name.includes('SwiftShader')?[{alpha:false,antialias:false}]:[]); + assert.equal(released,1); + } + Object.defineProperty(globalThis,'document',{configurable:true,value:{createElement:()=>({getContext:()=>{throw Error('WebGL unavailable');}})}}); + assert.doesNotThrow(()=>pocketPresentation().prepare({} as HTMLCanvasElement)); + } finally { if(before)Object.defineProperty(globalThis,'document',before);else Reflect.deleteProperty(globalThis,'document'); } +}); + +test('Pocket pauses expensive drawing for its native inspector without changing simulation or networking', () => { + const overlay={open:false}; + const presentation=pocketPresentation(overlay); + assert.equal(presentation.shouldDraw({now:0}),true); + assert.equal(presentation.shouldDraw({now:1}),true,'hardware retains normal rendering'); + overlay.open=true; + assert.equal(presentation.shouldDraw({now:2}),false); + overlay.open=false; + assert.equal(presentation.shouldDraw({now:3}),true); +}); + +test('actual software WebGL uses the real world with bounded drawing and no costly shadow or outline passes', () => { + const before=Object.getOwnPropertyDescriptor(globalThis,'window'); + Object.defineProperty(globalThis,'window',{configurable:true,value:{devicePixelRatio:2}}); + try { + let ratio=2; + const stage={renderer:{getContext:()=>({getExtension:()=>({UNMASKED_RENDERER_WEBGL:1}),getParameter:()=> 'ANGLE SwiftShader'}),setPixelRatio:(value:number)=>{ratio=value;},shadowMap:{enabled:true}},effect:{enabled:true}} as unknown as Stage; + let localModal = false; + const presentation=pocketPresentation({open:false}, () => localModal); + presentation.setup(stage); + assert.equal(ratio,0.6); + assert.equal(stage.renderer.shadowMap.enabled,false); + assert.equal(stage.effect.enabled,false); + assert.equal(presentation.shouldDraw({now:0}),true); + assert.equal(presentation.shouldDraw({now:199}),false); + assert.equal(presentation.shouldDraw({now:200}),true); + localModal = true; + assert.equal(presentation.shouldDraw({now:400}),false,'software drawing yields to the real settings and hiring dialogs'); + localModal = false; + assert.equal(presentation.shouldDraw({now:401}),true,'drawing resumes after the dialog closes'); + presentation.afterDraw(); + const completed=performance.now(); + assert.equal(presentation.shouldDraw({now:completed}),false,'the GPU gets idle time after submission'); + assert.equal(presentation.shouldDraw({now:completed+200}),true); + } finally { if(before) Object.defineProperty(globalThis,'window',before); else Reflect.deleteProperty(globalThis,'window'); } +}); diff --git a/tests/pocket-responsive.test.ts b/tests/pocket-responsive.test.ts new file mode 100644 index 000000000..c9b3a3fc9 --- /dev/null +++ b/tests/pocket-responsive.test.ts @@ -0,0 +1,53 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { initPocketBootstrap } from '../src/client/shared/pocket/bootstrap'; + +const read = (path: string) => readFileSync(new URL(`../src/client/${path}`, import.meta.url), 'utf8'); + +test('standalone bootstrap does not enable Pocket desktop dialog bounds', () => { + const saved = Object.getOwnPropertyDescriptor(globalThis, 'document'); + let added = false; + Object.defineProperty(globalThis, 'document', { configurable: true, value: { + querySelector() { return null; }, + documentElement: { classList: { add() { added = true; } } }, + } }); + try { + const target = { profile: { name: 'Standalone', color: '#ffffff', look: { skin: 0, hair: 0, style: 0 } }, me: { admin: false } }; + assert.equal(initPocketBootstrap(target), null); + assert.equal(added, false); + assert.equal(target.profile.name, 'Standalone'); + } finally { + if (saved) Object.defineProperty(globalThis, 'document', saved); + else Reflect.deleteProperty(globalThis, 'document'); + } +}); + +test('Pocket bounds desktop dialogs without replacing the original HUD, control sizes, or desktop layout', () => { + const css = read('shared/pocket/responsive.css'); + const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, ''); + for (const rule of withoutComments.matchAll(/(?:^|[{}])\s*([^{}]+)\{/g)) { + assert.ok(rule[1].trim().startsWith('html.pocket-office'), rule[1]); + } + assert.doesNotMatch(withoutComments, /@media|zoom\s*:|scale\s*\(|44px|\.topbar|\.side|\.chat|\.dock|\.settings-nav|\.settings-tab|\.hud-menu|grid-template/); + assert.match(css, /max-width: calc\(100vw - 32px\)/); + assert.match(css, /max-height: calc\(100dvh - 38px\)/); + assert.match(css, /\.modal > header\s*\{[^}]*flex: none;/); + assert.match(css, /\.modal > header \.close\s*\{[^}]*position: absolute;[^}]*right: 16px;/); + assert.match(css, /\.modal > \.close\.corner\s*\{[^}]*right: 10px;[^}]*left: auto;/); + assert.match(css, /\.modal > \.body\s*\{[^}]*overflow: auto;/); + assert.match(css, /\.modal > footer\s*\{[^}]*flex: none;[^}]*overflow: auto;/); + assert.doesNotMatch(withoutComments, /(?:^|[;{])\s*(?:font-size|border-radius|width|height)\s*:/); + assert.match(read('shared/pocket/bootstrap.ts'), /import '\.\/responsive\.css'/); +}); + +test('the isolated layout check uses only the approved full desktop viewports and verifies X plus Esc', () => { + const script = readFileSync(new URL('../integrations/pi-pocket/responsive-check.mjs', import.meta.url), 'utf8'); + assert.match(script, /\[\[1280,800\],\[1440,900\],\[1024,768\],\[1920,1080\]\]/); + assert.match(script, /min-width:1024px;min-height:600px/); + assert.match(script, /Keep the original desktop close-button width/); + assert.match(script, /The close button stays outside scrolling task content/); + assert.match(script, /X closes the desktop dialog/); + assert.match(script, /Esc closes the desktop popup/); + assert.doesNotMatch(script, /touch-sized|390,550|320,350|667,320|550,200/); +}); diff --git a/tests/pocket-room.test.ts b/tests/pocket-room.test.ts new file mode 100644 index 000000000..e531dca2c --- /dev/null +++ b/tests/pocket-room.test.ts @@ -0,0 +1,77 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +// @ts-ignore Pocket browser modules are deliberately plain JavaScript. +import { roomPeople, workerStatus, roomViews } from '../integrations/pi-pocket/office-v2-client/views.js'; +// @ts-ignore Pocket browser modules are deliberately plain JavaScript. +import { createBridge, validRequest } from '../integrations/pi-pocket/office-v2-client/bridge.js'; + +test('avatar action bridge uses only the fixed source and server-bound client identity', async () => { + const calls: unknown[][] = []; + const frame = {}; + const payload = { source: 99, clientId: 'another-person', action: { t: 'act' } }; + assert.equal(validRequest({ source: frame, origin: 'https://pocket.test', data: { type: 'office-v2:request', channel: 'channel', id: 'act-1', op: 'presence', payload } }, frame, 'https://pocket.test', 'channel'), true); + const handle = createBridge({ pocket: { api: async (...args: unknown[]) => { calls.push(args); return {}; } }, source: 3, clientId: 'fixed-client', signal: new AbortController().signal }); + await handle('presence', payload); + assert.deepEqual(calls[0].slice(0, 2), ['presence/3', { clientId: 'fixed-client', action: { t: 'act' } }]); +}); + +test('native people list groups tabs by authenticated account, never by display name', () => { + const owner = { id: 'owner:first-tab', name: 'Sam' }; + const otherTab = { id: 'owner:second-tab', name: 'Sam' }; + const guest = { id: 'guest:first-tab', name: 'Sam' }; + assert.deepEqual(roomPeople([owner, otherTab, guest]), [otherTab, guest]); + assert.deepEqual(roomPeople([{ id: 'unscoped' }, { id: 'another' }]).length, 2); +}); + +test('native room never equates unknown metadata or idle with successful completion', () => { + assert.equal(workerStatus('working'), 'Working'); + assert.equal(workerStatus('idle'), 'Idle'); + for (const status of ['offline', 'done', 'exited', undefined, 'unknown']) assert.equal(workerStatus(status), 'Status unavailable'); +}); + +test('native room components register without fetching assets, creating a scene, or accessing a browser', () => { + const views = roomViews({}, () => {}); + assert.equal(typeof views.Avatar, 'function'); + assert.equal(typeof views.Hire, 'function'); +}); + +test('optional 3D customization opens native Pocket preferences without a model or API request', async () => { + let opened = 0; + const frame = {}; + const request = { type: 'office-v2:request', channel: 'channel', id: 'customize-1', op: 'customize', payload: {} }; + assert.equal(validRequest({ source: frame, origin: 'https://pocket.test', data: request }, frame, 'https://pocket.test', 'channel'), true); + const handle = createBridge({ pocket: { api: () => { throw new Error('No API request allowed'); } }, source: 3, clientId: 'client', signal: new AbortController().signal, customize: () => opened++ }); + assert.deepEqual(await handle('customize', {}), { opened: true }); + assert.equal(opened, 1); +}); + +test('native open refreshes authorization without changing presence and cannot navigate after disposal', async () => { + const stop = new AbortController(); + let finish: (value: unknown) => void = () => {}; + let opened = 0; + let route = ''; + const pocket = { + api: (path: string) => { route = path; return new Promise((resolve) => { finish = resolve; }); }, + openConversation: () => opened++, + }; + const handle = createBridge({ pocket, source: 3, clientId: 'client', signal: stop.signal }); + const pending = handle('open', { workerId: 'worker' }); + assert.equal(new URL(route, 'https://pocket.test').searchParams.has('presence'), false); + stop.abort(); + finish({ bindings: [{ workerId: 'worker', conversationId: 4 }] }); + await assert.rejects(pending, /closed/); + assert.equal(opened, 0); +}); + +test('scoped inspector handoff checks lifetime after its asynchronous host opens', async () => { + const stop = new AbortController(); + let drafts = 0; + const pocket = { + api: async () => ({ bindings: [{ workerId: 'worker', conversationId: 4 }] }), + openConversation: () => { throw new Error('Parent navigation is forbidden'); }, + insertText: () => drafts++, + }; + const handle = createBridge({ pocket, source: 3, clientId: 'client', signal: stop.signal, inspect: async () => { stop.abort(); } }); + await assert.rejects(handle('open', { workerId: 'worker', prompt: 'Unsent follow-up' }), /closed/); + assert.equal(drafts, 0); +}); diff --git a/tests/pocket-scene-lifetime.test.ts b/tests/pocket-scene-lifetime.test.ts new file mode 100644 index 000000000..a261b8604 --- /dev/null +++ b/tests/pocket-scene-lifetime.test.ts @@ -0,0 +1,76 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +// @ts-ignore The public extension host deliberately uses plain JavaScript. +import setup from '../integrations/pi-pocket/office-v2-client/index.js'; + +test('a source change unmounts the old world and cannot bind it to the new floor before passive effects run', async () => { + const previousWindow=Object.getOwnPropertyDescriptor(globalThis,'window'); + const previousLocation=Object.getOwnPropertyDescriptor(globalThis,'location'); + const handlers=new Set<(event:unknown)=>void>(); + const worldWindow={postMessage:()=>{}}; + const frame={contentWindow:worldWindow}; + Object.defineProperty(globalThis,'window',{configurable:true,value:{addEventListener:(_type:string,fn:(event:unknown)=>void)=>handlers.add(fn),removeEventListener:(_type:string,fn:(event:unknown)=>void)=>handlers.delete(fn)}}); + Object.defineProperty(globalThis,'location',{configurable:true,value:{origin:'https://pocket.test'}}); + const states:unknown[]=[]; + const refs:{current:unknown}[]=[]; + let stateIndex=0,refIndex=0,frameCount=0; + let layout:(()=>void|(()=>void))|undefined; + let dispose:(()=>void)|undefined; + let Office:(()=>unknown)|undefined; + const calls:string[]=[]; + const pocket={ + html:(strings:TemplateStringsArray,...values:unknown[])=>{ + if(strings.join('').includes('