@openchamber/sdk 1.24.0 → 1.24.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/API.md CHANGED
@@ -25,8 +25,8 @@ Two entrypoints:
25
25
  | Must exist | When | Failure code |
26
26
  | -------------------------------------------------------------------------------- | ----------------------------------------------- | ------------------ |
27
27
  | Semver `version` on `package.json` (`1.0.0`) | Always on install | `invalid-manifest` |
28
- | `panel.entry` HTML file | When `panel.entry` is set (a tools-only package may omit it) | `invalid-manifest` |
29
- | Every relative `<script src="…">` `.js` from that HTML (usually `panel/main.js`) | When `panel.entry` is set | `missing-build` |
28
+ | `panel.entry` or `background.entry` HTML file | Every declared entry | `invalid-manifest` |
29
+ | Every relative `<script src="…">` `.js` from that HTML | Every declared entry | `missing-build` |
30
30
  | File named by `panel.icon` | Only when icon ends in `.svg` (e.g. `icon.svg`) | `invalid-manifest` |
31
31
  | File named by `service.entry` (e.g. `service/main.js`) | When `contributes.service` is set | `missing-build` |
32
32
 
@@ -73,6 +73,7 @@ Each returns an unsubscribe function. Late subscribers get the last known value
73
73
  | `onSettings(listener)` | `GuestSettings` | Declared integration fields only (`Record<string, string>`) |
74
74
  | `onItem(listener)` | `GuestItem | null` | The item this surface was opened for: the chip (`AttachIssueRequest`), a message (`GuestMessageItem`), or a session (`GuestSessionItem`); `null` from the rail icon or + menu |
75
75
  | `onResolve(handler)` | `{ command, args }` → `Promise<AttachIssueRequest \| null>` | Answers a `contributes.commands` slash command. Return the chip to attach, `null` for nothing (the user sees a short notice), or throw (the message reaches the user). One handler at a time |
76
+ | `onAction(handler)` | `GuestActionItem` → `void \| Promise<void>` | Runs a `mode: "background"` message or session action. Register synchronously after `connectHost`. Await every operation; the frame is removed when the handler settles. Throw to report an error. One handler at a time; returns an unsubscribe function |
76
77
 
77
78
 
78
79
  `HostReadyContext`
@@ -85,7 +86,7 @@ Each returns an unsubscribe function. Late subscribers get the last known value
85
86
  | `locale` | `string` | Host language tag |
86
87
  | `directory` | `string | null` |
87
88
  | `session` | snapshot or `null` | Title falls back to `id`. `busy` is live status. `model` is `providerID/id` when present |
88
- | `surface` | `'panel' | 'dialog'` |
89
+ | `surface` | `'panel' \| 'dialog' \| 'page' \| 'background'` | Where the host mounted this frame |
89
90
  | `connection` | `{ connected, account }` | Integration link state |
90
91
  | `settings` | `Record<string, string>` | Declared keys only |
91
92
  | `item` | `GuestItem | null` | Set when the user clicked this guest's chip on the composer, or ran one of this guest's `contributes.actions`. Narrow with `isGuestMessageItem` / `isGuestSessionItem` / `isGuestAttachItem` |
@@ -130,7 +131,7 @@ Access tokens never appear in `ready` or in request results.
130
131
 
131
132
  | Method | Arguments | Returns | Behavior |
132
133
  | ----------------- | --------------------------------- | ------------------------------ | ------------------------------------------------------------------------------------- |
133
- | `toast` | `{ kind: 'info' | 'success' | 'error', message }` |
134
+ | `toast` | `{ kind: 'info' \| 'success' \| 'error', message, copy?, dismiss?, persistent? }` | `Promise<void>` | Show a toast, optionally with host-owned Copy and OK buttons; see Toast buttons below |
134
135
  | `openUrl` | `url: string` | `Promise<void>` | Open URL in the host |
135
136
  | `openSurface` | `surfaceId: string` | `Promise<void>` | Switch host chrome to that surface |
136
137
  | `writeClipboard` | `text: string` | `Promise<void>` | Copy in the host (1–32000 chars) |
@@ -216,6 +217,30 @@ Storage belongs to the extension on the connected server and needs no extra capa
216
217
 
