@odyn-app/api 0.2.1 → 0.3.0

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/README.md CHANGED
@@ -29,8 +29,77 @@ defineExtension({
29
29
  - `@odyn-app/api/ui`: Odyn's `Button`, `Skeleton`, `SettingRow`, `Orb` and `cn`, for extension pages.
30
30
  - `@odyn-app/api/theme.css`: Odyn's colours, fonts and light/dark themes. Add `@import "@odyn-app/api/theme.css";` to a page's stylesheet.
31
31
 
32
+ ## A sidebar button, a full-window page and a dashboard card (kit version 5)
33
+
34
+ With `apiVersion: 5`, any extension can ask for these. The user approves each one at install.
35
+
36
+ - `ui:sidebar` + `sidebarItem: { title, icon, command }` — a button under Marketplace. A click runs `command`. `odyn.ui.setBadge(3)` shows a number on it; `null` or `0` clears it.
37
+ - `ui:dialog` + a page in `surfaces` with `placement: 'dialog'` — a full-window page. `odyn.ui.openDialog()` opens it, but only while handling a click on your button or card.
38
+ - `ui:dashboard` + `dashboardCard: { command }` — one card on the dashboard. Return `{ type: 'card', title, items, actions }` from `command`, or call `odyn.dashboard.setCard(card)` at any time (`null` hides it). Clicking an item or action runs `command` again with `itemId` or `actionId`.
39
+
40
+ ```ts
41
+ defineExtension({
42
+ commands: {
43
+ 'open-board': async ({ odyn }) => { await odyn.ui.openDialog() },
44
+ 'refresh-card': async ({ odyn, actionId }) => {
45
+ if (actionId === 'open') await odyn.ui.openDialog()
46
+ await odyn.ui.setBadge(2)
47
+ return { type: 'card', title: 'Standup', items: [{ id: 'a', title: 'Two updates waiting' }], actions: [{ id: 'open', title: 'Open board' }] }
48
+ },
49
+ },
50
+ })
51
+ ```
52
+
32
53
  Every extension runs sandboxed, and each call is checked against the permissions the user granted.
33
54
 
55
+ ## Shared storage and who called a tool (kit version 6)
56
+
57
+ With `apiVersion: 6`, no extra permission needed:
58
+
59
+ - `odyn.storage` in your commands, and `surface.storage` in your pages: one text store for the whole extension. `get(key)`, `set(key, value)` (`null` removes it) and `keys(prefix?)`. Save JSON as text. Each value can be up to 5 MB, and 50 MB altogether. Everything is deleted when the extension is uninstalled.
60
+ - `ui:dialog` + `composerMenuItem: { title, icon, command }` (kit version 6) — an entry in the message box's + menu, e.g. "Attach initiative". A click runs `command`, told which chat through `caller`.
61
+ - `odyn.ui.openDialog({ chatId?, item? })` (kit version 6) opens your page for a chat and at one of your own items, e.g. `{ chatId: caller.chatId, item: planId }`. The page reads both from its context (`context.chatId`, `context.item`). With no options it opens as before.
62
+ - `odyn.chats.list()` and `chats.current()` also say `isWorking` (the agent is mid-reply) and `workspaceChatId` (the workspace's main chat, shared by every chat in that workspace). Both are missing on older Odyn versions.
63
+ - `ui:chatHeader` + `chatChip: { command }` (kit version 6) — a small label with a coloured dot next to a chat's title. `odyn.ui.setChatChip(chatId, { label, color? })` sets it for one chat (label up to 40 characters; colour `gray`, `blue`, `green`, `amber`, `red` or `purple`); `null` removes it. A click runs `command`, told which chat through `caller`, and may open your page.
64
+ - Ask-first cards (kit version 6, no extra permission): an `agentTool` command may answer with a card that has one or two `actions`, e.g. "This looks like part of X — [Not now] [Link chat]". Odyn shows it in the chat under the tool call and tells the agent to wait. Nothing happens until the person clicks: the click runs the same command again with the same `input` and the button's id in `actionId`, and `caller` set to the chat. Each card can be answered once; if your command fails, the card opens again. Card buttons can't use `opensDialog` — call `odyn.ui.openDialog()` instead.
65
+ - A command with `placement: 'chatContext'` (one per extension, `agent:context` permission, kit version 6) is never shown. Odyn runs it when a chat starts a fresh session and after a long chat is summed up, telling it the caller like an agent tool. Its answer (text, or any view, read as text) goes in front of the user's message, cut to 2,000 characters. Answer within 2 seconds or the message goes without it; return `null` to add nothing.
66
+ - `odyn.notes.run(tool, target, input)` (and `surface.notes.run` in pages), Odyn's own extensions only: runs one of Odyn's note and initiative tools. With a chat id as `target` it runs as if that chat's agent called it; with `{ projectId }` it runs for the person on your page (no chat needed, changes are recorded as theirs, and chat-only extras such as a card in the chat are skipped). Either way it takes the same input and the same team-sharing rules, and returns `{ text, isError }`. `ListNotes`, `ReadNote`, `ListInitiatives` and `ReadInitiative` need `notes:read`; `CreateNote`, `EditNote`, `PatchNote`, `RenameNote`, `DeleteNote`, `CreateInitiative`, `MoveNotesToInitiative`, `MarkInitiativeComplete`, `EditInitiativeArtifacts` and `EditInitiativeGroups` need `notes:write`. Kit version 6.
67
+ - `odyn.notes.list(projectId)`, `odyn.notes.initiative(projectId, initiativeId)` and `odyn.notes.read(projectId, noteId)` (and the same on `surface.notes`), Odyn's own extensions only, need `notes:read`: structured reads for your page. `list` gives the project's initiatives and its loose notes (notes in no initiative), `initiative` one initiative's groups and documents in order (`null` if it isn't in that project), `read` one note as markdown with its `version` (pass that to `EditNote` / `PatchNote` so a newer copy is never overwritten). Same team-sharing rules as the tools.
68
+ - In pages (kit version 6), `surface.chats.list(projectId?)`, `surface.projects.list()` and `surface.ui.setChatChip(chatId, chip)` work the same as their `odyn.` versions, with the same `chats:read` / `projects:read` / `ui:chatHeader` permissions.
69
+ - An agent tool's context has `caller: { chatId, projectId, workspaceChatId, kind }` saying which chat (or `kind: 'spryte'` for Odyn's coordinating agent) called it. `workspaceChatId` is shared by every chat in one workspace. It is missing for clicks.
70
+
71
+ ```ts
72
+ defineExtension({
73
+ commands: {
74
+ 'link-chat': async ({ odyn, caller, input }) => {
75
+ if (!caller) return 'Only an agent can link its chat.'
76
+ await odyn.storage.set(`link:${caller.workspaceChatId}`, JSON.stringify({ planId: input.planId }))
77
+ return 'Linked.'
78
+ },
79
+ },
80
+ })
81
+ ```
82
+
83
+ ## Screenshots
84
+
85
+ Add `screenshots` to the `odyn` section of `package.json` to show a strip of pictures on your extension's page in Odyn's Marketplace. With none, Odyn shows a placeholder with your icon.
86
+
87
+ ```json
88
+ "screenshots": ["screenshots/1.png", "screenshots/2.webp"]
89
+ ```
90
+
91
+ - Up to 5, each a path inside your extension's folder.
92
+ - `.png`, `.jpg`, `.jpeg` or `.webp`, at most 1 MB each.
93
+ - Forward slashes only; no `..`, no paths starting with `/` or a drive like `C:`, and no web addresses.
94
+
95
+ `odyn-extension check` (and `build` and `publish`) makes sure every file is there and small enough.
96
+
97
+ ## Feedback
98
+
99
+ People can send feedback (a bug, an idea or a question, with an optional screenshot) and give a thumbs up from your extension's page in Odyn. Feedback arrives anonymously as a GitHub issue on [New-Genre/odyn-extensions](https://github.com/New-Genre/odyn-extensions/issues), labelled `ext:<your id>` plus the kind, and mentions your extension's `owners`.
100
+
101
+ Answer by commenting on the issue — the sender sees your replies inside Odyn under "Your feedback". Closing the issue shows it to them as closed. The sender is anonymous, so never ask for personal details in a reply.
102
+
34
103
  **[Read the docs](https://www.odyn.build/docs/extensions)** for what extensions can and can't do, every permission and limit, and the full reference. To publish one to Odyn's Marketplace, see the [contribution guide](https://github.com/New-Genre/odyn-extensions/blob/main/CONTRIBUTING.md).
35
104
 
36
105
  MIT licence.
package/cli/build.mjs CHANGED
@@ -10,12 +10,12 @@
10
10
  * Every manifest is checked with the same checker Odyn runs on a download,
11
11
  * so a broken one fails here rather than on someone's Mac.
12
12
  */
13
- import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
13
+ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
14
14
  import { join, relative, resolve } from 'node:path'
15
15
  import react from '@vitejs/plugin-react'
16
16
  import tailwindcss from '@tailwindcss/postcss'
17
17
  import { build } from 'vite'
18
- import { validateManifest } from '../dist/index.js'
18
+ import { MAX_EXTENSION_SCREENSHOT_BYTES, validateManifest } from '../dist/index.js'
19
19
 
20
20
  const PANEL_DIR = join('dist', 'panel')
21
21
  const HELPER_SOURCE = join('src', 'helper.ts')
@@ -43,7 +43,22 @@ export function checkExtension(sourceDir, { official = false } = {}) {
43
43
  const problems = []
44
44
  if (pkg.private !== true) problems.push('"private": true is required, so it is never published to npm by mistake.')
45
45
  const manifest = { ...pkg.odyn, version: pkg.version }
46
- problems.push(...validateManifest(manifest, { official }))
46
+ const manifestProblems = validateManifest(manifest, { official })
47
+ problems.push(...manifestProblems)
48
+ // The paths themselves are only worth looking for once the manifest is sound.
49
+ if (manifestProblems.length === 0) problems.push(...screenshotFileProblems(sourceDir, manifest.screenshots ?? []))
50
+ return problems
51
+ }
52
+
53
+ /** Checks every screenshot the manifest lists is a real picture file of at most 1 MB. */
54
+ function screenshotFileProblems(sourceDir, screenshots) {
55
+ const problems = []
56
+ for (const path of screenshots) {
57
+ const file = join(sourceDir, path)
58
+ const info = existsSync(file) ? statSync(file) : null
59
+ if (!info?.isFile()) problems.push(`Screenshot "${path}" was not found in this folder.`)
60
+ else if (info.size > MAX_EXTENSION_SCREENSHOT_BYTES) problems.push(`Screenshot "${path}" is bigger than 1 MB.`)
61
+ }
47
62
  return problems
48
63
  }
49
64
 
package/dist/index.d.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  /** The newest building-kit version. Bumped when something new is added. */
2
- export declare const EXTENSION_API_VERSION = 4;
2
+ export declare const EXTENSION_API_VERSION = 6;
3
3
  /** The oldest building-kit version Odyn still runs. */
4
4
  export declare const EXTENSION_API_MIN_VERSION = 1;
5
5
  /** Everything an extension can ask the user to allow. */
6
- export declare const EXTENSION_PERMISSIONS: readonly ["chats:read", "projects:read", "agents:start", "network", "composer:attach", "screenshots:read", "programs:run", "agent:tools"];
6
+ export declare const EXTENSION_PERMISSIONS: readonly ["chats:read", "projects:read", "agents:start", "network", "composer:attach", "screenshots:read", "programs:run", "agent:tools", "ui:sidebar", "ui:dialog", "ui:dashboard", "notes:read", "notes:write", "agent:context", "ui:chatHeader"];
7
7
  export type ExtensionPermission = (typeof EXTENSION_PERMISSIONS)[number];
8
8
  /** Permissions only extensions made by Odyn may ask for. */
9
9
  export declare const OFFICIAL_ONLY_PERMISSIONS: readonly ExtensionPermission[];
@@ -18,9 +18,18 @@ export declare function isOdynExtensionId(id: string): boolean;
18
18
  /** Systems an extension can say it runs on. Leave out to run everywhere. */
19
19
  export declare const EXTENSION_PLATFORMS: readonly ["darwin", "linux", "win32"];
20
20
  export type ExtensionPlatform = (typeof EXTENSION_PLATFORMS)[number];
21
- /** Where an extension can show up inside Odyn. */
22
- export declare const EXTENSION_PLACEMENTS: readonly ["sidebarPanel", "chatCard", "agentTool", "settings"];
21
+ /**
22
+ * Where an extension can show up inside Odyn. `chatContext` (kit version 6,
23
+ * `agent:context`) is never shown: Odyn runs it when a chat starts, and after
24
+ * a long chat is summed up, and puts its short text answer in front of the
25
+ * user's message.
26
+ */
27
+ export declare const EXTENSION_PLACEMENTS: readonly ["sidebarPanel", "chatCard", "agentTool", "settings", "chatContext"];
23
28
  export type ExtensionPlacement = (typeof EXTENSION_PLACEMENTS)[number];
29
+ /** The most a `chatContext` command's answer may add to a message; anything longer is cut. */
30
+ export declare const EXTENSION_CONTEXT_MAX_CHARS = 2000;
31
+ /** How long Odyn waits for a `chatContext` command before sending the message without it. */
32
+ export declare const EXTENSION_CONTEXT_TIMEOUT_MS = 2000;
24
33
  export interface ExtensionCommand {
25
34
  /** Unique within the extension, e.g. "show-standup". */
26
35
  readonly name: string;
@@ -46,11 +55,18 @@ export interface ExtensionCommandInput {
46
55
  /** A value the agent or a form handed to a command. */
47
56
  export type ExtensionInputValue = string | number | boolean;
48
57
  /** Where an extension's own page can be shown. */
49
- export declare const EXTENSION_SURFACE_PLACEMENTS: readonly ["composer", "settings"];
58
+ export declare const EXTENSION_SURFACE_PLACEMENTS: readonly ["composer", "settings", "dialog"];
50
59
  export type ExtensionSurfacePlacement = (typeof EXTENSION_SURFACE_PLACEMENTS)[number];
51
60
  /** The icons Odyn can draw on the button that opens a page. */
52
61
  export declare const EXTENSION_SURFACE_ICONS: readonly ["puzzle", "pencil", "image", "note"];
53
62
  export type ExtensionSurfaceIcon = (typeof EXTENSION_SURFACE_ICONS)[number];
63
+ /**
64
+ * The icons an extension can pick for itself in the Marketplace list and on
65
+ * its page. Any name Odyn doesn't know (say, one added in a newer Odyn) shows
66
+ * the puzzle piece instead, so it never stops an extension installing.
67
+ */
68
+ export declare const EXTENSION_ICONS: readonly ["puzzle", "pencil", "image", "note", "inbox", "cursor", "palette", "globe", "phone", "list"];
69
+ export type ExtensionIcon = (typeof EXTENSION_ICONS)[number];
54
70
  export declare const SURFACE_MIN_HEIGHT = 120;
55
71
  export declare const SURFACE_MAX_HEIGHT = 600;
56
72
  /**
@@ -63,7 +79,9 @@ export interface ExtensionSurface {
63
79
  readonly title: string;
64
80
  /**
65
81
  * `composer`: slides open above the message box, with a toolbar button.
66
- * `settings`: shown inside the extension's settings window.
82
+ * `settings`: shown inside the extension's settings page.
83
+ * `dialog`: fills a full-window dialog, opened only after a click (see
84
+ * `odyn.ui.openDialog`). Needs `apiVersion` 5 and the `ui:dialog` permission.
67
85
  */
68
86
  readonly placement: ExtensionSurfacePlacement;
69
87
  /** The page inside the extension's folder, e.g. "dist/panel/index.html". */
@@ -72,6 +90,47 @@ export interface ExtensionSurface {
72
90
  /** Height in pixels. Defaults to 260. */
73
91
  readonly height?: number;
74
92
  }
93
+ /** A button in Odyn's sidebar, under "Marketplace". Clicking it runs `command`. */
94
+ export interface ExtensionSidebarItem {
95
+ readonly title: string;
96
+ readonly icon: ExtensionSurfaceIcon;
97
+ /** A command in `commands`. It may call `odyn.ui.openDialog()`. */
98
+ readonly command: string;
99
+ }
100
+ /**
101
+ * An entry in the message box's + menu (kit version 6, `ui:dialog`). Clicking
102
+ * it runs `command`, told which chat it is through `caller`, so the command can
103
+ * open the extension's page for that chat with `odyn.ui.openDialog({ chatId })`.
104
+ */
105
+ export interface ExtensionComposerMenuItem {
106
+ readonly title: string;
107
+ readonly icon: ExtensionSurfaceIcon;
108
+ /** A command in `commands`. It may call `odyn.ui.openDialog()`. */
109
+ readonly command: string;
110
+ }
111
+ /** The chip next to a chat's title. Clicking it runs `command`, told which chat it is. */
112
+ export interface ExtensionChatChipSource {
113
+ /** A command in `commands`. It may call `odyn.ui.openDialog()`. */
114
+ readonly command: string;
115
+ }
116
+ /** The colours a chat chip's dot can be. Odyn picks the exact shade for the theme. */
117
+ export declare const EXTENSION_CHIP_COLORS: readonly ["gray", "blue", "green", "amber", "red", "purple"];
118
+ export type ExtensionChipColor = (typeof EXTENSION_CHIP_COLORS)[number];
119
+ /** The longest label a chat chip can carry. */
120
+ export declare const CHAT_CHIP_MAX_LABEL = 40;
121
+ /** What a chat chip shows: a short label and a coloured dot. */
122
+ export interface ExtensionChatChip {
123
+ readonly label: string;
124
+ /** Leave out for gray. */
125
+ readonly color?: ExtensionChipColor;
126
+ }
127
+ /** Why a chat chip can't be drawn. An empty list means it is fine. */
128
+ export declare function chatChipProblems(value: unknown): readonly string[];
129
+ /** Where a dashboard card comes from: Odyn runs `command` whenever the dashboard opens. */
130
+ export interface ExtensionDashboardCardSource {
131
+ /** A command in `commands` that returns `{ type: 'card', … }`, or nothing to hide the card. */
132
+ readonly command: string;
133
+ }
75
134
  export interface ExtensionPreference {
76
135
  readonly name: string;
77
136
  readonly title: string;
@@ -106,6 +165,13 @@ export interface ExtensionHelper {
106
165
  readonly links?: readonly string[];
107
166
  /** Hosts `download` may fetch from (redirects must stay inside the list). */
108
167
  readonly downloadHosts?: readonly string[];
168
+ /**
169
+ * Page calls (names given to `odyn.pages.handle`) that only look and never
170
+ * change anything, e.g. "setupStatus". Only these may be asked for from a
171
+ * page opened in a browser through Web Access; every other call stays on
172
+ * the Mac running Odyn.
173
+ */
174
+ readonly browserSafeCalls?: readonly string[];
109
175
  }
110
176
  /** A command the agent must ask before using, e.g. `{ program: 'xcrun', subcommand: 'simctl' }`. */
111
177
  export interface ExtensionGatedCommand {
@@ -150,11 +216,28 @@ export interface ExtensionManifest {
150
216
  readonly helper?: ExtensionHelper;
151
217
  /** A guide and permission-checked commands for the agent. */
152
218
  readonly agent?: ExtensionAgent;
219
+ /** Its icon in the Marketplace, one of `EXTENSION_ICONS`. Defaults to the puzzle piece. */
220
+ readonly icon?: ExtensionIcon;
153
221
  /**
154
222
  * GitHub usernames of the people who look after it. Changes to the
155
223
  * extension in the public repo need one of them (or Odyn) to approve.
156
224
  */
157
225
  readonly owners?: readonly string[];
226
+ /**
227
+ * Pictures of the extension, shown as a strip on its page in Odyn's
228
+ * Marketplace. Each is a path inside the extension's folder, e.g.
229
+ * "screenshots/1.png" — up to 5, each a .png, .jpg, .jpeg or .webp file of
230
+ * at most 1 MB. With none, Odyn shows a placeholder with the icon.
231
+ */
232
+ readonly screenshots?: readonly string[];
233
+ /** A button in Odyn's sidebar. Needs `apiVersion` 5 and `ui:sidebar`. */
234
+ readonly sidebarItem?: ExtensionSidebarItem;
235
+ /** A card on Odyn's dashboard. Needs `apiVersion` 5 and `ui:dashboard`. */
236
+ readonly dashboardCard?: ExtensionDashboardCardSource;
237
+ /** An entry in the message box's + menu. Needs `apiVersion` 6 and `ui:dialog`. */
238
+ readonly composerMenuItem?: ExtensionComposerMenuItem;
239
+ /** A chip next to chats' titles, set per chat with `odyn.ui.setChatChip`. Needs `apiVersion` 6 and `ui:chatHeader`. */
240
+ readonly chatChip?: ExtensionChatChipSource;
158
241
  }
159
242
  export interface ExtensionAction {
160
243
  readonly id: string;
@@ -168,11 +251,34 @@ export interface ListItem {
168
251
  readonly accessory?: string;
169
252
  readonly actions?: readonly ExtensionAction[];
170
253
  }
254
+ export declare const DASHBOARD_CARD_MAX_ITEMS = 5;
255
+ export declare const DASHBOARD_CARD_MAX_ACTIONS = 2;
256
+ export declare const DASHBOARD_CARD_MAX_TITLE = 80;
257
+ export declare const DASHBOARD_CARD_MAX_SUBTITLE = 140;
258
+ export interface DashboardCardItem {
259
+ readonly id: string;
260
+ readonly title: string;
261
+ readonly subtitle?: string;
262
+ }
263
+ export interface DashboardCardAction {
264
+ readonly id: string;
265
+ readonly title: string;
266
+ /** Opens the extension's `dialog` page straight away, without running code. */
267
+ readonly opensDialog?: boolean;
268
+ }
269
+ /** The card Odyn draws on its dashboard: a few updates or to-dos, and up to two buttons. */
270
+ export interface ExtensionDashboardCard {
271
+ readonly title: string;
272
+ readonly items: readonly DashboardCardItem[];
273
+ readonly actions?: readonly DashboardCardAction[];
274
+ }
171
275
  export type ExtensionView = {
172
276
  readonly type: 'list';
173
277
  readonly items: readonly ListItem[];
174
278
  readonly emptyText?: string;
175
- } | {
279
+ } | ({
280
+ readonly type: 'card';
281
+ } & ExtensionDashboardCard) | {
176
282
  readonly type: 'detail';
177
283
  readonly markdown: string;
178
284
  readonly actions?: readonly ExtensionAction[];
@@ -190,6 +296,14 @@ export interface ChatSummary {
190
296
  readonly projectId: string;
191
297
  readonly title: string;
192
298
  readonly updatedAt: string;
299
+ /** True while the chat's agent is in the middle of a reply. Odyn 2026-11 and later. */
300
+ readonly isWorking?: boolean;
301
+ /**
302
+ * The workspace's main chat. Every chat in one workspace shares it, so it is
303
+ * the key for anything that should apply to the whole workspace. Odyn
304
+ * 2026-11 and later.
305
+ */
306
+ readonly workspaceChatId?: string;
193
307
  }
194
308
  export interface ProjectSummary {
195
309
  readonly id: string;
@@ -201,6 +315,14 @@ export interface ChatMessageSummary {
201
315
  readonly createdAt: string;
202
316
  }
203
317
  /** Odyn's side of the conversation. Every call is permission-checked by Odyn. */
318
+ /** An extension's own shared storage (kit version 6). Save JSON as text. */
319
+ export interface ExtensionStorage {
320
+ get(key: string): Promise<string | null>;
321
+ /** `null` removes the value. */
322
+ set(key: string, value: string | null): Promise<void>;
323
+ /** Keys that start with `prefix` (all of them when left out), in order. */
324
+ keys(prefix?: string): Promise<readonly string[]>;
325
+ }
204
326
  export interface OdynApi {
205
327
  readonly chats: {
206
328
  list(projectId?: string): Promise<readonly ChatSummary[]>;
@@ -227,15 +349,168 @@ export interface OdynApi {
227
349
  showToast(message: string): Promise<void>;
228
350
  /** The values the user saved in the extension's settings. */
229
351
  preferences(): Promise<Readonly<Record<string, string | boolean>>>;
352
+ readonly ui: {
353
+ /**
354
+ * Opens the extension's `dialog` page. Only works while a command started
355
+ * by a click (the sidebar button, a dashboard card, a chat chip or the
356
+ * message box's + menu) is running. From kit version 6 it can say which
357
+ * chat and which item to show; the page reads them from its context.
358
+ */
359
+ openDialog(options?: ExtensionDialogOptions): Promise<void>;
360
+ /** The number on the sidebar button. `null` or 0 clears it. */
361
+ setBadge(count: number | null): Promise<void>;
362
+ /** The chip next to this chat's title. `null` removes it. Needs `ui:chatHeader` and a `chatChip`. */
363
+ setChatChip(chatId: string, chip: ExtensionChatChip | null): Promise<void>;
364
+ };
365
+ readonly dashboard: {
366
+ /** Replaces the dashboard card at any time. `null` hides it. */
367
+ setCard(card: ExtensionDashboardCard | null): Promise<void>;
368
+ };
369
+ /**
370
+ * The extension's own shared storage: text values by key, kept for the whole
371
+ * extension rather than one chat, and readable from its pages too. Cleared
372
+ * when the extension is uninstalled. Needs `apiVersion` 6; no permission.
373
+ */
374
+ readonly storage: ExtensionStorage;
375
+ /**
376
+ * Odyn's note and initiative tools, run as if the given chat's agent called
377
+ * them, under the same team visibility rules. Read tools need `notes:read`,
378
+ * the rest `notes:write`. Kit version 6.
379
+ */
380
+ readonly notes: ExtensionNotesApi;
381
+ }
382
+ /** The note and initiative tools that only look. */
383
+ export declare const EXTENSION_NOTE_READ_TOOLS: readonly ["ListNotes", "ReadNote", "ListInitiatives", "ReadInitiative"];
384
+ /** The note and initiative tools that change something. */
385
+ export declare const EXTENSION_NOTE_WRITE_TOOLS: readonly ["CreateNote", "EditNote", "PatchNote", "RenameNote", "DeleteNote", "CreateInitiative", "MoveNotesToInitiative", "MarkInitiativeComplete", "EditInitiativeArtifacts", "EditInitiativeGroups"];
386
+ export type ExtensionNoteTool = (typeof EXTENSION_NOTE_READ_TOOLS)[number] | (typeof EXTENSION_NOTE_WRITE_TOOLS)[number];
387
+ /** What a note tool answered: the same text an agent would see. */
388
+ export interface ExtensionNoteToolResult {
389
+ readonly text: string;
390
+ readonly isError: boolean;
391
+ }
392
+ /**
393
+ * Who a note tool works for. A chat id runs it as that chat's agent: the chat
394
+ * picks the project, and changes are recorded as the agent's. `{ projectId }`
395
+ * runs it for the person on the extension's own page, where there is no chat:
396
+ * changes are recorded as theirs, and chat-only extras (a card in the chat,
397
+ * attaching the chat's workspace) are skipped. The id is a chat's `projectId`
398
+ * (from `chats.list` or `projects.list`).
399
+ */
400
+ export type ExtensionNoteTarget = string | {
401
+ readonly projectId: string;
402
+ };
403
+ /** Where an initiative is in its life. */
404
+ export type ExtensionInitiativeStatus = 'active' | 'shipped' | 'archived';
405
+ /** One initiative in a project's list. */
406
+ export interface ExtensionInitiativeSummary {
407
+ readonly id: string;
408
+ readonly title: string;
409
+ readonly description: string | null;
410
+ readonly status: ExtensionInitiativeStatus;
411
+ readonly documentCount: number;
412
+ readonly updatedAt: string;
413
+ }
414
+ /** A note that is not in any initiative. */
415
+ export interface ExtensionLooseNote {
416
+ readonly id: string;
417
+ readonly title: string;
418
+ readonly description: string | null;
419
+ readonly updatedAt: string;
420
+ readonly visibility: 'private' | 'team';
421
+ }
422
+ /** A project's initiatives and loose notes, as the person may see them. */
423
+ export interface ExtensionNotesList {
424
+ readonly initiatives: readonly ExtensionInitiativeSummary[];
425
+ readonly notes: readonly ExtensionLooseNote[];
426
+ /** Why the synced half could not be read (the list is then this machine's notes only); null when complete. */
427
+ readonly degraded: string | null;
428
+ }
429
+ /** A named stretch of an initiative's documents. */
430
+ export interface ExtensionInitiativeGroup {
431
+ readonly id: string;
432
+ readonly name: string;
433
+ /** Where new documents go by default. At most one group is active. */
434
+ readonly active: boolean;
435
+ readonly done: boolean;
436
+ }
437
+ /** One document in an initiative, without its body. */
438
+ export interface ExtensionInitiativeDocument {
439
+ readonly id: string;
440
+ readonly title: string;
441
+ readonly description: string | null;
442
+ readonly updatedAt: string;
443
+ /** Who changed it last; null when unknown. */
444
+ readonly updatedBy: 'user' | 'agent' | null;
445
+ /** Changed since the person last opened it on this machine. */
446
+ readonly updatedSinceSeen: boolean;
447
+ /** Its group; null when ungrouped. */
448
+ readonly groupId: string | null;
449
+ }
450
+ /** One initiative with its groups and documents, in order. */
451
+ export interface ExtensionInitiative {
452
+ readonly id: string;
453
+ readonly title: string;
454
+ readonly description: string | null;
455
+ readonly status: ExtensionInitiativeStatus;
456
+ readonly createdAt: string;
457
+ readonly groups: readonly ExtensionInitiativeGroup[];
458
+ readonly documents: readonly ExtensionInitiativeDocument[];
459
+ }
460
+ /** A note or document read as markdown. */
461
+ export interface ExtensionNote {
462
+ readonly id: string;
463
+ readonly title: string;
464
+ readonly description: string | null;
465
+ readonly visibility: 'private' | 'team';
466
+ readonly updatedAt: string;
467
+ readonly markdown: string;
468
+ /** A document's version: pass it to `EditNote` / `PatchNote` so a newer copy is never overwritten. Null for other notes. */
469
+ readonly version: number | null;
470
+ /** Why this machine's copy was read rather than the server's; null when fresh. */
471
+ readonly offline: string | null;
472
+ }
473
+ export interface ExtensionNotesApi {
474
+ /** Runs one note tool for a chat or a project, with the tool's usual input. */
475
+ run(tool: ExtensionNoteTool, target: ExtensionNoteTarget, input: Readonly<Record<string, unknown>>): Promise<ExtensionNoteToolResult>;
476
+ /** A project's initiatives and loose notes, for a page. Needs `notes:read`. */
477
+ list(projectId: string): Promise<ExtensionNotesList>;
478
+ /** One initiative with its groups and documents, or null when it is not in that project. Needs `notes:read`. */
479
+ initiative(projectId: string, initiativeId: string): Promise<ExtensionInitiative | null>;
480
+ /** One note or document as markdown, or null when it is not in that project. Needs `notes:read`. */
481
+ read(projectId: string, noteId: string): Promise<ExtensionNote | null>;
230
482
  }
231
483
  /** What a command receives when it runs. */
232
484
  export interface CommandContext {
233
485
  readonly odyn: OdynApi;
234
- /** Set when the user picked an action on something the command drew. */
486
+ /**
487
+ * Set when the user picked an action on something the command drew. For an
488
+ * agent tool, set when the person clicked a button on the chat card the tool
489
+ * answered with — `input` is then the tool's original input, and `caller`
490
+ * the chat the card is in (kit version 6).
491
+ */
235
492
  readonly actionId?: string;
236
493
  readonly itemId?: string;
237
494
  /** Set when a form was submitted, or the agent called an agent tool. */
238
495
  readonly input?: Readonly<Record<string, ExtensionInputValue>>;
496
+ /**
497
+ * Who called this agent tool: their chat, project and workspace. Missing
498
+ * for clicks, and for agents Odyn cannot tie to one chat. Kit version 6.
499
+ */
500
+ readonly caller?: ExtensionToolCaller;
501
+ }
502
+ /** The chat whose agent called an agent tool. */
503
+ export interface ExtensionToolCaller {
504
+ readonly chatId: string;
505
+ readonly projectId: string;
506
+ /**
507
+ * The workspace's main chat. Every chat in one workspace (a folder of work
508
+ * with its own branch) shares it, so it is the key to use for anything that
509
+ * should apply to the whole workspace. A chat with no workspace is its own.
510
+ */
511
+ readonly workspaceChatId: string;
512
+ /** `spryte` when a Spryte (Odyn's coordinating agent) is calling. */
513
+ readonly kind: 'chat' | 'spryte';
239
514
  }
240
515
  /** Draws a view, or (for an agent tool) returns text for the agent. */
241
516
  export type CommandHandler = (context: CommandContext) => Promise<ExtensionView | string | void>;
@@ -249,6 +524,8 @@ export interface ExtensionRunRequest {
249
524
  readonly actionId?: string;
250
525
  readonly itemId?: string;
251
526
  readonly input?: Readonly<Record<string, ExtensionInputValue>>;
527
+ /** Who called an agent tool; see `CommandContext.caller`. */
528
+ readonly caller?: ExtensionToolCaller;
252
529
  }
253
530
  export type ExtensionRunResult = {
254
531
  readonly ok: true;
@@ -264,7 +541,7 @@ export interface ExtensionBridge {
264
541
  finish(callId: string, result: ExtensionRunResult): void;
265
542
  }
266
543
  /** Every method name the bridge accepts, so Odyn can refuse anything else. */
267
- export declare const EXTENSION_API_METHODS: readonly ["chats.list", "chats.messages", "chats.current", "projects.list", "agents.start", "fetch", "showToast", "preferences"];
544
+ export declare const EXTENSION_API_METHODS: readonly ["chats.list", "chats.messages", "chats.current", "projects.list", "agents.start", "fetch", "showToast", "preferences", "ui.openDialog", "ui.setBadge", "ui.setChatChip", "dashboard.setCard", "storage.get", "storage.set", "storage.keys", "notes.run", "notes.get"];
268
545
  export type ExtensionApiMethod = (typeof EXTENSION_API_METHODS)[number];
269
546
  /**
270
547
  * The one call an extension's entry file makes. Inside Odyn it hooks the
@@ -272,6 +549,23 @@ export type ExtensionApiMethod = (typeof EXTENSION_API_METHODS)[number];
272
549
  * hands the definition back.
273
550
  */
274
551
  export declare function defineExtension(definition: ExtensionDefinition): ExtensionDefinition;
552
+ /** The most pictures a manifest's `screenshots` may list. */
553
+ export declare const MAX_EXTENSION_SCREENSHOTS = 5;
554
+ /** The biggest a single screenshot file may be: 1 MB. */
555
+ export declare const MAX_EXTENSION_SCREENSHOT_BYTES: number;
556
+ /** The picture types a screenshot may be (compared ignoring capitals). */
557
+ export declare const EXTENSION_SCREENSHOT_EXTENSIONS: readonly string[];
558
+ /** Why a dashboard card can't be drawn. An empty list means it is fine. */
559
+ export declare function dashboardCardProblems(value: unknown): readonly string[];
560
+ /**
561
+ * Whether an agent tool's `card` answer can be shown in the chat as a card
562
+ * the person answers (kit version 6). It is a dashboard card with one or two
563
+ * buttons; a click runs the same tool again with the button's `actionId`.
564
+ * Use it to ask before doing something, e.g. "This looks like part of X —
565
+ * [Not now] [Link chat]". A card that fails these checks reaches the agent
566
+ * as plain text, as before.
567
+ */
568
+ export declare function chatCardProblems(value: unknown): readonly string[];
275
569
  /** Returns every problem with a manifest; an empty list means it is fine. */
276
570
  export interface ManifestCheckOptions {
277
571
  /**
@@ -286,15 +580,28 @@ export declare function validateManifest(value: unknown, options?: ManifestCheck
286
580
  /** Every message between a page and Odyn carries this tag. */
287
581
  export declare const SURFACE_MESSAGE_TAG = "odyn-surface";
288
582
  /** Every call a page can make, so Odyn can refuse anything else. */
289
- export declare const EXTENSION_SURFACE_METHODS: readonly ["chatStorage.get", "chatStorage.set", "composer.attachImage", "helper.call"];
583
+ export declare const EXTENSION_SURFACE_METHODS: readonly ["chatStorage.get", "chatStorage.set", "composer.attachImage", "helper.call", "storage.get", "storage.set", "storage.keys", "notes.run", "notes.get", "chats.list", "projects.list", "ui.setChatChip"];
290
584
  export type ExtensionSurfaceMethod = (typeof EXTENSION_SURFACE_METHODS)[number];
291
585
  /** The most a page may keep for one chat, and the largest picture it may attach. */
292
586
  export declare const SURFACE_STORAGE_MAX_BYTES = 5000000;
587
+ /** The most an extension may keep in its shared `storage` altogether. */
588
+ export declare const EXTENSION_STORAGE_MAX_BYTES = 50000000;
293
589
  export declare const SURFACE_IMAGE_MAX_BYTES = 10000000;
590
+ /** The longest `item` a dialog can be opened at. */
591
+ export declare const DIALOG_ITEM_MAX_LENGTH = 200;
592
+ /** Where to open the extension's page (kit version 6). Both are optional. */
593
+ export interface ExtensionDialogOptions {
594
+ /** The chat the page is for, e.g. the `caller.chatId` of a chip click. */
595
+ readonly chatId?: string;
596
+ /** Something of the extension's own to show first, e.g. an initiative id. Plain text. */
597
+ readonly item?: string;
598
+ }
294
599
  /** What the page is showing for. Sent on open and whenever it changes. */
295
600
  export interface SurfaceContext {
296
601
  readonly chatId: string | null;
297
602
  readonly theme: 'light' | 'dark';
603
+ /** The `item` the page was opened at with `odyn.ui.openDialog`, or `null`. */
604
+ readonly item?: string | null;
298
605
  }
299
606
  /** A picture handed over by Odyn (a screenshot) or attached by the page. */
300
607
  export interface SurfaceImage {
@@ -349,6 +656,23 @@ export interface OdynSurfaceApi {
349
656
  /** `null` removes the value. */
350
657
  set(chatId: string, key: string, value: string | null): Promise<void>;
351
658
  };
659
+ /** The extension's shared storage, the same store its own code sees as `odyn.storage`. Needs `apiVersion` 6. */
660
+ readonly storage: ExtensionStorage;
661
+ /** Odyn's note and initiative tools; see `OdynApi.notes`. Needs `notes:read` / `notes:write`. */
662
+ readonly notes: ExtensionNotesApi;
663
+ /** Chats, the same as `odyn.chats.list` (kit version 6). Needs `chats:read`. */
664
+ readonly chats: {
665
+ /** One project's chats when given its id, otherwise every chat. */
666
+ list(projectId?: string): Promise<readonly ChatSummary[]>;
667
+ };
668
+ /** Projects, the same as `odyn.projects.list` (kit version 6). Needs `projects:read`. */
669
+ readonly projects: {
670
+ list(): Promise<readonly ProjectSummary[]>;
671
+ };
672
+ readonly ui: {
673
+ /** Puts the extension's chip next to a chat's title, or removes it with `null`; the same as `odyn.ui.setChatChip` (kit version 6). Needs `ui:chatHeader`. */
674
+ setChatChip(chatId: string, chip: ExtensionChatChip | null): Promise<void>;
675
+ };
352
676
  readonly composer: {
353
677
  /** Puts a picture onto the user's message. Needs `composer:attach`. */
354
678
  attachImage(image: SurfaceImage): Promise<void>;
@@ -463,6 +787,14 @@ export interface OdynHelperApi {
463
787
  /** Sends a message to every open page of this extension. */
464
788
  post(message: unknown): Promise<void>;
465
789
  };
790
+ readonly ui: {
791
+ /** The number on the sidebar button. `null` or 0 clears it. Needs `ui:sidebar`. */
792
+ setBadge(count: number | null): Promise<void>;
793
+ };
794
+ readonly dashboard: {
795
+ /** Replaces the dashboard card. `null` hides it. Needs `ui:dashboard`. */
796
+ setCard(card: ExtensionDashboardCard | null): Promise<void>;
797
+ };
466
798
  }
467
799
  /** Things that happen, delivered to the helper. */
468
800
  export interface HelperEvents {
@@ -483,7 +815,7 @@ export interface HelperDefinition extends HelperEvents {
483
815
  start(odyn: OdynHelperApi): Promise<void> | void;
484
816
  }
485
817
  /** Every call a helper can make, so Odyn can refuse anything else. */
486
- export declare const EXTENSION_HELPER_METHODS: readonly ["programs.find", "programs.run", "programs.launch", "folders.find", "data.path", "data.download", "data.remove", "data.makeFolder", "openLink", "showToast", "sessions.set", "sessions.frame", "pages.post"];
818
+ export declare const EXTENSION_HELPER_METHODS: readonly ["programs.find", "programs.run", "programs.launch", "folders.find", "data.path", "data.download", "data.remove", "data.makeFolder", "openLink", "showToast", "sessions.set", "sessions.frame", "pages.post", "ui.setBadge", "dashboard.setCard"];
487
819
  export type ExtensionHelperMethod = (typeof EXTENSION_HELPER_METHODS)[number];
488
820
  /** The events Odyn sends a helper. */
489
821
  export declare const EXTENSION_HELPER_EVENTS: readonly ["watch", "unwatch", "input", "close", "agentCommand", "chatClosed", "shutdown", "pageCall"];
package/dist/index.js CHANGED
@@ -12,7 +12,7 @@
12
12
  // Keep it dependency-free: it is shared by Odyn, the store checker and every
13
13
  // extension.
14
14
  /** The newest building-kit version. Bumped when something new is added. */
15
- export const EXTENSION_API_VERSION = 4;
15
+ export const EXTENSION_API_VERSION = 6;
16
16
  /** The oldest building-kit version Odyn still runs. */
17
17
  export const EXTENSION_API_MIN_VERSION = 1;
18
18
  // --- Manifest -------------------------------------------------------------
@@ -30,9 +30,23 @@ export const EXTENSION_PERMISSIONS = [
30
30
  'programs:run',
31
31
  /** Give the agent a guide, and ask before it uses the listed commands. */
32
32
  'agent:tools',
33
+ /** Add a button to Odyn's sidebar (`sidebarItem`) with a number badge. Needs `apiVersion` 5. */
34
+ 'ui:sidebar',
35
+ /** Open a full-window page (a `dialog` surface) after a click. Needs `apiVersion` 5. */
36
+ 'ui:dialog',
37
+ /** Show a card on Odyn's dashboard (`dashboardCard`). Needs `apiVersion` 5. */
38
+ 'ui:dashboard',
39
+ /** Read the project's notes and initiatives. Official extensions only; needs `apiVersion` 6. */
40
+ 'notes:read',
41
+ /** Create, change, move and delete notes and initiatives, team-shared ones included. Official extensions only; needs `apiVersion` 6. */
42
+ 'notes:write',
43
+ /** Add a short note to a chat's first message (its `chatContext` command). Needs `apiVersion` 6. */
44
+ 'agent:context',
45
+ /** Put a chip next to a chat's title (`chatChip`). Needs `apiVersion` 6. */
46
+ 'ui:chatHeader',
33
47
  ];
34
48
  /** Permissions only extensions made by Odyn may ask for. */
35
- export const OFFICIAL_ONLY_PERMISSIONS = ['programs:run'];
49
+ export const OFFICIAL_ONLY_PERMISSIONS = ['programs:run', 'notes:read', 'notes:write'];
36
50
  /**
37
51
  * The extensions made by Odyn. Only these may run Mac programs or a background
38
52
  * helper, and only these show as "Official". Everything else in Odyn's
@@ -43,6 +57,7 @@ export const ODYN_EXTENSION_IDS = [
43
57
  'computer-use',
44
58
  'design-skills',
45
59
  'fast-browser',
60
+ 'initiatives',
46
61
  'message-outline',
47
62
  'phones',
48
63
  'scratchpad',
@@ -53,16 +68,67 @@ export function isOdynExtensionId(id) {
53
68
  }
54
69
  /** Systems an extension can say it runs on. Leave out to run everywhere. */
55
70
  export const EXTENSION_PLATFORMS = ['darwin', 'linux', 'win32'];
56
- /** Where an extension can show up inside Odyn. */
57
- export const EXTENSION_PLACEMENTS = ['sidebarPanel', 'chatCard', 'agentTool', 'settings'];
71
+ /**
72
+ * Where an extension can show up inside Odyn. `chatContext` (kit version 6,
73
+ * `agent:context`) is never shown: Odyn runs it when a chat starts, and after
74
+ * a long chat is summed up, and puts its short text answer in front of the
75
+ * user's message.
76
+ */
77
+ export const EXTENSION_PLACEMENTS = ['sidebarPanel', 'chatCard', 'agentTool', 'settings', 'chatContext'];
78
+ /** The most a `chatContext` command's answer may add to a message; anything longer is cut. */
79
+ export const EXTENSION_CONTEXT_MAX_CHARS = 2_000;
80
+ /** How long Odyn waits for a `chatContext` command before sending the message without it. */
81
+ export const EXTENSION_CONTEXT_TIMEOUT_MS = 2_000;
58
82
  /** The kinds of value an agent tool's input can take. */
59
83
  export const EXTENSION_INPUT_TYPES = ['string', 'number', 'boolean'];
60
84
  /** Where an extension's own page can be shown. */
61
- export const EXTENSION_SURFACE_PLACEMENTS = ['composer', 'settings'];
85
+ export const EXTENSION_SURFACE_PLACEMENTS = ['composer', 'settings', 'dialog'];
62
86
  /** The icons Odyn can draw on the button that opens a page. */
63
87
  export const EXTENSION_SURFACE_ICONS = ['puzzle', 'pencil', 'image', 'note'];
88
+ /**
89
+ * The icons an extension can pick for itself in the Marketplace list and on
90
+ * its page. Any name Odyn doesn't know (say, one added in a newer Odyn) shows
91
+ * the puzzle piece instead, so it never stops an extension installing.
92
+ */
93
+ export const EXTENSION_ICONS = [...EXTENSION_SURFACE_ICONS, 'inbox', 'cursor', 'palette', 'globe', 'phone', 'list'];
64
94
  export const SURFACE_MIN_HEIGHT = 120;
65
95
  export const SURFACE_MAX_HEIGHT = 600;
96
+ /** The colours a chat chip's dot can be. Odyn picks the exact shade for the theme. */
97
+ export const EXTENSION_CHIP_COLORS = ['gray', 'blue', 'green', 'amber', 'red', 'purple'];
98
+ /** The longest label a chat chip can carry. */
99
+ export const CHAT_CHIP_MAX_LABEL = 40;
100
+ /** Why a chat chip can't be drawn. An empty list means it is fine. */
101
+ export function chatChipProblems(value) {
102
+ if (typeof value !== 'object' || value === null)
103
+ return ['The chip is not an object.'];
104
+ const chip = value;
105
+ const problems = [];
106
+ if (typeof chip.label !== 'string' || chip.label.trim() === '' || chip.label.length > CHAT_CHIP_MAX_LABEL) {
107
+ problems.push(`The chip needs a label of at most ${CHAT_CHIP_MAX_LABEL} characters.`);
108
+ }
109
+ if (chip.color !== undefined && !EXTENSION_CHIP_COLORS.includes(chip.color))
110
+ problems.push('The chip has an unknown colour.');
111
+ return problems;
112
+ }
113
+ export const DASHBOARD_CARD_MAX_ITEMS = 5;
114
+ export const DASHBOARD_CARD_MAX_ACTIONS = 2;
115
+ export const DASHBOARD_CARD_MAX_TITLE = 80;
116
+ export const DASHBOARD_CARD_MAX_SUBTITLE = 140;
117
+ /** The note and initiative tools that only look. */
118
+ export const EXTENSION_NOTE_READ_TOOLS = ['ListNotes', 'ReadNote', 'ListInitiatives', 'ReadInitiative'];
119
+ /** The note and initiative tools that change something. */
120
+ export const EXTENSION_NOTE_WRITE_TOOLS = [
121
+ 'CreateNote',
122
+ 'EditNote',
123
+ 'PatchNote',
124
+ 'RenameNote',
125
+ 'DeleteNote',
126
+ 'CreateInitiative',
127
+ 'MoveNotesToInitiative',
128
+ 'MarkInitiativeComplete',
129
+ 'EditInitiativeArtifacts',
130
+ 'EditInitiativeGroups',
131
+ ];
66
132
  /** Every method name the bridge accepts, so Odyn can refuse anything else. */
67
133
  export const EXTENSION_API_METHODS = [
68
134
  'chats.list',
@@ -73,6 +139,15 @@ export const EXTENSION_API_METHODS = [
73
139
  'fetch',
74
140
  'showToast',
75
141
  'preferences',
142
+ 'ui.openDialog',
143
+ 'ui.setBadge',
144
+ 'ui.setChatChip',
145
+ 'dashboard.setCard',
146
+ 'storage.get',
147
+ 'storage.set',
148
+ 'storage.keys',
149
+ 'notes.run',
150
+ 'notes.get',
76
151
  ];
77
152
  function buildOdynApi(bridge) {
78
153
  const call = (method, ...args) => bridge.call(method, args);
@@ -87,6 +162,23 @@ function buildOdynApi(bridge) {
87
162
  fetch: (url, init) => call('fetch', url, init ?? null),
88
163
  showToast: (message) => call('showToast', message),
89
164
  preferences: () => call('preferences'),
165
+ ui: {
166
+ openDialog: (options) => call('ui.openDialog', options ?? null),
167
+ setBadge: (count) => call('ui.setBadge', count),
168
+ setChatChip: (chatId, chip) => call('ui.setChatChip', chatId, chip),
169
+ },
170
+ dashboard: { setCard: (card) => call('dashboard.setCard', card) },
171
+ storage: {
172
+ get: (key) => call('storage.get', key),
173
+ set: (key, value) => call('storage.set', key, value),
174
+ keys: (prefix) => call('storage.keys', prefix ?? ''),
175
+ },
176
+ notes: {
177
+ run: (tool, target, input) => call('notes.run', tool, target, input),
178
+ list: (projectId) => call('notes.get', 'list', projectId),
179
+ initiative: (projectId, initiativeId) => call('notes.get', 'initiative', projectId, initiativeId),
180
+ read: (projectId, noteId) => call('notes.get', 'note', projectId, noteId),
181
+ },
90
182
  };
91
183
  }
92
184
  function errorText(error) {
@@ -119,6 +211,47 @@ const ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
119
211
  /** A GitHub username: letters, digits and single dashes, up to 39 characters. */
120
212
  const GITHUB_USERNAME_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$/;
121
213
  const ENTRY_PATTERN = /^(?:[A-Za-z0-9_-]+\/)*[A-Za-z0-9_-]+\.html$/;
214
+ /** The most pictures a manifest's `screenshots` may list. */
215
+ export const MAX_EXTENSION_SCREENSHOTS = 5;
216
+ /** The biggest a single screenshot file may be: 1 MB. */
217
+ export const MAX_EXTENSION_SCREENSHOT_BYTES = 1024 * 1024;
218
+ /** The picture types a screenshot may be (compared ignoring capitals). */
219
+ export const EXTENSION_SCREENSHOT_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.webp'];
220
+ /** What is wrong with one screenshot path, or null when it is fine. */
221
+ function screenshotPathProblem(path) {
222
+ if (typeof path !== 'string' || path.trim() === '')
223
+ return 'Every screenshot must be a path to a file.';
224
+ const label = `Screenshot "${path}"`;
225
+ // A colon catches both web addresses ("https:") and Windows drives ("C:").
226
+ if (path.includes(':'))
227
+ return `${label} must be a path inside the extension's folder, not a web address or drive.`;
228
+ if (path.includes('\\'))
229
+ return `${label} must use forward slashes (/).`;
230
+ if (path.startsWith('/'))
231
+ return `${label} must be a path inside the extension's folder, not one starting with "/".`;
232
+ if (path.split('/').includes('..'))
233
+ return `${label} must not step outside the extension's folder with "..".`;
234
+ const lower = path.toLowerCase();
235
+ if (!EXTENSION_SCREENSHOT_EXTENSIONS.some((ending) => lower.endsWith(ending))) {
236
+ return `${label} must be a ${EXTENSION_SCREENSHOT_EXTENSIONS.join(', ')} file.`;
237
+ }
238
+ return null;
239
+ }
240
+ function screenshotProblems(screenshots) {
241
+ if (screenshots === undefined)
242
+ return [];
243
+ if (!Array.isArray(screenshots))
244
+ return ['`screenshots` must be a list of file paths.'];
245
+ const problems = [];
246
+ if (screenshots.length > MAX_EXTENSION_SCREENSHOTS)
247
+ problems.push(`\`screenshots\` can list at most ${MAX_EXTENSION_SCREENSHOTS} pictures.`);
248
+ for (const path of screenshots) {
249
+ const problem = screenshotPathProblem(path);
250
+ if (problem)
251
+ problems.push(problem);
252
+ }
253
+ return problems;
254
+ }
122
255
  function surfaceProblems(surfaces, apiVersion) {
123
256
  if (surfaces.length === 0)
124
257
  return [];
@@ -148,11 +281,151 @@ function surfaceProblems(surfaces, apiVersion) {
148
281
  if (surfaces.filter((surface) => surface.placement === 'composer').length > 1)
149
282
  problems.push('Only one page can sit in the message box.');
150
283
  if (surfaces.filter((surface) => surface.placement === 'settings').length > 1)
151
- problems.push('Only one page can sit in the settings window.');
284
+ problems.push('An extension can have only one settings page.');
285
+ if (surfaces.filter((surface) => surface.placement === 'dialog').length > 1)
286
+ problems.push('Only one page can fill the dialog.');
287
+ if (apiVersion < 5 && surfaces.some((surface) => surface.placement === 'dialog'))
288
+ problems.push('A dialog page needs `apiVersion` 5.');
152
289
  if (apiVersion < 3 && surfaces.some((surface) => surface.placement === 'settings'))
153
290
  problems.push('A settings page needs `apiVersion` 3.');
154
291
  return problems;
155
292
  }
293
+ /** Checks the sidebar button, the dashboard card and the `ui:` permissions (kit version 5). */
294
+ function uiProblems(manifest, apiVersion) {
295
+ const problems = [];
296
+ const permissions = Array.isArray(manifest.permissions) ? manifest.permissions : [];
297
+ const commandNames = Array.isArray(manifest.commands) ? manifest.commands.map((command) => command?.name) : [];
298
+ const asksUi = permissions.some((permission) => permission === 'ui:sidebar' || permission === 'ui:dialog' || permission === 'ui:dashboard');
299
+ if (apiVersion < 5 && asksUi)
300
+ problems.push('The `ui:` permissions need `apiVersion` 5.');
301
+ const asksNotes = permissions.some((permission) => permission === 'notes:read' || permission === 'notes:write');
302
+ if (apiVersion < 6 && asksNotes)
303
+ problems.push('The `notes:` permissions need `apiVersion` 6.');
304
+ const item = manifest.sidebarItem;
305
+ if (item !== undefined) {
306
+ if (apiVersion < 5)
307
+ problems.push('`sidebarItem` needs `apiVersion` 5.');
308
+ if (!permissions.includes('ui:sidebar'))
309
+ problems.push('`sidebarItem` needs the `ui:sidebar` permission.');
310
+ if (typeof item !== 'object' || item === null)
311
+ problems.push('`sidebarItem` must be an object.');
312
+ else {
313
+ if (typeof item.title !== 'string' || item.title.trim() === '' || item.title.length > DASHBOARD_CARD_MAX_TITLE)
314
+ problems.push('`sidebarItem` needs a short title.');
315
+ if (!EXTENSION_SURFACE_ICONS.includes(item.icon))
316
+ problems.push('`sidebarItem` has an unknown icon.');
317
+ if (!commandNames.includes(item.command))
318
+ problems.push('`sidebarItem.command` must name one of the `commands`.');
319
+ }
320
+ }
321
+ const menuItem = manifest.composerMenuItem;
322
+ if (menuItem !== undefined) {
323
+ if (apiVersion < 6)
324
+ problems.push('`composerMenuItem` needs `apiVersion` 6.');
325
+ if (!permissions.includes('ui:dialog'))
326
+ problems.push('`composerMenuItem` needs the `ui:dialog` permission.');
327
+ if (typeof menuItem !== 'object' || menuItem === null)
328
+ problems.push('`composerMenuItem` must be an object.');
329
+ else {
330
+ if (typeof menuItem.title !== 'string' || menuItem.title.trim() === '' || menuItem.title.length > DASHBOARD_CARD_MAX_TITLE)
331
+ problems.push('`composerMenuItem` needs a short title.');
332
+ if (!EXTENSION_SURFACE_ICONS.includes(menuItem.icon))
333
+ problems.push('`composerMenuItem` has an unknown icon.');
334
+ if (!commandNames.includes(menuItem.command))
335
+ problems.push('`composerMenuItem.command` must name one of the `commands`.');
336
+ }
337
+ }
338
+ const card = manifest.dashboardCard;
339
+ if (card !== undefined) {
340
+ if (apiVersion < 5)
341
+ problems.push('`dashboardCard` needs `apiVersion` 5.');
342
+ if (!permissions.includes('ui:dashboard'))
343
+ problems.push('`dashboardCard` needs the `ui:dashboard` permission.');
344
+ if (typeof card !== 'object' || card === null || !commandNames.includes(card.command))
345
+ problems.push('`dashboardCard.command` must name one of the `commands`.');
346
+ }
347
+ const chip = manifest.chatChip;
348
+ if (chip !== undefined) {
349
+ if (apiVersion < 6)
350
+ problems.push('`chatChip` needs `apiVersion` 6.');
351
+ if (!permissions.includes('ui:chatHeader'))
352
+ problems.push('`chatChip` needs the `ui:chatHeader` permission.');
353
+ if (typeof chip !== 'object' || chip === null || !commandNames.includes(chip.command))
354
+ problems.push('`chatChip.command` must name one of the `commands`.');
355
+ }
356
+ else if (permissions.includes('ui:chatHeader')) {
357
+ problems.push('`ui:chatHeader` needs a `chatChip`.');
358
+ }
359
+ const surfaces = Array.isArray(manifest.surfaces) ? manifest.surfaces : [];
360
+ if (surfaces.some((surface) => surface?.placement === 'dialog') && !permissions.includes('ui:dialog'))
361
+ problems.push('A dialog page needs the `ui:dialog` permission.');
362
+ return problems;
363
+ }
364
+ function isShortText(value, max) {
365
+ return typeof value === 'string' && value.trim() !== '' && value.length <= max;
366
+ }
367
+ /** Why a dashboard card can't be drawn. An empty list means it is fine. */
368
+ export function dashboardCardProblems(value) {
369
+ if (typeof value !== 'object' || value === null)
370
+ return ['The card is not an object.'];
371
+ const card = value;
372
+ const problems = [];
373
+ if (!isShortText(card.title, DASHBOARD_CARD_MAX_TITLE))
374
+ problems.push(`The card needs a title of up to ${DASHBOARD_CARD_MAX_TITLE} characters.`);
375
+ if (!Array.isArray(card.items) || card.items.length > DASHBOARD_CARD_MAX_ITEMS)
376
+ problems.push(`\`items\` must be a list of up to ${DASHBOARD_CARD_MAX_ITEMS}.`);
377
+ else {
378
+ const ids = new Set();
379
+ for (const item of card.items) {
380
+ if (typeof item !== 'object' || item === null || !isShortText(item.id, DASHBOARD_CARD_MAX_TITLE) || ids.has(item.id ?? ''))
381
+ problems.push('Every item needs its own `id`.');
382
+ else
383
+ ids.add(item.id ?? '');
384
+ if (!isShortText(item?.title, DASHBOARD_CARD_MAX_TITLE))
385
+ problems.push(`Every item needs a title of up to ${DASHBOARD_CARD_MAX_TITLE} characters.`);
386
+ if (item?.subtitle !== undefined && !isShortText(item.subtitle, DASHBOARD_CARD_MAX_SUBTITLE))
387
+ problems.push(`An item's subtitle can be up to ${DASHBOARD_CARD_MAX_SUBTITLE} characters.`);
388
+ }
389
+ }
390
+ if (card.actions !== undefined) {
391
+ if (!Array.isArray(card.actions) || card.actions.length > DASHBOARD_CARD_MAX_ACTIONS)
392
+ problems.push(`\`actions\` must be a list of up to ${DASHBOARD_CARD_MAX_ACTIONS}.`);
393
+ else {
394
+ const ids = new Set();
395
+ for (const action of card.actions) {
396
+ if (typeof action !== 'object' || action === null || !isShortText(action.id, DASHBOARD_CARD_MAX_TITLE) || ids.has(action.id ?? ''))
397
+ problems.push('Every action needs its own `id`.');
398
+ else
399
+ ids.add(action.id ?? '');
400
+ if (!isShortText(action?.title, DASHBOARD_CARD_MAX_TITLE))
401
+ problems.push(`Every action needs a title of up to ${DASHBOARD_CARD_MAX_TITLE} characters.`);
402
+ if (action?.opensDialog !== undefined && typeof action.opensDialog !== 'boolean')
403
+ problems.push('`opensDialog` must be true or false.');
404
+ }
405
+ }
406
+ }
407
+ return problems;
408
+ }
409
+ /**
410
+ * Whether an agent tool's `card` answer can be shown in the chat as a card
411
+ * the person answers (kit version 6). It is a dashboard card with one or two
412
+ * buttons; a click runs the same tool again with the button's `actionId`.
413
+ * Use it to ask before doing something, e.g. "This looks like part of X —
414
+ * [Not now] [Link chat]". A card that fails these checks reaches the agent
415
+ * as plain text, as before.
416
+ */
417
+ export function chatCardProblems(value) {
418
+ const problems = [...dashboardCardProblems(value)];
419
+ if (problems.length > 0)
420
+ return problems;
421
+ const actions = value.actions ?? [];
422
+ if (actions.length === 0)
423
+ problems.push('A chat card needs at least one button in `actions`.');
424
+ if (actions.some((action) => action.opensDialog !== undefined)) {
425
+ problems.push('Chat card buttons cannot use `opensDialog` — call `odyn.ui.openDialog()` from the command instead.');
426
+ }
427
+ return problems;
428
+ }
156
429
  const NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
157
430
  const SCRIPT_PATTERN = /^(?:[A-Za-z0-9_-]+\/)*[A-Za-z0-9_-]+\.js$/;
158
431
  const FOLDER_PATTERN = /^(?:[A-Za-z0-9_-]+\/)*[A-Za-z0-9_-]+$/;
@@ -203,6 +476,9 @@ function helperProblems(manifest, apiVersion) {
203
476
  if (helper.downloadHosts !== undefined && (!Array.isArray(helper.downloadHosts) || helper.downloadHosts.some((host) => typeof host !== 'string' || !/^[a-z0-9.-]+$/.test(host)))) {
204
477
  problems.push('`helper.downloadHosts` must be a list of host names.');
205
478
  }
479
+ if (helper.browserSafeCalls !== undefined && (!Array.isArray(helper.browserSafeCalls) || helper.browserSafeCalls.some((name) => typeof name !== 'string' || !NAME_PATTERN.test(name)))) {
480
+ problems.push('`helper.browserSafeCalls` must be a list of call names.');
481
+ }
206
482
  return problems;
207
483
  }
208
484
  function agentProblems(manifest, apiVersion) {
@@ -307,12 +583,13 @@ export function validateManifest(value, options = {}) {
307
583
  problems.push(`\`platforms\` must list some of: ${EXTENSION_PLATFORMS.join(', ')}.`);
308
584
  }
309
585
  }
310
- problems.push(...helperProblems(manifest, apiVersion), ...agentProblems(manifest, apiVersion));
586
+ problems.push(...helperProblems(manifest, apiVersion), ...agentProblems(manifest, apiVersion), ...uiProblems(manifest, apiVersion));
311
587
  if (manifest.owners !== undefined) {
312
588
  if (!Array.isArray(manifest.owners) || manifest.owners.some((owner) => typeof owner !== 'string' || !GITHUB_USERNAME_PATTERN.test(owner))) {
313
589
  problems.push('`owners` must be a list of GitHub usernames.');
314
590
  }
315
591
  }
592
+ problems.push(...screenshotProblems(manifest.screenshots));
316
593
  const hasSurfaces = Array.isArray(surfaces) && surfaces.length > 0;
317
594
  const hasHelper = manifest.helper !== undefined;
318
595
  // From version 4 a guide for the agent is enough on its own.
@@ -335,9 +612,24 @@ export function validateManifest(value, options = {}) {
335
612
  problems.push(`Agent tool "${command.name}" needs a description.`);
336
613
  problems.push(...inputProblems(command, apiVersion));
337
614
  }
615
+ problems.push(...contextProblems(manifest, apiVersion));
338
616
  }
339
617
  return problems;
340
618
  }
619
+ function contextProblems(manifest, apiVersion) {
620
+ const contextCommands = (manifest.commands ?? []).filter((command) => command?.placement === 'chatContext');
621
+ const asksContext = Array.isArray(manifest.permissions) && manifest.permissions.includes('agent:context');
622
+ if (contextCommands.length === 0)
623
+ return asksContext ? ['`agent:context` needs a `chatContext` command.'] : [];
624
+ const problems = [];
625
+ if (apiVersion < 6)
626
+ problems.push('A `chatContext` command needs `apiVersion` 6.');
627
+ if (!asksContext)
628
+ problems.push('A `chatContext` command needs the `agent:context` permission.');
629
+ if (contextCommands.length > 1)
630
+ problems.push('An extension can have only one `chatContext` command.');
631
+ return problems;
632
+ }
341
633
  // --- An extension's own page (kit version 2) -----------------------------
342
634
  //
343
635
  // The page runs in a sealed box inside Odyn's window. It talks to Odyn only by
@@ -346,10 +638,27 @@ export function validateManifest(value, options = {}) {
346
638
  /** Every message between a page and Odyn carries this tag. */
347
639
  export const SURFACE_MESSAGE_TAG = 'odyn-surface';
348
640
  /** Every call a page can make, so Odyn can refuse anything else. */
349
- export const EXTENSION_SURFACE_METHODS = ['chatStorage.get', 'chatStorage.set', 'composer.attachImage', 'helper.call'];
641
+ export const EXTENSION_SURFACE_METHODS = [
642
+ 'chatStorage.get',
643
+ 'chatStorage.set',
644
+ 'composer.attachImage',
645
+ 'helper.call',
646
+ 'storage.get',
647
+ 'storage.set',
648
+ 'storage.keys',
649
+ 'notes.run',
650
+ 'notes.get',
651
+ 'chats.list',
652
+ 'projects.list',
653
+ 'ui.setChatChip',
654
+ ];
350
655
  /** The most a page may keep for one chat, and the largest picture it may attach. */
351
656
  export const SURFACE_STORAGE_MAX_BYTES = 5_000_000;
657
+ /** The most an extension may keep in its shared `storage` altogether. */
658
+ export const EXTENSION_STORAGE_MAX_BYTES = 50_000_000;
352
659
  export const SURFACE_IMAGE_MAX_BYTES = 10_000_000;
660
+ /** The longest `item` a dialog can be opened at. */
661
+ export const DIALOG_ITEM_MAX_LENGTH = 200;
353
662
  export function isSurfaceMessage(value) {
354
663
  return typeof value === 'object' && value !== null && value.tag === SURFACE_MESSAGE_TAG;
355
664
  }
@@ -412,6 +721,20 @@ export function connectSurface() {
412
721
  get: (chatId, key) => call('chatStorage.get', chatId, key),
413
722
  set: (chatId, key, value) => call('chatStorage.set', chatId, key, value),
414
723
  },
724
+ storage: {
725
+ get: (key) => call('storage.get', key),
726
+ set: (key, value) => call('storage.set', key, value),
727
+ keys: (prefix) => call('storage.keys', prefix ?? ''),
728
+ },
729
+ notes: {
730
+ run: (tool, target, input) => call('notes.run', tool, target, input),
731
+ list: (projectId) => call('notes.get', 'list', projectId),
732
+ initiative: (projectId, initiativeId) => call('notes.get', 'initiative', projectId, initiativeId),
733
+ read: (projectId, noteId) => call('notes.get', 'note', projectId, noteId),
734
+ },
735
+ chats: { list: (projectId) => call('chats.list', projectId ?? null) },
736
+ projects: { list: () => call('projects.list') },
737
+ ui: { setChatChip: (chatId, chip) => call('ui.setChatChip', chatId, chip) },
415
738
  composer: {
416
739
  attachImage: (image) => call('composer.attachImage', image),
417
740
  close: () => post({ tag: SURFACE_MESSAGE_TAG, kind: 'close' }),
@@ -448,6 +771,8 @@ export const EXTENSION_HELPER_METHODS = [
448
771
  'sessions.set',
449
772
  'sessions.frame',
450
773
  'pages.post',
774
+ 'ui.setBadge',
775
+ 'dashboard.setCard',
451
776
  ];
452
777
  /** The events Odyn sends a helper. */
453
778
  export const EXTENSION_HELPER_EVENTS = ['watch', 'unwatch', 'input', 'close', 'agentCommand', 'chatClosed', 'shutdown', 'pageCall'];
@@ -483,6 +808,10 @@ export function defineHelper(definition) {
483
808
  },
484
809
  post: (message) => call('pages.post', message),
485
810
  },
811
+ ui: { setBadge: (count) => call('ui.setBadge', count) },
812
+ dashboard: {
813
+ setCard: (card) => call('dashboard.setCard', card),
814
+ },
486
815
  };
487
816
  const settle = (replyId, work) => {
488
817
  Promise.resolve()
@@ -564,6 +893,11 @@ export function starterFiles(options) {
564
893
  '```',
565
894
  '',
566
895
  'In Odyn, open Marketplace → Extensions → Install from folder and pick this folder.',
896
+ '',
897
+ 'Optional: add up to 5 pictures to the Marketplace page by listing them in the',
898
+ '`"odyn"` section of package.json, e.g. `"screenshots": ["screenshots/1.png"]`',
899
+ '(.png, .jpg, .jpeg or .webp, at most 1 MB each).',
900
+ '',
567
901
  'Docs: https://www.odyn.build/docs/extensions',
568
902
  '',
569
903
  ].join('\n'),
@@ -3,7 +3,7 @@ import { type VariantProps } from 'class-variance-authority';
3
3
  /** The Odyn button styles, for use on other elements too. */
4
4
  export declare const buttonVariants: (props?: ({
5
5
  variant?: "default" | "destructive" | "link" | "outline" | "secondary" | "ghost" | null | undefined;
6
- size?: "default" | "xs" | "sm" | "lg" | "icon" | "icon-xs" | "icon-sm" | "icon-lg" | null | undefined;
6
+ size?: "default" | "icon" | "xs" | "sm" | "lg" | "icon-xs" | "icon-sm" | "icon-lg" | null | undefined;
7
7
  } & import("class-variance-authority/types").ClassProp) | undefined) => string;
8
8
  export type ButtonProps = ComponentProps<'button'> & VariantProps<typeof buttonVariants>;
9
9
  /** A button in Odyn's style. Icon-only buttons need an `aria-label`. */
@@ -16,7 +16,15 @@ export declare const ORB_FRAMES_PER_SECOND = 12;
16
16
  export declare const ORB_ROOT_ATTRIBUTE = "data-orb";
17
17
  /** Present on an orb held on its opening frame; the clock leaves those alone. */
18
18
  export declare const ORB_PAUSED_ATTRIBUTE = "data-orb-paused";
19
- /** Moves every clocked animation to where it should be at `now`. */
19
+ /**
20
+ * Moves every clocked animation to where it should be at `now`.
21
+ *
22
+ * Reads first, then writes. Asking a CSS animation for its `playState` makes
23
+ * the browser bring every style on the page up to date, and setting
24
+ * `currentTime` makes them stale again. Mixing the two in one loop paid for a
25
+ * whole-page style pass per animation, per tick — the main cause of lag when
26
+ * switching chats. Splitting them means at most one style pass per tick.
27
+ */
20
28
  export declare function tickOrbClock(now: number): void;
21
29
  /**
22
30
  * Hands these animations to the shared clock, carrying on from wherever each
@@ -31,17 +31,28 @@ function stopClockIfIdle() {
31
31
  clearInterval(clockTimer);
32
32
  clockTimer = null;
33
33
  }
34
- /** Moves every clocked animation to where it should be at `now`. */
34
+ /**
35
+ * Moves every clocked animation to where it should be at `now`.
36
+ *
37
+ * Reads first, then writes. Asking a CSS animation for its `playState` makes
38
+ * the browser bring every style on the page up to date, and setting
39
+ * `currentTime` makes them stale again. Mixing the two in one loop paid for a
40
+ * whole-page style pass per animation, per tick — the main cause of lag when
41
+ * switching chats. Splitting them means at most one style pass per tick.
42
+ */
35
43
  export function tickOrbClock(now) {
36
- for (const [animation, origin] of clockedAnimations) {
37
- // The stylesheet removed it (the orb left the page, or reduce-motion was
38
- // switched on), so stop driving it — setting its time would revive it.
39
- if (animation.playState === 'idle') {
40
- clockedAnimations.delete(animation);
41
- continue;
42
- }
44
+ // Read every animation's state BEFORE moving any of them. Reading
45
+ // `playState` makes the browser bring the whole page's styles up to date,
46
+ // and moving an animation makes them stale again — so reading and moving
47
+ // in turn redid the whole page's styles once per dot, about 200 times a
48
+ // second, which grew with the page until a long chat froze the window.
49
+ const stale = [...clockedAnimations.keys()].filter((animation) => animation.playState === 'idle');
50
+ // The stylesheet removed these (the orb left the page, or reduce-motion was
51
+ // switched on), so stop driving them — setting their time would revive them.
52
+ for (const animation of stale)
53
+ clockedAnimations.delete(animation);
54
+ for (const [animation, origin] of clockedAnimations)
43
55
  animation.currentTime = now - origin;
44
- }
45
56
  stopClockIfIdle();
46
57
  }
47
58
  function tickWhenVisible() {
@@ -56,10 +67,12 @@ function tickWhenVisible() {
56
67
  * one already is. Handing over one that is already clocked does nothing.
57
68
  */
58
69
  export function clockOrbAnimations(animations, now = performance.now()) {
59
- for (const animation of animations) {
60
- if (animation.playState === 'idle' || clockedAnimations.has(animation))
61
- continue;
62
- clockedAnimations.set(animation, now - readCurrentTime(animation));
70
+ // Reads first, then writes — see `tickOrbClock` for why.
71
+ const fresh = animations
72
+ .filter((animation) => !clockedAnimations.has(animation) && animation.playState !== 'idle')
73
+ .map((animation) => [animation, now - readCurrentTime(animation)]);
74
+ for (const [animation, origin] of fresh) {
75
+ clockedAnimations.set(animation, origin);
63
76
  animation.pause();
64
77
  }
65
78
  if (clockedAnimations.size > 0)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@odyn-app/api",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "The public building kit for Odyn extensions.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/New-Genre/odyn-extensions",