@openchamber/sdk 2.4.0 → 2.5.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 +30 -7
- package/DOCUMENTATION.md +67 -6
- package/GUEST_SERVICES.md +131 -4
- package/dist/contract.d.ts +46 -4
- package/dist/contract.js +18 -2
- package/dist/host.d.ts +19 -1
- package/dist/host.js +30 -0
- package/dist/index.d.ts +6 -6
- package/dist/index.js +3 -3
- package/dist/manifest.d.ts +178 -4
- package/dist/manifest.js +30 -4
- package/dist/parse.d.ts +220 -0
- package/dist/parse.js +174 -2
- package/dist/protocol.d.ts +51 -0
- package/dist/protocol.js +14 -6
- package/dist/schemas.d.ts +1 -1
- package/dist/schemas.js +1 -1
- package/dist/service-provider-schemas.d.ts +511 -0
- package/dist/service-provider-schemas.js +221 -1
- package/dist/service-providers.d.ts +390 -2
- package/dist/service-providers.js +217 -0
- package/dist/ui/style.js +16 -9
- package/package.json +2 -2
package/API.md
CHANGED
|
@@ -74,6 +74,7 @@ Each returns an unsubscribe function. State subscriptions replay the last known
|
|
|
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
|
| `onStatusControl(listener)` | `{ id, value? }` | Host Work Status control activation. Register synchronously; only status frames with advertised status controls receive events |
|
|
76
76
|
| `onPopoverClosed(listener)` | `{ id, reason }` | Dismissal of this frame's anchored popover. Events go to the owner, are not replayed, and identify the opening being closed |
|
|
77
|
+
| `onLateGenerateResult(listener)` | `LateGenerateResult` | A `generate` answer that came after the frame that asked was gone (the user closed the panel, switched project, or the frame reloaded). `{ request, ok: true, result: { text } }` or `{ request, ok: false, code, error }`; `request` is what that frame sent. Each one goes to one frame of the same extension, surface and project in the same window, once. Requires `features.lateGenerateResults`; see Late generate answers below |
|
|
77
78
|
| `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 |
|
|
78
79
|
| `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 |
|
|
79
80
|
| `onFileOpen(listener)` | `FileEditorDocument` | File editors only: `{ path, name, readOnly, encoding: 'text', content }` or `{ ..., encoding: 'binary', bytes }` (a `Uint8Array`) of the file this frame edits. Replays the last file; the same file pushed again is not repeated. Registering also sends Cmd/Ctrl+S inside the frame to the host's save |
|
|
@@ -100,7 +101,7 @@ Each returns an unsubscribe function. State subscriptions replay the last known
|
|
|
100
101
|
|
|
101
102
|
`theme.tokens` includes `primaryText`, `successText`, `warningText`, `errorText`, and `infoText`. The host computes these for text on neutral surfaces and the UI kit's tinted controls. Keep using the base colors for fills and `primaryForeground` for text on a solid primary fill.
|
|
102
103
|
|
|
103
|
-
`features` is optional and only advertises implemented host operations: `deviceStorage?: true`, `statusControls?: true`, and `
|
|
104
|
+
`features` is optional and only advertises implemented host operations: `deviceStorage?: true`, `statusControls?: true`, `popovers?: true`, and `lateGenerateResults?: true`. Optional syntax tokens are `syntaxKeyword`, `syntaxString`, `syntaxNumber`, `syntaxFunction`, `syntaxType`, `syntaxComment`, `syntaxVariable`, and `syntaxOperator`. `applyHostReady` exposes each as `--oc-syntax-*` and the matching legacy `--syntax-*` name, and clears a missing token on a later snapshot. Use a semantic-token fallback in guest CSS when supporting older hosts.
|
|
104
105
|
|
|
105
106
|
`applyHostReady` exposes the computed colors as `--primary-text`, `--success-text`, `--warning-text`, `--error-text`, and `--info-text`, with matching `--oc-*-text` aliases. These are required theme fields. Apply each `onReady` snapshot to update them when the theme changes.
|
|
106
107
|
|
|
@@ -140,8 +141,8 @@ Access tokens never appear in `ready` or in request results.
|
|
|
140
141
|
| Method | Arguments | Returns | Behavior |
|
|
141
142
|
| ----------------- | --------------------------------- | ------------------------------ | ------------------------------------------------------------------------------------- |
|
|
142
143
|
| `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 |
|
|
143
|
-
| `openUrl` | `url: string` | `Promise<void>` | Open URL in the
|
|
144
|
-
| `openSurface` | `surfaceId: string` | `Promise<void>` |
|
|
144
|
+
| `openUrl` | `url: string` | `Promise<void>` | Open an http(s) URL. In the desktop app with a project open, a local address (`localhost`, `127.0.0.1`, `[::1]`) opens in the browser panel beside the chat, and other links follow the user's setting for chat links; elsewhere the system browser opens it |
|
|
145
|
+
| `openSurface` | `surfaceId: string` | `Promise<void>` | Show that surface; resolves once it is on screen, rejects `NO_DIRECTORY` or `UNSUPPORTED` when it cannot open |
|
|
145
146
|
| `writeClipboard` | `text: string` | `Promise<void>` | Copy in the host (1–32000 chars) |
|
|
146
147
|
| `compose` | `{ text, mode?: 'append' | 'replace' }` | `Promise<void>` |
|
|
147
148
|
| `attach` | `AttachIssueRequest` | `Promise<void>` | Composer chip, alongside other attached items |
|
|
@@ -165,7 +166,7 @@ Access tokens never appear in `ready` or in request results.
|
|
|
165
166
|
| `openPopover` | `GuestPopoverRequest` | `Promise<void>` | Open a sandboxed frame outside this frame's bounds, anchored to a rectangle in its viewport. Requires `features.popovers` |
|
|
166
167
|
| `closePopover` | `id, reason?: 'closed' \| 'escape'` | `Promise<void>` | Close this frame's matching opening, or the popover frame itself. A stale id cannot close a replacement |
|
|
167
168
|
| `setPopoverAnchorActive` | `id, active: boolean` | `Promise<void>` | Report anchor pointer/focus activity so the host can allow travel between the anchor and popover |
|
|
168
|
-
| `generate` | `{ prompt, system?, maxOutputTokens? }` | `Promise<{ text }>` | One-off text from the user's Small Model (capability `model`). No session, no history; the host picks the model. Waits up to
|
|
169
|
+
| `generate` | `{ prompt, system?, maxOutputTokens? }` | `Promise<{ text }>` | One-off text from the user's Small Model (capability `model`). No session, no history; the host picks the model. Waits up to 960 s |
|
|
169
170
|
| `reportFileChange` | `{ dirty, edited }` | `void` | File editors only. `dirty` against the last saved version; `edited` when the document itself changed, which holds autosave back until edits stop |
|
|
170
171
|
| `requestFileSave` | — | `void` | File editors only. Save now, as Cmd/Ctrl+S does |
|
|
171
172
|
| `reportFileUnsupported` | — | `void` | File editors only. This file cannot be opened here; the host shows its source and a notice |
|
|
@@ -288,6 +289,22 @@ The 20-second deadline covers loading and execution. At most eight background in
|
|
|
288
289
|
|
|
289
290
|
See [Actions without opening a panel](./README.md#actions-without-opening-a-panel) for a toast example.
|
|
290
291
|
|
|
292
|
+
### Late generate answers
|
|
293
|
+
|
|
294
|
+
A model can take minutes, and the user may close the panel, switch to another project, or reload the frame before `generate` answers. The request still runs to the server's deadline; there is no cancellation. When the answer arrives and the frame that asked is gone, the host keeps it. The next frame of the same extension on the same surface, in the same project and the same window, receives it through `onLateGenerateResult` as soon as it registers a listener; an answer that lands while such a frame already listens arrives at once. Each answer is handed over once. One that reaches the frame after its last listener was removed waits in the SDK for the next listener in that frame. A failed generation is kept as `{ ok: false, code, error }`, so the extension can say what happened. Hidden frames (background actions, and a panel entry the host loads only to answer a slash command) do not advertise the feature: they neither receive kept answers nor leave their own behind.
|
|
295
|
+
|
|
296
|
+
`request` is the trimmed `{ prompt, system?, maxOutputTokens? }` the frame sent; use it to tell which of your requests this answers. The extension decides what to show; the host shows nothing of its own.
|
|
297
|
+
|
|
298
|
+
The host keeps answers in memory for one hour, at most 8 per extension, the oldest dropped first. A page reload, an app restart or another window starts empty: the request belongs to the window that sent it. Disabling, removing or reinstalling the extension, or withdrawing its `model` approval, drops its kept answers. Older hosts do not advertise `features.lateGenerateResults` and never deliver them; the SDK then sends nothing. Extension frames load on web and desktop, directly or through the private relay; VS Code and mobile do not load extensions.
|
|
299
|
+
|
|
300
|
+
```ts
|
|
301
|
+
host.onLateGenerateResult((late) => {
|
|
302
|
+
if (late.request.system !== SUMMARY_SYSTEM) return;
|
|
303
|
+
if (late.ok) showSummary(late.result.text);
|
|
304
|
+
else showError(late.error);
|
|
305
|
+
});
|
|
306
|
+
```
|
|
307
|
+
|
|
291
308
|
### Anchored popovers
|
|
292
309
|
|
|
293
310
|
`openPopover` accepts `{ id, anchor: { x, y, width, height }, width, height, side?, focus?, data }`. The anchor uses CSS pixels relative to the calling iframe's viewport. Width is 160..640 pixels, height is 48..480 pixels, and `data` is JSON limited to 16,000 serialized characters. IDs contain 1..80 ASCII letters, digits, underscores or hyphens. Use a new id for each opening. `side` is `left`, `right`, `top` or `bottom`, defaulting to `left`; the host flips or shifts the card to fit the viewport. `focus` defaults to false, so pointer previews do not take keyboard focus.
|
|
@@ -358,7 +375,9 @@ Use `mountPopoverAnchor` from `@openchamber/sdk/ui` for DOM anchors. It measures
|
|
|
358
375
|
| `generate` prompt / system | 64 000 / 8 000 |
|
|
359
376
|
| `generate` `maxOutputTokens` | 4 000 |
|
|
360
377
|
| `generate` answer | 256 000 |
|
|
361
|
-
| `generate` timeout |
|
|
378
|
+
| `generate` timeout | 960 000 ms |
|
|
379
|
+
| Late `generate` answers kept per extension | 8 (the oldest goes first) |
|
|
380
|
+
| Late `generate` answer kept for | 3 600 000 ms |
|
|
362
381
|
|
|
363
382
|
|
|
364
383
|
---
|
|
@@ -500,7 +519,8 @@ Used by the OpenChamber host and by tools that validate packages. Guests rarely
|
|
|
500
519
|
| `fileEditors` | Optional, 1–8 editors: unique kebab-case `id`, `title` 1–60 chars, `match` 1–16 file-name patterns (no `/`, not only wildcards), `entry` package `.html` with built scripts, optional `content` `"text"` (default) or `"binary"` (`invalid-file-editors`). See File editors above |
|
|
501
520
|
| `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 |
|
|
502
521
|
| `integration` | Optional. Exactly one of `oauth`, `token`, or `host` (`provider: "linear"` only) |
|
|
503
|
-
| `service` | Optional. `entry` must be a built `.js` file on disk. `provides: ["browser"]` makes it the agent's browser when the user selects it; `surface: true` gives it a host-drawn live panel the user can take over (no `panel.entry` then). Neither needs a panel or background entry. See [GUEST_SERVICES.md](https://github.com/openchamber/openchamber/blob/main/packages/sdk/GUEST_SERVICES.md) |
|
|
522
|
+
| `service` | Optional. `entry` must be a built `.js` file on disk. `provides: ["browser"]` makes it the agent's browser when the user selects it; `provides: ["source-control"]` makes it a Git host and needs `sourceControl`; `surface: true` gives it a host-drawn live panel the user can take over (no `panel.entry` then). Neither needs a panel or background entry. See [GUEST_SERVICES.md](https://github.com/openchamber/openchamber/blob/main/packages/sdk/GUEST_SERVICES.md) |
|
|
523
|
+
| `sourceControl` | Required with `provides: ["source-control"]`, refused without it. The Git host the service implements: `wire: 1`, `label`, `terminology`, `referencePrefix`, `repositoryPath`, `instances`, `capabilities`, `token`, `transport`, `limits`, `trackedItems`, `presentation`. Strict; URL templates use only `{owner}`, `{namespace+}`, `{repo}`, `{number}`. `panel.id` is at most 28 characters (the host id is `ext-<panel.id>`). Change-based hosts may add the `change` term, `capabilities.reviewModel` and `capabilities.revisions`, the `submit` merge method (alone), `presentation.features.submitRequirements`, and `token.scheme: "basic"` with `token.username: true`; older hosts refuse these, so set an `engines.openchamber` floor. Errors are `invalid-service`. See [GUEST_SERVICES.md](https://github.com/openchamber/openchamber/blob/main/packages/sdk/GUEST_SERVICES.md#source-control-provider-provides-source-control) |
|
|
504
524
|
|
|
505
525
|
|
|
506
526
|
Extra keys are dropped, not forwarded.
|
|
@@ -516,7 +536,8 @@ Extra keys are dropped, not forwarded.
|
|
|
516
536
|
| `resolveAttachEntry(contributes)` | Dialog page from the object form, or `null` when the dialog reuses `panel.entry` |
|
|
517
537
|
| `hasGuestPage(contributes)` | `true` when `panel.entry` is set; background-only and tools-only packages return `false` |
|
|
518
538
|
| `resolveIntegrationAuth` / `resolveIntegrationApi` | Auth kind and API origin |
|
|
519
|
-
| `toPublicIntegration` / `toPublicService`
|
|
539
|
+
| `toPublicIntegration` / `toPublicService` / `toPublicSourceControl` | Catalog-safe public slices |
|
|
540
|
+
| `sourceControlProviderIdOf(guestId)` | The provider id a Git host extension gets (`ext-<guestId>`) |
|
|
520
541
|
| `isGuestPackageSvgIcon` | Whether icon is a package SVG path |
|
|
521
542
|
| `compareOpenChamberVersions` | Semver compare |
|
|
522
543
|
| `hostMeetsOpenChamberEngine` | Host vs `engines.openchamber` floor |
|
|
@@ -562,6 +583,8 @@ Manifest sketch: `service.entry` (path to **built** JS, e.g. `service/main.js`),
|
|
|
562
583
|
|
|
563
584
|
A service with `provides: ["browser"]` answers the agent's `browser.*` actions at `POST /browser-control` instead of the desktop app's browser panel, once the user selects it in Settings → OpenChamber Tools. The host starts it on the first action and stops it when idle. Parse the body with `readBrowserProviderRequest`; request and answer types (`BrowserProviderRequest`, `BrowserProviderResult`, per-action `Browser*Parameters` / `Browser*Data`) and the limits (`BROWSER_PROVIDER_*`) are exported from `@openchamber/sdk`. Full contract in [GUEST_SERVICES.md](https://github.com/openchamber/openchamber/blob/main/packages/sdk/GUEST_SERVICES.md#browser-provider-provides-browser).
|
|
564
585
|
|
|
586
|
+
A service with `provides: ["source-control"]` is a Git host next to GitHub, GitLab, Gitea and Gerrit, described by `contributes.sourceControl`. OpenChamber keeps the accounts and tokens and forwards reads and mutation steps to `POST /source-control` (`SOURCE_CONTROL_PROVIDER_PATH`); parse the body with `readSourceControlProviderRequest` and answer `{ ok, data }` or `{ ok: false, error }` (`SourceControlProviderResult`). The service reaches the forge only through `createSourceControlForge({ env: process.env }).request(handle, request)`, which goes through OpenChamber's outbound proxy (`OPENCHAMBER_HOST_CALLBACK_URL`); OpenChamber adds the token and keeps the request on the instance. Methods (`SOURCE_CONTROL_PROVIDER_METHODS`), inputs (`SourceControlProviderInputs`), deadlines (`sourceControlProviderTimeoutMs`) and caps (`SOURCE_CONTROL_*`) are exported from `@openchamber/sdk`; the host's answer schemas are `sourceControlProviderSchemasV1` in `@openchamber/sdk/schemas`. Full contract in [GUEST_SERVICES.md](https://github.com/openchamber/openchamber/blob/main/packages/sdk/GUEST_SERVICES.md#source-control-provider-provides-source-control); reference: `examples/source-control-provider`.
|
|
587
|
+
|
|
565
588
|
A service with `surface: true` shows a live picture in the extension's rail panel: the host pulls frames from `GET /surface/frame`, draws them, sends the user's input to `POST /surface/input`, and owns who is in control (nobody, the agent, the user). Paths, event types, and the `readSurface*` parsers are exported from `@openchamber/sdk`; contract in [GUEST_SERVICES.md](https://github.com/openchamber/openchamber/blob/main/packages/sdk/GUEST_SERVICES.md#shared-surface-surface-true).
|
|
566
589
|
|
|
567
590
|
Bundle the service with the Node target:
|
package/DOCUMENTATION.md
CHANGED
|
@@ -17,7 +17,7 @@ Public author pages live in `packages/docs/content/docs/sdk.mdx`, `sdk/host.mdx`
|
|
|
17
17
|
- `dist/`: the published output (`bun run --cwd packages/sdk build`, `tsc -p tsconfig.build.json`). `package.json` `exports` point here so Node loads the SDK from `node_modules` (packaged Electron, `openchamber serve`). Inside the monorepo, Vite aliases and tsconfig `paths` resolve `@openchamber/sdk` to `src/` so builds and type-checks never depend on a stale `dist`. The server under Bun and Node uses `dist`; the root `postinstall` builds it. The package ships to npm from the release workflow with `bun publish`, right before `@openchamber/web`; `scripts/bump-version.mjs` bumps it with the app, so the SDK version always equals the OpenChamber version it was released with.
|
|
18
18
|
- `src/file-editor.ts`: the `contributes.fileEditors` contract: limits, file-name pattern check and match, the document/snapshot/change types, and `createFileSaveTracker`. Zod-free, so a guest editor bundles it.
|
|
19
19
|
- `GUEST_SERVICES.md`: local service contract (`contributes.service`, `serviceRequest`, grant, loopback).
|
|
20
|
-
- `src/service-providers.ts`: contracts a service fulfils for a host role (`contributes.service.provides`).
|
|
20
|
+
- `src/service-providers.ts`: contracts a service fulfils for a host role (`contributes.service.provides`). `source-control` is described under Source-control role below. `browser`: the twelve `browser.*` actions, their parameter and answer types, the limits, `BrowserProviderContext` (the calling project and chat, filled by the host from the tool call; `null` fields mean unknown), and `readBrowserProviderRequest` (a zod-free boundary parse for the service side). `src/service-provider-schemas.ts` is the host's zod parse of the answer, exported from `@openchamber/sdk/schemas`. A providing service needs no panel or background entry (`runtimeContributions` in `parse.ts`). The host side is `packages/web/server/lib/browser-control/provider.js`.
|
|
21
21
|
- `src/service-surface.ts`: the shared-surface contract (`contributes.service.surface`): loopback paths and headers, input event types, limits, and the zod-free `readSurfaceInputBatch` / `readSurfaceControlNotice` / `readSurfaceResizeRequest` for the service side. `src/service-surface-schemas.ts` (host entry) holds the viewer↔host socket messages (`surfaceViewerMessageSchema`, `surfaceHostMessageSchema`) shared by `packages/ui/src/lib/guests/surface-client.ts` and `packages/web/server/lib/guests/surface.js`, plus the service answer schemas. A surface service needs no entry of its own; with `panel.entry` that page is docked to one edge of the picture, `panel.dock` (`GUEST_SURFACE_DOCKS`) and `panel.size` px (`GUEST_SURFACE_DOCK_SIZE_*`), only valid with both. The rail panel is `packages/ui/src/components/layout/GuestSurfacePane.tsx` (host-drawn canvas, not an iframe); `ContextPanel` lays the page iframe (kept mounted) beside the pane (mounted only while shown) per `DOCK_LAYOUT`, and `lib/guests/surfaces.ts#guestSurfaceDocking` decides. Viewer identity is host-set: input carries `x-surface-viewer` and `x-surface-frame-seq` (the service's own number of the frame that viewer last drew; a 409 refuses it as stale), control notices carry `viewer`, and page `serviceRequest`s from a window with a live viewer (`packages/ui/src/lib/guests/surface-viewers.ts`) also carry `x-surface-viewer-controls`.
|
|
22
22
|
- `src/ui/`: guest drawing kit. Building blocks only (`mountButton`, `mountTextField`, `mountSearchField`, `mountSelect`, `mountCheckbox`, `mountSwitch`, `mountTabs`, `mountBadge`, `mountList`, `mountEmpty`, `mountSpinner`, `mountBanner`, `mountSeparator`, `mountProgress`, `mountMenu`, `mountText`); the guest composes screens. See `src/ui/DOCUMENTATION.md`. Import `@openchamber/sdk/ui`. Do not import this from `packages/ui`. Kit changes follow package semver, not `apiVersion`.
|
|
23
23
|
|
|
@@ -28,6 +28,7 @@ Public author pages live in `packages/docs/content/docs/sdk.mdx`, `sdk/host.mdx`
|
|
|
28
28
|
- 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`.
|
|
29
29
|
|
|
30
30
|
- `contributes.statusSection` is `true` or `{ entry, title?, height?, defaultExpanded?, requiresProject? }` (`StatusSectionContribution`). `true` reuses `panel.entry` and fails as `invalid-status-section` without one; the object form's `entry` is package-local `.html`, `title` 1 to `GUEST_STATUS_SECTION_TITLE_MAX` (60), `height` `GUEST_STATUS_SECTION_HEIGHT_MIN`..`MAX` (24..320). `defaultExpanded` defaults to true and only applies when the user has no saved expansion choice. `requiresProject` defaults to false; when true, the actual Work Status directory controls both presence and body rendering. `resolveStatusSectionEntry` resolves shorthand. The object form is a frame of its own: without `panel.entry` or `background.entry` it permits `capabilities`, `service`, `integration`, and `filesystem`, and refuses `page`, `attach`, `actions`, and `commands` as `invalid-panel`, because those are opened or invoked elsewhere. The server validates the HTML/scripts, exposes `statusEntry`/`statusTitle`/`statusHeight`, and counts `statusEntry` as a runtime entry for serving and storage. The host mounts `PluginPane surface="status"` inside the Work Status panel (`components/chat/work-status/WorkStatusExtensionSection.tsx`, see that folder's `DOCUMENTATION.md`): the same sandbox, guest-scoped token, context, grants, approval, and pause gates as a panel. Guest `resize` (`host.setHeight`, `ResizeRequest { height }`, integer 0..`GUEST_FRAME_HEIGHT_MAX`) is a request answered with an empty result on every surface; only `status` sizes its frame from it, through `clampStatusSectionHeight`, and a page docked to a shared surface sizes its dock from it (`DockedGuestPage` in `ContextPanel`: thickness across the dock edge, at least `GUEST_SURFACE_DOCK_SIZE_MIN`, at most half the panel through CSS). Each frame's `setHeight` reaches only its own pane. A status frame never registers a command resolver, clears a badge, or closes rail tabs. Host-rendered controls exist only after a mounted, expanded status frame calls `setStatusControls`; folding, hiding, or retiring the frame removes them. A project change clears header controls; publish them again from `onDirectory`. Workspace subscriptions remain active.
|
|
31
|
+
- `openSurface(surfaceId)` posts `open-surface { surfaceId }`; an id that is not a context panel surface is `HOST_REJECTED`. The host (`lib/guests/open-surface.ts`) opens it the way its rail icon does, under the frame's directory, or under the directory the rail shows when the frame has none (a Work Status section in a chat draft without a project has none of its own). The result follows what the panel shows afterwards: a surface already on screen stays open where the icon would toggle it closed; one that did not open (`chat`, which needs a session beside it) is `UNSUPPORTED`; no directory yet is `NO_DIRECTORY`. VS Code and mobile answer `UNSUPPORTED` explicitly. A resolved call means the surface is on screen.
|
|
31
32
|
- `openCommit(sha)` posts `open-commit { sha }`. `sha` is 7 to 64 hex characters (`GUEST_COMMIT_SHA`); the client refuses anything else as `HOST_REJECTED` before posting and the host schema drops it. The host (`lib/guests/open-commit.ts`) reads the commit itself with `git.getGitLog(directory, { maxCount: 1, to: sha })` in the frame's current directory, checks the returned hash starts with the requested one (`NOT_FOUND` otherwise), selects it in `useCommitSelectionStore` under the Diff view's key (directory, current branch, git runtime key), and opens the context panel's Diff tab in `commit` scope. No project is `NO_DIRECTORY`. VS Code and mobile answer `UNSUPPORTED` explicitly; they load no guests today, so this is the contract for when they do. No capability is needed: it only shows the user their own history.
|
|
32
33
|
- Scrollbars in guest documents follow the app's overlay behavior: hidden at rest, shown on hover and for one second after the element last scrolled. `GUEST_SCROLLBAR_CSS` does the hiding and hover; `installGuestScrollbarActivity` (one capturing passive `scroll` listener per document) marks the scrolling element with `data-oc-scrolling`. The server appends both to every served guest HTML as `GUEST_SCROLLBAR_SCRIPT` (the function's own source), so already installed bundles get it without a rebuild; the UI kit installs it too for previews. An authored CSP that blocks inline scripts keeps the hover-only behavior. Forced-colors mode keeps system colors.
|
|
33
34
|
- The UI kit's look follows the running app; its geometry follows the bundle. `src/ui/style.ts` splits the kit CSS into sizes (display, dimensions, spacing, type scale), which ship in each bundle, and `GUEST_UI_LOOK_CSS` (colours, fonts, borders, radii, shadows, hover/pressed/selected/focus), exported from `@openchamber/sdk/ui`. The server appends the look to the same `data-openchamber-guest-styles` element after the authored document, so it follows the kit's own `<style>` in the head and wins at equal specificity: an installed extension takes a restyle without a rebuild, while a size change reaches it only when its author rebuilds. Look selectors keep the specificity of the rules they override in older bundles, and `style.test.ts` fails when a geometry property enters the look. Rules for markup an older bundle lacks are keyed on what newer markup adds (a select option carries `data-mark` with its check mark; an option without it keeps the selection fill). Authors override kit rules with a more specific selector; an equal-specificity override in the head loses to the host's look.
|
|
@@ -53,7 +54,7 @@ Public author pages live in `packages/docs/content/docs/sdk.mdx`, `sdk/host.mdx`
|
|
|
53
54
|
- Host → guest `resolve` is `{ id, payload: { command, args } }`; the guest answers `resolve-result` with the same `id` and `{ item }` (a clamped `AttachIssueRequest` or `null`) or `{ error }` (1 to `GUEST_RESOLVE_ERROR_MAX` characters). The host waits `GUEST_REQUEST_TIMEOUT_MS`, then treats silence as a failure and never sends a `result` for a `resolve-result`. `badge` is `{ count }` with `count` an integer 0..999 or `null`; the host schema drops anything else.
|
|
54
55
|
- `ready` always carries `session` (`{ id, title, busy }` or `null`), `surface` (`panel` on the rail, `dialog` in the attach window, `page` full screen, `background` hidden, `status` in the Work Status panel, `file` as a Files view editor), `connection` (`{ connected, account }`), and `settings` (declared keys only). Title falls back to `id` when the host record has no title. `busy` is live `session_status` `busy` or `retry`. Missing status is `busy: false`, not an empty success that clears a previous snapshot. `model` is `providerID/id` when the host session record has both. `agent` is the session agent name when present. `surface` is which host chrome mounted the iframe. It is not `openSurface`. `item` is the attached item the surface was opened for (the user clicked that item's chip on the composer; the same `AttachIssueRequest` shape the guest handed to `attach`, with `text` filled from the chip's context) or `null` from the rail icon and the + menu. The host also pushes `item` when it changes while the frame is up; `onItem` replays the latest value. `ready.theme.tokens` is the host colour bag: surfaces (`background`, `elevated`, `mutedSurface`, `subtle`), text (`foreground`, `elevatedForeground`, `muted`), interaction (`border`, `hover`, `active`, `selection`, `selectionForeground`, `focus`), `primary` / `primaryForeground`, the four status colours, `font`, `mono`, and `radius`. `background` is the page the frame sits on, not a fixed theme slot: a frame in a docked context or Work Status panel gets the panel's colour (the theme's muted surface in dark themes, halfway between it and the chat colour in light ones) and `mutedSurface` swaps to the chat colour so soft blocks stay a step off the page (`usePageColors` in `packages/ui`). `applyHostReady` writes it onto the iframe. The host also pushes `connection` and `settings` the same way as `session`. Access tokens never appear in `ready` or a result.
|
|
55
56
|
- `writeClipboard` text is 1–32000 characters. `compose` text is 1–16000 after trim. Compose default mode is `append`. Compose writes the chat composer. It does not send.
|
|
56
|
-
- `contributes.capabilities` is a list of `prompt`, `sessions`, `files`, `model`, and/or `shells` (`DECLARED_GUEST_CAPABILITIES`). `model` gates `generate`: `{ prompt, system?, maxOutputTokens? }` → `{ text }`, posted with a `GUEST_GENERATE_TIMEOUT_MS` (
|
|
57
|
+
- `contributes.capabilities` is a list of `prompt`, `sessions`, `files`, `model`, and/or `shells` (`DECLARED_GUEST_CAPABILITIES`). `model` gates `generate`: `{ prompt, system?, maxOutputTokens? }` → `{ text }`, posted with a `GUEST_GENERATE_TIMEOUT_MS` (960s, above the Small Model's largest prompt-derived deadline) timeout instead of the usual 20s because the model answers slowly; the host answers `NO_MODEL` when the small-model service resolves nothing (or only Claude Code) and `MODEL_FAILED` when the provider call fails. `requestedGuestCapabilities` adds `conversation`, `service`, `network`, `filesystem`, and `origins` from a session action with `payload: ["messages"]`, `contributes.service`, `contributes.integration`, `contributes.filesystem`, and `contributes.origins`; the user approves that whole list once at install (`PUT /api/guests/:id/capabilities`), and a package that later asks for more is re-approved. `hasGuestCapability` gates `prompt({ send })`, `startSession`, `request`, `serviceRequest`, the four file calls, and the three shell calls; a refusal is `NOT_GRANTED` (`NO_SERVICE` for the service).
|
|
57
58
|
- `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.
|
|
58
59
|
- `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.
|
|
59
60
|
- `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.
|
|
@@ -78,17 +79,17 @@ Git installation accepts HTTPS, `ssh://` and scp-style SSH addresses. `POST /api
|
|
|
78
79
|
|
|
79
80
|
Catalog loading and installation retain typed request diagnostics in `lib/guests/request-failure.ts`: fixed API route, method, HTTP status, or a transport/invalid-response classification. Settings shows catalog diagnostics and passes install diagnostics to the toast description so Copy includes them. Response bodies, headers, credentials, and arbitrary transport error messages are excluded. Catalog failures preserve a previously loaded list; diagnostics clear on success or runtime switch, and late failures from a retired runtime are ignored.
|
|
80
81
|
|
|
81
|
-
`lib/guests/useGuestFrameUrl.ts` owns iframe URL lifetime.
|
|
82
|
+
`lib/guests/useGuestFrameUrl.ts` owns iframe URL lifetime. A direct frame loads by a grant in the path (`/api/guest-files/<grant>/…`, see `packages/web/server/lib/guests/DOCUMENTATION.md`), or by the guest-scoped query token from a server without grants. It retains the credential's expiry, renews on an expired navigation, and remints after enable/version/runtime changes. Expiry alone never reloads a healthy document. Recovery is bounded until the guest's next `hello`; mint failure leaves the existing load-failed UI rather than mounting an unauthenticated URL. Late mint responses from a retired owner are discarded. Guest capability grants and provider OAuth are separate from this host file-loading authorization.
|
|
82
83
|
|
|
83
|
-
`GET /api/guests` plus `PluginPane` on the desktop/web rail. The iframe
|
|
84
|
+
`GET /api/guests` plus `PluginPane` on the desktop/web rail. The iframe loads from `/api/guest-files/<grant>/<entry>` with a grant minted for that extension (`resolveGuestFrameUrl`); against a server without grants it falls back to a token minted for `guest:<id>` scope, valid only for `GET /api/guests/<id>/*`. The session-wide URL token never enters a guest frame. Settings → Extensions installs a folder, a local `.zip`, or an https git / zip URL into that OpenChamber instance's data dir (`extensions.json`). Settings → Integrations shows a host card for each installed guest that declared `integration`. OAuth and pasted tokens live in `guest-auth.json`. Host Linear uses `linear-auth.json`. VS Code and mobile set the catalog to `unsupported`. That is not an empty success. A guest must ship a classic IIFE (`panel/main.js`). No runtime compiles guest TypeScript. The iframe cannot load ESM.
|
|
84
85
|
|
|
85
86
|
**Folder install** stores the realpath in `extensions.json` with `source: "path"`. Uninstall drops the catalog row and extension-owned storage. The user's folder stays.
|
|
86
87
|
|
|
87
88
|
**Zip / git install** copies into `{dataDir}/extensions/{id}` with `source: "zip"` or `"git"`. Only git installs update: Settings → Extensions compares the installed `package.json` `version` with the origin's and offers Update when the origin is newer, so bump `version` to ship one; a `#tag` or `#branch` on the install URL pins that channel. The archive is not kept open. The host reads it (local file or https download, 20MB cap), extracts with a small allowlist unzip (store/deflate only; no zip64, encryption, or `..` paths; 500 files / 40MB uncompressed), unwraps a single root folder when `package.json` sits inside it, inspects the manifest, then renames staging to `{dataDir}/extensions/{id}`. Runtime serves and spawns from that copy. Uninstall deletes the copy. The original `.zip` on disk is untouched.
|
|
88
89
|
|
|
89
|
-
The iframe is `sandbox="allow-scripts"` with no `allow-same-origin`, so its origin is opaque. Host posts to that frame's `contentWindow` with target `*`. The string `null` is not a legal `postMessage` targetOrigin. Receive still accepts only `event.source === contentWindow`. Every guest document also runs under `guestFramePolicy` (`src/frame-policy.ts`): the server sends it with `sandbox allow-scripts` on each served file, the relay loader as a `<meta>`. It keeps the guest off the network: scripts, styles, images, fonts, media, workers and nested frames load only from the package (`'self'`) or `data:`/`blob:`, and `connect-src` is the guest's own `/api/guests/<id>/`
|
|
90
|
+
The iframe is `sandbox="allow-scripts"` with no `allow-same-origin`, so its origin is opaque. Host posts to that frame's `contentWindow` with target `*`. The string `null` is not a legal `postMessage` targetOrigin. Receive still accepts only `event.source === contentWindow`. Every guest document also runs under `guestFramePolicy` (`src/frame-policy.ts`): the server sends it with `sandbox allow-scripts` on each served file, the relay loader as a `<meta>`. It keeps the guest off the network: scripts, styles, images, fonts, media, workers and nested frames load only from the package (`'self'`) or `data:`/`blob:`, and `connect-src` is the guest's own package path, `/api/guest-files/<grant>/` (or `/api/guests/<id>/` on the query-token route), named by the request's `Host`, since a sandboxed document has no origin for `'self'`; `'none'` in relay. `'self'` for connections would reach the whole OpenChamber API. External data goes through the host (`request` to the declared, approved `apiOrigin`) or to `contributes.origins`: 1 to `GUEST_ORIGINS_MAX` (8) plain https origins (literal hostnames, so no `*` or other CSP syntax can widen them), granted as the `origins` capability. Its scope is the sorted list (`grant-scope.js`), so an update that adds an origin is unapproved until the dialog runs again. Approved origins join `connect-src`, `img-src`, `font-src`, `style-src` and `media-src`, never `script-src`/`worker-src`/`frame-src`: code from elsewhere could change after approval. The server reads them from the effective grants when it serves a file; relay frames get them from `PluginPane` (`approvedOrigins`, part of the frame key so an approval reloads the frame). External CDN assets not declared there do not load; bundle them. Guest files are served with `Access-Control-Allow-Origin: null`, because fonts and fetch from the opaque-origin frame are CORS requests. A guest navigating its own frame elsewhere is refused by the desktop shell (`packages/electron/guest-frame-navigation.mjs`, `will-frame-navigate`); the web runtime has no such hook. Guest `connectHost` posts `hello` to `parent` with `*`. The host answers `hello` and also pushes `ready` on iframe `load`, because a cached panel can fire `hello` while React is swapping the listener.
|
|
90
91
|
|
|
91
|
-
File calls go through `POST /api/guests/:id/files` with the `x-opencode-directory` header of the open project (`lib/guests/files.ts`). `PluginPane` answers `NOT_GRANTED` and `NO_DIRECTORY` itself, mirroring the server, so a refused call costs no round trip; the server is still the authority.
|
|
92
|
+
File calls go through `POST /api/guests/:id/files` with the `x-opencode-directory` header of the open project (`lib/guests/files.ts`). The header is handed over as plain entries so the runtime can carry a project path outside Latin-1: such a value is sent percent-encoded with `x-opencode-directory-encoding: uri` and decoded again by `resolveOptionalProjectDirectory`. `PluginPane` answers `NOT_GRANTED` and `NO_DIRECTORY` itself, mirroring the server, so a refused call costs no round trip; the server is still the authority.
|
|
92
93
|
|
|
93
94
|
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.
|
|
94
95
|
|
|
@@ -132,6 +133,66 @@ The SDK adds `HostActionMessage`, `GuestActionResultMessage`, `GuestActionItem`,
|
|
|
132
133
|
|
|
133
134
|
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.
|
|
134
135
|
|
|
136
|
+
## Source-control role
|
|
137
|
+
|
|
138
|
+
`provides: ["source-control"]` plus `contributes.sourceControl` (`SourceControlContribution`, `manifest.ts`) make an extension a Git host. The SDK owns the manifest parse, the wire (`service-providers.ts`: `SOURCE_CONTROL_PROVIDER_PATH`, `SOURCE_CONTROL_PROVIDER_METHODS`, deadlines and caps, `readSourceControlProviderRequest`, the outbound types, `createSourceControlForge`) and the host's answer schemas (`sourceControlProviderSchemasV1` in `service-provider-schemas.ts`, from `@openchamber/sdk/schemas`). The host adapter that turns the contribution into a provider descriptor, the callback listener and the outbound proxy live in `packages/web/server/lib/source-control` and `lib/guests`. The service-side contract is in `GUEST_SERVICES.md`; `examples/source-control-provider` and its fake forge are the reference, run by `scripts/source-control-example.test.ts`.
|
|
139
|
+
|
|
140
|
+
- The role and the contribution come together: either alone fails as `invalid-service`, as does a `panel.id` longer than `SOURCE_CONTROL_GUEST_ID_MAX` (28), because the host id is `ext-<panel.id>` within 32 characters. Contribution field errors are `invalid-service` too, and the message starts with the failing field (`contributes.sourceControl.instances.defaultInstances[0]`), refined values included.
|
|
141
|
+
- Every contribution object is `.strict()`. Capabilities and `presentation.features` default to `false`; review threads, uploads, stacks, the reference picker, multiple accounts, `createVia` and `branchHeads` are not declarable.
|
|
142
|
+
- The change-based fields (`terminology.changeRequest: "change"`, `capabilities.reviewModel`, `capabilities.revisions`, the `submit` merge method, `presentation.features.submitRequirements`, `token.scheme: "basic"` with `token.username`) are optional with no default, so a manifest without them parses to the same object as before and the host builds the same descriptor. Two refines hold: `submit` is the only merge method when present, and `basic` comes with `username: true` and the other way round.
|
|
143
|
+
- The status schema accepts `localRevision` and `stack`; the vote labels, submit requirements and revisions ride on the context, which stays a pass-through read.
|
|
144
|
+
- URL templates accept literal segments and the `SOURCE_CONTROL_URL_TOKENS` placeholders only, each a whole segment, with `{repo}` and `{number}` once and the owner token matching `repositoryPath`. No template can carry regex syntax, a `.` or `..` segment (a normalized URL never matches one), or appear in both `changeRequest` and `issue` (change request patterns match first, so every issue link would read as a change request).
|
|
145
|
+
- `readSourceControlProviderRequest` checks the envelope (`v`, `requestId`, `method`, `provider`, `session`, plain-object `input`), not each method's input: the host built and validated it, and the service bundle stays free of zod.
|
|
146
|
+
- The answer schemas check deeply only what the host acts on (account, probe, head, source, mutation target/result/reconcile, live summaries, status). DTO reads the UI parses itself pass as any JSON object or array within `SOURCE_CONTROL_PROVIDER_RESPONSE_MAX`. `mutationTarget` and `mutationResult` hold exactly the keys `mutation-storage.js` accepts; keep them in step.
|
|
147
|
+
- `SOURCE_CONTROL_PROVIDER_METHODS` is one literal array. Spreading sub-lists into it keeps the arrays in every panel bundle, because Bun cannot drop an array spread.
|
|
148
|
+
|
|
149
|
+
### Why the Git host is declared in the manifest and not answered by the service
|
|
150
|
+
|
|
151
|
+
Descriptors are read synchronously by bindings, the Git planner and tracked items, and listing providers must never start a third-party process. In the manifest the description is parsed at install, shown in the approval dialog, and pinned by the grant scope.
|
|
152
|
+
|
|
153
|
+
### Why OpenChamber names the provider `ext-<panel.id>` and not the extension
|
|
154
|
+
|
|
155
|
+
Guest ids are already unique and stable, and bindings, the auth section and mutation records persist the provider id. A derived id cannot collide with a built-in or another extension, and cannot change between versions.
|
|
156
|
+
|
|
157
|
+
### Why tokens stay in OpenChamber and not in the service
|
|
158
|
+
|
|
159
|
+
Git transport needs the raw secret inside OpenChamber anyway, the auth store already handles accounts, revisions and invalidation, and the outbound proxy is the one place origin pinning, enterprise policy and audit can act. The service reaches the forge through a per-call handle. This is least exposure, not a sandbox.
|
|
160
|
+
|
|
161
|
+
### Why change-based fields are additive on wire 1 and not a wire 2
|
|
162
|
+
|
|
163
|
+
The envelope and the methods are unchanged; `revision` on three reads and `votes` in a review's input are new optional fields. The host sends them only to a host whose manifest declares the matching capability, and a service built before them cannot declare it, so no service ever receives a field it would ignore and answer wrongly. A new wire would make every existing extension rebuild for nothing. The cost is that older hosts refuse a manifest that uses the fields, which `engines.openchamber` makes an install-time message.
|
|
164
|
+
|
|
165
|
+
### Why OpenChamber owns mutation kinds and digests and not the service
|
|
166
|
+
|
|
167
|
+
The mutation store's kinds are a closed list and the digest decides whether a retry is a replay. A digest an extension computed could change between its versions and turn retained records into conflicts, or make two different writes look like one. The service resolves, performs and reconciles; the host decides what a failure means from what its proxy observed.
|
|
168
|
+
|
|
169
|
+
## Late generate answers
|
|
170
|
+
|
|
171
|
+
`LateGenerateResult`, `ready.features.lateGenerateResults`, the guest notice `generate-late-listen` and the host push `generate-late` live here; `HostClient.onLateGenerateResult` sends the notice on registration and again on every `ready` while a listener exists. The UI host owns keeping: `packages/ui/src/lib/guests/generate-results.ts` holds the outcomes, `PluginPane` decides when a `generate` reply has no frame to go to and registers a listening frame.
|
|
172
|
+
|
|
173
|
+
- A `generate` reply goes to the frame that asked while the pane still owns that frame and no later document in it has said `hello` (`PluginPane` counts them). Otherwise it is kept. A reply that lands while a frame is navigating, before the new document's `hello`, still goes to the frame and is lost there.
|
|
174
|
+
- Hidden frames (slash-command resolvers, background actions) do not advertise the feature, ignore `generate-late-listen`, and keep nothing: they are no panel the user pressed a button in, and a resolver frame would otherwise take an answer meant for the visible panel.
|
|
175
|
+
- A kept outcome belongs to runtime, extension id, installation `storageId`, surface and project directory, and goes to a listening frame with the same owner, once. A frame that cannot take it leaves it kept.
|
|
176
|
+
- Bounds: `GUEST_GENERATE_LATE_KEEP_MS` (one hour), `GUEST_GENERATE_LATE_MAX` (8) per extension with the oldest dropped first, and 2 000 000 characters across all kept outcomes.
|
|
177
|
+
- A ready catalog of the same runtime that no longer has the extension active, approved for `model`, or with the same `storageId` drops its outcomes at once; a loading catalog or another runtime's catalog drops nothing.
|
|
178
|
+
- The request runs to the server's own deadline. No layer cancels it when the frame goes.
|
|
179
|
+
|
|
180
|
+
### Why keep the answer and not cancel the request
|
|
181
|
+
|
|
182
|
+
The user pressed Generate and asked for that text; closing a panel or switching project is not a request to throw it away. Cancelling end to end (issue #5040's first proposal) needs a cancel message, an abort path through the host bridge and the route, and still loses the answer. The maintainer chose delivery; the server's prompt-derived deadline stays the only limit on the work.
|
|
183
|
+
|
|
184
|
+
### Why the host keeps it in the window's memory and not on the server
|
|
185
|
+
|
|
186
|
+
The answer already comes back to the window that sent the request, and that window is where the user pressed the button, so the window can hand it to the next frame with no new route and no copy of model output on disk. A reload or another window starts empty; the request belongs to the window that sent it, and an app restart ends the request anyway. A server store would deliver to whichever client opened the panel first, a phone or another window.
|
|
187
|
+
|
|
188
|
+
### Why the owner includes surface and project
|
|
189
|
+
|
|
190
|
+
The outcome should appear where the user pressed Generate. Panel tabs are kept per project, so a panel in another project or an attach dialog of the same extension is somewhere else. A frame that follows the user across projects reads its current directory at each delivery.
|
|
191
|
+
|
|
192
|
+
### Why a listen notice and not a push to every reconnecting frame
|
|
193
|
+
|
|
194
|
+
An extension built against an older SDK has no listener, and a push it ignores would count as delivered. The frame asks only when its code registered `onLateGenerateResult`, and the host only advertises the feature it implements, so old guests and old hosts keep working unchanged.
|
|
195
|
+
|
|
135
196
|
## Not here yet
|
|
136
197
|
|
|
137
198
|
`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 `` and `[label](https://…)` and hands link clicks to `onOpenUrl`. `ready.theme.tokens` carries the host surface and interactive colors. Call `applyHostReady` before mount.
|
package/GUEST_SERVICES.md
CHANGED
|
@@ -88,7 +88,7 @@ Parse rules:
|
|
|
88
88
|
- `contributes.service` is optional. A guest without `service` is HTML-only.
|
|
89
89
|
- `service.entry` is a relative path inside the package. Ship compiled JS; the host never compiles TypeScript.
|
|
90
90
|
- `service.runtime` phase 1 accepts only `"host"`.
|
|
91
|
-
- `service.provides` is optional: roles the service stands in for on the host
|
|
91
|
+
- `service.provides` is optional: roles the service stands in for on the host, `"browser"` and `"source-control"` (below). A service that provides a role needs no `panel.entry` or `background.entry`; the host starts it itself.
|
|
92
92
|
- `service.surface` is optional (`true`): the service shows a live picture the host draws in the extension's rail panel and takes the user's input back (below). With `panel.entry` too, that page is docked to one edge of the picture (`panel.dock`: `top`, `bottom`, `left`, or `right`, default `top`; `panel.size` in CSS px across that edge, 24 to 480, default 40); without it, the picture is the whole panel.
|
|
93
93
|
- `service.permissions.sockets` and `service.permissions.exec` are shown to the user in the approval dialog. They describe intent and do not confine the process: a service runs with the user's full access. Declaring `contributes.service` adds the `service` capability to the package's request list; the user approves the whole list once when the package is installed (Settings → Extensions), and the first `serviceRequest` is refused with `NO_SERVICE` until then.
|
|
94
94
|
- The catalog adds `service.socketBindings`: `{ id, candidates, resolved, override }` for this host. The user can override a path in Extensions. Override empty clears it and the next spawn re-resolves.
|
|
@@ -132,6 +132,7 @@ Env the host sets:
|
|
|
132
132
|
- `OPENCHAMBER_SERVICE_PORT` — port to bind on `127.0.0.1`
|
|
133
133
|
- `OPENCHAMBER_SERVICE_TOKEN` — shared secret
|
|
134
134
|
- `OPENCHAMBER_SERVICE_SOCKETS` — JSON map `{ [socketId]: absolutePath }` for every binding that resolved (override or first existing candidate)
|
|
135
|
+
- `OPENCHAMBER_HOST_CALLBACK_URL` — only for a service that provides `source-control`: OpenChamber's callback listener for forge requests (see below)
|
|
135
136
|
|
|
136
137
|
Inbound auth: every request, including ready, must send:
|
|
137
138
|
|
|
@@ -172,7 +173,7 @@ Ship `service/main.js` already built. Same packaging rule as `panel/main.js`.
|
|
|
172
173
|
|
|
173
174
|
## Browser provider (`provides: ["browser"]`)
|
|
174
175
|
|
|
175
|
-
The host's `openchamber_web` tool gives agents
|
|
176
|
+
The host's `openchamber_web` tool gives agents twelve `browser.*` actions. By default a connected desktop app answers them with its browser panel; on a server with no desktop client the agent gets nothing. A service that declares `provides: ["browser"]` can answer instead, from a browser it runs itself (headless Chrome over CDP, for instance). The agent sees one tool either way.
|
|
176
177
|
|
|
177
178
|
The user picks the provider in Settings → General → OpenChamber Tools → Browser provider. The dropdown lists installed, enabled, fully approved extensions with the role; with none it shows only "OpenChamber Web", disabled. The choice is `browserProvider` in the instance's `settings.json` (`builtin` or the extension id) and is read on every action, so it applies to the next action without a restart. Pausing, removing, or withdrawing approval from the selected extension puts `builtin` back and every open client shows a toast saying so; the same happens on the next action if the extension became unusable any other way.
|
|
178
179
|
|
|
@@ -214,18 +215,144 @@ Parameters the host sends and `data` the service answers; types are exported fro
|
|
|
214
215
|
| `browser.snapshot` | `selector?` (scope) | `url`, `title`, `scope`, `scrollY`, `maxScrollY`, `text`, `elements[]`, `viewport`, optional `textTruncated`/`textTotalChars`, `elementsTruncated`/`interactiveElementsOnPage`, `consoleProblems[]` |
|
|
215
216
|
| `browser.click` | `selector?` or `text?` (visible label) | `clicked` (selector), `label`, `url` |
|
|
216
217
|
| `browser.type` | `selector`, `value`, `submit` | `selector`, `url` |
|
|
218
|
+
| `browser.select` | `selector`, exactly one of `label` or `value` | `selector`, selected `label`, selected `value`, `url` |
|
|
217
219
|
| `browser.scroll` | `direction?` (`up`/`down`/`top`/`bottom`) or `selector?` | `scrollY`, `maxScrollY`, `atTop`, `atBottom`, optional `scrolledTo`/`direction` |
|
|
218
220
|
| `browser.back` / `browser.forward` | none | `url`, `title` |
|
|
219
221
|
| `browser.inspect` | `selector` | `selector`, `tag`, `label`, `bounds`, `inViewport`, `styles` (computed, as strings) |
|
|
220
222
|
| `browser.capture` | `label?` | `base64`, `mime`, `width`, `height`, `url`, `title`, `viewport`; the host writes the file into the project and returns its path |
|
|
221
223
|
| `browser.resize` | `viewport` (`mobile`/`tablet`/`desktop`/`fill`) | `viewport` |
|
|
224
|
+
| `browser.close` | none (see `tabId` below) | `closed: true`, optional `tabId`; a provider with a single page answers `ok: false` instead |
|
|
222
225
|
|
|
223
|
-
Every action may carry `tabId` (`BrowserTabTarget`): an id from the `tabs` your snapshot listed (`[{ id, title, url, active }]`, `active` being the tab the user sees), passed through from the agent untouched. Without it, act on the tab the user sees, except `browser.open`: without `tabId` it opens a new background tab and answers its id as `tabId` (`BrowserOpenData`), so the agent never replaces the user's page. Refuse an id you did not issue with `ok: false`; never act on another tab instead. A provider with one page lists no tabs and refuses every id.
|
|
226
|
+
Every action may carry `tabId` (`BrowserTabTarget`): an id from the `tabs` your snapshot listed (`[{ id, title, url, active }]`, `active` being the tab the user sees), passed through from the agent untouched. Without it, act on the tab the user sees, except `browser.open`: without `tabId` it opens a new background tab and answers its id as `tabId` (`BrowserOpenData`), so the agent never replaces the user's page. Refuse an id you did not issue with `ok: false`; never act on another tab instead. A provider with one page lists no tabs and refuses every id. `browser.close` is the exception to "the tab the user sees": it closes a tab the agent opened, so without `tabId` it closes the one this caller opened most recently and refuses a tab it did not open.
|
|
224
227
|
|
|
225
|
-
`viewport` in answers is `{ mode, width, height }` (`mode` may be `custom`; `fill` has `null` sizes). Snapshot `elements` carry `selector`, `tag`, `bounds`, and only the fields that apply (`inViewport`, `type`, `role`, `label`, `disabled`, `missingAccessibleName`).
|
|
228
|
+
`viewport` in answers is `{ mode, width, height }` (`mode` may be `custom`; `fill` has `null` sizes). Snapshot `elements` carry `selector`, `tag`, `bounds`, and only the fields that apply (`inViewport`, `type`, `role`, `label`, `disabled`, `missingAccessibleName`). Native `select` elements additionally carry `multiple` and an `options[]` list in DOM order. Each option has its live `label`, `value`, `selected`, and `disabled` state; the list is capped at 32 entries and each label/value at 80 characters. `optionsTruncated: true` means the list or one of its strings was capped. A selected option outside the cap is not moved into the returned prefix. Keep `text`, `elements`, and select options bounded yourself; report what was dropped with the truncation fields.
|
|
229
|
+
|
|
230
|
+
`browser.select` uses exact string matching. It targets native single-value HTML `<select>` elements, rejects custom controls and multiple selects, and refuses disabled selects or matching options. A missing or ambiguous label/value is an error, so the service must leave the page unchanged.
|
|
226
231
|
|
|
227
232
|
`examples/browser-provider-stub` is a checked-in provider with no browser: one in-memory page that answers every action. Install it to see the dropdown, the routing, and the idle stop before writing a real one.
|
|
228
233
|
|
|
234
|
+
## Source-control provider (`provides: ["source-control"]`)
|
|
235
|
+
|
|
236
|
+
A service with this role is a Git host next to GitHub, GitLab, Gitea and Gerrit: its own card in Settings → Integrations, its pull requests and issues on the board, its badges on branches, worktrees from its pull requests. The role needs `contributes.sourceControl` in the same manifest, and the manifest needs it only with the role; either alone fails as `invalid-service`. Hosts older than the role refuse the manifest the same way, so declare an `engines.openchamber` floor.
|
|
237
|
+
|
|
238
|
+
OpenChamber names the host `ext-<panel.id>` (provider ids are at most 32 characters, so `panel.id` is at most `SOURCE_CONTROL_GUEST_ID_MAX`, 28). The id stays the same across versions; accounts, bindings and mutation records are stored under it. `sourceControlProviderIdOf(guestId)` spells it.
|
|
239
|
+
|
|
240
|
+
### `contributes.sourceControl`
|
|
241
|
+
|
|
242
|
+
Declared in the manifest, not answered by the service: OpenChamber reads it to draw the card, route Git transport and recognise remotes without starting a process, and the approval dialog shows it. Every object is strict; an unknown key fails.
|
|
243
|
+
|
|
244
|
+
| Field | Contents |
|
|
245
|
+
|---|---|
|
|
246
|
+
| `wire` | `1` |
|
|
247
|
+
| `label` | Product name, 1–64 characters, shown untranslated |
|
|
248
|
+
| `terminology` | `{ changeRequest: "pullRequest" \| "mergeRequest" \| "change", ci: "checks" \| "pipeline" }` |
|
|
249
|
+
| `referencePrefix` | `{ changeRequest, issue }`, 1–4 characters each (`#`, `!`) |
|
|
250
|
+
| `repositoryPath` | `"owner/name"` or `"namespace/name"` (nested groups) |
|
|
251
|
+
| `instances` | `{ defaultInstances: up to 4 https origins, custom, allowLoopbackHttp, allowPort, placeholder }`. With `custom: false` the user can add only the default instances, and there must be at least one |
|
|
252
|
+
| `capabilities` | `projects`, `issues`, `changeRequests`, `draftChangeRequests`, `merge` (`false` or `{ methods: ["merge" \| "squash" \| "rebase" \| "submit"] }`, with `submit` the only method when present), `ci`, `comments`, `reviews`, `state`, `labels`, `reviewers`, `changeRequestPatch`, `liveSummaries`, `reviewModel?` (`"verdict"` by default, or `"votes"`), `revisions?`. One left out is off. Review threads, uploads, stacks, the reference picker, multiple accounts and how a change request is created are fixed by OpenChamber |
|
|
253
|
+
| `token` | `{ header: "authorization" \| "private-token", scheme: "bearer" \| "token" \| "basic" \| "none", username?, scopes, createPath? }`. `private-token` takes the bare token (`none`); `authorization` takes `Bearer`, `token` or `basic`. `basic` needs `username: true`, and `username: true` is refused with any other scheme |
|
|
254
|
+
| `transport` | `{ httpsUsername: { kind: "login" } \| { kind: "fixed", value }, appendDotGitToHttpsClone }`: how Git over HTTPS signs in with the stored token |
|
|
255
|
+
| `limits` | `{ commentMaxLength (≤ 1 000 000), reviewerId: "numeric" \| "login", labelsMax (≤ 100), reviewersMax (≤ 50) }` |
|
|
256
|
+
| `trackedItems` | `{ batchLimit }`, 1–50 items per `liveSummaries` call |
|
|
257
|
+
| `presentation` | `{ abbreviation (1–8), promptNoun, sharedNumberSpace, iconPath? (24×24 SVG path data), urls: { changeRequest[], issue[] }, features: { authorCanReview, issueCloseReason, submitRequirements? }, changeRequestTemplates[] }` |
|
|
258
|
+
|
|
259
|
+
`presentation.urls` are web path templates such as `/{owner}/{repo}/pulls/{number}`: `/`, then segments that are each a literal (`[A-Za-z0-9._~-]`) or one placeholder (`SOURCE_CONTROL_URL_TOKENS`). `{repo}` and `{number}` appear once, and the owner once as `{owner}` for `owner/name` paths or `{namespace+}` (one or more segments) for `namespace/name` paths. OpenChamber compiles them into the pattern that recognises a pasted link; an extension never supplies a regular expression. `changeRequestTemplates` are repository file paths (`.gitea/pull_request_template.md`), first match wins.
|
|
260
|
+
|
|
261
|
+
### Change-based hosts
|
|
262
|
+
|
|
263
|
+
A host that reviews one change at a time, with numbered revisions and per-label votes (Gerrit is the model), declares the fields below. Each is optional, and a manifest without them parses, installs and publishes exactly as before. Hosts older than these fields refuse a manifest that uses them as `invalid-service`, because every object is strict. Declare an `engines.openchamber` floor of the first release that has them, newer than 2.5.0.
|
|
264
|
+
|
|
265
|
+
| Field | Effect |
|
|
266
|
+
|---|---|
|
|
267
|
+
| `terminology.changeRequest: "change"` | The UI says "change" where other hosts say pull request or merge request |
|
|
268
|
+
| `capabilities.reviewModel: "votes"` | `/pr/review` takes `{ votes, body? }` instead of a verdict. `votes` maps a label name to an integer score. The labels, their ranges and the caller's permitted scores come with each change's context as `voteLabels`. A verdict sent to a votes host, or votes sent to a verdict host, is refused with a 400 before the service sees it |
|
|
269
|
+
| `capabilities.revisions: true` | `changeRequestContext`, `readChangeRequestPatch` and `readChangeRequestFile` may carry `revision`, a 40-hex commit sha, to read that revision. The context lists them as `revisions`, oldest first. Without the capability a read with `revision` is a 400 and never reaches the service |
|
|
270
|
+
| `capabilities.merge: { methods: ["submit"] }` | The merge button reads Submit. `/pr/merge` carries `{ method: "submit" }` |
|
|
271
|
+
| `presentation.features.submitRequirements: true` | The context carries `submitRequirements`, `[{ name, status, description? }]`, and the UI lists the unmet ones by the Submit button |
|
|
272
|
+
| `token: { header: "authorization", scheme: "basic", username: true }` | Settings asks for a username beside the token. The proxy sends `Authorization: Basic base64(<login>:<token>)` with the account's stored login. `auth.verify` goes out with the username the user typed; a login with a `:` or a control character is refused |
|
|
273
|
+
|
|
274
|
+
`changeRequestStatus` may add `localRevision: { sha, changeId, uploadedAs }` (the local tip, the change id in its commit message or `null`, the revision number it was uploaded as or `null`) and `stack: { number: null, base, layers }` with layers bottom first.
|
|
275
|
+
|
|
276
|
+
Creating a change by pushing to `refs/for/<branch>` stays host-only. An extension's host creates through `/pr/create`, and its change request heads are branches, so declare `changeRequests` only if the forge's API can open one. `auth.probe` never carries a token, with `basic` too: answer it from an anonymous request, or answer `{ supported: true }` when the instance refuses anonymous reads and let `auth.verify` decide.
|
|
277
|
+
|
|
278
|
+
The catalog row carries `toPublicSourceControl(contribution)`: `{ label, defaultInstances, custom }`, what the approval dialog shows.
|
|
279
|
+
|
|
280
|
+
### Lifecycle
|
|
281
|
+
|
|
282
|
+
OpenChamber registers the host when the extension is installed, enabled, approved for `service`, and declares the role; pausing, withdrawing approval, or an update without the role unregisters it, and its routes answer 404 until it comes back. Tokens stay through a pause; uninstalling forgets them. The service starts on the first call, not at registration, and stops after `SOURCE_CONTROL_PROVIDER_IDLE_MS` (ten minutes) without one. Every read starts a cold service at worst, so do not cache across calls: OpenChamber keeps its own status cache and drops it after each write.
|
|
283
|
+
|
|
284
|
+
### Calls
|
|
285
|
+
|
|
286
|
+
`POST /source-control` on the service loopback (`SOURCE_CONTROL_PROVIDER_PATH`), same bearer as every request:
|
|
287
|
+
|
|
288
|
+
```json
|
|
289
|
+
{
|
|
290
|
+
"v": 1,
|
|
291
|
+
"requestId": "scm-…",
|
|
292
|
+
"method": "listIssues",
|
|
293
|
+
"provider": "ext-teahouse",
|
|
294
|
+
"session": { "instance": "https://git.example.com", "handle": "…", "user": { "id": 7, "login": "mona" } },
|
|
295
|
+
"input": { "context": { … }, "directory": "/Users/me/app", "remote": "origin", "page": 1, "state": "open", "people": "any" }
|
|
296
|
+
}
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
`readSourceControlProviderRequest(body)` parses it (`null` → answer HTTP 400). `provider` is the id OpenChamber gave the host: put it in every DTO's `provider` field. `session.user` is `null` in `auth.verify` and `auth.probe`, before an account exists. `input` per method is `SourceControlProviderInputs`; the reads take the route shell's own input, with `directory` and `remote` as OpenChamber resolved them and `context` the binding it validated.
|
|
300
|
+
|
|
301
|
+
Answer HTTP 200 with `{ "ok": true, "data": … }` or `{ "ok": false, "error": { "message", "status"?, "code"? } }` (`message` ≤ 2000 characters, `status` 400–599, `code` `[A-Z_]{1,64}`). Any other status or shape is a malformed answer, never an empty one.
|
|
302
|
+
|
|
303
|
+
| Method | Answer OpenChamber checks |
|
|
304
|
+
|---|---|
|
|
305
|
+
| `auth.verify` | `{ user: { id, login, name?, email?, avatarUrl? } }`: who the candidate token belongs to; OpenChamber stores the account |
|
|
306
|
+
| `auth.probe` | `{ supported, version?, message? }`: whether the instance is this forge; the handle carries no token |
|
|
307
|
+
| `changeRequestStatus` | The branch's status as the badge shows it (`ChangeRequestStatus` in the UI): its open pull request, else its most recent closed or merged one, so a merged branch is not offered a new pull request |
|
|
308
|
+
| `listChangeRequests`, `changeRequestContext`, `listIssues`, `getIssue`, `issueComments`, `projectUpstream`, `projectBranches`, `listLabels`, `listReviewerCandidates` | A JSON object or array within `SOURCE_CONTROL_PROVIDER_RESPONSE_MAX`; the UI parses these DTOs itself |
|
|
309
|
+
| `readChangeRequestPatch` | `{ patch, meta: { owner, repo, number } }`: the change request's whole unified diff (`diff --git` per file) and the repository it was read from. Served to the Changes view and walkthroughs only when `capabilities.changeRequestPatch` is on. `sourceRepo`, when given, names the repository the change request lives in; read only within the bound repository's network |
|
|
310
|
+
| `readChangeRequestFile` | `{ original, modified }`: one file at the diff's base (the merge base) and at the head, for `path` (`previousPath` for the old side of a rename). `status` `A` has an empty `original`, `D` an empty `modified`. Same capability and network rule as the patch |
|
|
311
|
+
| `liveSummaries` | `{ summaries, issueSummaries }` for the refs asked; an item the forge cannot answer is left out, a 401 fails the batch |
|
|
312
|
+
| `readChangeRequestHead` | `{ project: { id, owner, name }, headSha (40 hex), headBranch, headOwner }` |
|
|
313
|
+
| `resolveChangeRequestSource` | `{ sourceProject, targetProject, classification, headSha, headRef, endpoint }`; OpenChamber checks the endpoint is on the instance and names `sourceProject`, and that the sha is the one it expected |
|
|
314
|
+
| `mutation.resolveTarget` | `{ providerTarget (≤ 4 KB JSON), target }`; `target` has exactly the keys OpenChamber stores and must match the context's target. On a replay, return `replayOf.target` unchanged |
|
|
315
|
+
| `mutation.perform` | Only the stored result keys (`number`, `merged`, `state`, `ready`, …) |
|
|
316
|
+
| `mutation.reconcile` | `{ state: "succeeded" \| "failed" \| "outcome-unknown", result? }`: after a restart, whether the write recorded as running landed |
|
|
317
|
+
|
|
318
|
+
A change request is in reach when the repository it targets is in the context's repository network (the primary remote's repository and its fork parent), as the built-in hosts do: refuse one whose target is outside it, and do not check where its head comes from. A maintainer's upstream clone reads, comments on, reviews and merges a contributor's fork pull request through the target repository, which the forge already authorises for the token. Creating one is the other direction: its `headRemote` must be in the network.
|
|
319
|
+
|
|
320
|
+
A capability opens the mutation routes below; one that is off answers 501 there and never reaches the service. `mutation.*` calls carry the route's `kind`, and its normalized `input` (without the digest). A service that cannot perform a kind its capabilities open should answer `UNSUPPORTED` (501), but the user then sees an action fail that the board offered, so implement every kind of every capability you turn on.
|
|
321
|
+
|
|
322
|
+
| Capability | Route | `kind` | Normalized `input` |
|
|
323
|
+
|---|---|---|---|
|
|
324
|
+
| `changeRequests` | `/pr/create` | `change-request-create` | `{ title, draft, body?, remote?, headRemote? }`. `remote` is where the pull request opens and `headRemote` where its head branch lives; either left out means the context's `primaryRemote`. A head on another remote is usually a fork: name its branch the way the forge expects (Gitea and GitHub take `owner:branch`) and refuse a remote that is not in the target's network |
|
|
325
|
+
| `changeRequests` | `/pr/update` | `change-request-update` | `{ title, body? }`: a new title, and a new body when given |
|
|
326
|
+
| `merge` | `/pr/merge` | `change-request-merge` | `{ method }`, one of the declared `merge.methods` |
|
|
327
|
+
| `draftChangeRequests` | `/pr/ready` | `change-request-ready` | `{}` |
|
|
328
|
+
| `comments` | `/pr/comment`, `/issues/comment` | `change-request-comment`, `issue-comment` | `{ body }` |
|
|
329
|
+
| `state` | `/pr/state`, `/issues/state` | `change-request-state`, `issue-state` | `{ state }`: `open` or `closed` |
|
|
330
|
+
| `labels` | `/pr/labels`, `/issues/labels` | `change-request-labels`, `issue-labels` | `{ names }`: the whole set |
|
|
331
|
+
| `reviewers` | `/pr/reviewers` | `change-request-reviewers` | `{ names }`: the whole set, as `limits.reviewerId` spells them |
|
|
332
|
+
| `reviews` | `/pr/review` | `change-request-review` | `{ verdict: "approve" \| "request-changes", body? }`, for the `headSha` in the target. With `reviewModel: "votes"`, `{ votes, body? }` instead |
|
|
333
|
+
|
|
334
|
+
Review threads have routes too, but they stay off for an extension's Git host.
|
|
335
|
+
|
|
336
|
+
Deadlines: `changeRequestStatus` 12 s (past it the last known status shows), other reads 20 s, mutation steps 30 s, `auth.*` 15 s (`sourceControlProviderTimeoutMs`).
|
|
337
|
+
|
|
338
|
+
OpenChamber decides, not the service, what a failure means. A mutation counts as definitely failed only when the service was never reached or every write the proxy saw got a 4xx; anything else is an unknown outcome to reconcile. A `status: 401` writes off the account only when OpenChamber's proxy saw the forge answer 401 on that call; a 401 the service reports on its own is a malformed answer.
|
|
339
|
+
|
|
340
|
+
### Reaching the forge
|
|
341
|
+
|
|
342
|
+
The service never holds a token. `createSourceControlForge({ env: process.env })` returns `{ request(handle, req) }`, which posts to `${OPENCHAMBER_HOST_CALLBACK_URL}/source-control/outbound` with `Authorization: Bearer <handle>`. `OPENCHAMBER_HOST_CALLBACK_URL` is a separate loopback listener, set only for services with this role.
|
|
343
|
+
|
|
344
|
+
- Request (`SourceControlOutboundRequest`): `{ method: GET|POST|PUT|PATCH|DELETE, path, query?, headers?, body? }`. `path` starts with one `/` and stays on the call's instance origin. `headers` (lower-case names) may be `accept`, `content-type`, `if-none-match` and `range`; OpenChamber sets the rest, the token included, per `token.header` and `token.scheme` (with `basic`, the account's login too). `body` is text up to `SOURCE_CONTROL_OUTBOUND_BODY_MAX` (1 MB).
|
|
345
|
+
- Answer: HTTP 200 with `{ status, headers, body }`, the forge's status (any, 401 included), the headers in `SOURCE_CONTROL_OUTBOUND_RESPONSE_HEADERS` plus `x-ratelimit-*`, and the body as text up to `SOURCE_CONTROL_OUTBOUND_RESPONSE_MAX` (8 MB). Redirects are not followed. `request` resolves with it.
|
|
346
|
+
- Refusal: any other status with `{ error: { message, code } }` (an expired or unknown handle, a path off the instance, a body too large). `request` throws `SourceControlForgeError` with that `code` and `status`; `HOST_UNAVAILABLE` when there was no usable answer.
|
|
347
|
+
|
|
348
|
+
A handle is minted for one call, carries `auth.verify`'s candidate token or the bound account's token (none for `auth.probe`), and is revoked when the call ends. Never store it.
|
|
349
|
+
|
|
350
|
+
This is least exposure, not a sandbox. OpenChamber keeps tokens because Git transport needs them anyway and the proxy is the one place origin pinning, enterprise policy and audit can act. An approved service still runs as the user and could read OpenChamber's files itself.
|
|
351
|
+
|
|
352
|
+
VS Code lists no Git hosts from extensions: the guest catalog is unsupported there.
|
|
353
|
+
|
|
354
|
+
`examples/source-control-provider` is a checked-in provider for a subset of Gitea's REST v1, with a fake forge its tests run against.
|
|
355
|
+
|
|
229
356
|
## Shared surface (`surface: true`)
|
|
230
357
|
|
|
231
358
|
A surface lets a person watch what an agent is working in and step in for a manual step. It is not about browsers: a service that drives a browser, a simulator, or a desktop app can show one. The host draws it in the extension's rail panel (a canvas; the extension runs no code there), sends the user's pointer, keyboard, and paste back, and decides who is in control.
|
package/dist/contract.d.ts
CHANGED
|
@@ -167,6 +167,22 @@ export type GenerateRequest = {
|
|
|
167
167
|
export type GenerateResult = {
|
|
168
168
|
text: string;
|
|
169
169
|
};
|
|
170
|
+
/**
|
|
171
|
+
* The outcome of a `generate` whose frame was gone when the model answered:
|
|
172
|
+
* the panel closed or its frame reloaded first. `request` is what that frame
|
|
173
|
+
* sent. The host keeps it for a frame of the same extension on the same
|
|
174
|
+
* surface and project, and hands it over once (`onLateGenerateResult`).
|
|
175
|
+
*/
|
|
176
|
+
export type LateGenerateResult = {
|
|
177
|
+
request: GenerateRequest;
|
|
178
|
+
} & ({
|
|
179
|
+
ok: true;
|
|
180
|
+
result: GenerateResult;
|
|
181
|
+
} | {
|
|
182
|
+
ok: false;
|
|
183
|
+
code: HostRequestErrorCode;
|
|
184
|
+
error: string;
|
|
185
|
+
});
|
|
170
186
|
export type HostResultPayload = GuestStorageResult | GuestWorkspaceSnapshot | GuestShellOutputResult | GuestShellStopResult | GuestRequestResult | StartSessionResult | PromptResult | ServiceStatusResult | FileReadResult | FileWriteResult | FileListResult | FileStatResult | GenerateResult;
|
|
171
187
|
export declare const isStartSessionResult: (value: HostResultPayload | undefined) => value is StartSessionResult;
|
|
172
188
|
export declare const isPromptResult: (value: HostResultPayload | undefined) => value is PromptResult;
|
|
@@ -193,6 +209,8 @@ export type HostReadyContext = {
|
|
|
193
209
|
deviceStorage?: true;
|
|
194
210
|
statusControls?: true;
|
|
195
211
|
popovers?: true;
|
|
212
|
+
/** The host keeps `generate` answers that arrive after their frame is gone (`onLateGenerateResult`). */
|
|
213
|
+
lateGenerateResults?: true;
|
|
196
214
|
};
|
|
197
215
|
};
|
|
198
216
|
export type GuestItemRole = 'user' | 'assistant';
|
|
@@ -352,8 +370,23 @@ export declare const GUEST_GENERATE_SYSTEM_MAX = 8000;
|
|
|
352
370
|
export declare const GUEST_GENERATE_OUTPUT_TOKENS_MAX = 4000;
|
|
353
371
|
/** Characters in a `generate` answer. */
|
|
354
372
|
export declare const GUEST_GENERATE_TEXT_MAX = 256000;
|
|
355
|
-
/**
|
|
356
|
-
|
|
373
|
+
/**
|
|
374
|
+
* How long `generate` waits for the model before `HOST_TIMEOUT`.
|
|
375
|
+
*
|
|
376
|
+
* Must exceed the largest deadline the Small Model server can derive for a
|
|
377
|
+
* guest-shaped request, plus margin for the iframe → host bridge → HTTP chain.
|
|
378
|
+
* The server no longer cuts at a flat 60 s: it budgets 60 s base plus 40 ms per
|
|
379
|
+
* estimated token (~4 chars/token), capped at 900 s
|
|
380
|
+
* (`generationBudgetMs` in `packages/web/server/lib/small-model/index.js`,
|
|
381
|
+
* issue #5017). At the guest limits (`GUEST_GENERATE_PROMPT_MAX` +
|
|
382
|
+
* `GUEST_GENERATE_SYSTEM_MAX` = 72 000 chars ≈ 18 000 tokens) that is 780 s,
|
|
383
|
+
* so 960 s leaves the chain ~3 minutes.
|
|
384
|
+
*/
|
|
385
|
+
export declare const GUEST_GENERATE_TIMEOUT_MS = 960000;
|
|
386
|
+
/** How long the host keeps a `LateGenerateResult` nobody has taken. */
|
|
387
|
+
export declare const GUEST_GENERATE_LATE_KEEP_MS: number;
|
|
388
|
+
/** `LateGenerateResult`s the host keeps per extension; a newer one drops the oldest. */
|
|
389
|
+
export declare const GUEST_GENERATE_LATE_MAX = 8;
|
|
357
390
|
/** Characters of one message's text on a `GuestMessageItem` or a `GuestSessionItem` message. */
|
|
358
391
|
export declare const GUEST_ITEM_MESSAGE_TEXT_MAX = 200000;
|
|
359
392
|
/** `JSON.stringify` length ceiling for a `GuestSessionItem`; the host drops the oldest messages to stay under it. */
|
|
@@ -481,6 +514,11 @@ export type HostFileSavedMessage = Envelope & {
|
|
|
481
514
|
version: string;
|
|
482
515
|
};
|
|
483
516
|
};
|
|
517
|
+
/** A kept `generate` outcome, pushed once to a frame that sent `generate-late-listen`. */
|
|
518
|
+
export type HostGenerateLateMessage = Envelope & {
|
|
519
|
+
type: 'generate-late';
|
|
520
|
+
payload: LateGenerateResult;
|
|
521
|
+
};
|
|
484
522
|
export type HostResultMessage = Envelope & {
|
|
485
523
|
type: 'result';
|
|
486
524
|
id: string;
|
|
@@ -502,7 +540,7 @@ export type HostShellsMessage = Envelope & {
|
|
|
502
540
|
export type HostMessage = (Envelope & {
|
|
503
541
|
type: 'workspace';
|
|
504
542
|
payload: GuestWorkspaceUpdate;
|
|
505
|
-
}) | HostShellsMessage | HostReadyMessage | HostDirectoryMessage | HostSessionMessage | HostConnectionMessage | HostSettingsMessage | HostSessionLifecycleMessage | HostItemMessage | HostStatusControlEventMessage | HostPopoverClosedMessage | HostResolveMessage | HostActionMessage | HostFileOpenMessage | HostFileSnapshotMessage | HostFileSavedMessage | HostResultMessage;
|
|
543
|
+
}) | HostShellsMessage | HostReadyMessage | HostDirectoryMessage | HostSessionMessage | HostConnectionMessage | HostSettingsMessage | HostSessionLifecycleMessage | HostItemMessage | HostStatusControlEventMessage | HostPopoverClosedMessage | HostResolveMessage | HostActionMessage | HostFileOpenMessage | HostFileSnapshotMessage | HostFileSavedMessage | HostGenerateLateMessage | HostResultMessage;
|
|
506
544
|
type GuestCall<Type extends string, Payload = never> = Envelope & {
|
|
507
545
|
type: Type;
|
|
508
546
|
id: string;
|
|
@@ -586,6 +624,10 @@ export type GuestFileSaveMessage = Envelope & {
|
|
|
586
624
|
export type GuestFileUnsupportedMessage = Envelope & {
|
|
587
625
|
type: 'file-unsupported';
|
|
588
626
|
};
|
|
627
|
+
/** This frame takes kept `generate` outcomes; the host pushes them as `generate-late`. Fire and forget, repeated on every `ready`. */
|
|
628
|
+
export type GuestGenerateLateListenMessage = Envelope & {
|
|
629
|
+
type: 'generate-late-listen';
|
|
630
|
+
};
|
|
589
631
|
export type GuestShellsSubscribeMessage = GuestCall<'shells-subscribe', GuestShellsSubscription>;
|
|
590
632
|
export type GuestShellsUnsubscribeMessage = GuestCall<'shells-unsubscribe', {
|
|
591
633
|
subscriptionId: string;
|
|
@@ -598,7 +640,7 @@ export type GuestMessage = GuestCall<'workspace-read', GuestWorkspaceQuery> | Gu
|
|
|
598
640
|
subscriptionId: string;
|
|
599
641
|
}> | GuestShellsSubscribeMessage | GuestShellsUnsubscribeMessage | GuestShellOutputMessage | GuestShellStopMessage | GuestCall<'storage', GuestStorageRequest> | GuestCall<'open-session', {
|
|
600
642
|
sessionId: string;
|
|
601
|
-
}> | GuestHelloMessage | GuestToastMessage | GuestOpenUrlMessage | GuestOpenSurfaceMessage | GuestClipboardWriteMessage | GuestComposeMessage | GuestAttachMessage | GuestStartSessionMessage | GuestPromptMessage | GuestSessionLinkMessage | GuestCloseMessage | GuestOauthStartMessage | GuestOauthDisconnectMessage | GuestRequestMessage | GuestServiceRequestMessage | GuestServiceStatusMessage | GuestFileReadMessage | GuestFileWriteMessage | GuestFileListMessage | GuestFileStatMessage | GuestGenerateMessage | GuestBadgeMessage | GuestResizeMessage | GuestOpenCommitMessage | GuestStatusControlsMessage | GuestPopoverOpenMessage | GuestPopoverCloseMessage | GuestPopoverAnchorMessage | GuestActionResultMessage | GuestResolveResultMessage | GuestFileSnapshotResultMessage | GuestFileChangeMessage | GuestFileSaveMessage | GuestFileUnsupportedMessage;
|
|
643
|
+
}> | GuestHelloMessage | GuestToastMessage | GuestOpenUrlMessage | GuestOpenSurfaceMessage | GuestClipboardWriteMessage | GuestComposeMessage | GuestAttachMessage | GuestStartSessionMessage | GuestPromptMessage | GuestSessionLinkMessage | GuestCloseMessage | GuestOauthStartMessage | GuestOauthDisconnectMessage | GuestRequestMessage | GuestServiceRequestMessage | GuestServiceStatusMessage | GuestFileReadMessage | GuestFileWriteMessage | GuestFileListMessage | GuestFileStatMessage | GuestGenerateMessage | GuestBadgeMessage | GuestResizeMessage | GuestOpenCommitMessage | GuestStatusControlsMessage | GuestPopoverOpenMessage | GuestPopoverCloseMessage | GuestPopoverAnchorMessage | GuestActionResultMessage | GuestResolveResultMessage | GuestFileSnapshotResultMessage | GuestFileChangeMessage | GuestFileSaveMessage | GuestFileUnsupportedMessage | GuestGenerateLateListenMessage;
|
|
602
644
|
export declare const isServiceStatusResult: (value: HostResultPayload | undefined) => value is ServiceStatusResult;
|
|
603
645
|
export declare const isGuestRequestResult: (value: HostResultPayload | undefined) => value is GuestRequestResult;
|
|
604
646
|
export declare const isFileReadResult: (value: HostResultPayload | undefined) => value is FileReadResult;
|