217
218
  `request` **/** `serviceRequest` **rules:** `method` is `GET` | `POST` | `PUT` | `PATCH` | `DELETE`. `path` must start with `/`, no scheme, stay on the declared origin (cloud API or service loopback). Guest parses `body` as JSON when needed.
218
219
 
220
+ ### Toast buttons
221
+
222
+ `ToastRequest.copy` is an optional boolean or `{ text: string }`. `true` copies the displayed message; an object supplies the clipboard value. `false` or omission adds no Copy button. The custom text must contain 1 to `GUEST_CLIPBOARD_TEXT_MAX` characters, with whitespace preserved. The displayed message is trimmed and must contain 1 to `GUEST_TOAST_MAX` characters. The client rejects invalid text lengths as `HOST_REJECTED` before sending; the host independently validates the wire payload.
223
+
224
+ `dismiss: true` adds OK. `persistent: true` disables automatic expiry and always adds OK regardless of `dismiss`, so every persistent toast can be closed. Omitted or false `persistent` retains the host's usual duration.
225
+
226
+ Copy leaves the toast open, reports success on the button, and shows a retryable error when the clipboard fails. OK dismisses only its own toast. Buttons use the host's locale and clipboard helper. They retain only the supplied text and toast identity, work after guest disposal or a runtime switch, and never call back into the extension. The `toast` promise resolves after display acknowledgement, without waiting for a click. Web and desktop use the same behavior; extension support on other runtimes is unchanged.
227
+
228
+ ### Background actions
229
+
230
+ `contributes.actions[].mode` accepts `"open"` or `"background"`. Omitted mode keeps the existing panel/dialog routing and `onItem` delivery. Background actions load `background.entry` when declared, otherwise `panel.entry`. They never use separate attach-dialog HTML. They run on web and desktop, including connections through the private relay; VS Code and mobile still do not load extensions.
231
+
232
+ `contributes.background` is `{ entry: "<package-local .html>" }`. It starts on demand for an action or command and adds no visible UI. With a background entry, `panel.entry` may be omitted: there is no rail icon, attach picker, or full-screen page, but background actions, slash commands, storage and granted APIs work. Identity remains in `panel.id/name/icon`. Open-mode actions, `page`, and enabled `attach` require `panel.entry`; invalid combinations fail as `invalid-panel`. Without either entry, the extension remains tools-only. Invalid background paths or a missing entry field fail as `invalid-background`.
233
+
234
+ When both entries exist, the visible panel cannot register the slash-command resolver: the hidden background entry handles `onResolve` and receives `surface: "background"`. Existing panel-only commands retain their current behavior. Clicking an attached chip from a background-only extension shows a no-panel notice; its browser action remains available.
235
+
236
+ Every background click mounts a fresh sandboxed iframe and sends one `action` request with an invocation `id` and a `GuestActionItem` payload. `host.onAction` receives that message or session item and sends `action-result` with `{ ok: true }` when the handler finishes, or `{ ok: false, error }` when it throws. The error is limited to `GUEST_RESOLVE_ERROR_MAX` characters. The host validates the reply and accepts only the matching invocation from that frame. It sends no acknowledgement for `action-result`.
237
+
238
+ `ready.surface` is `"background"` and `ready.item` is `null`. Context updates never replay the action. The session and directory context remain scoped to the clicked target; the item itself is the snapshot captured at click time. Calls that explicitly write the composer still write the currently visible composer. `close()` is a no-op in a background frame; returning from the handler completes the action.
239
+
240
+ The 20-second deadline covers loading and execution. At most eight background invocations may run concurrently. Completion, failure, timeout, disabling, lost approval, extension update/removal, runtime switch, and host unmount release the frame and its listeners. Late messages are ignored. Already completed or server-accepted effects are not undone, and the host does not automatically retry actions. A background action has the same declared capabilities as its extension. It does not hide the extension's rail icon.
241
+
242
+ See [Actions without opening a panel](./README.md#actions-without-opening-a-panel) for a toast example.
243
+
219
244
  ### 1.3 Error codes (`HostRequestError.code`)
220
245
 
221
246
 
@@ -259,6 +284,7 @@ Storage belongs to the extension on the connected server and needs no extra capa
259
284
  | Request response | 256 000 |
260
285
  | Request timeout | 20 000 ms |
261
286
  | `resolve` answer (host waits) | 20 000 ms |
287
+ | Background action loading and execution | 20 000 ms |
262
288
  | Badge count | 999 |
263
289
  | Message item `text` | 200 000 |
264
290
  | Session item (serialized) | 2 000 000 |
@@ -400,10 +426,11 @@ Used by the OpenChamber host and by tools that validate packages. Guests rarely
400
426
  | `engines.openchamber` | Optional. Only `1.22.0` or `>=1.22.0`. Older host → `host-too-old` |
401
427
  | `panel.id` | kebab-case |
402
428
  | `panel.icon` | Remixicon kebab name (`window`) **or** package `.svg` path. Remixicon needs no file. An `.svg` path must exist on disk or install fails (`invalid-manifest`). No URLs/absolute paths |
403
- | `panel.entry` | Optional. Path inside package. No `..`, absolute, or URL. HTML must exist; its relative `.js` scripts must exist (`missing-build` if not). Omit it for a page-less extension: then only `tools` (plus `engines` and `version`) may be declared; `attach`, `actions`, `commands`, `service`, `integration`, `capabilities`, or `filesystem` without an entry fail parse as `invalid-panel`. A page-less extension has no rail icon, + menu row, or frame; the Extensions card says "No panel". `hasGuestPage(contributes)` tells the two apart |
429
+ | `panel.entry` | Optional visible-panel HTML path inside the package. No `..`, absolute path, or URL. Its scripts must be built. Omitting it removes the rail icon and visible views; `background.entry` can still run code. With neither entry, only `tools` plus package identity/version/engines are allowed. `hasGuestPage` means a visible panel, not background execution |
430
+ | `background.entry` | Optional package-local `.html` path. Loaded on demand for background actions and slash commands, preferred over `panel.entry` for these calls. Adds no rail icon. The file and its built scripts must exist. Malformed declarations are `invalid-background` |
404
431
  | `attach` | `true` / `"panel"` → + menu opens rail; `"dialog"` → host window; omit/`false` → off menus. Object form `{ "mode": "panel" \| "dialog", "entry"?: "panel/attach.html" }`: `entry` (dialog only, same path rules as `panel.entry`, must exist with built scripts) is the page the dialog loads instead of `panel.entry` |
405
432
  | `capabilities` | Optional list of `prompt`, `sessions`, `files`, `model`. `files` is read **and** write inside the open project; `model` is `generate`. Approved once at install |
406
- | `actions` | Optional, 1–8 entries, unique kebab-case `id`, `label` 1–40 chars, optional `icon` (same rules as `panel.icon`, falls back to it), `where: "message" \| "session"`. Message actions may narrow `roles` to `["user"]` / `["assistant"]` (default both); session actions may ask for `payload: ["messages"]`, which adds the `conversation` capability. Bad shape is `invalid-actions`. The entry shows in that message's or session's menu and opens the guest with the item as `ready.item` (the attach window for `attach: "dialog"`, otherwise the rail) |
433
+ | `actions` | Optional, 1–8 entries, unique kebab-case `id`, `label` 1–40 chars, optional `icon` with the `panel.icon` rules, `where: "message" \| "session"`, optional `mode: "open" \| "background"`. Message actions may narrow `roles` to `["user"]` / `["assistant"]`, default both. Session actions may request `payload: ["messages"]`, adding the `conversation` capability. Invalid shape is `invalid-actions`. Default `open` mode opens the guest with `ready.item`, using the attach dialog for `attach: "dialog"` and the rail otherwise. `background` calls `onAction` in a temporary hidden frame; see Background actions above |
407
434
  | `commands` | Optional, 1–8 entries, unique `name` matching `/^[a-z][a-z0-9-]{0,23}$/`, optional `description` 1–80 chars (`invalid-commands`). `/name args` in the chat box calls `onResolve` instead of the model and attaches what it returns. A name the composer already has (built-in, OpenCode command, skill) is ignored with a console warning |
408
435
  | `tools` | Optional, 1–16 entries that say how the extension's tool calls look in the chat. `match` is the full tool name OpenCode reports (`mcp.jira.search`, `jira_search`), 1–128 chars of `[A-Za-z0-9_.:-]`, with `*` allowed once at the end as a suffix wildcard (`mcp.jira.*`). Optional `name` (1–40, the header title when `title` is absent or renders empty), `icon` (Remixicon name or package `.svg` path, same rules as `panel.icon`; the SVG is drawn in the text colour at the glyph size), `title` / `subtitle` templates (1–200, `{input.path}` / `{output.path}` / `{metadata.path}` placeholders, a missing path renders empty, values are cut at 200), `output` `"auto"` (default) \| `"text"` \| `"json"` \| `"markdown"` \| `"code"` \| `"table"`, `language` (code only), `columns` (table only, 1–16 dotted paths; rows are the output array or `output.items`). Bad shape is `invalid-tools`. An exact `match` beats a wildcard from any extension; among equals the first extension wins. Only an enabled, fully approved extension's rules apply |
409
436
  | `filesystem` | Optional, 1–16 globs, each 1–256 chars, starting with `/` or `~/`; `**` spans folders, `*` / `?` stay in one segment; no `..`, empty segment, or backslash (`invalid-filesystem`). Declaring it adds the `filesystem` capability and the dialog lists the globs |
@@ -422,7 +449,7 @@ Extra keys are dropped, not forwarded.
422
449
  | `parseManifestJson(json)` (`@openchamber/sdk/schemas`) | String → same result |
423
450
  | `resolveAttachMode(attach)` | Normalize to `'panel' |
424
451
  | `resolveAttachEntry(contributes)` | Dialog page from the object form, or `null` when the dialog reuses `panel.entry` |
425
- | `hasGuestPage(contributes)` | `true` when `panel.entry` is set; a page-less package may only declare `tools` |
452
+ | `hasGuestPage(contributes)` | `true` when `panel.entry` is set; background-only and tools-only packages return `false` |
426
453
  | `resolveIntegrationAuth` / `resolveIntegrationApi` | Auth kind and API origin |
427
454
  | `toPublicIntegration` / `toPublicService` | Catalog-safe public slices |
428
455
  | `isGuestPackageSvgIcon` | Whether icon is a package SVG path |
package/DOCUMENTATION.md CHANGED
@@ -20,6 +20,8 @@ Public author pages live in `packages/docs/content/docs/sdk.mdx`, `sdk/host.mdx`
20
20
 
21
21
  ## Invariants
22
22
 
23
+ - `scripts/bump-version.mjs` refreshes `bun.lock` after bumping package versions; include both in the release commit. Bun resolves `workspace:*` in published archives from lockfile versions, and a frozen install can succeed with stale versions. `scripts/bump-version.test.mjs` covers the bump, frozen install, and packed dependency versions together.
24
+
23
25
  - Extension activity uses `useConfigStore.isConnected` plus live status records and directory `sessionStatusReady` authority. `useUIStore.eventStreamStatus` is an unmaintained diagnostic field, not a connection gate; its default `idle` must not hide known running or idle sessions as `unknown`.
24
26
 
25
27
  - `contributes.page` is `true` or `{ entry, title? }`, requires `panel.entry`, and adds a user-opened full-screen page. `resolvePageEntry` resolves shorthand; the server validates its HTML/scripts and exposes `pageEntry`/`pageTitle`. `PluginPane surface="page"` uses the same sandbox. `useUIStore.openGuestPageId` is transient and mutually exclusive with other main pages. The sidebar menu opens it; `close()` or explicit session navigation closes it. Pages are not context-rail surfaces.
@@ -27,7 +29,7 @@ Public author pages live in `packages/docs/content/docs/sdk.mdx`, `sdk/host.mdx`
27
29
  - `storage` is instance-owned JSON under the guest's namespace, without a new capability. `storage.js` serializes authorization, read-modify-write, and removal per namespace; writes use unique temporary files and rename. Missing keys differ from stored null; corrupt files and failed writes fail without clearing data. Limits and public result shapes live in `API.md`. For project data, extensions namespace keys with project IDs.
28
30
 
29
31
  - Manifest `apiVersion` is `1`. Wire envelope `v` stays `1`. Any other value is `unsupported-api-version`. Optional `engines.openchamber` is `1.22.0` or `>=1.22.0`. Install refuses with `host-too-old` when this OpenChamber build is older. Package installs also need a semver `version` on `package.json` (`1.0.0`); the catalog exposes it and Settings → Extensions shows `v1.0.0` on the card. Already-installed packages without `version` still list; new installs without it fail as `invalid-manifest`.
30
- - `panel.entry` is optional. `id`, `name`, and `icon` stay required: they identify the extension on the Extensions card and in the approval dialog. A package without `entry` is page-less (`hasGuestPage(contributes)` is `false`): it never mounts an iframe, so parse refuses `attach` (other than `false`), `actions`, `commands`, `service`, `integration`, a non-empty `capabilities`, and `filesystem` as `invalid-panel` (the message names the offending keys and says they need `panel.entry`). `tools`, `engines`, and `version` are all it may carry. Install skips the HTML and built-script checks, the catalog row has no `entry`, the served-file route refuses HTML and JS for that id (its SVG icons still serve), and the UI gives it no rail surface, attach row, action, command, or frame; the Extensions card shows "No panel" after the version. `examples/tools-only` is the reference.
32
+ - `panel.entry` is optional; `panel.id/name/icon` remain the identity on Settings and approval cards. `hasGuestPage` means a visible panel, not an execution runtime. `background.entry` provides independent package-local `.html` for on-demand code without a rail icon. Without a panel, actions must explicitly use `mode: "background"`; `page` and enabled `attach` fail as `invalid-panel`. A background-only extension can declare commands, services, integrations and capabilities, with the existing grants. Without either entry, only tools are allowed, HTML/JS serving is refused, and `examples/tools-only` remains the reference. Settings shows "No panel" whenever the visible entry is absent.
31
33
  - Panel `id` is kebab-case. `icon` is a Remixicon kebab-case name (`RiWindowLine` → `window`, `RiGitMergeLine` → `git-merge`) or a package `.svg` path (`icon.svg`). URLs and absolute paths fail parse. `entry` stays inside the package (no `..`, no absolute, no URL). The rail paints a Remixicon from the host sprite, or a `currentColor` mask for a package SVG. Package SVGs often fill the 24×24 viewBox; the rail scales that mask so it matches Remixicon optical size. A Remixicon name the sprite does not carry falls back to `apps`.
32
34
  - Extra manifest keys are dropped, not forwarded.
33
35
  - Theme text fields are required on `apiVersion: 1`: `primaryText`, `successText`, `warningText`, `errorText`, `infoText`. Host calculation and UI-kit application are documented in `src/ui/DOCUMENTATION.md`. Keep the wire schema, TypeScript contract and iframe mapping in sync.
@@ -46,7 +48,7 @@ Public author pages live in `packages/docs/content/docs/sdk.mdx`, `sdk/host.mdx`
46
48
  - `contributes.filesystem` is 1 to 16 globs, each 1 to 256 characters, starting with `/` or `~/`, with no `..`, empty segment, NUL, or backslash (`isGuestFilesystemPattern`; parse failure is `invalid-filesystem`). `**` spans directories, `*` and `?` stay inside one segment, and a pattern ending in `/**` also matches the directory itself. The public catalog row carries the list as `filesystem` so the approval dialog can show it.
47
49
  - `readFile` / `writeFile` / `listDir` / `stat` are `file-read` / `file-write` / `file-list` / `file-stat` on the wire. `guestFileScope` decides the grant: a relative path is `project` (capability `files`, joined to the open project; none open is `NO_DIRECTORY`), `/…` or `~/…` is `filesystem` (must match a declared pattern). The server compares canonical paths, so a symlink out of either tree is `BAD_PATH`, as is any `..` segment. Results are `{ content }`, `{ written: true }`, `{ entries: { name, kind }[] }`, and `{ kind, size, mtime }` where `kind` may be `missing`. `GUEST_FILE_PATH_MAX` (1024), `GUEST_FILE_CONTENT_MAX` (2 000 000 characters, read and write), `GUEST_FILE_LIST_MAX` (2000 entries). The client refuses an empty, oversized, NUL, or backslash path as `BAD_PATH` and oversized content as `FILE_TOO_LARGE` before posting; the host schema accepts only what `isGuestFilePath` does, so a dropped message cannot turn into `HOST_TIMEOUT`. Nothing about the write is logged, not even the path.
48
50
  - `contributes.attach` is `true`, `false`, `"panel"`, `"dialog"`, or `{ "mode": "panel" | "dialog", "entry"?: string }`. `true` and `"panel"` add a + menu row that opens the rail. `"dialog"` opens a host window that mounts the same iframe. The object form's `entry` is an HTML page inside the package (same path rules as `panel.entry`, install checks it exists and that its scripts are built) that the dialog loads instead of `panel.entry`; it is only accepted with `mode: "dialog"`, and `resolveAttachEntry(contributes)` returns it or `null`. The public catalog row carries it as `attachEntry`. Clicking the guest's chip on the composer reopens that guest with the chip as `ready.item`: a dialog guest in the attach window, a panel guest on the rail. New Worktree shows the same dialog-mode guests next to GitHub and Linear. Omitted or `false` stays off those menus. Public 1.0 is web and desktop. VS Code and mobile mark the guest catalog `unsupported`. They do not show the row.
49
- - `contributes.integration` is optional. Panel stays required. Exactly one of `oauth`, `token`, or `host` is required. The public catalog slice is name, description, `auth`, `apiOrigin` (the one origin `request` may call; the approval dialog shows it, it is not a secret), token scheme, and setting field defs. OAuth URLs stay on the server. Client id, secret, and pasted tokens are not in the package. `host.provider: "linear"` binds the first-party Linear connection. `apiOrigin` is `https://api.linear.app` only.
51
+ - `contributes.integration` needs either a panel or background entry. Exactly one of `oauth`, `token`, or `host` is required. The public catalog slice is name, description, `auth`, `apiOrigin` (the one origin `request` may call; the approval dialog shows it, it is not a secret), token scheme, and setting field defs. OAuth URLs stay on the server. Client id, secret, and pasted tokens are not in the package. `host.provider: "linear"` binds the first-party Linear connection. `apiOrigin` is `https://api.linear.app` only.
50
52
  - `attach` puts `{ providerId, id, title, url }` on the composer as a chip, exclusive with GitHub and Linear. `kind` is `issue` or `pull` and defaults to `issue`. `author` is a login. `branches` is `{ head, base }` on a pull. Avatar URLs stay off this payload. It does not invent a GitHub `number`. `text` is optional model context, 1–16000 after trim. `data` is optional plain JSON the guest chose (`JsonValue`); the host stores it on the chip, the queued context part, and the session snapshot, and hands it back byte-identical as `ready.item.data` / `onItem`. It never reaches the model or the chip text. Serialized size is capped at `GUEST_ATTACH_DATA_MAX` (16 000 characters): `clampAttachRequest` drops a larger value, the host schema refuses it. `connectHost.attach` clamps id/title/url/text/author/branches to the protocol max so the host schema does not drop the message. The rail and the attach window both write `pendingGuestIssue` on the composer store. Send writes `guest-issue` or `guest-pr` and a session snapshot. `close` dismisses the attach window and is a no-op on the rail.
51
53
  - `startSession` extends the existing operation with `projectId`, root/existing/new worktree selection, optional new name/base, and `navigation` defaulting to `preserve`. It calls the owning session creation action with that navigation policy, so registration/message initialization/global cache updates still happen while selection stays unchanged. Worktree creation reuses `createWorktreeWithDefaults` and the configured bootstrap wait. It preserves a created worktree on bootstrap/session failure and returns that partial result. It captures send selection before async creation, rechecks runtime and grants before subsequent mutations, and distinguishes session creation from first-message and linked-item persistence outcomes. See `API.md` for the exact result. Web and desktop only.
52
54
  - `prompt` is `{ text, send? }`. `send` omitted or `false` replace-composes on the current session. `send: true` sends on that session with the same model path as `startSession`. No session is `NO_SESSION`. A busy session on send is `SESSION_BUSY`. Compose still works while busy. The result is `{ sent }`. The guest does not pick a model or agent.
@@ -81,12 +83,36 @@ File calls go through `POST /api/guests/:id/files` with the `x-opencode-director
81
83
 
82
84
  Clipboard writes run in the host (`copyTextToClipboard`). Compose uses `useInputStore.setPendingInputText`. Attach writes the composer chip. The iframe has no clipboard, no composer, and no chip. The + menu and the attach window chrome are host. Guest JS is not in that React tree.
83
85
 
84
- Actions, commands, and the badge are host chrome too. `useGuestActions` (`packages/ui/src/hooks/useGuestSurfaces.ts`) lists the actions of active guests (empty on VS Code and mobile); the message hover row and touch sheet, the sidebar session menu, and the header session menus add them after the built-ins. A click builds the item (`buildGuestMessageItem` / `buildGuestSessionItem`; the session conversation is loaded the same way Export as Markdown loads it, and a failed load is a toast, not an empty conversation) and `openGuestWithItem` routes it exactly like a chip click: the attach window for `attach: "dialog"` (through `useGuestDialogStore`, rendered by `GuestHosts` in `MainLayout`), otherwise the rail with `useGuestItemStore`. `useGuestCommands` lists commands minus reserved names; the composer routes `/name args` (`submit/guestCommands.ts`) to `runGuestCommand`, which asks the registered resolver of a mounted pane (`lib/guests/resolve.ts`) or mounts a hidden headless `PluginPane` through `GuestHosts` for the call. `PluginPane` registers its resolver once the guest said `hello` or the iframe loaded, posts `resolve`, and settles on `resolve-result` or the 20s deadline. A returned chip goes through `useInputStore.setPendingGuestIssue`, the same slot a panel `attach` uses. `badge` writes `useGuestBadgeStore` (memory only); the rail paints it like the git count and the pane clears it when the panel opens. A paused or not fully approved guest contributes no actions, commands, tool presentations, or badge.
86
+ Actions, commands, and the badge are host chrome too. `useGuestActions` (`packages/ui/src/hooks/useGuestSurfaces.ts`) lists active guests' actions for message and session menus, empty on VS Code and mobile. A click builds the item with `buildGuestMessageItem` / `buildGuestSessionItem`; failed conversation loading is a toast, never an empty conversation. `runGuestAction` dispatches the declared mode. In default `open` mode, `openGuestWithItem` routes like a chip click: the attach window for `attach: "dialog"` through `useGuestDialogStore`, otherwise the rail through `useGuestItemStore`. Background mode is documented below.
87
+
88
+ `useGuestCommands` lists commands minus reserved names. The composer routes `/name args` through `submit/guestCommands.ts` to `runGuestCommand`, which asks a mounted pane's registered resolver or mounts a hidden `PluginPane` through `GuestHosts`. The pane registers after hello/load, posts `resolve`, and settles on `resolve-result` or the 20s deadline. A returned chip uses `useInputStore.setPendingGuestIssue`. `badge` writes the memory-only `useGuestBadgeStore`; the rail paints the count and opening the panel clears it. A paused or unapproved guest contributes no actions, commands, tool presentations, or badge.
85
89
 
86
90
  Add a type when the host grows that hole, not before. Any new `connectHost` method ships with a `PluginPane` / `answerGuestMessage` branch in the same change.
87
91
 
88
92
  Rebuild every example bundle after an SDK change (`bunx openchamber-guest-bundle` per entry, see `examples/README.md`); the built files are checked in.
89
93
 
94
+ ## Guest toasts
95
+
96
+ `ToastRequest` adds optional `copy: boolean | { text: string }`, `dismiss: boolean`, and `persistent: boolean`. The client checks message/copy length before sending; the host schema validates the complete payload. `HostBridgeEffects.toast` receives the request intact. `lib/guests/toast.tsx` owns toast creation and per-toast dismissal; `components/layout/GuestToastActions.tsx` renders the buttons and clipboard success/failure state. Copy keeps the toast open. Persistent toasts always include OK, including when the guest passes `dismiss: false`.
97
+
98
+ Toast acknowledgement means displayed, not clicked. Button closures retain only text and toast identity, not the guest's lifetime or runtime. They survive action completion, iframe teardown, and runtime switches. Labels resolve through the host's reactive i18n context. The SDK and schema limits use the existing `GUEST_TOAST_MAX` and `GUEST_CLIPBOARD_TEXT_MAX`; custom copy text is not trimmed. See `API.md` for the author contract.
99
+
100
+ ## Background actions
101
+
102
+ `contributes.actions[].mode` is `open` or `background`, defaulting to `open`. The manifest parser and UI catalog parser preserve it; server catalog projection already carries actions unchanged. Existing `onItem` routing stays with open-mode actions. `runGuestAction` revalidates the captured declaration, active grants, target role, and runtime before dispatch. Session actions also discard history loaded across a runtime switch.
103
+
104
+ `lib/guests/run-action.ts` owns temporary invocation state, a 20-second loading/execution deadline, and an eight-frame limit. Each click gets a distinct `PluginPane` from `GuestHosts`, preferring `backgroundEntry` and falling back to `entry`, with `surface: 'background'` and `headless`. It never registers as a slash-command resolver, consumes a parked item, clears a badge, opens a tab, or changes session selection. The pane pins session/directory context to the clicked target. Composer-writing methods retain their existing current-composer behavior.
105
+
106
+ The catalog carries `backgroundEntry` independently of the visible `entry`. Both HTML files and their built scripts are checked at install. The serving route's `hasRuntime` gate permits code when either exists; storage applies that same entry check plus enabled/approval gates. Background-only chips cannot reopen a rail panel, so the composer shows a no-panel notice while retaining the existing browser action.
107
+
108
+ Slash commands use the background entry whenever it is declared. A visible pane then does not register a resolver; only the temporary command host does, with `ready.surface: 'background'`. Panel-only extensions retain resolver reuse. Resolver registrations are retired when the frame URL or entry changes and refuse calls after runtime/grant/version changes. `examples/hello-kit/background` is the reference for a separate entry; omit that example's visible entry and page to make it background-only.
109
+
110
+ Both `MainLayout` and the separate-window `MiniChatLayout` mount `GuestHosts`; a message action must have an execution host in the window that owns its catalog and session state.
111
+
112
+ The SDK adds `HostActionMessage`, `GuestActionResultMessage`, `GuestActionItem`, `ActionResultPayload`, and `HostClient.onAction`. Wire `action` carries one message/session item and an invocation ID; `action-result` carries that ID and a discriminated success/error payload. Error text shares `GUEST_RESOLVE_ERROR_MAX`. `ready.item` stays null; a one-shot `takeMessage` prevents hello/load/context updates from executing an action twice. The handler must be registered synchronously and await its work before returning. Host schema validation plus the frame's source and invocation ID reject unrelated replies.
113
+
114
+ The runner cancels on runtime/catalog invalidation and marks the operation inactive synchronously, so queued messages cannot mutate host state during React teardown. Timeout, completion, frame-load failure, or host unmount clears the invocation and subscriptions. Unmount cleanup yields one microtask for Strict Mode's effect reattachment. Already accepted remote effects are not rolled back; no automatic retry. Web/desktop use the existing direct or relay frame loader; VS Code and mobile remain unsupported. `examples/hello-kit` demonstrates a background message-length toast.
115
+
90
116
  ## Not here yet
91
117
 
92
118
  `issues.search` / `issues.get` on the host, a public OAuth broker, a second `host.provider`, file watching or globbing from the guest (it gets `listDir` and `stat`, not a search). `attach` is the chip. `startSession` creates a session and writes that snapshot. `sessionLink` writes that snapshot on the current session. `prompt` writes or sends on that session. `onSessionLifecycle` is the live phase. Search and the task list live in the guest. `@openchamber/sdk/ui` is the drawing kit: buttons, fields, select, list, tabs, badges, banners, menu, and `mountText`. No issue page, card, attach picker, or PR window; the guest composes those from the blocks. `mountText` lifts `![alt](https://…)` and `[label](https://…)` and hands link clicks to `onOpenUrl`. `ready.theme.tokens` carries the host surface and interactive colors. Call `applyHostReady` before mount.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @openchamber/sdk
2
2
 
3
- Build extensions for [OpenChamber](https://openchamber.dev). An extension is a small web page that OpenChamber shows on its right-hand rail. It can read the current project and session, show toasts, put text in the chat box, attach a task to a session, and, once the user approves it, start sessions and send prompts. This package is the contract between that page and the app.
3
+ Build extensions for [OpenChamber](https://openchamber.dev). An extension can show a panel on the right-hand rail or run actions in the background without a panel. It can read the current project and session, show toasts, put text in the chat box, attach a task to a session, and, once the user approves it, start sessions and send prompts. This package is the contract between the extension and the app.
4
4
 
5
5
  Full guide: [Build an extension](https://openchamber.dev/docs/sdk/). Reference: [Host API](https://openchamber.dev/docs/sdk/host/) and [UI kit](https://openchamber.dev/docs/sdk/ui/). Extensions with a local process: [GUEST_SERVICES.md](./GUEST_SERVICES.md).
6
6
 
@@ -88,15 +88,71 @@ Git installs also accept SSH addresses such as `git@github.com:owner/extension.g
88
88
  - `version` is required semver. Settings → Extensions shows it on the card.
89
89
  - `apiVersion` is `1`. Anything else is refused.
90
90
  - `engines.openchamber` is optional (`1.24.0` or `>=1.24.0`). Older OpenChamber builds refuse the install.
91
- - `panel.id` is kebab-case and unique. `icon` is a Remixicon name (`RiWindowLine` becomes `window`) or an SVG inside the folder. `entry` is the HTML file inside the folder. Leave `entry` out for an extension that only declares `tools`: it gets no rail icon or page, just its tool rules in the chat (`examples/tools-only`).
91
+ - `panel.id` is kebab-case and unique. `icon` is a Remixicon name (`RiWindowLine` becomes `window`) or an SVG inside the folder. `entry` is the visible panel's HTML file. Leave `entry` out to have no rail icon or panel. Add `background.entry` for executable actions and commands; with neither entry, the extension can only declare `tools`, as in `examples/tools-only`.
92
+ - `background: { "entry": "background/index.html" }` supplies separate sandboxed HTML for background actions and slash commands. Its scripts must be built. It runs on demand, not continuously or at installation. A panel can coexist with it.
92
93
  - `attach` is optional. `"dialog"` opens the page in a window from the + menu next to the chat box; `true` or `"panel"` opens the rail panel instead. `ctx.surface` tells the page which one it is in. The object form `{ "mode": "dialog", "entry": "panel/attach.html" }` gives the window its own page. When the user clicks the attached chip, the page opens again with that item in `ctx.item` (`null` from the + menu), so it can show the item instead of the list.
93
- - `actions` is optional: menu entries on messages (`where: "message"`, optionally only `roles: ["assistant"]`) and on sessions (`where: "session"`). Picking one opens your page with that message or session in `ctx.item` (`kind: "message"` with the text, or `kind: "session"`; add `payload: ["messages"]` to get the conversation too). Up to 8.
94
+ - `actions` is optional: menu entries on messages (`where: "message"`, optionally only `roles: ["assistant"]`) and on sessions (`where: "session"`). By default, picking one opens your page with that message or session in `ctx.item` (`kind: "message"` with the text, or `kind: "session"`; add `payload: ["messages"]` to get the conversation too). Set `mode: "background"` to call `onAction` without opening UI, as shown below. Up to 8.
94
95
  - `commands` is optional: slash commands for the chat box, up to 8. `/task DEMO-2` calls your `host.onResolve` handler instead of the model; return a chip to attach it, or `null` for nothing. A name the app already has is ignored.
95
96
  - `tools` is optional: how your tool calls look in the chat, up to 16, no code. `match` is the tool name OpenCode reports (`mcp.tasks.*` matches every tool under that prefix); `name` and `icon` (a Remixicon name or an SVG inside the folder, like `panel.icon`) set the header, `title` and `subtitle` are templates like `{input.id}` or `{output.total} open`, and `output` picks the body: `text`, `json`, `markdown`, `code` (with `language`), or `table` (with `columns`, rows from the output array or `output.items`). Leave `output` out to keep the app's own detection.
96
97
  - `capabilities` lists what needs the user's approval: `prompt` to send messages, `sessions` to create sessions and worktrees, `files` to read and write inside the open project, `model` for one-off text generation with the user's Small Model (`host.generate`, no session involved). An `integration` adds `network`, a `service` adds `service`, `filesystem` patterns (like `["~/.config/opencode/opencode.json"]`) add `filesystem`, which lets `readFile`, `writeFile`, `listDir`, and `stat` reach those paths outside the project, and a session action with `payload: ["messages"]` adds `conversation`. The user approves the whole list once at install. Calls outside it fail with `NOT_GRANTED`.
97
98
  - `integration` is optional. It adds a card at Settings → Integrations. `token` takes a pasted API token (`scheme: "bearer"` for `Authorization: Bearer`, `"basic"` for a username and token pair as Jira Cloud wants), `oauth` runs an authorize flow with a pasted client id, and `host: { "provider": "linear" }` reuses the Linear account already connected in OpenChamber. The page never sees the token; OpenChamber makes the calls through `host.request`.
98
99
  - `service` is optional. It declares a local process OpenChamber starts next to the extension. It runs with the user's full access and no sandbox, so declare one only when the page cannot do the job. See [GUEST_SERVICES.md](./GUEST_SERVICES.md).
99
100
 
101
+ ## Actions without opening a panel
102
+
103
+ Set `mode: "background"` on a message or session action to run it without opening the rail or a dialog:
104
+
105
+ ```json
106
+ { "id": "message-length", "label": "Show message length", "where": "message", "mode": "background" }
107
+ ```
108
+
109
+ Register `onAction` immediately after `connectHost` in your background script, or your panel script when no background entry is declared:
110
+
111
+ ```ts
112
+ const host = connectHost();
113
+ host.onAction(async (item) => {
114
+ if (item.kind === 'message' && item.action === 'message-length') {
115
+ await host.toast({
116
+ kind: 'info',
117
+ message: `Message length: ${item.text.length} characters.`,
118
+ copy: true,
119
+ dismiss: true,
120
+ persistent: true,
121
+ });
122
+ }
123
+ });
124
+ ```
125
+
126
+ Each click runs in a fresh hidden iframe. Await all work inside the handler, including the toast. OpenChamber removes the iframe when the handler finishes and reports thrown errors as toasts. Loading and execution together have a 20-second limit. A runtime switch, disabling the extension, or withdrawing approval also ends the invocation. Completed side effects are not rolled back or retried.
127
+
128
+ `ctx.surface` is `"background"`; skip drawing your UI in that case. `ctx.item` stays `null`, so the action runs only through `onAction`, without repeated `onItem` snapshots. The session and directory context stay with the clicked target even if the user changes chats. OpenChamber loads `background.entry` when declared and falls back to `panel.entry` for existing extensions. Omit `mode`, or use `"open"`, to open a visible panel or dialog. See `examples/hello-kit` for separate panel and background scripts.
129
+
130
+ To remove the panel and its rail icon entirely, use this `contributes` block:
131
+
132
+ ```json
133
+ {
134
+ "panel": { "id": "message-tools", "name": "Message Tools", "icon": "apps" },
135
+ "background": { "entry": "background/index.html" },
136
+ "actions": [
137
+ { "id": "message-length", "label": "Show message length", "where": "message", "mode": "background" }
138
+ ]
139
+ }
140
+ ```
141
+
142
+ The `panel` object retains the extension's identity for Settings and approval dialogs; only `panel.entry` creates a visible panel. Without it, every action must use `mode: "background"`, and `attach` and `page` cannot open a view. Slash commands call `onResolve` in the background entry, with `ctx.surface` also set to `"background"`. Capabilities, services, integrations, storage, and file access use the same approval checks. An attached chip can still be sent to the model or opened with its browser button; clicking to reopen the extension shows a no-panel notice.
143
+
144
+ This addition requires a matching OpenChamber build. For unreleased SDK preview builds, use the app built from the same revision.
145
+
146
+ ## Toast buttons
147
+
148
+ `host.toast` accepts three optional fields:
149
+
150
+ - `copy: true` adds Copy for the displayed message. Use `copy: { text: "..." }` to copy a different value, up to 32,000 characters. Whitespace in that value is preserved.
151
+ - `dismiss: true` adds OK to close the toast.
152
+ - `persistent: true` keeps the toast on screen until dismissed and always adds OK, even if `dismiss` is false.
153
+
154
+ Copy keeps the toast open and shows Copied on success. A failed copy shows an error beside the button so the user can retry. OpenChamber translates the buttons and handles clicks itself, so they work after a background action's iframe has closed. `await host.toast(...)` waits only for the host to show the toast, not for a button click. Omit these fields for an ordinary timed toast.
155
+
100
156
  ## In the page
101
157
 
102
158
  For a full-screen board, add `"page": true` under `contributes`, or `"page": { "entry": "panel/page.html", "title": "Board" }` for separate HTML. Users open it from the Extension pages menu above the session list. `ctx.surface` is `"page"`. The extension cannot open the page itself.
@@ -51,7 +51,7 @@ export type SessionSnapshot = {
51
51
  agent?: string;
52
52
  };
53
53
  /** Which host chrome mounted this iframe. Not `openSurface`. */
