Skip to content

Latest commit

 

History

History
120 lines (90 loc) · 27.3 KB

plugin-runtime.md

File metadata and controls

120 lines (90 loc) · 27.3 KB
subsystem plugin-runtime
owned_paths
core/src/host/hooks.js
core/src/host/plugin-model.js
core/src/host/flybot-feed.js
core/src/client/flybot-slots.js
core/src/client/flybot-registry.js
core/types/flybot.d.ts
core/test/flybot.test.js
core/test/flybot-feed.test.js
core/test/flybot-client.test.js

plugin-runtime

FlyBot's plugin API surface on top of the patched core: host hooks, client slots/registry, and types.

Owns

  • core/src/host/hooks.js — host-side plugin hooks.
  • core/src/host/flybot-feed.js — the team activity feed (turn.*, tool.*, exchange.delivered, decision) folded from session/event (#241).
  • core/src/client/flybot-slots.js — client slot registry.
  • core/src/client/flybot-registry.js — plugin discovery/registration.
  • core/types/flybot.d.ts — public types.
  • Flybot unit tests (flybot.test.js, flybot-client.test.js, flybot-feed.test.js); flybot-client.test.js also pins the one name a mention wears on every surface — menu insert, bubble chip, sidebar preview, at-source row — and that two friends' same-named Bots stay apart while every alias still routes to its own handle (#967), the sidebar footer button (Connect apps opens Connectors), the account menu's Plugins row, the account menu's labels (Connect apps, Scheduled tasks opening Settings → Scheduled tasks, Settings, and the only classic Agent mode switch; #392-#394), and the one-line face-room row with its hover title (#304).

Does not own

  • Generic host/client code under core/src/ (→ ds-bot-core).
  • Concrete plugins plugins/tree/, plugins/social/.

Key files

File Role
core/src/host/hooks.js Host hook points: send middleware, transports, grants, the owner-bound held-send approvals (#52), task hold/decide/delete seams (#54), and bot.update({ name?, hidden? }) for plugin rename/hide (#452). A hold's waiting status is the shared rule src/shared/approval-waiting.js isApprovalWaiting(): decide(), heldOf() (the chat card), PENDING_MAX, the in-flight filter and the APPROVALS_KEPT cleanup all ask it instead of comparing to pending, so a pending_approval hold is decidable, drawn as waiting, counted and never cleaned up mid-wait (#754). It also exposes flybot.approvals.stats() — per-Bot, per-status counts of held sends, read-only (#396/#57 direction). A dispatch that fails keeps its error on the record, and an error the sender marks noRetry: true — a refusal nothing can change, such as a friend’s Bot no longer shared — reaches the chat view as retry: false so the card draws no Retry (#954); every other failure stays retryable. Whether a failure can still be retried is one rule in src/shared/held-retry.js (#968): canRetry(record) reads the flags or, for an older record, the stored sentence, and approvals.list marks a failed terminal approval retry: false so a dock card and the chat card answer the same question the same way.
core/src/host/flybot-feed.js Activity feed: folds a Bot Session's session/events into flybot.on('turn.start' | 'turn.end' | 'tool.start' | 'tool.end' | 'exchange.delivered' | 'decision') (#241). turn.end also carries the turn's token counts (summed from its assistant/message usage, floored and capped, counts only) under tokens (#357). decision is fired by decisions.js (#396), not folded from a Session event.
core/src/client/flybot-slots.js Client slots, including the approval card queue (flybot.approvals, flybot/decide) and flybot.sidebar.structure (parentOf, optional include, menuItems, nodeActions). Every flybot.sidebar.section entry is wrapped in div.bt-flybot-section, and a wrapper a section leaves empty (its component renders null) draws no separator: the :empty rule zeroes that margin, border and padding, keyed on emptiness rather than on a section id, so the next null-rendering section is covered too (#818; core/test/flybot-section-empty.test.js renders the host entry and pins both rules). Its Approve / Reject and the held-reply card both ask approval-waiting.js isApprovalWaiting() (pending and pending_approval wait), so a hold carrying the spelling Bot Social's own outbound hold uses stays answerable (#734). Its dock card draws Retry only while src/shared/held-retry.js canRetry(approval) allows it — a terminal failure asks one shared predicate instead of status === 'failed' (#968) — and it publishes globalThis.__flybot__.canRetry so a classic-script plugin's own dock asks the same rule rather than keeping a second copy.
core/src/client/flybot-registry.js Registry. A friend's Bot row in the @ menu carries remoteMentionLabel — the one name its bubble and the sidebar preview wear — so the menu inserts the name the user reads, while the row keeps the handle as its value and the send still routes by handle (#967).
core/types/flybot.d.ts Types (BotView.hidden, bot.update, …).

Entry points

  • Host: hooks.js loaded by the core host.
  • Client: slots/registry wired into the built client.

Invariants

  1. Hooks are the only sanctioned extension surface; never monkey-patch DSH.
  2. Slots/registry must keep the client build green (npm run build).
  3. Types in flybot.d.ts stay in sync with hooks/slots. A tool keeps one schema from flybot.tools.register to the model, and that schema names properties and required on every object node: the wrapper in core/src/host/tool-schema.js completes what DSH's compiler leaves out (a strict OpenAI-compatible gateway reads an absent member as null and rejects the whole request), so a plugin's parameter map needs no such member of its own.
  4. Plugins report a host-visible failure with flybot.warn (Settings → Feedback / diagnostics.jsonl). They must not depend on ctx.logger: DSH may export none, and flybot.warn delegates to rt.warn. A tool whose visibleTo says no is taken out of that Bot's list with agent.ctx.tools.restrict({ deny }) (hidePluginTools). DSH's tools.schemas(agent) lists only what the Agent sees, so the tools the current mask hides count as known when the mask is recomputed; otherwise every other resolve lifts the mask and the chat shows Tool added / Tool removed on each turn (#276). The mask is recomputed when the Agent is created, on every resolveAgent (delivery), and when a turn starts in the Session (a session/event turn/start), so a Bot a plugin adopts only after its Agent was created — the tree taking a Main Bot made after the server started — gets its tools on the next turn, with no restart (#594). The names of these gated tools go to the client as the roster's pluginTools (rt.pluginToolNames), and the client's ContextCell hides Tool added / Tool removed rows for them in every chat, so the first mask leaves no line either (#419).
  5. Reach (#90): the core's owner policy (reach.js) is not a middleware. hooks.js runs it before the bot.send chain, on approval release and on every deliver that carries a sender or a remote origin; a plugin's allow or bypass never skips it, and plugins see its denial as { action: 'deny', by: 'owner-policy' }. Plugins read grants through flybot.bot.hasGrant / hasAnyGrant, BotView.granted, flybot.grants and the bot.grants event. A group's mention receipt (mentionReceipt) turns a not-shared refusal (cannot message their Bots, #273) into its short invite-or-add hint.
  6. flybot.roomApprovals.setProvider lets a plugin (social) attach pending outbound holds to a shared-room view. It does not enable team mode. An allow verdict may say autoSent: true (with recipient) when it skips a hold only because the owner turned Auto-send on; the host then marks an auto-reply's turn Auto-sent (#417). It confers nothing. A hold verdict may carry recipient, who gets the message as the owner knows them (a person's name, trimmed, at most 120 characters); the record keeps it as recipientName and the cards name it (#418). A Bot's held sends are also visible to Bot coordination: list_bots tags the waiting Bot with N to approve (a count only, src/host/tools.js, #587). A flybot.approvals entry with inline: true and a policy is not an overlay card: it draws the decision on a held reply's own bubble in the chat (held-reply.js, props { held, decide, call, actions }), and the overlay skips it. Approval claims (claimApprovals, chatOpened) live in approval-claims.js and are re-exported by flybot-slots.js.
  7. cleanRemote is the record boundary for a friend's granted Bot: name, homeName and owner strip @ (so they cannot look like a mention of another Bot in nameMentions chips/previews) and control characters/newlines, then cap at 60 characters. Handles stay on the handle field. Optional look fields (color, avatar object, avatarUpdatedAt) pass through when present so remote chats, group lists, and headers can render a peer's chosen look; missing look stays handle-seeded. Those labels then flow into delivery fromName (still cleanName'd and the body fenced) and into list_bots friends lines via the remote-chat roster. Group room logs use local team names, not peer labels.
  8. A user's @ of a friend's Bot in a group is answered in that group (#825): the line reaches the Bot as a room line and its reply comes back as one, which is why mentionReceipt (hooks.js, relay role mention) posts only what went wrong — why the send was denied, held, or failed — and no "sent" line at all. The receipt's role stays outside the room round trigger (groups.js opens), so it never opens a round; peer names go through cleanName. A human line that names a friend's Bot already in that room is not 1:1-relayed as the group's admin (Chief): the room message is the delivery, so Chief's missing grant does not block it (#143). A Chief-originated bot.send to an ungranted friend Bot is still deny. A remaining grant deny on a mention that is not in the room says to invite the Bot or add it to the group.
  9. flybot.rooms hosts a Bot Social face-to-face matching room as a native room (upsertFace / applyFaceLine / ofFace / dropFace / onFaceLine (fn(room, line, { mentions }): for the owner's typed line, the handles its @ names, #293) / postNotice / postHuman (quiet; it reads the friend's line for @ of a local Bot by label or handle and passes callIds, so only those Bots answer, #287; an optional mentions handle list from an attested line is resolved first, #293; attested: true, passed only for a line the server stamps typed_by_owner, lets a plain or @all line start this home's Bots in one capped pass, #295; a plain line is answered by the Bots that judge it theirs, @all is not routed, #299) / postRemote; postHuman draws another member's typed line as a chat bubble from that person, #180). Slot flybot.room.overview is a block at the top of a group's overview: while joining, a tile grid (avatar above name + owner) with Leave and Enter chat on one row; once open, compact Leave and Auto-send — not Open/Start/Leave together. Slot flybot.room.members adds rows at the foot of a local group's Members card (not a face or shared room), with host(endpoint, payload) for core endpoints (#876). Slot flybot.new-chat.action adds a row to the sidebar + short action menu (after New group chat; label / hint go through the same t() map). Still not team mode. upsertFace also takes lead ({ handle, picked, canPick, supported }, #476) and a member role label.
  10. Held sends are owner-bound (#52): a hold stamps server-derived provenance on the record — requestId, a sha256 version over the request content, origin (via/hop/audience) and the sender Bot's ownerId; message claims (meta, text) never confer authority. flybot/decide passes authz for any signed-in actor and hooks binds the decision to the sender Bot's owner's session; release revalidates and marks records stale (changed request, deleted sender/target Bot, changed owner — deleteBot invalidates eagerly; a demoted Chief's sender-side holds are invalidated eagerly on setMain, not inside staleReason). release() re-checks immediately before dispatch so an in-flight approve cannot go out after a concurrent invalidation. Unknown invalidateFor field values throw. The pending list is filtered per actor under member enforcement. The default approval card shows senderName/targetName and a delegated marker (Approval.origin).
  11. Task lifecycle (#54): bot.send stamps meta.taskId from the working task (rt.taskIdForSend); a held send then calls rt.taskHeld (blocks that task). The owner's flybot/decide calls rt.taskDecided (approve resumes, reject cancels); deleteBot of a local Bot calls rt.taskBotDeleted (open tasks fail). Incoming holds stay the owner's. The state machine lives in tasks.js; hooks only fire those three seams. Clarification and workBotId wait on #55.
  12. Activity feed (#241): plugins that want to see where team time goes subscribe through flybot.on(...) and never to raw DSH session/event (the maintainer decision on #241). Events: turn.start / turn.end (a Bot's own chat place: 'chat', its hidden group Session 'group', the group's own Session 'room', whose turn is one round, or a friend's Bot chat 'remote' (#248: its Session runs no model and only carries lines to and from the friend, so those turns are transport, not a team Bot's work; remote: {handle, name} names the friend as the sidebar does (Helper · Bob's Bot, Bob's Chief; a {kind: 'remote'} party carries the same name when the roster knows it), and the friend's line into the chat is an exchange.delivered with role: 'remote')), tool.start / tool.end (paired by callId, closed interrupted when their turn ends first) and exchange.delivered (a message arrived in a Session's log: exchangeId as in state.exchanges, replyTo of the answer, from a user / Bot / group / friend's Bot). The feed is metadata only: message text, tool arguments, tool output and model output never leave flybot-feed.js, so a consumer that stores it cannot persist a secret. Strings from outside the team (tool names, remote handles) go through cleanLabel (which also removes untrusted fence markers when spaced out or split), error outcomes carry an upper-case code (RATE_LIMIT, else UNKNOWN) and no message. turn.end carries model (provider/model, cleaned) when the turn made a model call (#366): the model of the Session's latest request/header (DSH logs one only when it changes, so the last one seen applies; before the feed saw one, the Session's own requestHeader()). A turn with no step, and every 'remote' turn, has none; turn.start never has one, since the model is known only once the first request goes out. Delivered means "in the recipient's log", not read; the feed cannot see read. Host nudges (retry, resume, deferred and re-sent deliveries) are not deliveries. Not replayed: a listener hears events after it subscribes. Listeners get copies and a throwing one cannot reach the Session or the other listeners. Events fire in a read-only host too, since they only observe. A friend's Bot in a feed entry is named as the sidebar names it, Helper (Bob) (remoteTitle → remoteBotTitle, #384); the approval overlay's recipient uses the same name. One event is not folded from a Session: decision (#396) is fired directly by decisions.js through rt.fireFeed when the host enables config.decisions, carrying { botId, option, choice, why?, time } for one considered-and-rejected choice. Every new local Bot is announced with flybot.on('bot.created', …): the first Main Bot onboarding creates (on the first state request) and a duplicate-bot copy included, so a plugin that adopts new Bots (the tree root) never misses one; bot.removed fires after a Bot is deleted.

Chat-row pending badge

  • flybot.chatApprovals.setProvider attaches pending Bot Social holds to a 1:1 friend's-Bot chat the same way roomApprovals does for groups, and to a local Bot for its own held sends (#371). The team view puts pendingCount on that bot; the sidebar paints the gold wait dot. flybot.chatApprovals.changed() tells the host the list changed (a hold arrived or was decided), so the badge refreshes at once instead of on the next unrelated revision (#226). Still not team mode.
  • flybot.watched() is true while a FlyBot window on screen reads the team (its roster poll, within the last 8 s; a hidden window does not count, #497). A plugin that polls a remote inbox reads it at its live cadence then, so what arrives reaches the open window within seconds; with no window it keeps its idle cadence. It only observes the client polls and changes nothing.

Sidebar + friend actions

  • hooks.js hands a transport approved: true (never read from the message) when the owner approved that send on a FlyBot card; social uses it to finish Bot Social's own hold with the same click (#258).
  • A flybot.session.dock entry may set policy (the by of the approvals it draws; social registers policy: 'social') and receives the host translator t and claim(ids): call it with the approval ids the dock shows so the overlay draws no second card for them (#260). The host also marks the chat each mounted session dock belongs to as open (chatOpened), and the overlay draws no pointer for an approval of the open chat (#271). A dock entry also gets blank: true only while the host is drawing no session header at all — the session has said nothing (session.blank) and nothing has been sent to it (!session.promptAttempted; the host's own condition is session.blank && conversationPhase(...) === "blank", and a sent-to session reads engaging), and a failed first send never turns it blank again. Social's group strip (social-group-strip) stands in for that missing header exactly then and is gone at the first send (#998). The pointer's chat is the approval's room, else the friend's chat it is addressed to, else its sender Bot (#278).
  • Social registers a second flybot.session.dock entry, social-room-end (FaceRoomStatus, order 40): the plain ended-room line for a left/closed face room (#191).
  • Social registers flybot.settings.page (SocialServerSettings, label Bot Social): the server address, its status and the switch form (#337). The host side is social/server (a read) and social/server-check / social/server-set (writes: blocked on a read-only host and, like every plugin write, while the team has more than one member).
  • Social also registers flybot.account.header (MyHandle): my own Bot Social handle plus Copy at the top of the account menu (#181), hidden while not connected.
  • Social registers two flybot.new-chat.action rows (Join or create by number or link, Add friend). Each run closes the picker. Copying an invite and seeing your own handle happen inside the Add friend panel (#181). Still not team mode.
  • A friend's Bot opens Bot settings through core remote-bot-settings (openSettings('bots', id)): a compact sheet (data-flybot=remote-bot-sheet) with FlyBotDetails from slot flybot.bot.details, not the local Bot editor. The sheet stacks above its scrim like the host settings overlay (#166).
  • In an own Bot's settings the host frames flybot.bot.details in its titled Sharing card (FlyBotDetails({ wrap })), so an entry draws no title of its own; the friend's-Bot sheet and the details drawer draw the entries without a frame (#406).

Inbound shared rooms on flybot.rooms

  • flybot.rooms.applyInbound turns a Bot Social inbox envelope into the owner's inbound-share projection; leaveInbound hides those rooms when the invited Bot is removed or its grant is revoked. Face-to-face methods are unchanged. Still not team mode.
  • flybot.rooms.serveInvites({ identify, mint, preview, claim, verify, revoke }) registers Bot Social's record of a local room's invite tickets (#884; rt.roomInviteServer, see core-host's "A local room's invite link"); it returns the unregister function. Registering it also runs rt.identifyInviteBots once (#899), so a link minted on a room-only Bot gets that Bot's full identity through identify. mint answers undefined when the server has no local_room_invites, and a refusal throws an Error whose code is Bot Social's. flybot.rooms.ticketPeers() lists the pairs a link opens ({ from, to, scope, ticket }, reach.js ticketPeers), so a transport can let a room share through to exactly those, under scope, and to nobody else off its roster.

Face-to-face owner lines

  • onFaceLine receives every appended room line, including the owner's typed human lines. Channel send posts those through postRoomMessage (not sessionController.prompt) so they reach this hook immediately; the social plugin sends them through Bot Social as the owner's local room Bot with a FLYBOT-FACE-HUMAN envelope so every human in the room sees the text; incoming envelopes apply as authorKind: 'human' and the stored body is fenced (#98). A held owner line stays on the in-chat approval card (human text, never the raw envelope) unless face Auto-send is on. Bot replies stay bot-authored. For a Bot line the hook may answer { held: true, approvalId } when Bot Social holds it; the host then keeps the line off the room's bubbles until flybot.rooms.settleHeld(approvalId, 'approved' | 'rejected') (#456). The displayed sender is the Bot Social from snapshot (trustedFaceWho); the envelope who is ignored so a peer cannot impersonate another member. An event with no from falls back to the authenticated handle's home label, then Someone. Still not team mode.

Host Bot look for classic-script plugins

  • Classic-script plugins cannot import src/client/mark.js. The host client registry() therefore also sets globalThis.__flybot__.BotAvatar, BotMark and lookOf to the same components the sidebar / session list / chat header use (configured image / shape / color / emoji, else a stable look from the Bot id or a remote handle). Plugins typically render BotAvatar at 24px with badge: false; face-to-face tiles pass 48. A stored color / avatar on the record still wins; a remote Bot without one is seeded by remoteSeed(handle). A face-room members[] look overlays the chat record (lookBotForRemote). For the same reason registry() sets globalThis.__flybot__.names to { friendBotTitle, friendBotOwner, remoteMentionLabel } from shared/remote-label.js (typed in types/flybot.d.ts), so a plugin names a friend's Bot (Chief (SelfB1233)) or its owner exactly as the sidebar and approval cards do (#498), and a plugin's @ row inserts the one name a mention wears — the one the bubble and the sidebar preview chip — instead of a bare handle (#967). For the same reason registry() sets globalThis.__flybot__.RoomInvitePreview, invitePreviewOf and inviteCountdown from src/client/room-invite-preview.js (#876): the invite card is one component for the face-room paste flow and the member-invite dialog, and a classic-script plugin renders it instead of keeping a second copy of the markup. kind only renames the data-flybot hooks (face-link-preview / room-invite-preview), and invitePreviewOf says only what the server reply can honestly say (a name cleaned at fixed point, the lead, the seat count, the deadline).

A plugin's sanctioned model call (#450)

  • flybot.model.ask({ prompt, system?, maxTokens?, model? }) is the one pattern for a plugin to make a short model call (a classification, a title, a one-line summary). src/host/plugin-model.js builds it; hooks.js exposes it per scope as model.ask.
  • Off unless named. The host config flybot.modelPlugins is an allow-list; an unnamed plugin gets { ok: false, code: 'not-allowed' }, and the list is empty by default, so nothing calls a model unless the deployment opts in.
  • A per-plugin default. The host config flybot.modelFor ({ '<plugin>': '<provider/model>' }) picks the model that plugin's calls use when the call does not name one (#579): the order is input.model, then modelFor[plugin], then the team default. A plugin the host did not name still runs on the team default, a truthy model in the call always wins, and an entry that is not a non-empty string is ignored rather than passed through, so modelFor: { tree: '' } does not turn a working call into no-model. The team default is not asked for a plugin that has its own entry.
  • Capped and safe. Input and system length, output tokens and calls-per-minute are bounded (MODEL_LIMITS); the call streams through the host's own model selection and returns { ok: true, text } or { ok: false, code } — a short token, never provider text (a provider may echo a key). It refuses on a read-only host. It answers with text only: it creates or changes no Bot, room or memory, so a plugin's model call has no side effect the owner cannot see.
  • Tests: core/test/plugin-model.test.js (limits, allow-list, rate, error mapping, and the three-step model precedence of #579) and the flybot.model case in core/test/flybot.test.js.

A plugin's memory access (#450 item 1)

  • flybot.memory.read(botId) returns a Bot's own saved entries ({ text, meta }, capped), and flybot.memory.append(botId, text, meta?) writes one through the ordinary memory path (secret-scanned, size-capped, journaled). It is the seam the tree uses to carry a merged Bot's memory into the one that keeps it.
  • Off unless named. The host config flybot.memoryPlugins is an allow-list; an unnamed plugin's read returns [] and its append resolves { ok: false, code: 'not-allowed' }. The list is empty by default.
  • Bounded and safe: at most 200 entries are read; an entry with a secret or over the entry size is refused; append never throws into the caller (it resolves a short code). A read-only host refuses the write. Tests: the flybot.memory case in core/test/flybot.test.js.

Tests

core/test/flybot.test.js, core/test/flybot-client.test.js, core/test/plugin-model.test.js via npm test.

Common mistakes

  • Reaching into DSH internals instead of using hooks/slots.
  • Changing slots without updating types or client tests.
  • flybot.remote.chats.deliver({ handle, text, name?, home?, human?, ownerName? }) (#227): human: true is the plugin's server-attested "the friend typed this line themselves" (Bot Social typed_by_owner, never derived from text) and ownerName the authenticated owner label; the host then draws that turn's reply as the person. Plugins that cannot attest leave both out and the line stays the Bot's.
  • flybot.bot.send({ from: { kind: 'remote', handle, home?, typedBy? }, ... }): typedBy is the plugin's server-attested owner name for a line the friend typed (Bot Social typed_by_owner); toRemote (src/host/hooks.js) cleans it and the host stores it as the message source's remote.typedBy so the chat draws the person, not the Bot. Never derive it from text; plugins that cannot attest omit it.
  • A remote sender's line that reaches a local Bot's own chat through flybot.bot.send stamps that Bot's friendLineAt in the roster (its row turns unread in a window where the chat is not open) and marks the sender's first line remote.firstContact (#420, src/host/delivery.js). A refused send stamps nothing. Covered by core/test/flybot.test.js.
  • The approval card queue (FlyBotApprovals) runs all its hooks on every render, before its empty-queue early return: a hook after it makes React throw when the first hold arrives and no card ever draws (#254; core/test/overlay-approvals.test.js).