54
- export type GuestHostSurface = 'panel' | 'dialog' | 'page';
54
+ export type GuestHostSurface = 'panel' | 'dialog' | 'page' | 'background';
55
55
  export type GuestConnection = {
56
56
  connected: boolean;
57
57
  account: string;
@@ -208,6 +208,14 @@ export type GuestSessionItem = {
208
208
  truncated?: boolean;
209
209
  };
210
210
  export type GuestItem = AttachIssueRequest | GuestMessageItem | GuestSessionItem;
211
+ /** The captured target of a background action. Delivered once through `onAction`, not `onItem`. */
212
+ export type GuestActionItem = GuestMessageItem | GuestSessionItem;
213
+ export type ActionResultPayload = {
214
+ ok: true;
215
+ } | {
216
+ ok: false;
217
+ error: string;
218
+ };
211
219
  export declare const isGuestMessageItem: (item: GuestItem | null) => item is GuestMessageItem;
212
220
  export declare const isGuestSessionItem: (item: GuestItem | null) => item is GuestSessionItem;
213
221
  /** The composer chip: what the guest handed to `attach`, with `text` filled from the chip. */
@@ -231,6 +239,14 @@ export type ToastKind = 'info' | 'success' | 'error';
231
239
  export type ToastRequest = {
232
240
  kind: ToastKind;
233
241
  message: string;
242
+ /** Show Copy. `true` copies the message; an object supplies different text, up to 32,000 characters. */
243
+ copy?: boolean | {
244
+ text: string;
245
+ };
246
+ /** Show an OK button that dismisses the toast. */
247
+ dismiss?: boolean;
248
+ /** Keep the toast until dismissed. Always includes OK, even when `dismiss` is false. */
249
+ persistent?: boolean;
234
250
  };
235
251
  export type ComposeRequest = {
236
252
  text: string;
@@ -395,6 +411,11 @@ export type HostResolveMessage = Envelope & {
395
411
  id: string;
396
412
  payload: ResolveRequest;
397
413
  };
414
+ export type HostActionMessage = Envelope & {
415
+ type: 'action';
416
+ id: string;
417
+ payload: GuestActionItem;
418
+ };
398
419
  export type HostResultMessage = Envelope & {
399
420
  type: 'result';
400
421
  id: string;
@@ -409,7 +430,7 @@ export type HostResultMessage = Envelope & {
409
430
  export type HostMessage = (Envelope & {
410
431
  type: 'workspace';
411
432
  payload: GuestWorkspaceUpdate;
412
- }) | HostReadyMessage | HostDirectoryMessage | HostSessionMessage | HostConnectionMessage | HostSettingsMessage | HostSessionLifecycleMessage | HostItemMessage | HostResolveMessage | HostResultMessage;
433
+ }) | HostReadyMessage | HostDirectoryMessage | HostSessionMessage | HostConnectionMessage | HostSettingsMessage | HostSessionLifecycleMessage | HostItemMessage | HostResolveMessage | HostActionMessage | HostResultMessage;
413
434
  type GuestCall<Type extends string, Payload = never> = Envelope & {
414
435
  type: Type;
415
436
  id: string;
@@ -454,11 +475,17 @@ export type GuestResolveResultMessage = Envelope & {
454
475
  id: string;
455
476
  payload: ResolveResultPayload;
456
477
  };
478
+ /** Completes a host `action`. The host sends no `result` back. */
479
+ export type GuestActionResultMessage = Envelope & {
480
+ type: 'action-result';
481
+ id: string;
482
+ payload: ActionResultPayload;
483
+ };
457
484
  export type GuestMessage = GuestCall<'workspace-read', GuestWorkspaceQuery> | GuestCall<'workspace-subscribe', GuestWorkspaceSubscription> | GuestCall<'workspace-unsubscribe', {
458
485
  subscriptionId: string;
459
486
  }> | GuestCall<'storage', GuestStorageRequest> | GuestCall<'open-session', {
460
487
  sessionId: string;
461
- }> | GuestHelloMessage | GuestToastMessage | GuestOpenUrlMessage | GuestOpenSurfaceMessage | GuestClipboardWriteMessage | GuestComposeMessage | GuestAttachMessage | GuestStartSessionMessage | GuestPromptMessage | GuestSessionLinkMessage | GuestCloseMessage | GuestOauthStartMessage | GuestOauthDisconnectMessage | GuestRequestMessage | GuestServiceRequestMessage | GuestServiceStatusMessage | GuestFileReadMessage | GuestFileWriteMessage | GuestFileListMessage | GuestFileStatMessage | GuestGenerateMessage | GuestBadgeMessage | GuestResolveResultMessage;
488
+ }> | GuestHelloMessage | GuestToastMessage | GuestOpenUrlMessage | GuestOpenSurfaceMessage | GuestClipboardWriteMessage | GuestComposeMessage | GuestAttachMessage | GuestStartSessionMessage | GuestPromptMessage | GuestSessionLinkMessage | GuestCloseMessage | GuestOauthStartMessage | GuestOauthDisconnectMessage | GuestRequestMessage | GuestServiceRequestMessage | GuestServiceStatusMessage | GuestFileReadMessage | GuestFileWriteMessage | GuestFileListMessage | GuestFileStatMessage | GuestGenerateMessage | GuestBadgeMessage | GuestActionResultMessage | GuestResolveResultMessage;
462
489
  export declare const isServiceStatusResult: (value: HostResultPayload | undefined) => value is ServiceStatusResult;
463
490
  export declare const isGuestRequestResult: (value: HostResultPayload | undefined) => value is GuestRequestResult;
464
491
  export declare const isFileReadResult: (value: HostResultPayload | undefined) => value is FileReadResult;
package/dist/contract.js CHANGED
@@ -198,7 +198,7 @@ export const isFileStatResult = (value) => Boolean(value && 'kind' in value && '
198
198
  export const isGenerateResult = (value) => Boolean(value && 'text' in value && String(value.text) === value.text && !('status' in value));
199
199
  const HOST_PUSH_TYPES = new Set([
200
200
  'workspace',
201
- 'ready', 'directory', 'session', 'connection', 'settings', 'session-lifecycle', 'item', 'resolve',
201
+ 'ready', 'directory', 'session', 'connection', 'settings', 'session-lifecycle', 'item', 'resolve', 'action',
202
202
  ]);
203
203
  const asWireRecord = (data) => (Object(data) === data ? data : null);
204
204
  const isNonEmptyString = (value) => (String(value) === value && value.length > 0);
package/dist/host.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { type GuestProjectsSnapshot, type GuestWorktreesSnapshot, type GuestSessionsSnapshot } from './workspace.ts';
2
2
  import type { JsonValue } from './contract.ts';
3
- import { type AttachIssueRequest, type ComposeRequest, type PromptRequest, type PromptResult, type SessionLifecycleEvent, type StartSessionRequest, type GuestConnection, type GuestItem, type GuestMessage, type GuestRequest, type GuestRequestResult, type GuestSettings, type HostReadyContext, type HostRequestErrorCode, type ResolveRequest, type SessionSnapshot, type StartSessionResult, type ToastRequest, type ServiceStatusResult, type FileListResult, type FileReadResult, type FileStatResult, type FileWriteResult, type GenerateRequest, type GenerateResult } from './contract.ts';
3
+ import { type AttachIssueRequest, type GuestActionItem, type ComposeRequest, type PromptRequest, type PromptResult, type SessionLifecycleEvent, type StartSessionRequest, type GuestConnection, type GuestItem, type GuestMessage, type GuestRequest, type GuestRequestResult, type GuestSettings, type HostReadyContext, type HostRequestErrorCode, type ResolveRequest, type SessionSnapshot, type StartSessionResult, type ToastRequest, type ServiceStatusResult, type FileListResult, type FileReadResult, type FileStatResult, type FileWriteResult, type GenerateRequest, type GenerateResult } from './contract.ts';
4
4
  export type HostFrame = {
5
5
  addEventListener: Window['addEventListener'];
6
6
  removeEventListener: Window['removeEventListener'];
@@ -49,6 +49,12 @@ export type HostClient = {
49
49
  * toast. One handler at a time; the returned function removes it.
50
50
  */
51
51
  onResolve: (handler: (request: ResolveRequest) => Promise<AttachIssueRequest | null> | AttachIssueRequest | null) => () => void;
52
+ /**
53
+ * Run a `mode: "background"` action. Register synchronously after connectHost.
54
+ * Await all work, including toast calls: the hidden frame is removed when
55
+ * this handler settles. Throw to report failure. One handler at a time.
56
+ */
57
+ onAction: (handler: (item: GuestActionItem) => void | Promise<void>) => () => void;
52
58
  toast: (request: ToastRequest) => Promise<void>;
53
59
  openUrl: (url: string) => Promise<void>;
54
60
  openSurface: (surfaceId: string) => Promise<void>;
package/dist/host.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { OPENCHAMBER_SDK_API_VERSION, OPENCHAMBER_SDK_CHANNEL } from "./api-version.js";
2
2
  import { GUEST_STORAGE_KEY_MAX, GUEST_STORAGE_VALUE_BYTES } from "./workspace.js";
3
- import { GUEST_FILE_CONTENT_MAX, GUEST_FILE_PATH_MAX, GUEST_GENERATE_OUTPUT_TOKENS_MAX, GUEST_GENERATE_PROMPT_MAX, GUEST_GENERATE_SYSTEM_MAX, GUEST_GENERATE_TIMEOUT_MS, GUEST_REQUEST_TIMEOUT_MS, GUEST_RESOLVE_ERROR_MAX, isGuestFilePath, isGuestRequestPath, clampAttachRequest, clampBadgeCount, clampPromptRequest, clampStartSessionRequest, readHostMessage, isGenerateResult, isFileListResult, isFileReadResult, isFileStatResult, isJsonValue, isFileWriteResult, isServiceStatusResult, isGuestRequestResult, isPromptResult, isStartSessionResult, } from "./contract.js";
3
+ import { GUEST_FILE_CONTENT_MAX, GUEST_CLIPBOARD_TEXT_MAX, GUEST_TOAST_MAX, GUEST_FILE_PATH_MAX, GUEST_GENERATE_OUTPUT_TOKENS_MAX, GUEST_GENERATE_PROMPT_MAX, GUEST_GENERATE_SYSTEM_MAX, GUEST_GENERATE_TIMEOUT_MS, GUEST_REQUEST_TIMEOUT_MS, GUEST_RESOLVE_ERROR_MAX, isGuestFilePath, isGuestRequestPath, clampAttachRequest, clampBadgeCount, clampPromptRequest, clampStartSessionRequest, readHostMessage, isGenerateResult, isFileListResult, isFileReadResult, isFileStatResult, isJsonValue, isFileWriteResult, isServiceStatusResult, isGuestRequestResult, isPromptResult, isStartSessionResult, } from "./contract.js";
4
4
  export class HostRequestError extends Error {
5
5
  code;
6
6
  constructor(code, message) {
@@ -32,6 +32,7 @@ export const connectHost = (options = {}) => {
32
32
  const settingsListeners = new Set();
33
33
  const itemListeners = new Set();
34
34
  let resolveHandler = null;
35
+ let actionHandler = null;
35
36
  const pending = new Map();
36
37
  const workspaceListeners = new Map();
37
38
  let disposed = false;
@@ -135,6 +136,23 @@ export const connectHost = (options = {}) => {
135
136
  emit(itemListeners, message.payload.item);
136
137
  return;
137
138
  }
139
+ if (message.type === 'action') {
140
+ const answer = (payload) => {
141
+ if (!disposed)
142
+ post({ channel: OPENCHAMBER_SDK_CHANNEL, v: OPENCHAMBER_SDK_API_VERSION,
143
+ type: 'action-result', id: message.id, payload });
144
+ };
145
+ const handler = actionHandler;
146
+ if (!handler) {
147
+ answer({ ok: false, error: 'This extension does not handle background actions.' });
148
+ return;
149
+ }
150
+ Promise.resolve().then(() => handler(message.payload)).then(() => answer({ ok: true }), (error) => {
151
+ const text = (error instanceof Error ? error.message : String(error)).trim();
152
+ answer({ ok: false, error: (text || 'Action failed.').slice(0, GUEST_RESOLVE_ERROR_MAX) });
153
+ });
154
+ return;
155
+ }
138
156
  if (message.type === 'resolve') {
139
157
  const answer = (payload) => {
140
158
  post({
@@ -239,6 +257,11 @@ export const connectHost = (options = {}) => {
239
257
  return result;
240
258
  };
241
259
  return {
260
+ onAction: (handler) => {
261
+ actionHandler = handler;
262
+ return () => { if (actionHandler === handler)
263
+ actionHandler = null; };
264
+ },
242
265
  listProjects: async () => {
243
266
  const result = await readWorkspace({ kind: 'projects' });
244
267
  if (result.kind !== 'projects')
@@ -344,13 +367,22 @@ export const connectHost = (options = {}) => {
344
367
  resolveHandler = null;
345
368
  };
346
369
  },
347
- toast: (payload) => request({
348
- channel: OPENCHAMBER_SDK_CHANNEL,
349
- v: OPENCHAMBER_SDK_API_VERSION,
350
- type: 'toast',
351
- id: nextId(ids),
352
- payload,
353
- }),
370
+ toast: (payload) => {
371
+ const message = payload.message.trim();
372
+ if (!message || message.length > GUEST_TOAST_MAX) {
373
+ return Promise.reject(new HostRequestError('HOST_REJECTED', `Toast message must contain 1 to ${GUEST_TOAST_MAX} characters.`));
374
+ }
375
+ if (payload.copy && payload.copy !== true && (!payload.copy.text.length || payload.copy.text.length > GUEST_CLIPBOARD_TEXT_MAX)) {
376
+ return Promise.reject(new HostRequestError('HOST_REJECTED', `Toast copy text must contain 1 to ${GUEST_CLIPBOARD_TEXT_MAX} characters.`));
377
+ }
378
+ return request({
379
+ channel: OPENCHAMBER_SDK_CHANNEL,
380
+ v: OPENCHAMBER_SDK_API_VERSION,
381
+ type: 'toast',
382
+ id: nextId(ids),
383
+ payload: { ...payload, message },
384
+ });
385
+ },
354
386
  openUrl: (url) => request({
355
387
  channel: OPENCHAMBER_SDK_CHANNEL,
356
388
  v: OPENCHAMBER_SDK_API_VERSION,
@@ -588,6 +620,7 @@ export const connectHost = (options = {}) => {
588
620
  workspaceListeners.clear();
589
621
  disposed = true;
590
622
  resolveHandler = null;
623
+ actionHandler = null;
591
624
  target.removeEventListener('message', onMessage);
592
625
  for (const waiter of pending.values()) {
593
626
  clearTimeout(waiter.timer);
package/dist/index.d.ts CHANGED
@@ -5,9 +5,9 @@ export { GUEST_STORAGE_KEY_MAX, GUEST_STORAGE_KEYS_MAX, GUEST_STORAGE_VALUE_BYTE
5
5
  export type { OpenChamberManifestApiVersion } from './api-version.ts';
6
6
  export { compareOpenChamberVersions, hostMeetsOpenChamberEngine, openChamberEngineMinimum, OPENCHAMBER_ENGINE_PATTERN, parseOpenChamberVersion, } from './host-version.ts';
7
7
  export { DECLARED_GUEST_CAPABILITIES, GUEST_ACTIONS_MAX, GUEST_ACTION_LABEL_MAX, GUEST_CAPABILITIES, GUEST_COMMANDS_MAX, GUEST_COMMAND_DESCRIPTION_MAX, GUEST_COMMAND_NAME, GUEST_TOOLS_MAX, GUEST_TOOL_COLUMNS_MAX, GUEST_TOOL_COLUMN_MAX, GUEST_TOOL_LANGUAGE_MAX, GUEST_TOOL_MATCH, GUEST_TOOL_MATCH_MAX, GUEST_TOOL_NAME_MAX, GUEST_TOOL_OUTPUTS, GUEST_TOOL_TEMPLATE_MAX, GUEST_TOOL_TEMPLATE_VALUE_MAX, guestActionsNeedConversation, GUEST_FILESYSTEM_PATTERN_MAX, GUEST_FILESYSTEM_PATTERNS_MAX, HOST_LINEAR_API_ORIGIN, isGuestFilesystemPattern, hasGuestCapability, hasGuestPage, isGuestApproved, isGuestPackageSvgIcon, requestedGuestCapabilities, resolveAttachEntry, resolvePageEntry, resolveAttachMode, resolveIntegrationApi, resolveIntegrationAuth, toPublicService, toPublicIntegration, } from './manifest.ts';
8
- export type { ServiceContribution, ServicePermissions, GuestActionContribution, GuestActionPayload, GuestActionRole, GuestActionWhere, GuestCommandContribution, GuestToolContribution, GuestToolOutput, AttachContribution, AttachContributionObject, AttachMode, DeclaredGuestCapability, GuestAuthorization, GuestCapability, IntegrationAuth, IntegrationContribution, IntegrationHost, IntegrationHostProvider, IntegrationOAuth, IntegrationOAuthAccount, IntegrationSettingField, IntegrationToken, IntegrationTokenScheme, OpenChamberContributes, OpenChamberEngines, OpenChamberManifest, PanelContribution, PageContribution, ParseManifestErrorCode, ParseManifestFailure, ParseManifestResult, ParseManifestSuccess, PublicService, PublicServicePermissions, PublicGuestCapabilities, PublicIntegration, PublicIntegrationToken, PublicSocketBinding, ResolvedGuestApi, SocketBinding, SocketPlatform, } from './manifest.ts';
8
+ export type { ServiceContribution, ServicePermissions, GuestActionContribution, GuestActionPayload, GuestActionRole, GuestActionWhere, GuestCommandContribution, GuestToolContribution, GuestToolOutput, AttachContribution, AttachContributionObject, AttachMode, DeclaredGuestCapability, GuestAuthorization, GuestCapability, IntegrationAuth, IntegrationContribution, IntegrationHost, IntegrationHostProvider, IntegrationOAuth, IntegrationOAuthAccount, IntegrationSettingField, IntegrationToken, IntegrationTokenScheme, OpenChamberContributes, OpenChamberEngines, OpenChamberManifest, PanelContribution, BackgroundContribution, PageContribution, ParseManifestErrorCode, ParseManifestFailure, ParseManifestResult, ParseManifestSuccess, PublicService, PublicServicePermissions, PublicGuestCapabilities, PublicIntegration, PublicIntegrationToken, PublicSocketBinding, ResolvedGuestApi, SocketBinding, SocketPlatform, } from './manifest.ts';
9
9
  export { connectHost, HostRequestError } from './host.ts';
10
10
  export type { HostClient, HostClientOptions, HostFrame } from './host.ts';
11
11
  export { clampAttachRequest, clampBadgeCount, clampPromptRequest, clampStartSessionRequest, guestFileScope, isAttachData, isJsonValue, isFileListResult, isFileReadResult, isFileStatResult, isGenerateResult, isFileWriteResult, isGuestFilePath, isGuestAttachItem, isGuestMessageItem, isGuestSessionItem, isServiceStatusResult, isGuestRequestPath, isGuestRequestResult, isHostRequestErrorCode, isPromptResult, isStartSessionResult, readHostMessage, resolveHostRequestErrorCode, } from './contract.ts';
12
- export type { BadgeRequest, GuestBadgeMessage, GuestItem, GuestItemRole, GuestMessageItem, GuestResolveResultMessage, GuestSessionItem, GuestSessionItemMessage, HostResolveMessage, ResolveRequest, ResolveResultPayload, FileListEntry, FileListRequest, FileListResult, FileReadRequest, FileReadResult, FileStatRequest, FileStatResult, FileWriteRequest, FileWriteResult, GenerateRequest, GenerateResult, GuestFileEntryKind, GuestFileListMessage, GuestFileReadMessage, GuestFileScope, GuestFileStatKind, GuestFileStatMessage, GuestGenerateMessage, GuestFileWriteMessage, ServiceStatus, ServiceStatusResult, HostRequestErrorCode, AttachBranches, AttachIssueRequest, AttachThreadKind, JsonValue, HostResultPayload, PromptRequest, PromptResult, SessionLifecycleEvent, SessionLifecyclePhase, StartSessionRequest, StartSessionResult, StartSessionSent, ComposeRequest, GuestServiceRequestMessage, GuestServiceStatusMessage, GuestAttachMessage, GuestClipboardWriteMessage, GuestCloseMessage, GuestComposeMessage, GuestConnection, GuestHelloMessage, GuestMessage, GuestOauthDisconnectMessage, GuestOauthStartMessage, GuestOpenSurfaceMessage, GuestOpenUrlMessage, GuestRequest, GuestRequestMessage, GuestRequestMethod, GuestPromptMessage, GuestSessionLinkMessage, GuestStartSessionMessage, GuestRequestResult, GuestSettings, GuestToastMessage, GuestHostSurface, HostConnectionMessage, HostDirectoryMessage, HostItemMessage, HostMessage, HostReadyContext, HostReadyMessage, HostResultMessage, HostSessionLifecycleMessage, HostSessionMessage, HostSettingsMessage, HostTheme, HostThemeMode, HostThemeTokens, SessionSnapshot, ToastKind, ToastRequest, } from './contract.ts';
12
+ export type { BadgeRequest, ActionResultPayload, GuestActionItem, GuestActionResultMessage, HostActionMessage, GuestBadgeMessage, GuestItem, GuestItemRole, GuestMessageItem, GuestResolveResultMessage, GuestSessionItem, GuestSessionItemMessage, HostResolveMessage, ResolveRequest, ResolveResultPayload, FileListEntry, FileListRequest, FileListResult, FileReadRequest, FileReadResult, FileStatRequest, FileStatResult, FileWriteRequest, FileWriteResult, GenerateRequest, GenerateResult, GuestFileEntryKind, GuestFileListMessage, GuestFileReadMessage, GuestFileScope, GuestFileStatKind, GuestFileStatMessage, GuestGenerateMessage, GuestFileWriteMessage, ServiceStatus, ServiceStatusResult, HostRequestErrorCode, AttachBranches, AttachIssueRequest, AttachThreadKind, JsonValue, HostResultPayload, PromptRequest, PromptResult, SessionLifecycleEvent, SessionLifecyclePhase, StartSessionRequest, StartSessionResult, StartSessionSent, ComposeRequest, GuestServiceRequestMessage, GuestServiceStatusMessage, GuestAttachMessage, GuestClipboardWriteMessage, GuestCloseMessage, GuestComposeMessage, GuestConnection, GuestHelloMessage, GuestMessage, GuestOauthDisconnectMessage, GuestOauthStartMessage, GuestOpenSurfaceMessage, GuestOpenUrlMessage, GuestRequest, GuestRequestMessage, GuestRequestMethod, GuestPromptMessage, GuestSessionLinkMessage, GuestStartSessionMessage, GuestRequestResult, GuestSettings, GuestToastMessage, GuestHostSurface, HostConnectionMessage, HostDirectoryMessage, HostItemMessage, HostMessage, HostReadyContext, HostReadyMessage, HostResultMessage, HostSessionLifecycleMessage, HostSessionMessage, HostSettingsMessage, HostTheme, HostThemeMode, HostThemeTokens, SessionSnapshot, ToastKind, ToastRequest, } from './contract.ts';
13
13
  export { EMPTY_GUEST_CONNECTION, GUEST_BADGE_MAX, GUEST_ITEM_MESSAGE_TEXT_MAX, GUEST_ITEM_SESSION_MAX, GUEST_RESOLVE_ERROR_MAX, GUEST_FILE_CONTENT_MAX, GUEST_FILE_ENTRY_KINDS, GUEST_FILE_LIST_MAX, GUEST_FILE_PATH_MAX, GUEST_GENERATE_OUTPUT_TOKENS_MAX, GUEST_GENERATE_PROMPT_MAX, GUEST_GENERATE_SYSTEM_MAX, GUEST_GENERATE_TEXT_MAX, GUEST_GENERATE_TIMEOUT_MS, GUEST_FILE_STAT_KINDS, SERVICE_STATUS_VALUES, HOST_REQUEST_ERROR_CODES, SESSION_LIFECYCLE_PHASES, START_SESSION_SENT, GUEST_ACCOUNT_MAX, GUEST_SESSION_AGENT_MAX, GUEST_SESSION_MODEL_MAX, GUEST_ATTACH_AUTHOR_MAX, GUEST_ATTACH_BRANCH_MAX, GUEST_ATTACH_DATA_MAX, GUEST_ATTACH_ID_MAX, GUEST_ATTACH_TEXT_MAX, GUEST_ATTACH_TITLE_MAX, GUEST_ATTACH_URL_MAX, GUEST_CLIPBOARD_TEXT_MAX, GUEST_TOAST_MAX, GUEST_COMPOSE_TEXT_MAX, GUEST_REQUEST_BODY_MAX, GUEST_REQUEST_PATH_MAX, GUEST_REQUEST_RESPONSE_MAX, GUEST_REQUEST_TIMEOUT_MS, GUEST_SETTING_VALUE_MAX, } from './contract.ts';
@@ -2,8 +2,8 @@ import type { OpenChamberManifestApiVersion } from './api-version.ts';
2
2
  export declare const PANEL_ID: RegExp;
3
3
  /**
4
4
  * The extension's identity on the rail, the Extensions card, and the approval
5
- * dialog. `entry` is the panel page; without it the extension has no page and
6
- * may only declare `tools` (see `hasGuestPage`).
5
+ * dialog. `entry` is the optional visible panel page. Code can instead run
6
+ * from `background.entry` without adding a rail icon.
7
7
  */
8
8
  export type PanelContribution = {
9
9
  id: string;
@@ -11,6 +11,10 @@ export type PanelContribution = {
11
11
  icon: string;
12
12
  entry?: string;
13
13
  };
14
+ /** Sandboxed HTML loaded on demand for background actions and slash commands. */
15
+ export type BackgroundContribution = {
16
+ entry: string;
17
+ };
14
18
  export type AttachMode = 'panel' | 'dialog';
15
19
  /**
16
20
  * Object form of `contributes.attach`. `entry` is an HTML page inside the
@@ -42,7 +46,7 @@ export declare const GUEST_COMMAND_DESCRIPTION_MAX = 80;
42
46
  /** A slash command name: lower-case, digits and dashes, up to 24 characters. */
43
47
  export declare const GUEST_COMMAND_NAME: RegExp;
44
48
  /**
45
- * A menu entry on a message or a session. Clicking it opens the guest with
49
+ * A menu entry on a message or a session. By default it opens the guest with
46
50
  * the message or session as `ready.item`. `roles` narrows a message action
47
51
  * to user or assistant messages (default both); `payload: ["messages"]` on
48
52
  * a session action asks for the conversation and needs the `conversation`
@@ -54,6 +58,8 @@ export type GuestActionContribution = {
54
58
  /** Remixicon name or package `.svg` path, same as `panel.icon`. Falls back to the panel icon. */
55
59
  icon?: string;
56
60
  where: GuestActionWhere;
61
+ /** `background` calls `host.onAction` in a temporary hidden frame. Default: `open`. */
62
+ mode?: 'open' | 'background';
57
63
  roles?: GuestActionRole[];
58
64
  payload?: GuestActionPayload[];
59
65
  };
@@ -231,6 +237,7 @@ export declare const GUEST_FILESYSTEM_PATTERN_MAX = 256;
231
237
  export declare const isGuestFilesystemPattern: (value: string) => boolean;
232
238
  export type OpenChamberContributes = {
233
239
  panel: PanelContribution;
240
+ background?: BackgroundContribution;
234
241
  attach?: AttachContribution;
235
242
  page?: PageContribution;
236
243
  capabilities?: DeclaredGuestCapability[];
@@ -254,10 +261,8 @@ export type PublicGuestCapabilities = {
254
261
  };
255
262
  export declare const requestedGuestCapabilities: (contributes: Pick<OpenChamberContributes, "capabilities" | "integration" | "service" | "filesystem" | "actions">) => GuestCapability[];
256
263
  /**
257
- * Whether the package ships a panel page. A page-less package (no
258
- * `panel.entry`) never mounts an iframe: no rail surface, attach row, action,
259
- * command, service, integration, or capability; parse refuses those without
260
- * an entry. `tools` is the only contribution it may carry.
264
+ * Whether the package ships a visible panel. Background-only extensions can
265
+ * execute code but have no rail surface, attach picker, or full-screen page.
261
266
  */
262
267
  export declare const hasGuestPage: (contributes: Pick<OpenChamberContributes, "panel">) => boolean;
263
268
  export declare const isGuestApproved: (capabilities: PublicGuestCapabilities) => boolean;
@@ -277,7 +282,7 @@ export type OpenChamberManifest = {
277
282
  engines?: OpenChamberEngines;
278
283
  contributes: OpenChamberContributes;
279
284
  };
280
- export type ParseManifestErrorCode = 'not-object' | 'missing-openchamber' | 'unsupported-api-version' | 'invalid-engines' | 'invalid-version' | 'missing-panel' | 'invalid-panel' | 'invalid-panel-id' | 'invalid-panel-name' | 'invalid-panel-icon' | 'invalid-panel-entry' | 'invalid-attach' | 'invalid-page' | 'invalid-capabilities' | 'invalid-integration' | 'invalid-service' | 'invalid-filesystem' | 'invalid-actions' | 'invalid-commands' | 'invalid-tools';
285
+ export type ParseManifestErrorCode = 'not-object' | 'missing-openchamber' | 'unsupported-api-version' | 'invalid-engines' | 'invalid-version' | 'missing-panel' | 'invalid-panel' | 'invalid-panel-id' | 'invalid-panel-name' | 'invalid-panel-icon' | 'invalid-panel-entry' | 'invalid-background' | 'invalid-attach' | 'invalid-page' | 'invalid-capabilities' | 'invalid-integration' | 'invalid-service' | 'invalid-filesystem' | 'invalid-actions' | 'invalid-commands' | 'invalid-tools';
281
286
  export type ParseManifestFailure = {
282
287
  ok: false;
283
288
  code: ParseManifestErrorCode;
package/dist/manifest.js CHANGED
@@ -119,10 +119,8 @@ export const requestedGuestCapabilities = (contributes) => {
119
119
  return GUEST_CAPABILITIES.filter((capability) => declared.has(capability));
120
120
  };
121
121
  /**
122
- * Whether the package ships a panel page. A page-less package (no
123
- * `panel.entry`) never mounts an iframe: no rail surface, attach row, action,
124
- * command, service, integration, or capability; parse refuses those without
125
- * an entry. `tools` is the only contribution it may carry.
122
+ * Whether the package ships a visible panel. Background-only extensions can
123
+ * execute code but have no rail surface, attach picker, or full-screen page.
126
124
  */
127
125
  export const hasGuestPage = (contributes) => Boolean(contributes.panel.entry);
128
126
  export const isGuestApproved = (capabilities) => (capabilities.requested.every((capability) => capabilities.granted.includes(capability)));
package/dist/parse.d.ts CHANGED
@@ -12,6 +12,9 @@ export declare const openChamberManifestSchema: z.ZodObject<{
12
12
  icon: z.ZodString;
13
13
  entry: z.ZodOptional<z.ZodString>;
14
14
  }, z.core.$strip>;
15
+ background: z.ZodOptional<z.ZodObject<{
16
+ entry: z.ZodString;
17
+ }, z.core.$strip>>;
15
18
  attach: z.ZodOptional<z.ZodUnion<readonly [z.ZodBoolean, z.ZodEnum<{
16
19
  panel: "panel";
17
20
  dialog: "dialog";
@@ -104,6 +107,10 @@ export declare const openChamberManifestSchema: z.ZodObject<{
104
107
  message: "message";
105
108
  session: "session";
106
109
  }>;
110
+ mode: z.ZodOptional<z.ZodEnum<{
111
+ background: "background";
112
+ open: "open";
113
+ }>>;
107
114
  roles: z.ZodOptional<z.ZodArray<z.ZodEnum<{
108
115
  user: "user";
109
116
  assistant: "assistant";
@@ -150,6 +157,9 @@ export declare const packageManifestSchema: z.ZodObject<{
150
157
  icon: z.ZodString;
151
158
  entry: z.ZodOptional<z.ZodString>;
152
159
  }, z.core.$strip>;
160
+ background: z.ZodOptional<z.ZodObject<{
161
+ entry: z.ZodString;
162
+ }, z.core.$strip>>;
153
163
  attach: z.ZodOptional<z.ZodUnion<readonly [z.ZodBoolean, z.ZodEnum<{
154
164
  panel: "panel";
155
165
  dialog: "dialog";
@@ -242,6 +252,10 @@ export declare const packageManifestSchema: z.ZodObject<{
242
252
  message: "message";
243
253
  session: "session";
244
254
  }>;
255
+ mode: z.ZodOptional<z.ZodEnum<{
256
+ background: "background";
257
+ open: "open";
258
+ }>>;
245
259
  roles: z.ZodOptional<z.ZodArray<z.ZodEnum<{
246
260
  user: "user";
247
261
  assistant: "assistant";
package/dist/parse.js CHANGED
@@ -149,6 +149,7 @@ const actionSchema = z.object({
149
149
  label: z.string().trim().min(1).max(GUEST_ACTION_LABEL_MAX),
150
150
  icon: z.string().trim().refine(isPanelIcon).optional(),
151
151
  where: z.enum(['message', 'session']),
152
+ mode: z.enum(['open', 'background']).optional(),
152
153
  roles: z.array(z.enum(['user', 'assistant'])).min(1).max(2).optional(),
153
154
  payload: z.array(z.enum(['messages'])).max(1).optional(),
154
155
  }).refine((value) => value.where === 'message' || value.roles === undefined, { path: ['roles'] })
@@ -178,6 +179,9 @@ const toolSchema = z.object({
178
179
  const toolsSchema = z.array(toolSchema).min(1).max(GUEST_TOOLS_MAX);
179
180
  const contributesSchema = z.object({
180
181
  panel: panelSchema,
182
+ background: z.object({
183
+ entry: z.string().trim().refine((value) => isSafeAssetPath(value) && value.toLowerCase().endsWith('.html')),
184
+ }).optional(),
181
185
  attach: attachSchema.optional(),
182
186
  page: z.union([z.literal(true), z.object({
183
187
  entry: z.string().trim().refine(isSafeAssetPath),
@@ -192,12 +196,9 @@ const contributesSchema = z.object({
192
196
  tools: toolsSchema.optional(),
193
197
  });
194
198
  /**
195
- * Everything that only makes sense with an iframe to mount. Without
196
- * `panel.entry` there is nothing to open from the rail, the + menu, a menu
197
- * action, or a slash command, and nothing to hand a capability, a service,
198
- * an integration, or a filesystem grant to; only `tools` stays meaningful.
199
+ * Contributions that need either the panel or background execution frame.
199
200
  */
200
- const pageOnlyContributions = (contributes) => {
201
+ const runtimeContributions = (contributes) => {
201
202
  const declared = [];
202
203
  if (contributes.page !== undefined)
203
204
  declared.push('page');
@@ -225,13 +226,28 @@ export const openChamberManifestSchema = z.object({
225
226
  contributes: contributesSchema.superRefine((contributes, ctx) => {
226
227
  if (hasGuestPage(contributes))
227
228
  return;
228
- const needsPage = pageOnlyContributions(contributes);
229
- if (needsPage.length === 0)
229
+ if (contributes.background) {
230
+ const needsPanel = [];
231
+ if (contributes.page !== undefined)
232
+ needsPanel.push('page');
233
+ if (contributes.attach !== undefined && contributes.attach !== false)
234
+ needsPanel.push('attach');
235
+ if (contributes.actions?.some((action) => action.mode !== 'background'))
236
+ needsPanel.push('actions with mode "open"');
237
+ if (needsPanel.length > 0)
238
+ ctx.addIssue({
239
+ code: 'custom', path: ['panel'],
240
+ message: `${needsPanel.join(', ')} needs panel.entry; background-only actions must declare mode "background".`,
241
+ });
242
+ return;
243
+ }
244
+ const needsRuntime = runtimeContributions(contributes);
245
+ if (needsRuntime.length === 0)
230
246
  return;
231
247
  ctx.addIssue({
232
248
  code: 'custom',
233
249
  path: ['panel'],
234
- message: `${needsPage.map((key) => `contributes.${key}`).join(', ')} needs panel.entry; an extension without a page may only declare tools.`,
250
+ message: `${needsRuntime.map((key) => `contributes.${key}`).join(', ')} needs panel.entry or background.entry; an extension without either may only declare tools.`,
235
251
  });
236
252
  }),
237
253
  });
@@ -293,6 +309,9 @@ const failureFromIssue = (issue) => {
293
309
  if (path === 'contributes.page' || path.startsWith('contributes.page.')) {
294
310
  return fail('invalid-page', 'contributes.page must be true or { entry: "<package HTML>", title?: "Page title" }.');
295
311
  }
312
+ if (path === 'contributes.background' || path.startsWith('contributes.background.')) {
313
+ return fail('invalid-background', 'contributes.background needs an entry ending in .html inside the package.');
314
+ }
296
315
  if (path === 'contributes.attach' || path.startsWith('contributes.attach.')) {
297
316
  return fail('invalid-attach', 'contributes.attach must be true, false, "panel", "dialog", or { "mode": "panel" | "dialog", "entry"?: "<html inside the package, dialog only>" }.');
298
317
  }
@@ -143,6 +143,7 @@ export declare const hostMessageSchema: z.ZodUnion<readonly [z.ZodObject<{
143
143
  panel: "panel";
144
144
  dialog: "dialog";
145
145
  page: "page";
146
+ background: "background";
146
147
  }>;
147
148
  connection: z.ZodObject<{
148
149
  connected: z.ZodBoolean;
@@ -751,8 +752,53 @@ export declare const hostMessageSchema: z.ZodUnion<readonly [z.ZodObject<{
751
752
  } | {
752
753
  text: string;
753
754
  } | undefined;
754
- }>>]>;
755
+ }>>, z.ZodObject<{
756
+ type: z.ZodLiteral<"action">;
757
+ id: z.ZodString;
758
+ payload: z.ZodUnion<readonly [z.ZodObject<{
759
+ kind: z.ZodLiteral<"message">;
760
+ action: z.ZodString;
761
+ sessionId: z.ZodString;
762
+ sessionTitle: z.ZodString;
763
+ directory: z.ZodNullable<z.ZodString>;
764
+ messageId: z.ZodString;
765
+ role: z.ZodEnum<{
766
+ user: "user";
767
+ assistant: "assistant";
768
+ }>;
769
+ text: z.ZodString;
770
+ }, z.core.$strip>, z.ZodObject<{
771
+ kind: z.ZodLiteral<"session">;
772
+ action: z.ZodString;
773
+ sessionId: z.ZodString;
774
+ sessionTitle: z.ZodString;
775
+ directory: z.ZodNullable<z.ZodString>;
776
+ messages: z.ZodOptional<z.ZodArray<z.ZodObject<{
777
+ id: z.ZodString;
778
+ role: z.ZodEnum<{
779
+ user: "user";
780
+ assistant: "assistant";
781
+ }>;
782
+ text: z.ZodString;
783
+ createdAt: z.ZodNumber;
784
+ }, z.core.$strip>>>;
785
+ truncated: z.ZodOptional<z.ZodBoolean>;
786
+ }, z.core.$strip>]>;
787
+ channel: z.ZodLiteral<"openchamber.sdk">;
788
+ v: z.ZodLiteral<1>;
789
+ }, z.core.$strip>]>;
755
790
  export declare const guestMessageSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
791
+ type: z.ZodLiteral<"action-result">;
792
+ id: z.ZodString;
793
+ payload: z.ZodDiscriminatedUnion<[z.ZodObject<{
794
+ ok: z.ZodLiteral<true>;
795
+ }, z.core.$strip>, z.ZodObject<{
796
+ ok: z.ZodLiteral<false>;
797
+ error: z.ZodString;
798
+ }, z.core.$strip>], "ok">;
799
+ channel: z.ZodLiteral<"openchamber.sdk">;
800
+ v: z.ZodLiteral<1>;
801
+ }, z.core.$strip>, z.ZodObject<{
756
802
  type: z.ZodLiteral<"workspace-read">;
757
803
  id: z.ZodString;
758
804
  payload: z.ZodDiscriminatedUnion<[z.ZodObject<{
@@ -831,6 +877,11 @@ export declare const guestMessageSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
831
877
  success: "success";
832
878
  }>;
833
879
  message: z.ZodString;
880
+ copy: z.ZodOptional<z.ZodUnion<readonly [z.ZodBoolean, z.ZodObject<{
881
+ text: z.ZodString;
882
+ }, z.core.$strip>]>>;
883
+ dismiss: z.ZodOptional<z.ZodBoolean>;
884
+ persistent: z.ZodOptional<z.ZodBoolean>;
834
885
  }, z.core.$strip>;
835
886
  channel: z.ZodLiteral<"openchamber.sdk">;
836
887
  v: z.ZodLiteral<1>;
package/dist/protocol.js CHANGED
@@ -146,7 +146,7 @@ const readyPayloadSchema = z.object({
146
146
  locale: z.string().min(1),
147
147
  directory: z.string().nullable(),
148
148
  session: sessionSnapshotSchema,
149
- surface: z.enum(['panel', 'dialog', 'page']),
149
+ surface: z.enum(['panel', 'dialog', 'page', 'background']),
150
150
  connection: guestConnectionSchema,
151
151
  settings: guestSettingsSchema,
152
152
  item: guestItemSchema,
@@ -253,9 +253,24 @@ export const hostMessageSchema = z.union([
253
253
  }),
254
254
  }),
255
255
  hostResultSchema,
256
+ z.object({
257
+ ...envelope,
258
+ type: z.literal('action'),
259
+ id: z.string().min(1),
260
+ payload: z.union([messageItemSchema, sessionItemSchema]),
261
+ }),
256
262
  ]);
257
263
  const filePathSchema = z.string().min(1).max(GUEST_FILE_PATH_MAX).refine(isGuestFilePath);
258
264
  export const guestMessageSchema = z.discriminatedUnion('type', [
265
+ z.object({
266
+ ...envelope,
267
+ type: z.literal('action-result'),
268
+ id: z.string().min(1),
269
+ payload: z.discriminatedUnion('ok', [
270
+ z.object({ ok: z.literal(true) }),
271
+ z.object({ ok: z.literal(false), error: z.string().trim().min(1).max(GUEST_RESOLVE_ERROR_MAX) }),
272
+ ]),
273
+ }),
259
274
  z.object({ ...envelope, type: z.literal('workspace-read'), id: z.string().min(1), payload: guestWorkspaceQuerySchema }),
260
275
  z.object({ ...envelope, type: z.literal('workspace-subscribe'), id: z.string().min(1), payload: z.object({ subscriptionId: z.string().min(1).max(128), query: guestWorkspaceQuerySchema }) }),
261
276
  z.object({ ...envelope, type: z.literal('workspace-unsubscribe'), id: z.string().min(1), payload: z.object({ subscriptionId: z.string().min(1).max(128) }) }),
@@ -272,6 +287,9 @@ export const guestMessageSchema = z.discriminatedUnion('type', [
272
287
  payload: z.object({
273
288
  kind: z.enum(['info', 'success', 'error']),
274
289
  message: z.string().trim().min(1).max(GUEST_TOAST_MAX),
290
+ copy: z.union([z.boolean(), z.object({ text: z.string().min(1).max(GUEST_CLIPBOARD_TEXT_MAX) })]).optional(),
291
+ dismiss: z.boolean().optional(),
292
+ persistent: z.boolean().optional(),
275
293
  }),
276
294
  }),
277
295
  z.object({
package/dist/ui/menu.js CHANGED
@@ -51,8 +51,12 @@ export const mountMenu = (root, initial) => {
51
51
  setActive(actions(props.items).some((item) => item.id === activeId) ? activeId : null);
52
52
  };
53
53
  const close = () => {
54
- closePopup?.();
54
+ // Removing a focused row fires `focusout` synchronously from inside the disposer, and
55
+ // that handler calls `close` again. Clearing the slot first makes the re-entry a no-op
56
+ // instead of a second `popup.remove()` that throws before the pick reaches `onChange`.
57
+ const dispose = closePopup;
55
58
  closePopup = null;
59
+ dispose?.();
56
60
  activeId = null;
57
61
  triggerNode?.setAttribute('aria-expanded', 'false');
58
62
  };
package/dist/ui/select.js CHANGED
@@ -63,8 +63,12 @@ export const mountSelect = (root, initial) => {
63
63
  setActive(options.some((option) => option.id === activeId) ? activeId : options[0]?.id ?? null);
64
64
  };
65
65
  const close = () => {
66
- closePopup?.();
66
+ // Removing a focused row fires `focusout` synchronously from inside the disposer, and
67
+ // that handler calls `close` again. Clearing the slot first makes the re-entry a no-op
68
+ // instead of a second `popup.remove()` that throws before the pick reaches `onChange`.
69
+ const dispose = closePopup;
67
70
  closePopup = null;
71
+ dispose?.();
68
72
  query = '';
69
73
  search.value = '';
70
74
  trigger.setAttribute('aria-expanded', 'false');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openchamber/sdk",
3
- "version": "1.24.0",
3
+ "version": "1.24.1",
4
4
  "description": "Contract for OpenChamber guests: manifest parse and iframe host bridge.",
5
5
  "private": false,
6
6
  "license": "MIT",