Skip to content

Latest commit

 

History

History
429 lines (294 loc) · 145 KB

core-host.md

File metadata and controls

429 lines (294 loc) · 145 KB
subsystem core-host
owned_paths
core/index.js
core/src/host/
core/src/shared/
core/web-search.js
core/test/identity.test.js
core/test/room-membership.test.js
core/test/remote-room.test.js
core/test/reach-policy.test.js

core-host

The server side of the patched ds-bot core (core/): the part of ds-bot-core that runs in DSH. Split out of that page so a change to the browser client or to the pin and patch tooling does not have to edit this one. Pin, patch format, core/dev/ and the invariants about ds-bot/ and client.js stay on ds-bot-core.

Owns

  • Core server: roster/store, tools, turns/delivery, groups, conversation parts, search index, images, browser bridge, api/bot channel.
  • core/index.js — plugin server entry; core/src/host/ — host modules (roster/store, tools, turns/delivery, groups, channel, remote chats); core/src/shared/ — code both sides import.
  • core/web-search.js — standalone auto web-search provider.
  • core/test/identity.test.js — the state identity schema tests (store state.json v2, #47).
  • core/test/room-membership.test.js — typed room participants, authorship and the membership-window history rule (#50).
  • core/test/remote-room.test.js — granted remote participants, per-viewer presentation and ungranted-Bot refusal; #90 room rules (a room with friends' Bots takes granted local Bots only, revoke evicts, fanout checks author and recipient, room-code Bots); a friend's chat pendingCount from listChatApprovals. The refusal of an ungranted local Bot next to a friend's Bot (names, details, the same group made once the Bot is shared, an ungranted Bot's lines never fanned out, details through the channel); core/test/new-chat.test.js covers ungrantedLocalFix and the new-chat card.
  • core/test/reach-policy.test.js — #90 reach policy: per-Bot grants (default deny, Chief included), the allow/deny matrix per direction, no bypass, hold release and flagged deliveries re-checked, setGrants and bot.grants.

Does not own

  • The browser client, core/client.js → core-client.
  • Pin, patches, core/dev/, core/types/ → ds-bot-core.
  • FlyBot plugin API surface (core/src/host/hooks.js) → plugin-runtime.
  • plugins/tree/, plugins/social/ → their own pages.

Key files

File Role
core/index.js Server entry.
core/src/host/ Host-side helpers (non-hooks).
core/src/host/delivery-board.js The delivery board (#1173): one read-only snapshot of the repository's GitHub issues, pull requests and CI runs, laid out by delivery stage. Every request is a GET; the token is read host-side (rt.secretValue, falling back to none) and never leaves the payload, which carries no credential. read({ force }) caches per URL with the ETag and revalidates with If-None-Match, serving the old snapshot on a 304 and while a refresh runs; there is no timer — the client asks. Issues are placed by label (agent-working → working, agent-ready → ready, else original), pull requests in review, and pull requests merged, and issues or pull requests closed, since the day start in done today — a pull request closed without merging counts, so finished work never sits in no column. A PR card carries the verdict badge of a recent Verdict: on its own tip (an old-tip verdict does not count; CI decides) — every comment page is read, so a verdict past the first hundred comments still decides — and an issue is linked to a pull request only when a closing keyword (close/fix/resolve with #n) names it, never because a body merely mentions the number (#1189), and the header reports the Asia/Shanghai day, the 7-day window and CI as counted of total. Endpoint delivery-board, allowed to any signed-in member because it reads one repository rather than an owner's Bots. Tests: core/test/delivery-board.test.js.
core/src/host/prompt.js The Bot system prompt; a Main Bot's section is headed You are the Main Bot and uses that one term, the one the UI shows (#276). The team rules state the idle default (#699): a Bot does nothing until the user addresses it, it is @-mentioned in a group, its parent gives it a task, or unfinished tasks it was already given are still on its board. It does not invent work. A coordinator acts on a user task rather than on its own initiative — an addressed Bot answers as before.
core/src/host/room-membership.js Typed room participants (human / local bot / granted remote), authorship, membership-window history (#50); rooms with friends' Bots or other owners take granted local Bots only and evict a Bot that stops qualifying (#90). Face-to-face rooms (#83) reuse this model (origin: 'face-to-face', room-only remotes without a person-grant, upsertFaceRoom / syncFaceParticipants / applyFaceRoomLine); a face-room local Bot is viaCode (brought by number), evictUnshared skips face rooms (membership is the server's), and a Bot that returns to the member list drops the empty local left line (#236). Refusing an ungranted local Bot next to a friend's Bot throws ungrantedRefusal: a message that names the Bot, the friend and the two ways out, plus error.details { reason: 'ungranted-local', bots, remotes }; channel.js fail forwards a plain-object details (empty otherwise) and client/api.js puts it on the thrown error, where client/new-chat.js draws a card (Copy invite link for {Bot} via __flybot__.social.createInvite, Leave {Bot} out) read through ungrantedLocalFix in shared/remote-label.js; the same file's copiedToClipboard reports whether the invite link really reached the clipboard (none, or a refused write, is false), and the card then shows the link to copy by hand instead of saying it was copied.
core/src/host/face-mentions.js Who "@" calls in a face-to-face room (#287): every alias of a participant (faceAliases, kept in shared/remote-label.js so the bubble chips the same names, #305; bare name, the picker label <owner>'s Chief / <name> · <owner>'s Bot, <name> · <owner>, handle), local and friend's Bots told apart. faceMentions().handles is the handle of every Bot a line names (the explicit mention list a sender attaches, #293) and mentionedByHandle resolves a received list to my Bots (known counts handles the room has).
core/src/host/reach.js Per-Bot grants and the owner-policy reach() check that hooks.js runs outside the plugin chain (#90); rt.setGrants, rt.hasGrant, rt.hasAnyGrant. A local Bot refused toward a friend's Bot is told that it is not shared with that friend and where to share it (Sharing, #273). rt.reach also reads the room a send already belongs to when the caller names none (#948): sharedRoom applies only to the roomOnly identity — the Bot holds no grant at all and came into that face-to-face room by its number (isCodeJoined) — and looks for the one such room where the exact target is still seated; a Bot that holds a grant, or a room of this home, is judged as it always was and keeps asking for an explicit roomId, so a Bot that joined a room by number — a roomOnly identity with no person-grant — may line that seat, which is the same allowance reach() already applies to an explicit roomId; the room itself is named outbound by the transport, never assumed here. A user-mention with no named room is also judged under mentionRoom (#1126): one live local or inbound group where that Bot is a member and the exact target is still a remote seat, including a seat a group invite admitted (admittedBy: 'ticket'). A plain line, message_bot, a seat that has left, and a stranger outside every such room stay on the grant rule. Face-to-face stays on sharedRoom. Tests: core/test/reach-policy.test.js, core/test/flybot.test.js.
core/src/host/schedules.js Scheduled tasks through DSH's schedule service: list, create, edit, pause/resume, retry and delete for Settings → Scheduled tasks, each task's last run, and moving a Bot's tasks to its fresh part (#354, #473, see Scheduled tasks: edit, pause, missed runs and Scheduled tasks: last run and Retry). Also the bot_schedules tool: the Bot-wide list DSH's own schedule_list cannot give, group chats' own Sessions included (#628, see Scheduled tasks: the Bot-wide list). Tests: core/test/schedules.test.js. Tests for team quiet hours, its refusals and the settings write: core/test/quiet-hours.test.js; the rule itself is in core/src/shared/quiet-hours.js (#1166).
core/src/host/automation-default.js Automation tasks on by default (#378): once per profile, offerBundle asks DSH's plugin manager to switch on @deepseek-ai/dsh-experimental-schedule-bundle and writes the offer to dsh-defaults.json next to the team. Tests: core/test/automation-default.test.js.
core/src/host/notify.js Out-of-app delivery, first half (#355): a transport-agnostic adapter with Slack's chat.postMessage shape, one key per reminder so a retried send cannot duplicate, exponential backoff that honours Retry-After, three attempts at most, a structural failure not retried, and zero transport calls while the channel is off. Ships fakeSlack for tests; nothing calls it until the real Slack transport lands (see Out-of-app delivery, first half). Tests: core/test/notify.test.js.
core/src/host/harness.js Declarative harness (#874 Option B). Step 0 (#903): default names today's prompt / tools / model / step·retry / verifiers (rt.harness, rt.resolveHarness); behavior-neutral. Step 1: lite / medium / heavy load through the same resolver with distinct declared budgets; rt.routeHarness reads one triage label (harness:lite, harness:medium, harness:heavy). applied stays false, so the named modules are not rewritten. harnessPro checks a config offline against CI, core/dev/eval, and rsi-baseline and does not call a model. Step 2: rt.harnessExperience reads the experience library and does not attach it to a tier. Tree prompt exception: @flybot/tree keeps its marked prompt under plugins/tree (outside the three mirrors); listed on the harness, injection stays outside this file. Installed in MODULES right after store. Tests: core/test/harness.test.js, core/test/experience.test.js.
core/src/host/experience.js Experience library read API (#874 Step 2). lookupExperience({ repo, techStack, query, root }) reads core/dev/experience/. Data and check.mjs stay under core/dev/ (see ds-bot-core).
core/src/host/usage.js Usage ledger at $DSH_HOME/bot/usage.sqlite (calls). Ledger v3 (#903): every row carries harness, issue_pr, tool_calls, wall_ms, plus existing token columns (withUsageFields / missingUsageFields). Session turns are metered via foldCalls over turn events (tool/call count, wall time from attempt start, issuePr from turn text or rt.noteUsageIssuePr(sessionId, ref)), not from llm/stream alone. A triage tier is stamped with rt.noteUsageHarness(sessionId, idOrLabels) and lands on the row's harness field; the resume cursor (marks.fold) stays provider / model / retry. Sample row fixture: core/test/fixtures/usage-sample.sqlite. Tests: core/test/usage-fields.test.js, core/test/usage.test.js.
core/src/host/tool-schema.js The tool definitions the model sees: its defineTool wraps DSH's and completes the compiled schema with explicitObjectShape, which fills properties: {} and required: [] on every object node (recursing through properties, items, oneOf/anyOf/allOf, $defs, …). A strict OpenAI-compatible gateway reads an absent member as null and rejects the whole request (null is not of type "array"), so one argument-less tool would break every turn; argument validation keeps using DSH's schema unchanged. Every host module registers tools through this wrapper. Tests: core/test/tool-schema.test.js.
core/src/shared/remote-label.js Owner-badge labels for a friend's Bot and “Shared room from …” titles. friendBotTitle(bot) / friendBotOwner(bot) name a friend's chat from the roster (Chief (SelfB1233)) and its owner (SelfB1233, the person an approval card names) with one formatter for every client surface (#498); friendRecord reads a published roster record (roster.remoteBots, name/owner/home at the top level) as well as a chat's remote, so the name does not change when the chat appears (#967). remoteMentionLabel(record, others) is the one name a mention wears — that title, and on a title two different handles share the home too (Chief (Bob) · bob2.home), so the menu, the bubble, the preview and the host's match cannot drift apart (#967). remoteBotName / isAutoOwnerName / seatOwnerName keep group and face-room labels in that format too (#526): Bot Social's <Bot> · <owner> member label is not a Bot name, and an owner name made up from an install id (FlyBot 682b8a43) gives way to the username in the home handle (Reviewer (flybot682b8a43)). isListedRemoteChat / listedChatBots keep gone, hidden, and home-level (@something.home) remotes out of the 发给 picker, mentions, and list_bots friends lines: a share that ended is not a place to send to. The sidebar asks a different question through isShownRemoteChat (#956): a chat whose Bot left the published list keeps its row, marked no longer shared, while only a hide the user set (bot.hidden) takes a row out. History stays on disk either way.

Invariants

Add a new invariant under the heading it belongs to (or a new heading), not at the end of the page: parallel PRs then do not edit the same lines.

Default harness and usage ledger (#903)

Step 0 of #874 Option B is behavior-neutral: one declarative harness and five usage fields that every calls row must carry.

  • src/host/harness.js installs as rt.harness / rt.resolveHarness / rt.routeHarness after store in MODULES (core/index.js). rt.harness is always the default harness: it names the existing prompt / tools / model / step·retry modules and notes that verifiers live in CI / merge-gate / core/dev/eval, not as an in-process Step 0 list. Unknown harness ids throw. Magpie stays out of scope; nothing under core/dev/harness/ is edited here.
  • Tree exception: @flybot/tree keeps its own marked prompt under plugins/tree (not one of ds-bot/ · patches/ds-bot/ · core/). Every harness lists that exception; injection stays outside the harness file so eval P3-tree-split still passes.
  • Usage ledger calls is version 3 (LEDGER_VERSION): columns include harness, issue_pr, tool_calls, wall_ms beside the existing token fields. withUsageFields fills defaults (harness → default, the rest empty/0); missingUsageFields fails a row that lacks any of the five. foldCalls counts tool/call events, computes wallMs from the attempt start, and takes issuePr from turn text (extractIssuePr) or from rt.noteUsageIssuePr when a caller tags the session. Rows without those turn signals stay '' / 0 by design, not as a silent shell that never wires turn data. foldCalls also copies from.harness onto the row when a caller passes a known id, and leaves state as { provider, model, retry }.
  • Fixture for acceptance: core/test/fixtures/usage-sample.sqlite holds one real calls row. Covered by core/test/harness.test.js and core/test/usage-fields.test.js.

Harness tiers (#874 Step 1)

Lite, Medium, and Heavy are three configs on the Step 0 harness, not a second runtime.

  • resolveHarness('lite' | 'medium' | 'heavy') returns the same module pointers as default plus a declared budget: prompt.maxChars, tools.maxCalls and tools.allow (null on Heavy means the full set the host registers), model.maxTokens, stepRetry.maxSteps and stepRetry.maxRetries. The numbers rise from Lite to Heavy. applied is false: prompt, tools, model, and turn code keep running as they do for default.
  • Triage selects a tier with exactly one label: harness:lite, harness:medium, harness:heavy (catalogued in .github/labels.json; a maintainer creates them with sync-labels.mjs --apply). No such label, or any other label including risk:T*, routes to default. Two tier labels, or an unknown harness:* label, throw. The controller does not write these labels.
  • harnessPro(config) is the offline check an RSI edit of a tier has to pass: the config still names ./prompt.js, ./tools.js, ./models.js, and ./turns.js, its verifiers.offline list is ci, core/dev/eval, rsi-baseline, and it carries no key or token field. The function does not run those three checks and does not call a model. Running them is the pull request's own CI, node core/dev/eval/run.mjs --list, and node --test .github/scripts/rsi-baseline.test.mjs.
  • rt.noteUsageHarness(sessionId, idOrLabels) stores the chosen tier for that Session. Later foldCalls rows and live meter rows record it in harness. Clearing it records default again. Issue/PR, tool-call count, and wall time stay the Step 0 fields.

Experience library (#874 Step 2)

Lessons keyed by repository and tech stack, kept off the global LESSONS.md bag and off the harness tier objects. This is not Option D: nothing rewrites a tier while a task runs, and the library does not call a model.

  • Files live in core/dev/experience/repos/<owner>/<name>/<id>.json and core/dev/experience/stacks/<techStack>/<id>.json. A repo file names that owner/name. A shared stack file has repo: null and lives only under stacks/. A repo file may list techStacks; those tags do not publish it to other repos. The shape is trigger, wrong, right, plus a date, the same spirit as LESSONS.md, scoped to the key.
  • lookupExperience reads only the directories the caller named. A call that passes neither key returns an empty list. A repo lookup opens that repo's directory only. A tech-stack lookup returns repo: null entries from that stack. Passing both returns the repo's tagged entries plus the shared stack entries. query filters id, trigger, wrong, and right after that scope is fixed. Sort is date descending, then id ascending.
  • harnessExperience / rt.harnessExperience call that reader. resolveHarness still returns the Step 0 / Step 1 config. applied stays false. Budgets stay as declared in Step 1. harnessPro still names CI, core/dev/eval, and rsi-baseline. The library does not replace those checks.
  • A new lesson is a JSON file in git. node core/dev/experience/check.mjs checks the tree (path and fields must agree). add writes one new file under that tree and refuses to overwrite. It does not edit harness configs. core/dev/ is outside the npm files list, so a packaged install's default read returns no entries instead of failing the host. Covered by core/test/experience.test.js.

Room execution boundary

groups.js owns room scheduling, membership checks, private-brief selection, prompts, seen cursors and reply presentation. DSH session opening, member turns, running snapshots and cancellation go through room-participant.js (#216). Existing room.sessions / groupOwners links and host permission paths remain unchanged. The driver releases completed waiter timers/listeners and follows context rollover for execution events. This internal DSH boundary adds no external backend or task-specific session mapping; see the same-room participant contract for those gaps.

A member whose Bot record carries external is spoken for by a supervised subprocess instead of a DSH Session (#198). room-participant.js createRoomParticipants is the one boundary: it keeps the DSH driver for every other member and routes a member with that record — and later that member's own session id — to room-participant-external.js. The record is a typed block, external: { driver: 'subprocess', command: [argv...], cwd?, env? }, written through roster.js cleanExternal, which refuses a string command (a shared room setting that runs a shell line is an injection surface), any driver but subprocess, and an interpreter as command[0] (sh, bash, pwsh, env and the like): an argv array whose first word is a shell is the same shell line spelled differently. One process is supervised per (room, Bot) and talks one JSON object per line on stdio ({ type: 'turn', text, note, from, exchangeId } in, { text } or { error } out), so the same external Bot can be invited to a second room as a second process over one identity. open, runTurn, snapshot and cancel reach each driver through the router. Start, clean exit, crash, a user stop and the room-turn timeout are explicit states: a crash is reported as the Bot's own line in the room (groups.js externalRoomLine), and a turn that reaches a dead process ends as { failure } instead of silence. A process that died is not a dead end: the next turn in that room starts a fresh one, which is what the room's line promises. A stop asks on the protocol first ({ type: 'stop' }), sends SIGTERM after a short grace and SIGKILL after a longer one, so a child that ignores the request and the signal does not outlive its seat; an entry whose stop is still in flight is never reused for the next turn, and a turn the user stopped ends as nothing to answer rather than a failure. A crash line carries the last ~200 characters of the child's stderr, flattened to one line, so exit 1 is diagnosable from the room itself; a timeout reads as timed out. The seat owns the process: store.js dropGroupSession closes the (room, Bot) process when a member is removed or the Bot is deleted, and groups.js install closes the rest on host teardown (ctx.effect).

Group standing section (#894)

A member's group Session reads the group's fixed rules from a system-prompt section, bot-room (order 10150, after DSH's own sections so the member's group Session and its own chat share the longest system-prompt prefix), not from each turn's message. groups.js standingText renders it from room settings only: the members line (or the face roster), @Name calls, the notice, the admin note or the face lead / member note, the face rule for lines that name no Bot (routeRules: the member's default, the exception for a line to everyone, other homes' Bots answering in their own home, never explain a pass), and ROOM_REPLY. DSH renders sections on every model step and re-projects them after a checkpoint or in a new part, so the rules are always in front of the model while the history keeps one copy per change instead of one per turn. The own chat and the group's relay Session render no section (''). Each turn's message keeps the round (earlier lines, this round, references, who is still working, speaking order, position), a short routeNote (this line: to everyone, the lead's or "Default to [PASS]", who spoke last, who already answered), and ROOM_REPLY_NOW (or ROOM_REPLY_FORCED). room.noticeSeen[botId] keeps a 16-hex SHA-256 mark of the notice a member last saw, so its next turn says only that the notice changed or was cleared. DSH runtime context (systemPrompt.context) is not used: its runtime-context user messages are not a no-request kind in turn-origin.js. Covered by core/test/rounds.test.js and core/test/context-sharing.test.js.

Group loop guards (#557)

post_to_group has no cap on the rounds Bots start in one group between the user's lines. The old ROOM_BOT_ROUNDS (3) gate was turned off by owner decision 2026-10-21 07:34 Asia/Shanghai: it refused a recurring scheduled task's fourth post with a tool result only, so owner-set runs stopped overnight with nothing shown in the group. A saved room may still carry botRounds; nothing reads it, and channel.js keeps it out of the roster. The guards that stay: MAX_HOPS (6) ends a Bot-to-Bot chain (message_bot, and post_to_group from a relayed turn); MAX_UNREAD_REQUESTS (10) per Bot chat; ROOM_TURNS_EACH / ROOM_ADMIN_TURNS_EACH (2 / 4) per member per round, except the room's admin when a member names it with @: that call is never dropped by the cap (#804), because it is a report to the coordinator rather than a chain, and the round's own turn limit still bounds the round; ROOM_LATE_ROUNDS (10) late replies answered before the user speaks again; and a Bot cannot post_to_group into the room it is speaking in. Face-to-face rooms take the same path. Covered by core/test/rounds.test.js.

A group round's closing report (#913)

When a Bot posts with report_back, reportRound sends it the round's closing report. A member the round's line named and that ended its turn without a line (a pass, or no reply at all) was addressed and did not answer, which is not the same thing as a member that read the line and had nothing to add: the first gets No answer to your @: <names>. (the poster's own line or a person's line named it; another member's call is Called, but no answer: <names>.), and only the rest stays under Stayed silent:. A slot keeps addressed: true when a person's line named it, and by: <bot id> when a Bot's post did, so the report reads the slots instead of round.passed alone. Nothing is re-called: the everyone-passed fallback (fallbackDue) keeps its single extra turn, and the member prompt is unchanged. Covered by core/test/rounds.test.js.

An @Name in a Bot's own line calls that member (#932)

A member's own line calls the members it names in two shapes where the call used to be lost. While a round runs, post_to_group used to be shown in the round and re-delivered to the Session after it, so the names in it were called only by whatever round that re-delivery opened — that is, after the round's report had already listed them as silent. showInRound now reads the line's @ names (the members of the room, never the sender) and keeps them as round.calls; runRound drains them at its loop head (drainMidCalls) with the same ROOM_TURNS_EACH / ROOM_ADMIN_TURNS_EACH bound and the same round limit as a call made from a reply, so the call lands inside the running round and never bypasses the cap. The held post carries called: true (queued by turns.js into roomInbox) and openingSpeakers opens no round for it, so a named member is called exactly once. Second shape: a late line past ROOM_LATE_ROUNDS is quiet, and openingSpeakers read quiet before its @ — an explicit name was swallowed and the admin answered a line that named someone else. The mentions are now resolved first: quiet caps only the spread of a line nobody was named in (it returns nobody then), while a name written in the line is called. An event post (a member's failure, stop or resume notice) still calls nobody, and a name outside the room or the sender itself is not a call. Covered by core/test/rounds.test.js.

Team state and access

  • The saved team (state.json) is versioned. Version 2 (#47) carries the identity schema — members records, a per-Bot ownerId, the owner's designatedChiefId. A legacy version-1 file is migrated on load with a one-time state.json.v1 backup; an existing backup counts only when it is a readable file holding a parseable version-1 state, and anything else (or any malformed ownership) fails closed (read-only). An older binary refuses a newer version read-only. This is schema foundation only — team mode stays disabled, see team. On top of it, member auth (#48) adds host module src/host/member-auth.js, installed after store in MODULES (core/index.js): invitations and authSessions maps live on state (no STATE_VERSION bump), the channel resolves the actor from the x-flybot-member-session header, and endpoints are auth-invite / auth-redeem / auth-revoke / auth-whoami / auth-remove-member / auth-revoke-invite with body.actorId ignored; auth-invite alone also opens for an anonymous caller on a host with no sessions yet (bootstrap first invite), auth-revoke-invite kills an unredeemed invitation (redeeming it afterwards fails with revoked), removeMember refuses a member that still owns a Bot (has_bots), and the auth mutations are serialized behind an in-process lock so concurrent removeMember calls cannot both pass the last-owner check. Sign-in surface only — still not team mode; details in team. On top of it, owner-scoped access (#49) adds host module src/host/authz.js, installed after member-auth: when any authSessions exist, the channel authorize() default-denies cross-owner Bot/room/session access, blocks shared non-isolatable resources (secrets, connectors, folder picks, browser bridge, update-install, diagnostics, set-prefs, add-agent — the DSH Agent is one per deployment and carries no owner) with bot/blocked-shared when more than one member exists, stamps create-bot with actor.memberId, and filters view()/live() to the actor; with no sessions the single-human behavior is unchanged. Session targets resolve through the store's owner maps — a Bot id via botOf, current chats and group Sessions and earlier parts via selfOf/groupOwners/pastOwners (a shared room Session also resolves for its current participants, #50) — and unresolvable ids deny. approval-answer gates on the approval's own Bot or room (resolved by approval id), never on caller-supplied session fields. flybot/* plugin endpoints (src/host/hooks.js) reach any signed-in actor for reads; plugin writes follow the shared rule. view()/live() narrowing (rt.ownedView/rt.ownedLive in authz.js) keeps per-session entries (questions, exchangeTurns) the actor owns, and deployment-wide surfaces (agents, secrets, connectors, memory reviews) only while a single member exists — the same rule as the shared writes; rooms the actor belongs to and the Bots in them (#50) stay visible. create-room refuses a request with neither a member Bot nor any participant. On top of it, room membership (#50) adds host module src/host/room-membership.js, installed after groups: a room may carry typed participants (human / local bot / granted remote bot with handle + owner fields, roles admin / member, joinedSeq / leftSeq); log lines carry authorId + authorKind; history is the membership window (current members and the owner of an invited Bot see seq >= their interval; leave withdraws access; rejoin starts a new interval); local cross-owner rooms accept each side's granted Bots (#90: default deny, Chief included); a friend's Bot from social/remote-bots (cleanRemote) may be added and ungranted Bots stay private, and a room with a friend's Bot holds only granted local Bots (a Bot that joined by room code, viaCode, only for that room); view() labels remotes with an owner badge and shows an invited owner Shared room from <inviter>. Face-to-face rooms (#83) reuse the same participants / membership-window / owner-badge presentation with origin: 'face-to-face': room-only remotes may sit in the room without a person-grant or a friendship, presentRoom treats the viewer as a member (not Shared room from), leave skips the two-Bot floor, and fanoutRoomLine goes to rt.faceRoomFanout instead of friend flybotSend. Still not team mode — see team. Per-Bot grants (#90) add host module src/host/reach.js, installed after room-membership: state.grants[botId] is the list of independent grants (one Bot → one grantee, optionally room-scoped); default deny, Chief included; a Bot whose owner is not a member has none. Grants are set with rt.setGrants, exposed as BotView.granted, flybot.bot.hasGrant / hasAnyGrant, flybot.grants and the bot.grants event. reach() is the owner policy: a friend's Bot may reach only local Bots granted to that sender, only a granted Bot (or one code-joined in that room) may message a friend's Bot, another owner's Bot is reached only if both sides have matching grants or both are members of the room the message belongs to. It is not a middleware: hooks.js runs it before the bot.send chain, again on approval release, and on every deliver with a sender or remote origin (resumed, retried, deferred, re-sent), so no verdict or bypass can skip it and a queued message is judged by what is true when it is delivered. Revoking a grant, or replacing its Chief, removes it from rooms that have friends' Bots or several owners at once (rt.evictUnshared). Approval binding (#52) completes the held-send half of this: state.flybot.approvals records carry requestId, a sha256 version over the request content, server-derived ownerId and origin (via/hop/audience) — claims in the message (meta, text) never confer authority. flybot/decide is allowed through authz for any signed-in actor and the per-record check happens in hooks (decide requires the sender Bot's owner's session, bot/forbidden otherwise, bot/unauthorized without a session); release() revalidates before dispatch and marks the record stale when the request changed, the sender or target Bot is gone (also invalidated eagerly on deleteBot), or the Bot changed owners; a demoted Chief's sender-side holds are invalidated eagerly on setMain (not inside staleReason), and release() re-checks immediately before dispatch so an in-flight approve cannot go out after a concurrent invalidation. Unknown invalidateFor field values throw. flybot/approvals lists only the actor's own holds while enforcement is on; decided stale records keep their attribution in state but appear in no member's list. Which statuses still wait for that decision is one rule in src/shared/approval-waiting.js (#754): decide(), heldOf() in the chat view, the in-flight filter that invalidates holds, PENDING_MAX and the APPROVALS_KEPT cleanup all ask isApprovalWaiting(), and channel.js counts a row's waiting replies with it, so a pending_approval hold (Bot Social's own spelling) is decidable, drawn as waiting and never dropped mid-wait.

  • One writer per home (src/host/home-lock.js): the first dsh to load ~/.dsh takes state.lock (pid, host, a random token in the file, its mtime bumped on a timer) and a second window on the same data only reads, answering bot/read-only and naming the other host. Takeover is on the next write, not on a poll (#1027): a window that was read-only claims the free lock inside save() too, and a change made outside a request (a plugin, a scheduled task, a delivery) belongs in rt.mutate(fn), which takes over and adopts what the other host left before the change is made and written. The writers that changed rt.state inline and then called save() are gone (#1048): delivery.js (a friend's first line and its unread mark, a team exchange) and agents.js (add, adopt, archive and remove of the DSH Agent) take the free lock and adopt first, re-resolving their records inside the change, so a write from a window that was read-only keeps what the other host left instead of replacing it with its older copy. save() itself never adopts: adopt() replaces rt.state whole, so adopting there would discard a change the caller has already made (#1032). Either takeover prints one line — the other dsh has closed; this one now saves the Bot team (was pid <pid> on <host>) — from refresh() and from save() alike, so a window that writes over the file another host left leaves a trace (#1041). A lock file still naming a live holder on this host (its pid answers) keeps the window read-only, which is the #14 rule. A write that fails does not decide the delivery (#1096): the friend-line mark and the exchange record both land before the agent has the line, so a rejected mutate() (the lock was taken away, or the disk refused) prints one warning naming what was not recorded and the line is still handed over — a throw there would drop a message that exists nowhere else.

  • list_bots (src/host/tools.js) tags a Bot with N to approve while one of its outbound sends is held for the owner's decision (#587): a count of that Bot's pending held sends (state.flybot.approvals), never the held message's text, so a coordinator reading the roster sees who is blocked and the tag clears once the owner decides. Covered by the roster case in core/test/flybot.test.js.

  • The Main Bot list is deployment-wide (#613): set-main requires the actor to own body.id and, when body.replace names the Main Bot whose place the target takes, that Bot too, so a member cannot swap another owner's Main Bot for their own or demote it (both are bot/forbidden); roster.setMain keeps the same invariant for every caller, refusing a replace across owners and leaving state.mainBotIds unchanged. A swap inside one owner's team, the plain promotion of a Bot the caller owns, and the deployment-wide list itself are unchanged.

  • Context sharing (#53) is specified in team, Context sharing foundation: the own-chat brief is share-gated (share-brief / briefShared), a memory review reads only its owner as the user (isUserLine) and labels another human as peer (not bot), and keeps cross-owner entries out of the team scope (teamAllowedFrom), list_bots lists only the caller's owner's Bots (a granted child: that child's createdBy subtree), Bots granted to the same owner, and Bots in the current room — another owner's folder and instructions stay private — and a dsh-session: chip in actor-authored send / room-post / answer-question / schedule-create text must resolve for the author (sessionOwnedBy; schedule-create authz keys off body.target). Bot-authored group posts and Bot-to-Bot / own-chat followups drop chips so they cannot tunnel a private chat into another Session. Host modules: src/host/groups.js, src/host/memory.js, src/host/tools.js, src/host/channel.js, src/host/room-membership.js, src/host/authz.js. Team mode stays disabled.

Memory recall: semantic and graph

  • src/host/memory-search.js derives two recall aids from the memory files that src/host/memory.js already reads; both are pure and rebuildable, and the Markdown files stay the only source of truth.
    • Hybrid ranking: termsOf/buildIndex/searchIndex build a sparse TF-IDF vector per entry over words, word character trigrams (4+ letters, so a shared stem matches across endings) and CJK uni/bi-grams, and rank by cosine. recall uses it only when called with semantic: "true"; the default exact-word path is unchanged, so an existing call scores and prints exactly as before.
    • Graph: buildGraph/relatedOf turn [[topic]] references into topic→topic edges and a by: metadata value into file→Bot edges; recall with related walks them (undirected, up to two hops) and lists what is near a topic or Bot. Tests: core/test/memory-search.test.js (pure) and the two recall cases in core/test/memory.test.js.

Plugin memory seam (#450 item 1)

  • memory.js exposes rt.botMemoryRead(botId) (a Bot's own entries, capped at 200) and rt.botMemoryAppend(botId, text, meta) (one entry through the ordinary path — secret-scanned, size-capped, journaled). hooks.js gates them as flybot.memory for a plugin the host names in flybot.memoryPlugins (empty by default). The tree uses it to carry a merged Bot's memory into the kept Bot. Tests: the flybot.memory case in core/test/flybot.test.js.

Decisions a Bot records (#396)

  • src/host/decisions.js gives a Bot one small tool, record_decision({ option, choice, why }) (choice is used or rejected), so a "considered and rejected" choice a reviewer cannot otherwise see is written down. The host appends one JSON line per decision to <home>/decisions.jsonl (bounded on disk at the newest 2000 lines and 1 MB, trimmed by the host as it appends; a read parses at most 1 MB; one write at a time; a write failure is a warning, never a turn failure) and fires the entry on the activity feed as decision. A Bot has no tool to edit or delete a past entry.
  • Opt-in (config.decisions): with the flag off the tool is not registered, nothing is written and no event is fired, so a default install is unchanged. A key, token or password in the text is refused (looksSecret), so the log needs no secret. Tests: core/test/decisions.test.js.

Tasks

The requester may also cancel a result-ready task before acceptance (#202). task-cancel keeps the returned summary and actor history; the cancelled task survives restart and cannot later be accepted, restarted or returned again. Accepted/failed/cancelled tasks stay terminal. This records the requester's task disposition, not proof that an external runtime stopped; no runtime abort or side-effect rollback is implied.

A tracked request with a stable id (task-<hex>) and a small state machine, owned by src/host/tasks.js with state.tasks (additive; corrupt values reset, no version bump). States: open → claimed → working → blocked ⇄ working → result-ready → accepted, with cancelled/failed terminal and accepted reachable only from result-ready — a failed or cancelled task can never display as accepted (#54). Transitions after create are the requester's (server-derived requesterId, the signed-in actor or the single owner; bot/forbidden otherwise, bot/stale on invalid moves), recorded with per-status actor entries. Create does not require owning the Bot: a matching per-Bot grant, or both the requester and the Bot as current members of the named room, is enough; outgoing holds stay the owner's (#52 card). A working task's held send blocks that task (meta.taskId stamped on the send, matching origin when the Bot has several working tasks; updatedAt is strictly increasing, so two starts in one millisecond still order) with the approval id; the owner's decide resumes (approve) or cancels (reject) it; deleting the responsible Bot fails its open tasks. result-ready is protected from prune while it waits for acceptance. Endpoints tasks / task-create / task-accept / task-start / task-return / task-accept-result / task-cancel are an explicit authz allow-list and are record-checked in tasks.js. Records store pointers and a copied summary, never private transcripts. Clarification and workBotId wait on #55's exchange wiring. Team mode stays disabled.

Task input waits

task-wait-input { id, question } is a requester-only bookkeeping action on a working task (#207). It records blocked, waitingFor: 'input' and a fresh inputRequest { id, question, requestedAt }. task-answer-input { id, questionId, answer } requires that exact current question, the same requester and no pending task approvals before returning to working. Question/answer must be nonblank strings of at most 2,000 characters. Only the latest selected question/answer snapshot is kept, with actor entries linking its question ID; chat history is not copied. The wait and snapshot survive restart.

task-start starts a claimed task only. It cannot resume a blocked task; approval decisions and matching input answers are the respective resume paths. An approval-only wait cannot be replaced by an input request. Explicitly stamped meta.taskId sends may still link an input-waiting task; untargeted sends do not guess that association. Such holds accumulate in inputApprovalIds, preserving all outstanding holds while a sibling task stays untouched. Each approval clears only its own ID; approval does not answer the question. Any rejection cancels the task. Cancellation/Bot deletion clear active input/approval-wait markers while retaining the selected input snapshot; late answers cannot revive a terminal task.

These endpoints do not prompt, deliver a clarification, resume an agent or grant execution/tool permissions. Automatic exchange wiring, runtime-scoped observation credentials and team mode remain separate work under #55/#198.

Bot task board (#1058)

Each Bot's work sits on the same state.tasks records. A column is read from the status (open/claimed not started, working/blocked/result-ready in progress, accepted/cancelled/failed done) by src/shared/task-board.js. task-update sets priority (urgent/high/normal/low), progress (0–100), note and a column; a column walks the existing states forward (open → claimed → working → result-ready → accepted) and refuses a reverse move or a blocked wait. The requester or the Bot's owner may update. Anyone else, including a group admin, may not.

track_tasks is the conversation path. The calling Bot creates or updates only its own tasks (source: 'conversation', requesterId the Bot's owner, origin.sessionId the turn, origin.roomId the group when the turn is in one). The same title in the same group updates the open task instead of adding another. The prompt tells the Bot to record work the user or a group actually handed it, to continue the unfinished task the tool names next, and to pass ask when it cannot continue without an answer.

A group job is one overall task plus a subtask per Bot (#1099). The overall task is kind: 'group', owned by the Bot that records it, and tagged with origin.roomId. A group keeps one open overall task; recording another updates that one. A subtask sets parentId to that task. One Bot divides the board (#1182): room.taskLead when that Bot is still a member, otherwise the group admin. A face-to-face room has no board, so it has no task lead. Another Main Bot does not plan. The user, a Main Bot, or the group's admin sets the lead with update_group (task_lead, none clears it) or update-room (taskLead); group settings shows Make task lead / Use the admin on a group that lives here. Assigning a subtask to another member claims it (open → claimed) so the assignee starts without a confirmation. The requester stays that Bot's owner, so the lead still cannot move the card through task-update. A member updates the card assigned to it, records its own room task, or links its own subtask to the overall task. Only the lead records the overall task or assigns a subtask to someone else. Reading the list orders unfinished work by priority (urgent, high, normal, low) and prefers an in-progress task when the priority is the same. ask walks the task to working and then to the existing input wait (waitingFor: 'input'); the reply tells the Bot to ask in its group reply when the task belongs to a group, and to call ask_user only in its own chat. answer resumes that wait. The question is the same record the inbox already shows for a blocked task.

A task may name where it came from (target, #1154): task-create and track_tasks take https://github.com/<owner>/<repo>/issues|pull/<n> or <owner>/<repo>#<n> and store { owner, repo, number, href } with the href always on the /issues/<n> form; anything else is refused (bot/invalid). The target is optional, and a task without one behaves exactly as before: no writeBack, nothing extra on the card, no extra wake.

Deciding such a task leaves one comment for its Bot to post. task-accept-result and task-send-back set task.writeBack = { target, at, state: 'pending', body }; the body starts with Accepted: <title> or Sent back: <note>, carries the result summary, and ends with Decided in FlyBot by <name>. and Decision by: <name> — the person who decided, in a field of its own: Written-by: is the review attribution review-gate.mjs parses (#627), and a decision comment is not a conclusion, so no line of it carries that shape, not even one quoted from a note or a summary, and not through the decider's own display name, which is folded to one line before it is written (#1172). The host holds no token and posts nothing: the Bot that owns the task has the connector, so the comment goes out on its next turn. track_tasks postResult { task, ok, error } records how that went — posted, or failed with the one line the card shows (a failure without error is refused). Only the owning Bot may report, and only while the comment is pending or unconfirmed: one report per decision, no retry loop, and the decision itself stays recorded either way. A pending comment is work for the drive, but not for ever (#1172): only a wake that actually went out counts (writeBack.wakes), the reminder stops after two, and a day after writeBack.at the record turns unconfirmed — a quiet card line, not an error — instead of waking the Bot again. A task that is also real work keeps waking after the cap, and a report that arrives after that is still taken — posted or failed — so a comment that did go out clears the line. Tests: core/test/tasks.test.js, core/test/bot-tasks.test.js, core/test/task-drive.test.js.

tasks without a filter returns tasks the actor requested or whose Bot they own, including a descendant (createdBy) they do not themselves own. tasks { botId } is that Bot's list under the same rule. tasks { roomId } is the group view: a current human admin, or the owner of room.admin, sees tasks member Bots tagged with that group, and nobody else does. A room that never grew participants is read through deriveParticipants, so its members still count. A plain list does not include another Bot's group tasks. Names come back as ownerBotName, and a subtask also carries parentTitle. Tests: core/test/bot-tasks.test.js.

Delivery board (#1173)

The dialog's Delivery view is one snapshot of this repository's GitHub work, not a Bot's task board. src/host/delivery-board.js reads issues, pulls and workflow runs, lays them into raw, ready, working, review and done-today, and caches each URL with its ETag; a stale snapshot is still served while a refresh runs, and a 304 reuses the cached body. It installs no timer: the browser asks (delivery-board, optional force) and the host answers.

The module is read-only by construction: every request is a GET, and the payload carries no token. Issues are placed by state label (agent-working → working, agent-ready → ready, otherwise raw), pull requests in review, and pull requests merged, and issues or pull requests closed, since the Asia/Shanghai day start in done today (a closed pull request counts whether or not it merged). A pull request card shows the newest Verdict: on its own tip — a verdict that names an older tip is ignored and CI speaks instead — plus the tip sha. The header reports today and the 7-day window, medians for issue close and pull merge, the open counts, and CI as counted of total (the list is capped, and the payload says so rather than pretending the tail does not exist). authz.js allows the endpoint to any signed-in member: it reads one repository, not an owner's Bots.

Task-driven turns (#1109)

Unfinished conversation tasks wake the owning Bot. src/host/task-drive.js runs after a board write and after a turn ends. For each owner, the next task is the one compareForWork would name: open, claimed, or working, not waitingFor: 'input', and not an overall group card (kind: 'group'). A decision whose comment is still pending counts as work even when the task is already accepted, and the wake says where to post it (#1154). That card is the plan; the subtasks are the work. One drive pass wakes every such owner, so a group assignment starts each member's own subtask in that same pass. One Bot still receives only its own next task. A task with no group wakes the Bot's own chat. The wake is a follow-up whose header is [Continue task] (delivery.js, role task). It names the task id, priority, and title. It is not a group post, so the reply is not posted back and does not start a round. A subtask wakes that Bot's Session in the group, opening the Session the first time (memberSession) so the driver does not wait for someone to speak there (#1182). That wake is a room turn (role: 'room', taskDrive: true, header [Group chat "…" · your task]). The cue says to start the claimed card and not to wait for a confirmation, and to ask a blocking question in the reply, not in the Bot's own chat. There is no round waiter, so when the turn ends the reply is posted into the room as a late line: a late line calls only the Bots it names, and the driver itself does not post the cue or open a round.

Tests reach the driver through ctx.taskDrive (now, settled, consider). That handle is a Cordis service registered with provide, the same path as ctx.flybot. A live context refuses a bare property write, which stopped startup before any Bot loaded (#1120).

The driver skips a Session that already has a turn, and a second wake while the first is still queued or in progress. A queued wake that has not started is replaced when a different task becomes that Bot's next one. A live group round defers member wakes until the round's turn ends; the parallel wake is the assignment pass, not an extra turn beside a round. A wake that ends with the same board (status, progress, priority, note, title, who is waiting, and the state and target of a pending comment — not updatedAt, which a no-op track_tasks call still bumps) waits 30s, then 2min, 10min, and 30min before trying that Session again. A change on the board, or a person speaking in that Session, clears the wait. A task that is waiting on an answer does not wake: the turn that called ask asks once, and the driver does not loop. The idle rule still forbids inventing work; unfinished work the user or a group already handed the Bot counts as that task. Tests: core/test/task-drive.test.js.

Work view step (#546)

work.bots[id] (turns.js workView) carries step: what the newest running call acts on, from its arguments (command, cmd, pattern, query, url, file_path, path, else description), whitespace collapsed and capped at 80 characters. It is in the owner-only Bot view (authz.js ownedLive picks the actor's own Bots), not in work.rooms, which other members of a shared group read. It goes with the call's result and the turn's end. Covered by core/test/group-status.test.js.

Tool-call count per turn (#471)

turns.js counts a turn's tool/call events (the same events the activity feed folds; a program's sub-calls are not counted) and the count rides the work view: the running turn's count is calls on work.bots[id] and on its entry in work.rooms[roomId], only while it is above 0; a turn that ended keeps its own count as calls on its endedView() entry. One place only — the Agent inbox reads the count for a card's own place (that Bot's own chat, or that one group) from those fields, never summed across places and shown as nothing for 0 or unknown. Covered by core/test/approval-notices.test.js and the inbox model's tests.

Work handed out from a group, in the work view (#553)

An own-chat turn that runs work a group member handed out with message_bot (turns.js handedFromGroup, the rule below that #550's group questions also use, exposed as rt.handedFromGroup) carries fromRoomId in work.bots[id], so that group can show the work as its own while other rooms and the Bot's unrelated own-chat work stay apart. Like step, it is only in the owner-only Bot view. Covered by core/test/group-work.test.js.

Questions about work handed out from a group (#550)

Work a member hands a Bot with message_bot from its group Session runs in a hidden turn of the Bot's own chat (the request carries senderRoomId), and the reply goes back to that member's Session in the group. ask_user in such a turn (turns.js handedFromGroup: role request, not audience: 'user', not a friend's, the sender still has a Session in the room) does not write the Bot's own-chat card: it writes state.questions[<room id>] with header (the asking Bot's name), botId, replyTo ({ botId, hop } of the member that handed the work over), the asking turn's origin and allowCustom: true, so the card shows in the group's composer dock (the client's QuestionDock reads questions[sessionId]). One card per group: while another Bot's waits there, the tool tells the Bot to ask in its reply. answer-question on the room id calls rt.answerGroupQuestion: the answer reaches the asking Bot as a hidden request from the member (fromRoomId = the room, header names the group via deliver's answeredIn), so its reply goes back to the group exactly as the work did and the Bot's own chat gets no card and no visible turn; the group shows a quiet notice (<who> answered <Bot>: <question> → <answer>). The answer keeps the asking turn's origin only when the Bot's owner gave it (fail closed: other for anyone else). A line typed in the group does not close the group's card (it goes to the group's round). authz.js limits seeing (ownedView) and answering/dismissing such a card to the asking Bot's owner or the room's owner. Deleting the room or the Bot drops the card. Face-to-face rooms take the same path on the local home. ask_user inside a group Session is still refused (the Bot asks in its reply, which the group shows). Covered by core/test/group-work.test.js and core/test/authz.test.js.

Inbound shared rooms

  • Bot Social conversations are 1:1; it does not list a FlyBot group. When a room gains a granted remote Bot (create-room, addParticipants, or 新建群聊 picking a friend's chat), the host fans out a FLYBOT-ROOM-SHARE envelope (src/shared/room-share.js) through flybotSend (via: 'room-share' for join/leave, via: 'room' for lines, meta.roomId set) so the Bot's owner's FlyBot can project it immediately — including a room with no chat lines yet. Receive is rt.applyInboundRoomShare (origin: 'inbound-share', recordInboundSharedRoom), labelled Shared room from <inviter> with the room name stored. The envelope names the person who invited the Bot (#848): a human participant in the room when there is one, else the owner-level ownerName / home the social plugin keeps on that Bot's member record (read from whoami), so a group made from the sidebar no longer says a friend; a human participant whose name is this home's own label You (an owner with no name yet) is skipped for that owner-level name, so the far side never reads itself as the inviter (#883); a room projected in from an inbound share forwards its own sharedFrom inviter instead of this home's owner, so re-sharing never renames the person who invited it. A leave envelope, hide, or grant revoke marks it left/hidden (#1167: the host sends that leave while the seat is still a live member, because reach and a room link's pairs only see current participants). The mirror then stays hidden: a later line is refused and a join that is not newer than that leave does not open it again; a newer join can. A hidden mirror sends nothing further. A projection is named from the envelope and matched by the transport (#873, #909): applyInboundRoomShare still shows data.inviterName (so a room carried here by a Chief's Bot reads after the person who invited it), but which room a share may touch is decided by event.fromHome / event.fromHandle — a share naming a room of mine while arriving from a home that room does not belong to is refused not-inviter, and the recorded sharedFrom identity only ever follows the transport, so an envelope can be displayed but never re-write whose room it is. Only op: 'join projects a room: a line envelope for an unknown remote room is refused unknown-room instead of opening one named by its own payload. A line keeps its own who, or the sender when it names nobody, never the inviter; its authorId follows the payload only when the handle traces to the transport sender, a remote seat of that room, or its inviter from the home that invited the room (#918 — the seat branch and the inviter branch now ask the same question) — otherwise the line is shown as the Bot that delivered it with unverified: true, and it is still kept so a forged claim cannot silence a message. The room match stays on the inviter handle alone, so a line presenting that handle from another home still lands and is only attributed to its sender: a room may match where a signature does not. Which room a share belongs to is recorded by a join alone; a line follows that record and never rewrites the inviter home or handle, or every message would make its sender the inviter. A room recorded before #873 may hold no inviter home at all, and then the only signature it keeps is the transport naming the sender itself (claim === sender); the next join records the home and the room stops being legacy. reach() allows a local member to message a remote that already sits in that room when roomId is on the send. Still not team mode. #904: the projection now records the host's side too. recordInboundSharedRoom keeps the guest's own Bot and one remote participant for the Bot that carried the join (draft.inviterHandle, with admittedBy: 'ticket' / roomOnly when a link seated it) — invitedHandle on this side names one of the guest's own Bots, so it is never recorded as a remote, and the friend path keeps its one remote. The rest of the host's room rides the join as roster and notice: notifyRoomShare sends { handle, home, name, ownerName } for every Bot of this home in the room, and { name, ownerName, connected: false } for one with no Bot Social identity; nothing of another guest's home is published, and the roster is re-sent whenever the host adds or removes a Bot of its own. applyInboundRoomShare reads a roster only when the envelope names this mirror's room (data.roomId = sharedFrom.remoteRoomId) and comes from its bound inviter, and merges it into the participants: one remote participant per entry (fromRoster, roomOnly), keyed by (home, handle) so two same-named Bots of different homes stay distinct, verified only for the seat or a published friend Bot. Verification is a signature, not a mention: an unverified entry is displayed and is not who a line may claim to be, an identity-less one is shown as not connected, and an @ of either — the title the @ menu inserts, or the roster name when it has no handle — still starts that Bot's turn on its own home (#949). A roster is the whole list, so a join that drops an entry marks it left (soft leave: the entry stays with leftSeq, history still resolves) and a re-sent join clears the mark; an empty roster: [] clears the list, while a join without the field changes nothing. The notice is replaced by the next join that carries one; an explicit empty string clears it and a missing field leaves it. #923: the one pair a room link opened is enumerated even when the seat record predates it. A seat a link admitted is a pair through its own admittedBy: 'ticket' / ticket, and, when that record is older, through the link's own homes — the home-to-handles map the accept writes and keeps — reached from the Bot whose handle it knocked on, or from the room's lead when the seat did not even keep that. So a group envelope from this home reaches that guest as the room's traffic (sendTicketShare names the room the link was minted for) instead of falling back to a friendship the two homes never had; a friend's Bot added by hand is named by no link and stays out. This is FlyBot's half of the pair: Bot Social still decides at send time, so a room-started conversation it refuses needs the server-side fix.
  • A Bot named in a shared room answers in that room (#825). fanoutRoomLine (src/host/room-membership.js) puts the calls a line makes in the envelope's calls (resolved where the room's mentions are resolved: the social handle, the roster name, and the title the @ menu inserts, so @Chief (owner) and @Data (Friend) name the same Bot a bare name would; a Bot with no social handle is called by that roster name, and a not-connected: id is never a token — #949). A fenced or quoted @name stays literal text. The envelope also carries the line's hop: a person's line is hop 0 and a Bot's answer is one hop on. The receiving applyInboundRoomShare keeps only the calls that name this room's own Bot (localCalls), opens one turn for each through rt.startInboundTurn (groups.js: participant.open + participant.runTurn + appendRoom), and skips it when the line's hop has reached the room's maxHops (kept on the room, DEFAULT_MAX_HOPS 3) or when the same remote line was already answered — the last 200 keys remoteRoomId:seq:botId live in room.answered. That turn's answer is an ordinary room line, so fanoutRoomLine sends it back to the inviter as an op: 'line' envelope under the inviter's roomId, one hop on; an answer its owner's approval holds or refuses still shows as one notice line in the room instead of silence. Remote text stays inside the fencedUntrusted fence.

A local room's invite link (#852)

  • A room that lives in FlyBot can hand out one link: https://social.botawaken.com/r/<inviter Bot handle>/<ticket>. core/src/shared/room-share.js parseRoomInviteLink reads both shapes in the one invite field and says which one it saw ({ kind: 'local', handle, ticket } / { kind: 'face', ticket }), and roomInviteUrl builds the local one. The ticket is randomBytes(16) in base64url — 22 characters of [A-Za-z0-9_-], inside the 64-character shape FACE_INVITE_TICKET already accepts. The handle is its own path segment because send_as_member addresses a Bot by handle, and a ticket cannot carry one inside that limit.
  • The room record is the only authority: room.localInvites [{ ticket, handle, botId, expiresAt?, mintedAt, mintedBy, server?, url?, homes?, acceptedAt?, withdrawnAt? }]. A link has no deadline and is not single-use (owner decision 2026-10-24, #892, Bot Social #65): anyone it is given to can join with it, each home once (homes: { [home]: [seat handles] }; a record from before keeps its one acceptedBy / seats, read through homesOf), until the owner withdraws it or the room is deleted. A link minted with an expiresAt (an older Bot Social, or before the change) keeps that deadline. Several may be live at once (#878); a record written before the list (room.localInvite) is moved into it on first read. rt.mintRoomInvite(roomId, actor) adds one (dead links that seated nobody are dropped then); rt.localInvitesOf(roomId) lists the open ones (not withdrawn, not out of date; a link someone joined with stays open) and rt.localInviteOf(roomId) is the newest; rt.roomOfLocalInvite(ticket) resolves the room from the ticket; rt.revokeRoomInvite(roomId, ticket?) withdraws one link, or without a ticket every open one: it sets withdrawnAt (nobody new joins; the server ticket is revoked without endClaims), the seats it made stay, and a withdrawn link that seats nobody is dropped. A shared (inbound-share) or face-to-face room refuses to mint — it has its own invitation.
  • Three steps on the same envelope, answered by the room record: the invitee's host sends { op: 'ask', ticket }; the inviter answers { op: 'invite', ticket, roomId, name, lead, expiresAt }, which is information only (it consumes nothing and seats nobody); the accepting knock { op: 'ask', ticket, accept: true } runs verify → consume → add → the existing join, which addRoomParticipant sends. applyInboundRoomShare handles both ops before the branch that projects a room for an unmatched join / line, so a knock never conjures a room; parseRoomShare accepts ask without a roomId and requires one on invite. The asker is event.fromHandle (the transport's sender), never a field of the payload. The accepting home is added to homes: the same home retrying is idempotent success (the lost join reply is sent again) and another home may join too (#892); an add the room refuses removes that home again. Refusals are local on the inviter's host (unknown / revoked / expired / no-sender / not-addable) and change nothing. The UI half — the Members card's Invite by link row and the dialog the offer draws — is still to come. Covered by core/test/remote-room.test.js (#852).
  • The endpoints the UI calls (#876): room-invite reads the live link (rt.localInviteOf, what the Members card copies and counts down), room-invite-mint and room-invite-revoke are the room owner's or a human admin's call (authz.js), and room-invite-ask is the invitee's side: rt.askLocalInvite reads the handle and ticket out of the link and sends { op: 'ask', ticket } from one of the actor's own Bots whose home has a Bot Social identity — the link cannot name a sender, so the transport does, as on the answering side. Join is the same call with accept: true, and the answer arrives as invite through applyInboundRoomShare (rt.onLocalInviteOffer). A face-room link, a bare ticket and a ticket outside the 16–64 shape are refused in one sentence and send nothing; the knock carries no roomId, so an invitee can never name a room it has not seen.
  • Strangers (#884, #878): when the social plugin registers Bot Social's ticket record (flybot.rooms.serveInvites({ identify, mint, preview, claim, verify, revoke, endClaim }), rt.roomInviteServer), a mint goes to the server (server: true; a server that fails refuses the mint — it is never swapped for a local ticket — and one without local_room_invites answers undefined, which keeps the friend-only local ticket). The link knocks on invite.botId, the room lead when it has a Bot Social identity; a lead with none gets one first through the seam's identify (the link is the owner's consent, as accepting a friend's invite is), and so do the Bots a guest brings on Join. A room-only identity (left by a face-to-face room) counts as none (#899): it is not polled and cannot send the knock, so identify promotes it, and when the seam is registered rt.identifyInviteBots promotes the Bot of every server link still open or holding a seat. room-invite-preview answers roomId and joined next to claimed: a claim is not a seat and a seat is not a claim — joined is true when this home holds the room, matched by that link's ticket or, since #1176, by sharedFrom.remoteRoomId for a room the host added one of these Bots to by add-member (no ticket), so a link for a room already here is not reported as a knock. On the invitee's side, Join (room-invite-ask with accept: true and bots, the invitee's own Bot ids) claims the ticket at the server for those Bots, all or none, then the first of them sends { op: 'ask', ticket, accept: true, roomId, bots }; room-invite-preview (rt.previewLocalInvite) reads the server's public record before Join. On the inviter's side the knock is only a request: seatByTicket reads the claims back with this home's own credential (verify → claimers, every home that joined; an older server's single claimer) and seats exactly the Bots of the claim that brought the asker, from that home, in this room, or nothing — refusals unclaimed / claimed-elsewhere / wrong-room / lookup-failed / expired / revoked / not-addable, each its own. A seat is { remote: true, admittedBy: 'ticket', ticket, hostBotId }: no published friend (publishedRemoteOf stays empty, nothing in the sidebar), and notifyRoomShare / fanoutRoomLine reach it from hostBotId only, with meta.scope = the room id so the transport sends it under that room (Bot Social's room reach). A Bot Social link is the minting owner's consent to show the room as it is: while a link is open or holds a seat, that owner's Bots pass mayShareRoom for the room (reach.js openedByLink). Lines and leaves a remote seat sends to a room of this home are ingested into it (ownSeatOf → ingestSeatShare: logged, fanned out to the other seats but not back to the sender's, called local Bots answer through rt.startInboundTurn); a line for a room of this home from anyone it does not seat is dropped (not-seated) and never projects a copy. Withdrawing a link does not end its seats (owner decision on #853). A seat ends when the host removes it or its home leaves (once that home's last seat is gone its claim ends at the server, settleTicket → endClaim; the link stays open for others), and when the room is deleted (rt.dropRoomInvites, called by deleteRoom, revokes every link with endClaims). On the guest's side a join naming a ticket projects only when this home claimed it: state.roomClaims[ticket] names that inviter (the transport's sender), that room, and the Bot the join names, else unclaimed and nothing is made. One projected room holds every Bot a ticket seated (sharedFrom.ticket; each join adds or revives only the Bot it names) and the owner as a human participant, so the person the link admitted can write there; a person's own line goes under their Bot Social owner name when this home only knows them as You or their member id (seatSayer, both directions). Ticket lines carry the text as written (seatText: a stored fence is opened, never nested) and the receiving home fences it once. a seated Bot's line goes from that Bot under the inviter's room id, and the last of its Bots leaving gives the claim back at the server. Reach: reach.js ticketPeers(state) lists the only pairs a link opens — { from, to, scope, ticket } for a seat here (from its hostBotId), for a room of another home that seated Bots of mine (from those Bots to its inviter), and for a claim this home made (state.roomClaims[ticket], the accepting knock's way out, dropped when the last seated Bot leaves or after a day unanswered). reach() allows a local → remote send that matches one exactly under meta.scope (the exact handle, never any Bot of that home), and the social plugin reads the same list (flybot.rooms.ticketPeers()) for its transport. A seat is enumerated from the link's own record of who it admitted, and reach.js reads that record through homesOf (room-membership.js) — one reading, used by both halves, so a link accepted before homes was written (acceptedBy + seats) enumerates the same pair and a room that predates the field does not lose it (#929); linkSeats stays read-only, writes no state and sends nothing. A join also carries history: the recent chat lines (who, authorKind, seq, unwrapped text, and a Bot's handle when this home has one). An absent field does not backfill (an older host); an empty list means there were no chat lines. The guest copies them into the mirror once, only while that log has no chat lines yet, and does not start a turn for them (applyShareHistory). A line that arrives after the join — on the guest mirror (applyInboundRoomShare) and on the host room (ingestSeatShare), and a guest Bot's own reply from startInboundTurn — is posted into the room Session with rt.showSharedLine (displayOnly). The conversation pane is that Session; room.log alone only feeds the sidebar preview, because the channel view strips log. The followup is not appended again, so it does not fan out a second time, and openingSpeakers returns nothing for it, so a name in the text does not open a round. A redelivery of the same remote seq does not draw a second bubble (room.shownShare). #1064: the knock is not the only way those seats are made. The social plugin's inbox poll calls rt.seatOpenClaims (flybot.rooms.seatOpenClaims), which verifys every open server link and seats each claiming home the same way, then sends join only for a seat whose notice has not yet left (invite.told). A lost, denied or unread knock still seats the guest, and the join opens the group on the guest's sidebar. A room that cannot be shown (not-addable), a notice waiting on approval (held) or one that does not leave (not-delivered, after a few tries) keeps waiting on the link (reason, and for not-addable the local Bot names). The Members card says so, and Seat them calls room-invite-seat (the room admin, same rule as mint), which runs that read again with the tries reset. #1161: a quiet poll reads one open server link about once a minute and, when the preview says Too many attempts (or answers rate_limited / locked_out), waits out the minutes that sentence names or the Retry-After, up to 10 minutes, so a home that stays open does not preview again before that cool-down elapses; Seat them and a read of one room still verify at once, and a preview that did not answer is tried again on the next tick. Every room-share text, a join and a line alike, is kept within Bot Social's 1–4000 character limit (ROOM_SHARE_TEXT_MAX): instruction-override lines are left out of its history and notice, then the oldest history, the notice, and roster entries are dropped until it fits; a history or roster emptied only to fit is omitted, so it is not a false "no lines" or "no Bots". A seating send that is still pending_approval is hold and is not recorded as told; one whose hold is not listed yet is retried as not delivered. Covered by core/test/room-ticket-seat.test.js and the #923/#929 cases in core/test/remote-room.test.js.

Face-to-face owner send

  • Channel send on a face-to-face room (origin: 'face-to-face') always postRoomMessages, then shows a quiet local notice (postToRoom quiet+notice). It does not call sessionController.prompt unless a local Bot is @mentioned. Unsigned-in cloud UI uses implicitRoomOwner (the local human) so the line still fans out over onFaceLine / FLYBOT-FACE-HUMAN and the other FlyBot instance shows it; that path must not open a bot round (#124). Covered by core/test/remote-room.test.js and core/test/rounds.test.js.
  • A face room's auto title (faceRoomFallbackTitle) is built from every member the room has had (the poll's snapshot plus stored participants) and the creation time (faceRoomCreatedAt: spec.createdAt, else the joining deadline minus the 10-minute window, else the start; #194), so every home shows the same title and a member who left is not dropped from it (#182).
  • upsertFaceRoom keeps the six-digit number a room was joined by (spec.joinCode / spec.code) on room.face.joinCode, and the auto title uses it whenever a later upsert (a poll, a leave) does not repeat it, so the title never falls back from the number to a date (#304).
  • Another member's own face-room line arrives through flybot.rooms.postHuman (#180): a quiet postToRoom post carrying source.human { authorId, name }. group_relay turns it into a round reply { kind: 'user', other: true, name, authorId } (not the grey notice), so it draws as a left-hand bubble from that person; it asks no Bot to answer. postNotice stays for system events (joined, left, started). Covered by core/test/rounds.test.js.
  • Lines from elsewhere mid-turn (#455): a postHuman or postRemote post (source.human / source.remote) that arrives while the group Session is running is not followuped, which would park it in DSH's composer queue (raw [Group post from …], steerable). postToRoom holds it (heldPosts): the round that is running (rt.roomProgress) shows it at once, as personReply or a { kind: 'remote' } reply (left out of round reports), and a Bot's line is logged then (a person's line is in the log already through applyFaceLine); after agent.whenIdle() it is followuped with shown: true, so its own round routes it by the usual rules but group_relay neither shows nor logs it again. With no round running it is only held and goes as before. The same hold covers this home's own Bot's line (#595: a late reply or a post_to_group that lands mid-turn showed the raw header in the composer queue): an ordinary line is logged and drawn as the ordinary { botId, name, color, seq } bubble its own reply is, and a Bot's round event (failed, stopped, resumed) or other quiet post is held without being drawn, so its header never reaches the composer either and group_relay draws it as that event once the turn is done. A room notice (the face-notice sender) keeps the queue path: it is not a Bot's line. Covered by core/test/rounds.test.js.
  • flybot.chatApprovals.changed() saves the team (moving the revision) so bot.pendingCount reaches the browser at once when a hold arrives or is decided (#226). Covered by core/test/flybot.test.js.
  • The DSH composer still prompts the group Session (user/message → group_relay). Face-to-face turn rule (#167, changed in #287 and #295), openingSpeakers: a plain line (no @ naming a Bot) starts one round in which every local Bot may answer, the admin first then the room's order, capped at FACE_PLAIN_SPEAKERS (6), one at a time and each after the earlier replies; that round is single (isPlainFaceLine): replies' own @s start no follow-up turns inside it. Routed by intent (#299): a plain line that is not @all (route on the round) also tells each Bot in its prompt (routeNote) to answer only when the line is theirs — it names them or their role, no other member's role fits better, or it follows up on their own last answer — and to reply [PASS] otherwise (the default; no "add something new" licence); the first Bot that posts, works on it or waits for approval ends a non-broadcast routed round (answeredAlready): the Bots after it are marked passed without a model call or a line, so three Bots never post three similar answers. The owner's own typed line reaches a face round through the Session inbox (the round's opener is the quiet name: text notice), so drainRoomInbox routes it (routeFor, single, lastFirst; lines queued together by the newest one) when it starts the round; a friend's attested plain line routes at the opener the same way (#462): postHuman posts it from the quiet face-notice sender with human, and isPlainFaceLine reads such a line by its plain flag (friendLine), so it gets route, single and the last speaker first; a friend's line that asks my Bots (plain or callIds, friendAsks) picks the speakers over a Bot post queued with it, while their @Bot line (callIds) stays unrouted. A line to everyone (ROOM_EVERYONE) is not cut; a pass (isPass) posts, logs and shows nothing. A line to everyone (ROOM_EVERYONE: 你们/大家/everyone/introduce yourselves…) says each may answer. The Bot that spoke last before the line (recentSpeaker, a follow-up) is asked first and named in the note. When every Bot passes on the owner's own line (not a friend's attested one, whose home answers it; not a follow-up to a friend's Bot), fallbackDue appends one turn so the first Bot that passed answers with no pass option. Other homes' answers count (#475): each home runs its own round over its own Bots, so a friend's Bot line logged after the person's line and after the round opened (friendAnswers; a Bot post queued before the round is not counted, it may be older than the line) ends a non-broadcast routed round like a local answer and cancels fallbackDue; a Bot still asked (a line to everyone) is told in routeNote that <Bot> already answered this line or posted a plan; add only your role's part. everyone in the post's source marks a friend's @all (not routed). Covered by core/test/rounds.test.js. @Name, the label the picker shows (Friend's Chief, Writer · Friend's Bot) or a handle picks that local Bot (face-mentions.js); @all is everyone; a line that only @s a friend's Bot starts none here; A friend's line (flybot.rooms.postHuman, only when the server attests typed_by_owner, passed as attested) names my Bot by label or handle (callIds in the post's source, read by openingSpeakers before the quiet rule), or is plain or @all and then carries plain: true, so my Bots answer it in the same single pass (#295); a line that only names a friend's Bot, or one from a server that cannot attest, stays quiet; a bare @Chief from a friend whose own Chief is in the room calls nobody here. A Bot's post in a face room still starts only the Bots it @s, so Bots in two homes cannot set each other off; another home's Bot's @all / @所有人 wakes none of mine, only the local Bots it also names (#475). The reply is held / sent under the usual approval and Auto-send rules. An @ that names nobody in the room adds one quiet notice line (No one in this room is called @X …). A face-room line the send path already posted (rt.notePostedLine) is not appended or sent again when the round reads it from the Session. A line to a room whose face.status is left or closed is refused by send and room-post (bot/room-ended) and nothing is appended: a room a switched-away server can no longer reach must not look sent (the social plugin marks those rooms left when the server changes). One answer per Bot per line (#456): a person's plain line (the owner's, opened by drainRoomInbox or the opener; a friend's attested plain line once it is in the log) opens room.faceLine (seq, answered, asked exchange ids); a Bot already in answered is passed without a model call, a second answer from the same round is dropped, and lateRoomReply drops a late answer from a turn asked for that line when the Bot already answered it. A newer person line ends it. Held Bot lines (#456): fanoutRoomLine records a local Bot's face line in room.heldLines (seq, botId, text, status: sending, capped at 50) before the transport sends it; a transport answer { held: true, approvalId } makes it pending, anything else drops the record. rt.settleHeldLine(approvalId, decision) (flybot.rooms.settleHeld) removes it on approve and marks it rejected on reject; it is saved with the room, and the roster view carries heldLines (not faceLine). Covered by core/test/rounds.test.js, core/test/face-mentions.test.js and plugins/social/test/social.test.js.
  • New Bot model (#410): roster.js createBot fixes a Bot's model at creation. A Bot made by another Bot (create_bot, a tree child via flybot.bot.create with createdBy) and given no model starts on its maker's effective model (desiredModel) and its thinking level resolved against that model (dropped when that model does not serve it), so its first request never goes to an unconfigured profile default. A model the caller names wins; a Bot the user makes in the UI keeps the client's start model (Main Bot's, #230). Covered by core/test/roster.test.js.
  • Silent creation (#645): createBot and create-room send nothing by themselves. A Bot's model is applied without a turn, and a new group posts no opening round, so making a team costs no model turn. A line the user typed in the new-Bot card is their first request: new-chat.js sends it to the new Bot as one user turn and the card carries no brief; roster.js turns a brief into Why you exist: … instructions only when a Bot created the Bot (create_bot, a tree child), where the brief is a parent's context. The kickoff role, the [Bot team setup] text and the group-created cue are gone (an older transcript's kickoff line is still skipped by historyEntry), and create_bot answers that the new Bot starts no turn. Covered by core/test/roster.test.js, core/test/rounds.test.js and core/test/new-chat.test.js.
  • Every new Bot is announced (bot.created) through the rt.createBot wrapper, including the first Main Bot bootstrap creates on the first state request and a duplicate-bot copy. Creating those through the module-local createBot used to skip the event, so a plugin that adopts new Bots (the tree root) could miss the first Chief or leave a copy outside the tree until a restart. Covered by core/test/flybot.test.js.
  • Group Session models (#289, #1127): applyModel does not trust appliedModel while a Bot is modelStale. A failed model selection in a group Session (from applyModel or from room-participant.js open()) marks the Bot stale; the next apply (model-list change, restart, or the Bot's next turn in a known group Session) selects every Session again and clears the mark once all took it. A settled record (appliedModel matches and modelStale is unset) still re-selects a group Session whose last request used another provider or model, on that apply and before the Session's next turn; the Bot's own chat is left as recorded. A Session whose route cannot be read is left alone, so a settled Bot is not selected again on every refresh. A fresh part takes the Bot's model, not the previous request's route. Covered by core/test/models.test.js, core/test/room-participant.test.js and core/test/fresh-part-model.test.js.

Face-to-face room lead (#476)

  • core/src/host/face-lead.js holds a face room's lead (总负责人): cleanFaceLead (what upsertFaceRoom keeps on room.face.lead: { handle, picked, canPick, supported }; a spec without lead keeps the old one), faceLeadOf, faceRoster (everyone in the room, from every home, with each Bot's role: mine from the roster, a friend's from Bot Social members[].role, kept as roleLabel on the remote participant and cleaned like a name), FACE_LEAD_NOTE and faceMemberNote.
  • upsertFaceRoom makes the lead the room's admin when it is one of this home's Bots (else admin: null), so plainSpeakers asks it first and adminNote gives it the lead's rules (plan once, assign parts by @Name); every other Bot gets faceMemberNote (do only your part, answer when named, no re-plan; a plain line is the lead's). roomPrompt's Members line is the full roster in a face room. A lead change reaches the next round on every home through the next poll.
  • The face lead's admin role is not a team role: updateRoom refuses it member or admin changes in a face room, and memory.js writesTeam ignores face rooms. MAIN_RULES says a Main Bot leads a face room only when it is that room's lead; TEAM_RULES' "write a deliverable in full" leaves the plan to the lead in a group that has one.
  • Routing (groups.js): on a line that names no Bot, my lead's routeNote carries FACE_LEAD_ROUTE (the line is its own; it passes only when the line is plainly another Bot's) in place of "Default to [PASS]", which the other Bots keep. fallbackDue forces no answer when the lead is another home's Bot in the room now (faceLeadIsRemote): that lead answers what my Bots leave. With no lead known, my own lead, or a lead who left, #475's fallback stays.

Face-to-face participant look

  • syncFaceParticipants / faceRemoteRecord keep optional color / avatar from the Bot Social member payload on the remote participant. The client seeds a distinct generated look from the handle when those fields are absent (see core-client). Face-room REST still does not require a person-grant.
  • rt.uiLanguage() defaults to English until a browser reports a locale; the memory tools use it to name the language the owner writes in on a fresh home (#173).
  • Face titles (#172): core/src/shared/face-title.js lists every participant owner in stable handle order and isAutoFaceTitle marks auto titles, so a roster change refreshes an auto-named room but never a renamed one; both homes derive the same title. A hosted face room that falls off the Bot Social list keeps its local transcript (poll cleanup no longer calls dropFace), so Past rooms Open can reopen it (#171).
  • A member that left and joins again is the same participant record with leftSeq cleared and a new joinedSeq — not a second live row. upsertFaceRoom treats startedAt (or an explicit open) as an open room so the Friends' Bots card does not stay on the matching countdown after Enter chat. A later snapshot whose status is not open, closed, or left, and that omits startedAt, does not demote a room that is already open, closed, or left back to candidate (#1072).

FlyBot display copy

  • Host-facing copy the user reads says FlyBot: workspace title, store / read-only messages, diagnostics table row, secrets descriptions, tools settings hints, parts notes, and update errors as FlyBot core. update.js still reads package.json name / repository for the check and GitHub links.

Test connection (#409)

  • rt.testModel(botId) (models.js, endpoint test-model, Bot-owner only in authz.js BOT_TARGETED) sends one real request on the route the Bot's next run takes (desiredModel): a one-line prompt, maxTokens 16, the model's least thinking (off, else its first level), a 30 s timeout, tagged to the Bot's chat as other usage. Nothing retries it, and a Bot runs at most one test at a time (a second click is refused). Any non-error finish, a reply cut off at the limit included, is a pass; a stream with no finish is UNKNOWN ({ ok: true, ref, ms }). A failure is { ok: false, ref, code }, where testFailure maps the DSH failure (code, status, a missing-key message) to a fixed code; the provider's message is never returned or logged, since it may echo a key. A Bot with no model answers NO_MODEL without a request; a friend's Bot is refused.

Ended turns for notifications (#353)

  • turns.js keeps endedView() (trimEnded): the last 20 turns, plus, past those, each place's (Bot + group) latest turn while it is a failure younger than 7 days (at most 50, for the Agent inbox's failed turns, #471), of the user's own Bots that finished (reason.kind === 'completed') or failed (failed: the failure's message, else its code, at most 160 characters; an AUTH message stays out as in turnFailure), as { id: '<session>:<turn>:<at>', botId, sessionId | roomId, at, calls?, silent?, streak? } (calls is that turn's tool-call count, #471; left out for 0; a failure that produced no assistant text at all is silent: true and carries streak, how many silent turns in a row at that place, so a reader can tell one blip from a provider that has been refusing for hours, #1044 — a turn that wrote text and then failed is not silent, and a finished turn resets the streak). Process-local, never saved. Not listed: a turn stopped, switched to a new part or retried for a tool call written as text; a friend's Bot (bot.remote); a turn a friend started (fromRemote, a remote-chat line); a hidden turn another Bot asked for (exchangeTurns); a group turn in a room with an origin (face-to-face, inbound-share). It rides live().work.ended; rt.ownedLive keeps the actor's own Bots' entries. set-prefs takes notifications: false (kept) or true / null (cleared: on by default), and notify: { approval | finished | failed: boolean } (#469: merged into prefs.notify, only the false ones kept, removed when empty). The client's use is in core-client.

A burst of the user's lines (#642)

  • Channel send in the Bot's own chat (no room on that Session) collects the user's lines: the first line that arrives with no turn running starts a 1500 ms SETTLE_MS timer on that Session (channel.js settleLine), further lines inside the window join its text, and the timer opens one turn carrying them in arrival order, each on its own line. No sender labels, no "read N messages as one" notice, no new setting; the order is arrival order, never sorted.
  • A line arriving while a turn runs is not held: the window flushes what it holds first (so the queued items stay in arrival order) and the line lands as its own queued item, as before. A Bot-to-Bot deliver never waits behind the window — it has its own queue and dedupe (#641).
  • A group room is not collected: its line must reach the Session at once so a running round can drain it (group_relay, #299), so settleLine is only reached when rt.roomOf(sessionId) is undefined. The pending timer is cleared on dispose. Covered by core/test/settle.test.js.

Out-of-app delivery, first half (#355)

  • A reminder has to reach the owner with the desktop app closed, so its delivery cannot be a client toast. src/host/notify.js is the adapter that half needs, and nothing calls it yet: schedules.js wiring is the second half, once a real channel exists.
  • createNotifier({ transport, channel }) speaks Slack's chat.postMessage shape — postMessage({ channel, text, blocks?, threadTs? }) → { ok: true, ts }, or a typed { ok: false, error } — with the transport injected. The production Slack transport waits for the owner's credential and is not in this repo; fakeSlack(script) is the stand-in the tests run on, recording every call and scripting each answer (a function or an Error too), and rateLimited(ms) / channelNotFound() / networkFailure() spell the three failures.
  • Delivery policy: one key per reminder, so the same reminder handed to send() again after it went out answers duplicate with the first ts and never reaches the transport, and one handed over while it is in flight joins that attempt. A retry waits min(base · 2^(n-1), max) — 500 ms then 1 s by default — and retryAfterMs from a 429 is a floor, never a ceiling: waiting less only earns another 429. Three attempts at most; running out is not a verdict about the channel, so a later call may try again.
  • A structural failure (channel_not_found, not_in_channel, anything untyped) is permanent: one call, permanent: true, and a second send() for that key is answered without another call. A thrown error and network are the same broken link: retryable.
  • enabled is checked before every attempt, not only at the start, so switching a channel off stops a reminder that is already waiting out a backoff, and a channel that is off makes zero transport calls. Sent keys are capped at 1 000 process-local entries; nothing is persisted, so a restart forgets them and at-least-once survives a crash.
  • core/test/notify.test.js drives every retry with fake timers, needs no network and no key.

Diagnostics

  • Host diagnostics never depend on ctx.logger: DSH may export no logger, and a test or plugin-local host may carry one without warn. rt.warn still writes stderr and the diagnostics log in that case (src/host/diagnostics.js). Plugins reach the same log through flybot.warn (#68); see plugin-runtime.

Untrusted fences in every shared room

  • authoredRoomLine fences peer-controlled text by the line's origin, not the room kind (#98): a remote participant, a local remote-chat record (state.bots[id].remote), a botId that is no team Bot, or line.remote. A friend's Bot speaking in a non-face group is fenced the same way as in a face room. A complete wrap is left as-is so outbound fanout does not nest residue; a mention of the marker in the body still fences. stripUntrusted is one fixed-point pass over both untrusted-message and untrusted-file, and its pattern allows whitespace inside the tag (< / untrusted-message>), since a parser that ignores whitespace reads the same marker; stripMarkers does the same for one tag. core/test/fence-property.test.js fuzzes a corpus of reconstitution attempts to keep this a fixed point. Inbound-share lines from the other instance are remote: true so owner-typed text is fenced too. Face join/leave notices stay unfenced platform copy. Channel room previews use unwrapUntrusted so the sidebar never shows the wrap. A human line whose who is User is rewritten from the signed-in display name before it is stored or fenced.
  • A late group model failure posts a short friendly line into the room (This Bot could not answer right now. / This Bot has no model set up yet.); failureLine stays on Bot-to-Bot deliver and in diagnostics, not in the human transcript.

Remote chats and mentions

  • Channel view() puts pendingCount on a friend's chat (bot.remote) from rt.listChatApprovals, the same gold wait badge rooms already use. A local team Bot gets the count of its own held sends when the provider names them (fromId, #371), plus its own waiting held replies to friends (#418); otherwise 0. Those replies are also listed on the Bot as heldReplies (rt.heldRepliesOf, hooks.js: id, policy, status pending / approved / rejected / failed, or auto for a reply a middleware let go on the owner's Auto-send (#417, state.flybot.autoSent, last 200), the chat sessionId and turn the reply answers, the friend's handle and toOwner; no text), for the Bot's owner only under enforcement. turns.js passes the answered turn to flybotReplyRemote (replyIn), which puts it on the auto-reply's meta.replyIn; a hold record superseded by a later hold (next) is left out. A middleware's hold verdict may name its recipient (a person's name, at most 120 characters); the record keeps it as recipientName for the card. A failed dispatch keeps its error, and an error the sender marks noRetry: true — a refusal nothing can change, such as a friend's Bot no longer shared — is passed on as retry: false so the chat draws no Retry (#954); every other failure keeps the Retry it always had. Which failures those are is one rule in src/shared/held-retry.js (#968): canRetry(record) answers from the flags (retry: false, noRetry: true) or, for a record written before them, from the stored error/code naming a terminal reason (no such remote bot, is not shared with your Bots, is no longer shared with you, has not granted chat.public, bot_not_shared, bot_not_found; the bare code capability_not_granted is not one, #988), so heldOf() maps a hold's retry: false through it and approvals.list marks a failed terminal approval the same way — the two spellings cannot drift apart. The social plugin supplies the list via flybot.chatApprovals.setProvider (plugin-runtime). The seam those two spellings cross is covered end to end by core/test/held-reply-seam.test.js (#986): a real hold whose dispatch fails noRetry lands in the state view as retry: false and the card drawn from that same object offers no Retry, a retryable failure keeps the button, and both suppressions (a friend's chat, a viewer who is not the owner) are read through the same view. Still not team mode.
  • Channel live() also sends pluginTools: the names of plugin tools a per-Bot mask may take away (rt.pluginToolNames, from hidePluginTools' gated set), so the client hides their Tool added / Tool removed rows (#419). A plugin reload changes them without a new revision, so they ride every state answer, an unchanged one too.
  • Channel state notes when a window on screen last read the team (#497): the client sends watching: !document.hidden with every poll (a visible window asks every 1.5 s, a hidden one every 10 s), and watching: false does not count; an older client that sends nothing counts as on screen. rt.watched() is true for 8 s after such a read and reaches plugins as flybot.watched() (plugin-runtime). Process-local, never saved. Covered by core/test/flybot.test.js #497, which also locks in that a friend's first line and its held reply move the revision, so a window that keeps polling reads friendLineAt, pendingCount and heldReplies with no reload.
  • A face-to-face room's presented Bot participants carry label = remoteBotTitle, <Bot> (<owner>), for remote and local seats alike (room-membership.js, #384); faceAliases still answers to the older <Bot> · <owner>'s Bot / <owner>'s Chief labels.
  • A friend's DM that is waiting on a reply (remoteAwaiting, #443) is told when the other home's turn dies (#1068). turns.js remoteFailureNotice picks one of two fixed sentences — Could not reply: this Bot has no model set up yet. for MISSING_CREDENTIAL, NO_KEY, or a provider line that says the route has no API key, and Could not reply: this Bot could not answer right now. for every other reason.kind === 'error'. The provider's message is never copied (it may echo part of a key; AUTH is the generic sentence even when its text mentions a key). flybotReplyRemote carries failure: true. A stopped turn sends nothing. A line whose body is already one of those sentences does not earn another, so two homes that cannot run a model do not trade it. The sender's chat shows it as that Bot's reply and drops the weighing row, the same way any reply does (remote-chat.js deliver).
  • remoteTurn (src/host/remote-chat.js) finds what the user typed on a friend's remote chat by skipping host-appended role:'user' context: forms snapshot, instructions (the AGENTS.md baseline on a chat's first turn) and notice, and the time-context kinds. Match by form, not by a whitelist of senders, or the first message of a new chat is dropped (#66).
  • remoteChats.sync(plugin, list) (src/host/remote-chat.js) records that a chat's Bot left the published list as bot.remote.gone and never as a bot.hidden of its own (#956). The two are separate: gone is the platform's fact, hidden is the user's Hide from sidebar (roster.setFlags) and nothing else writes it. So a share that ends keeps its row in the sidebar (isShownRemoteChat, marked no longer shared) with its history and its way back, a hide the user set keeps the row out whether the Bot is published or not, and a Bot published again is an ordinary chat again with no leftover marker. It remembers which chats were gone before open() clears the marker, and takes back a hidden that a version before #956 wrote beside its remote.hiddenByGone marker on the next sync — and deletes the marker with it, because taking that hide back is its only use and one left behind would make a later Hide from sidebar read as ours and be deleted by the next sync — so an existing state file shows the row again without the user doing anything, and the user’s own hide works from then on. Covered by the #956 cases in core/test/remote-chat.test.js and core/test/new-chat.test.js.
  • notSentLine (src/host/remote-chat.js) is the one short line for a failed send to a friend's Bot. It names only steps a FlyBot owner can take (#404): a paused chat says to approve a waiting message or remove and re-add the friend (or, when the plugin's error says resend, to send the message again, which starts a new chat; there is no Extend and no turn limit), and the fallbacks end in Try again.; it never points to the Bot Social console, which a FlyBot owner cannot sign in to. A friend's Bot Bot Social no longer knows (bot_not_found / "no such remote bot") gets its own sentence — the Bot is no longer shared with you, so messages to it cannot be delivered. Ask its owner for a new invite. — which the social plugin also writes and the host reuses whole instead of translating again; the social plugin's "not shared with your Bots" sentence from a revoked share (has not granted chat.public, #971) is passed through whole the same way, and a raw chat.public refusal that arrives before that mapping still gets that one sentence rather than the server's words. A bare capability_not_granted, or the same code naming another capability, is not that fact (#988). No line carries Try again. twice (#954).
  • A friend chat waits on its reply (#443): a successful forward sets state.remoteAwaiting[chatId] (saved before the send turn ends), the friend's reply clears it as it arrives (deliver, before the reply is written into the chat) and again in the echo turn. A failed send never sets it. channel.js exposes it as roster.remoteAwaiting, and authz.js narrows it per owner like remoteTypedTurns.
  • Remote-chat open copies optional published color / avatar / avatarUpdatedAt onto the chat record (cached by avatarUpdatedAt) so headers, the picker, and group lists match Friends' Bots. A missing look stays handle-seeded (#137).
  • 0002-flybot-api-host cleanRemote sanitizes those peer-controlled name / homeName / owner labels (strip @ and controls/newlines, 60-char cap) before they are stored. Details in plugin-runtime (#75).
  • A friend's Bot @-mentioned in a group is answered in the group (#825): the line reaches it as a room line and its reply comes back as one, so the host no longer posts a "sent" receipt — mentionReceipt (src/host/hooks.js, source role mention) keeps only what went wrong (a deny, a hold) as one muted line per mention, and the client renders it as an event line (src/client/cells.js). The role stays outside the room's round trigger (groups.js opens) and outside RELAY_ROLES, so the receipt opens no round and folds no turns. When that friend's Bot is already a current participant of the room, sendUserMentions skips the 1:1 bot.send entirely (#143): the human line in the room is the delivery, and Chief's grant is not consulted. sendUserMentions matches the name the @ menu inserts (remoteMentionLabel, the same one the bubble and the preview render) as an alias of that friend's Bot, keeps the bare handle as another, and routes every alias to the one handle it owns — so the token the user reads is not the transport key (#967). A friend's line Bot Social attests they typed themselves (#227) arrives as remoteChats.deliver({ human: true, ownerName }): the message source carries typedBy, turns.js claimTrigger records the turn in state.remoteTypedTurns[sessionId][turn] (capped at 400 per Session, dropped with the part), channel.js publishes it as roster.remoteTypedTurns (narrowed per owner in authz.js), and the client draws that turn's reply as the person (typedBy in src/client/text.js, PersonMessage in cells.js). The flag is never derived from text. A failed 1:1 remote-chat send (remote-chat.js forward / notSentLine) is one short English line with a next step, never a raw Bot Social send_as_member: … preview. A local Bot's answer to a friend's Bot (turns.js answer → flybotReplyRemote, role: 'reply', via: 'auto-reply') that the send chain refuses is logged with its reason (#272): a deny resolves, it does not throw.
  • deliver() (src/host/delivery.js) copies a cleaned, plugin-attested remote.typedBy onto the message source.remote for a friend's line to a local Bot; the client uses it to draw the person (#233 follow-up).
  • A friend's line in a Bot's own chat (#420): deliver() (noteFriendLine) stamps the receiving Bot with friendLineAt (published in the roster, so the save moves the revision) and keeps the senders seen per Bot in state.remoteContacts[botId][handle] (host only, capped at 200, dropped with the Bot). A sender's first line carries source.remote.firstContact. Group lines, and re-deliveries (deferred, resumed, retried, resent), stamp nothing. Covered by core/test/flybot.test.js.
  • Pickup after a restart (resume.js): every running Bot turn and every delivered-but-not-taken message is noted in inflight.json. At the next start a noted turn is picked up once only if a restart really cut it: the durable Session log's turn/end for that turn is interrupted (or an abort caused by disposed, or there is no end). A turn that finished while this plugin was not listening — completed, blocked, max-tokens, forked, or aborted by the user / hook / parent — is not repeated; a turn/start seen after the start means the noted turn ended too. Notes from a build before the record carried turn keep the old behaviour. A pickup is confirmed before its note is dropped: the notes read at start are seeded into the live turns/queued (so the file keeps everything still owed), a turn's note stays while its pickup runs and is dropped only once handed over (unless the resumed turn has written its own note first), and a queued message whose resend fails is kept for the next start, an exchange is noted once (a resend delivers the exchangeId the notes already hold, and noting it again left a copy behind after a turn took it up, so the start after that sent the message once more), and a turn taking up a message forgets every note of its exchange; a crash or a failed delivery during the pickup no longer loses the work — a pickup is at-least-once, not once: a crash between handing the turn over and the write that drops its note repeats that pickup once, because the taken set and the inbox that would tell the next start otherwise are process-local. That is the safe side of the trade, a repeated turn the user can see instead of work the user was told about going silently dead. Covered by core/test/follow-through.test.js.
  • A repeated Bot-to-Bot line becomes one turn (#641): deliver() keys the last 60 s of Bot-to-Bot text by sender + recipient Session + the text with its whitespace collapsed (DEDUPE_MS, duplicateDelivery), drops a repeat before it is recorded as an exchange or handed to the Session, and journals the drop as delivery: dedupe <sender> -> <session> in the host warning log. The key includes the Session, so the same line to another Session (a group copy, another Bot) is its own delivery; deferred / resumed / retried / resent carry on a message the Session already has and are never judged, and a friend's line, the user's own message and a group round are not Bot-to-Bot. Routing (parent and direct children) and the existing unread-queue refusal are unchanged, and the map is process-local and capped at 500 entries. Covered by core/test/repeated-delivery.test.js.
  • A checkpoint is judged by the prompt that is left (parts.js pressureLeft): the token meter stays anchored on the provider usage from before the span was replaced, and its surface delta only subtracts a 4-characters-per-token estimate of the removed text. A denser report — the mock's 3 characters per token, or CJK on a real model — can therefore still read above the check line after the checkpoint has replaced the span, and the part would move on even though the steward said to condense in place. After a compaction, the part stays when the heuristic size of the remaining surface plus the tool schemas is under the check line. A surface that is still over it moves on, as before. The decision to cross the line in the first place still uses the anchored total (#917).
  • A part is committed before its handoff runs (parts.js moveOn): the Bot's sessionId / room.sessions, the new segment and the rescued mark are saved first, so the turn the handoff starts can never see an uncommitted part — a failure handler that did would start a second switch against it and lose the previous part's note, and the rescue would not know it may only happen once. The handoff package is written next; if that fails, the pointer is rolled back (best effort saved, the fresh Session archived, the rescued mark dropped), so the old part is current and the finalizer hands the input on there. Two windows stay open, and both are written down rather than discovered later: a crash after that save and before the handoff package is flushed leaves the fresh part current with no package — the stopped turn's input still goes to it, so no work is lost, but the note is, and the rollback never runs; and the rollback's own save is best effort (rt.save().catch(() => {})), so a failing rollback leaves the file showing the fresh part while memory holds the old one, and the next start behaves like the first window. Neither is a reason to clear the pointer before the save: a note lost this way is a part without a handoff note, while clearing first loses the handoff package itself. The move is marked done only after the stopped turn's input and the old inbox messages are forwarded; a switch that never starts (no place, another switch, a read-only host) lets the step continue instead of rejecting its messages, which DSH has already taken out of the inbox. An armed scheduled task keeps the earlier Session active and DSH refuses to archive it, so the switch keeps the moveSchedules promise and waits for the move before archiving the earlier part; when the move failed and the refusal reports schedule as the only activity, the plain archive is retried with { stopActivity: true } (the way a deletion does, roster.js:450), so a finished part is never left active with a task nobody sees; a refusal caused by a job, turn or subagent is not retried, because stopActivity would stop that too (#896). Covered by core/test/work-dir.test.js, core/test/part-archive.test.js and core/test/bot-messages.test.js.

Connectors: GitHub and the Composio gateway (#311)

src/host/connectors.js mounts one MCP server per connector through DSH's own MCP client, so a Bot's tools are mcp__github__* and mcp__composio__* for the owner's own use of every Bot, and for other people's use of a Bot only when the owner allowed it (per-Bot access, below). Composio is a gateway: one API key (COMPOSIO_API_KEY) and a list of apps the user picked; the host asks Composio's tool-router for a session limited to those apps (workbench off) and mounts the session's MCP address. The session is made once per connect or app-list change and kept as state.connectors.composio.session ({ id, apps }: never the key and, since #335, never the session's address): the key check, the mount and Try again reuse it while the apps are the same and GET /tool_router/session/{id} (x-api-key) still finds it, else a new one is made. The mount's address is the mcp.url that answer carries (Composio's reference lists it as required in both the create and the get answer), taken only when it is https on the API's own host and held in memory for that mount; a session whose answer names no usable address is treated as gone. Why the address is not stored (#335): Composio's docs show the session address always used together with the key header and say to treat the exported address-and-headers config as a secret, but they do not say the address alone cannot authenticate, so it is not kept in state.json (backups, exports and anything that reads the profile) and is rebuilt from the id and the key at each mount. A record an older version wrote with url is rewritten without it at start (scrubAddresses), and the in-memory address is also taken out of a connector error ([session address]) before it is shown or logged. A superseded session (the old one after Choose apps or a new key, the new one when saving it failed, the kept one on Disconnect) is deleted with DELETE /tool_router/session/{id}, using the key it was made with; the delete is best effort and never an error for the user, and stopping the host leaves the session for the next start. Composio asks for each app's sign-in the first time a Bot needs it, in the chat. A stable flybot-<uuid> user id kept in state.connectors.composio (kept across Disconnect) is what Composio ties signed-in apps to. Rules: the key is a secret kept for all of the owner's Bots like any key the owner saves (#545; until then a connector saved it with scope [], no Bot's shell), sent only as the x-api-key MCP header and to the API address, never written to state.json, a profile or an error (errors go through redactSecrets); the API address must be https, the answer's MCP address must be https on the API's own host, and the session request does not follow redirects; the key is proved by creating the session before it is saved; connector-apps changes the app list without the key and keeps the old list when Composio refuses the new one; connector-app-search { id, query, token? } searches Composio's app list (GET /toolkits?search=&sort_by=usage&limit=20, x-api-key, no redirects) with the pasted token when given (not saved) or the saved key, and returns only { slug, name } with slugs a session can take; connector-app-status { id } reads the kept session's apps (GET /tool_router/session/{id}/toolkits?toolkits=…) and returns { [slug]: 'ready' | 'sign-in' }, ready when the app needs no sign-in or has an ACTIVE connected account (#359); connector-* writes are SHARED_BLOCKED; github and composio are reserved capability names; composio: false hides the connector. Which Composio tools run without asking is DSH's normal tool approval, not a FlyBot gate (a per-app approval is a later step).

Secret scope defaults (#545)

A key the owner saves is for all of the owner's Bots (scope: 'all') unless the owner narrows it: a card's "Only ", the Secrets page's Chosen Bots or No Bot. That holds for a connector key too (GITHUB_TOKEN, COMPOSIO_API_KEY): connector-connect no longer passes scope: [], and a reconnect keeps the scope the key already has. A scope the owner chose is stored with scopeSet: true (secret-update, secret-set with a scope, a card's "Only ", and a deleted Bot leaving a picked list), so an empty list stays "No Bot". On load, an empty list without scopeSet is the old connector default and becomes 'all' once (written back by the host that holds the team; a reading host widens it in memory only). An empty list the owner picked before #545 carries no marker and is widened too: there is no way to tell it apart. Every turn of every one of the owner's own Bots gets the keys its scope allows (owner decision 2026-10-22, #601: 「放开这个权限管理,我的bot都应该能看到」). Where the turn came from no longer matters for secrets: a group round, a Bot-to-Bot relay, a scheduled turn, a turn a friend started on a shared Bot, a turn with no recorded origin, and bash inside a sub-agent all get them. A sub-agent's shell resolves to its parent Bot along the same chain as the connector guard (rt.whoseAgent, from connector-access.js); a chain that cannot be followed to a Bot gets no value. The boundaries that stay: a friend's Bot (remote) never gets the owner's keys, and a key scoped to chosen Bots goes only to those Bots. A capability (canUseSecret) follows the same rule. This replaces the turn-origin rule of #545/#581, under which a shell got 'all' keys only on a turn that settled as the owner's. The connector tools follow the same rule (#601, below); secrets no longer read turn-origin.js. Owner decision 2026-10-20 (#359, AGENTS.md hard rule 4).

A withheld key says why (#651)

A key that is not in a shell used to look exactly like a key the owner never saved: resolveEnv returned {} and the Bot reported "no key configured". resolveEnv writes DSH_SECRETS_WITHHELD — a comma-separated NAME:reason list, never a value — with one reason from a closed set. Since #601 only the real boundaries are left: remote-bot (a friend's Bot), not-in-scope (the owner scoped the key to other Bots, or to none) and unknown-bot (a sub-agent whose parent chain cannot be followed to a Bot). The per-turn reasons of #651 (turn-not-owner, shared-bot-scheduled, shared-room, unknown-turn) are gone with the rule they explained. A Session of no Bot (the DSH Agent) gets {} as before. list_secrets and request_secret say "you may use it" for every key in the Bot's scope, and that a friend's Bot never gets one. DSH_SECRETS_WITHHELD is declared in the ds-bot-secrets shell-env contribution next to the DSH_SECRET_* keys: DSH's ShellEnvRegistry.collect throws on a key a contributor did not declare, which would fail the whole bash call. The test bench's env() enforces that same contract. Tests: the #601 and #651 cases in core/test/secrets.test.js.

Per-Bot connector access (#335, #369, #601)

The connector tools are mounted once for the whole DSH profile, so which Bot may use them is decided in src/host/connector-access.js.

Decision (the owner, 2026-10-22, #601: 「放开,主要应该用连接器工具」; it supersedes #369's "off by default for other people's use"). Every turn of every one of the owner's own Bots may use the connected connectors, whatever started it: the owner, a group round of any origin (also a room with a friend or another person in it), a Bot-to-Bot relay, a scheduled turn, a friend talking to a shared Bot, a turn with no recorded origin. The connector tools no longer read turn-origin.js.

  • Per-Bot record (only what differs from the default is stored): bot.connectors = { github?: false, composio?: false } is an explicit off for that Bot (absent = on; a legacy true from #346 reads as on). bot-connectors { botId, connector, enabled } writes it; a scope an older client still sends is ignored. The retired "also when others use it" offer (bot.connectorsShared) means nothing any more and is dropped on the Bot's next switch or on a disconnect.
  • Boundaries that stay. A friend's Bot (bot.remote) never gets the tools. An Agent whose Bot cannot be determined (a parent Session that is gone, or a parent chain deeper than eight) gets none either (fail closed), so an unresolved sub-agent never reads as a plain owner Session. A chain that really ends at a Session of no Bot (the DSH Agent, a plain Session) keeps them. A sub-agent Session is followed to its parent Bot (whoseAgent, also on rt.whoseAgent for secrets.js).
  • Enforcement is two layers. A ctx.tools.guard refuses every mcp__github__* / mcp__composio__* call that is not allowed, and agent.ctx.tools.restrict({ deny }) takes the same tools out of that Agent's tool list. The mask is refreshed when the Agent is created, on every resolveAgent (delivery), when a switch changes, and on tools/change (a connector that mounts or goes away later). Turns and sharing no longer change it. DSH's hidden "Tool added/removed" notices for these tools are not drawn in the chat (cells.js).
  • Bots prefer the connector for GitHub. The Bot rules (prompt.js) say to use the GitHub connector tools first and $DSH_SECRET_GITHUB_TOKEN with gh or git in the shell only for what they cannot do, such as git push.
  • Defaults and disconnect. New and duplicated Bots (and tree children) start on. The Connectors row reads "On for all N Bots", "On for K of N Bots" or "Off for every Bot"; Choose Bots and each Bot's Connectors card show one switch per Bot and connector. Disconnect clears every Bot's record for that connector. The switches change only through the bot-connectors channel endpoint, which Bots do not call; it is in SHARED_BLOCKED, so with more than one member it is refused like the other connector writes.
  • Whose request a turn answers (turn-origin.js, recorded by turns.js, carried by relays, posts and resume records) is still tracked: standing approvals (approvals.js) and resumed turns read it. It no longer decides connector tools or secrets.
  • Not covered here: a per-app choice per Bot (the Composio app list is still one list for the connection), and DSH's own tool approval, which still decides which allowed tool runs without asking.

About FlyBot data: repository, build and update text (#374)

The About page's facts come from src/host/update.js and the core's package.json, which now name ThinkFlowLab/FlyBot (repository.url, directory: "core", homepage, bugs), not the upstream DS Bot: repoSlug() feeds the Star button and its live count (repo-stars, hidden when the answer fails), Source code, Release notes (<repo>/releases) and the DSH-update check, and ISSUES_URL in diagnostics.js is ThinkFlowLab/FlyBot/issues/new (its bug.yml has the what and diagnostics fields the Feedback page fills). The DSH-update check reads the latest FlyBot release's core/package.json for a dsBot.verifiedDsh declaration; none is declared today, so no DSH update is offered, which matches the README's pinned DSH version.

  • Build of a git install. sourceOf gives a non-version ref as branch (github:ThinkFlowLab/FlyBot#main&path:/core → main), and a 40-hex ref as commit (#<sha40>: pinned, no branch, no tag; a shorter hex run stays a branch name, #379). commitOf takes the commit from, in order: the spec's own pinned hash; the profile's pnpm-lock.yaml (lockedCommit: the ds-bot@…/tar.gz/<sha40> entry of this package only, so the social/tree plugins from the same repository never answer for it, and two different commits for it give none; a DSH profile is hoisted, so the real path is plain node_modules/ds-bot and holds no commit); then installedCommit, the 40-hex commit that follows (or precedes) the repository name in the running package's real path (the isolated pnpm layout). No match means no commit is shown, never a guess. The view carries build: { branch?, commit?, pinned? } (commit is the short 7; pinned when the spec names the commit) for git installs and null otherwise. The lockfile format was checked against a real pnpm add into a hoisted profile.
  • How far behind. For a branch install with a known commit (a pinned commit has no branch, so nothing is compared), a check makes one unauthenticated GET /repos/<owner>/<repo>/compare/<commit>...<branch> and reports behind = ahead_by only when the answer's status is ahead or identical (null on a failure, a diverged branch, a branch reset or force-pushed to before the commit (behind), or a branch name with odd characters), so a commit that is no longer on the branch never reads "Up to date". Nothing is ever offered to install for a branch install: updates come from the maintainer reinstalling FlyBot. config.packagePath overrides the path read, for tests.
  • Version. current is still the core's own version (0.1.5-flybot.1); the page adds · <branch> <commit> to it. FlyBot's product version (tags such as v0.1.0) is not in the install, so it is not shown.

Automation tasks on by default (#378)

DSH 0.2 offers the schedule service only through its optional bundle @deepseek-ai/dsh-experimental-schedule-bundle ("Automation tasks"), and the bundle list in a profile belongs to DSH. Without the bundle schedules.js has no service and Settings → Scheduled tasks shows only the hint. The bundle ships inside the DSH install, so the core adds no dependency (core/package.json is unchanged).

  • How. About 20 seconds after start, automation-default.js calls pluginManager.listBundles() and, for a profile that has not been offered yet, setBundleEnabled(bundle, true): the same call the Plugins page makes, applied live. It does not write the profile's manifest or patch files itself. A static cordis.patch.yml insert was rejected: the Plugins page could not turn it off and it would duplicate the rows of anyone who already switched the bundle on.
  • Once per profile. The offer is written first to dsh-defaults.json in the team's home (offers[<bundle>][<profile name>]), then made. A bundle that is already on is noted the same way. After that the profile decides, so a person who switches Automation tasks off keeps it off across restarts and updates.
  • Fail closed. An unreadable record, a record that cannot be written, a DSH without the bundle or without a plugin manager, a bundle with a load error, and a window that only shows the team (rt.readOnly()) change nothing. A refusal by the manager withdraws the note so the next start tries again.
  • Opt out. automationTasks: false in the bot config.

Scheduled tasks: the Bot-wide list (#628)

DSH's own schedule_list answers for the Session the call runs in, and its [] is indistinguishable from "this Bot has nothing scheduled": a task the owner sees in the UI is invisible from another Session of the same Bot. On 2026-10-21 that produced a written claim that a live schedule had never existed, and it came close to a Bot "cleaning up" a timer it could not see.

  • bot_schedules (no arguments) answers for the calling Bot. It reads the host-wide catalog() and keeps the Sessions that speak for it: its own chat (chatOwners), the archived parts of it (pastOwners with no roomId), its group Sessions, live and archived (groupOwners / pastOwners with a roomId, named by the group chat), and a group chat's own Session — the group's tasks live on the room's Session id, kept when the calling Bot is in room.members, so every member (the admin too) sees them and a Bot outside the group does not. Its paused tasks come from state.pausedSchedules, including those on the group's own Session. Each line carries the id, the title, the next run, the state and the owning Session; the answer says in so many words that it is Bot-wide and that schedule_list is per Session, so an empty answer cannot be read as a claim about another Session.
  • Read-only, and it changes nothing about the scoping of the rest. schedule_create / schedule_update / schedule_delete and DSH's own tools keep their session rule; the tool adds a way to see the Bot's tasks, not a way to edit them without knowing the Session.
  • Tests: core/test/schedules.test.js (#628: bot_schedules answers for the Bot…, the group-chat-own-Session cases, and the service-off and empty-list cases).

Scheduled tasks: edit, pause, missed runs (#354)

  • Edit (schedule-update). On the same Session an active task goes through DSH's compare-and-update with the exact record list returned, so its id, next run and delivery records stay; the timing is sent only when it changed, so renaming a task does not move its next run. Moved to another Bot or group chat, or a finished one-time task, the new task is created first and the old one deleted after, the same order as a part move (moveSchedules), so a failure keeps the old task. when: { kind: 'keep' } keeps the timing (a cron rule the form cannot show). The edit needs the task's Session and its new target (authz.js), and its text gets the same chip check as schedule-create.
  • Pause / Resume (schedule-pause, schedule-resume). DSH has no pause (its README: "Pause ... not supported"). Pause writes the task's record to state.pausedSchedules[<id>] ({ id, sessionId, record, pausedAt }) and saves the team before DSH deletes the task; a failed delete takes the entry back out, so the task keeps running. Resume creates the task again on the Bot's current part (or the group's Session) before the entry goes, so a failed create leaves it paused. A paused task lists with status: 'paused' and its id, can be edited (it stays paused) or deleted, and a one-time task whose time passed while paused is refused until it is edited. A recurring task's next run is computed afresh on Resume.
  • Missed runs (DSH's behavior, checked against the pinned @deepseek-ai/dsh-schedule). Tasks are stored by the host and survive a restart, but run only while the app is running. After the app was closed, a recurring task (every N minutes, daily, weekly, cron) runs once, for its latest missed time, and then waits for its next future time: it never runs once per missed time. A one-time task whose time passed while the app was closed runs once when the app starts again. A paused task runs no missed time at all. DSH notes one exception it does not rule out: a crash or shutdown between delivering a reminder and saving that it did can deliver that one reminder again. Test: a recurring task missed while the app was closed runs once, for its latest time in core/test/schedules.test.js.

Scheduled tasks: team quiet hours (#1166)

A window that a new or edited task must fall outside — not a mute. The window itself is a wall clock, but the time a task would be due is read in that task's own zone (the next bullet), so the two are compared as instants rather than in one team-wide clock. state.prefs.quietHours holds { on, from, to } beside the other team preferences, and its absence or on: false is off, which is the default (src/shared/quiet-hours.js, and setPrefs in channel.js).

  • Checked once, at the create/edit moment. schedule-create and schedule-update refuse before any service.create / service.update / service.delete, with the named code bot/quiet-hours and a sentence naming when the window ends, so the form's existing error line shows it and nothing is created or moved. when: { kind: keep } changes no time and is never checked. The window is half-open [from, to) — from refuses, to allows — and a window whose end is earlier than its start crosses midnight.
  • What the clock is. A one-shot at is an instant, read in the zone the form sends with the timing (the browser's), falling back to the host's when that zone is unreadable. daily and weekly carry their wall clock and zone with the time itself, so the check reads the clock as written.
  • every and cron are allowed, and said so. DSH computes a repeat rule's next fire and catalog() answers only for a stored task, so FlyBot cannot check one without duplicating DSH's scheduler. The view carries quietUnchecked: true while quiet hours are on and such a task exists, and that flag is the only place the rule is computed: the page renders it as one line and does not scan the task list itself (core/src/client/schedules.js), so a repeat task deleted between two polls cannot leave the line behind, and a line cannot appear without the host's claim. Visible, never a silent claim of silence.
  • Nothing existing is touched. A task that already fires inside the window keeps firing, and turning quiet hours on makes no update or delete call. The suppression half — muting a delivery and recording it, with a count on the team view — is not implemented: DSH has no pre-delivery veto and no pause (ScheduleService has create, list, catalog, history, delete, update), so it waits for a hook (#1166, tracked as blocked-on-DSH).
  • Not on other paths. Direct messages, @-mentions and approval requests are untouched: the change reaches no turn or notify code.
  • Tests: core/test/quiet-hours.test.js — the window and the zone reading, a refusal with no service write, the end minute allowed, a refused edit that leaves the task as it was, the two allowed rule kinds and their marker, an existing task left alone, and the settings write refusing an invalid window.

Scheduled tasks: last run and Retry (#473)

DSH's catalog carries lastDelivery, an inbox receipt (the reminder reached the Session), not how the Bot's turn went. FlyBot records each task's last run in state.scheduleRuns[<id>] ({ at, result, reason?, messageId?, missed? }, the newest 200 tasks), saved with the team.

  • A run. turns.js hands every user/message whose source is schedule to rt.scheduleRunStarted, which reads the task ids from DSH's framing (schedule_id_json, or each reminders_json entry of a batch) and records running. The turn's turn/end settles it (rt.scheduleRunEnded): failed with the turn's error line when the turn errored, stopped when the user stopped it, ran otherwise. A group task's run is the group Session's turn; it fails when a member's turn in that round failed (rt.scheduleRoundFailed, from group_relay).
  • What counts as failed (Owner decision 2026-10-20): DSH could not deliver the reminder, or the Bot's turn errored. A turn that ends without an answer has run.
  • Not delivered. DSH logs a delivery it could not make and does not retry it by itself (its runtime: "refused admission does not retry automatically"), so the task stays active with its time in the past. A check every minute (and on each schedules read) notes such a task the first time it sees it and calls it failed ("DSH could not deliver it") when it is still due 2 minutes later, once per missed time. Seeing it due once is not enough: right after the app starts, DSH is still delivering the times it missed while closed.
  • The row. schedules adds lastRun: { at, result, reason? } to each task: FlyBot's record, or delivered when DSH's lastDelivery is a newer message than the record (the Bot has not taken it up yet).
  • Retry (schedule-retry, { sessionId, id }, the same ownership check as Pause). It sends the task's prompt once now to the Bot's current part (or the group's Session) as a schedule-source message in DSH's reminder framing, so the turn is that task's run, a group gets its prompt (reminderText), and the connector verdict is the scheduled one (turn-origin.js). DSH's own times are untouched: no missed time is delivered again. A paused task is refused ("Resume the task first").
  • Needs you. A failed turn of a scheduled run is already one of the ended turns (work.ended) that the finish/fail notifications read (#353). A reminder DSH did not deliver ran no turn, so schedules.js adds it there itself (rt.noteRunFailure, failed: Scheduled task "<name>" was not delivered), with the same exclusions (a friend's Bot, a room shared with other people). The Agent inbox reads failed ended turns from that same list (#471); no second event path.
  • Limits (#56) are not part of this step.
  • Tests: core/test/schedules.test.js (a run fails only when its turn errors…, Retry runs the same prompt once now…, a due task DSH does not deliver…).

State journal (#511, opt-in)

A local, replayable record of every change to the saved team, for multi-Bot bugs that reproduce only sometimes. Owner decision 2026-10-20 (option a): state mutations only (decisions stay in #396), off by default, 7 days or 20 MB, dev-only replay, local only.

  • On. stateJournal: true in the bot config. Off (the default) nothing is opened or written, so every other test runs as before.
  • What. src/host/journal.js. After each successful state.json write, store.js hands the exact snapshot text to the journal, which appends the difference from the last recorded snapshot to <home>/journal/state.jsonl (file mode 0600, folder 0700). Lines are { seq, t, op, key, prev, next, botId?, actor }; op is base (the whole team: the first line, and any change only a key reorder explains), set, delete, or append (prevLength, drop, the new items: a group log or other list that grew or slid). Paths go at most three keys deep (bots.<id>.<field>); botId is set on a bots.<id> path; actor is host for a save and retention for a folded base (per-call attribution is not recorded). Only the host that holds the state lock writes, so a read-only window records nothing.
  • Retention. When a write would pass 20 MB, or the oldest change is older than 7 days, the oldest lines are dropped down to 90% of each cap and folded into the base line (atomic rewrite), so the kept lines still replay. A team that alone exceeds the cap stops recording.
  • Fail closed. Replay (replayTo(text, seq)) refuses the whole journal on an unreadable or incomplete line, an unknown op, a gap in seq, a first line that is not a base, or a prev that does not match what the earlier lines built; a seq outside the file is refused, not approximated. On start the writer replays the existing file the same way; a journal that does not replay, a path that is not a plain file, or a failed write stops recording for that run with a warning and leaves the file as it is. Saving the team never depends on the journal.
  • Replay. core/scripts/replay.mjs (dev-only, not in the package, see install) prints or writes (--out, new file only, 0600) the team after a given --to <seq>; it makes no network or process calls. Test: core/test/state-journal.test.js (a 3-Bot round replays byte-identical at every step).

Standing tool approvals (#540)

DSH's approval outcomes are one-shot (ApprovalOutcome: allowed-once / rejected / cancelled / unavailable; ApprovalPolicy is ask / never), so a permanent approval lives in FlyBot, not in DSH (no DSH patch). src/host/approvals.js keys a request by its kind (approvalKind: the tool, plus the sandbox mode when DSH's reason reads escalate sandbox to <mode>:, e.g. bash:escalate:danger-full-access). approval-answer takes always-bot (kept on the Bot as standingApprovals: [{ kind, label, at, ownerId? }]) and always-room (kept on the room the ask came from; refused for an own-chat ask); both save, answer this ask allowed-once and release other waiting asks the grant covers. The approval/request listener answers allowed-once itself, before any card, only for a turn the owner started (settled(...) === 'owner' from turn-origin.js; a friend's message, another member's line or a room with other people in it still asks), or for a turn that carries no verdict at all in the Bot's own chat — DSH starts such a turn for its own retry after the owner answered, and nobody's request is in it (#608) — and only when the Bot (a local one, never bot.remote) has the kind, or the ask comes from a group Session of a room holding the kind for the Bot's owner (ownerId matches while authz is enforced; before anyone signs in every local Bot is the one person's); a group grant does not cover the Bot's own chat. DSH's audit pair (approval/asked / approval/decided) still logs each auto-answered ask. A grant that a turn cannot use is not skipped silently: the first such ask per Session and kind logs the standing approval for <kind> was not used: the turn is "<verdict>". standing-approval-remove { scope: 'bot' | 'room', id, kind } revokes (a signed-in remover takes only their own group grant). Authz: an always-* answer needs the Bot's owner (owning the room is enough only for Allow once); removing a Bot's grant needs the Bot's owner, a group's the member who gave it. Tests: core/test/group-status.test.js, core/test/authz.test.js.

Discarded promise .catch (#213)

A discarded .catch(() => {}) in the host sources either starts with void (fire and forget) or carries a one-line reason comment (an awaited wait); a .catch(() => undefined) whose value is used stays. check-empty-catch enforces it (see docs/code-standards.md).

Tests

npm test in core/; smoke test for index.js / src/host/ changes.

Images a text-only model cannot see (#779)

src/host/images.js reads a chat image with a model that declares image input before the request reaches a model that cannot (ocrRoute: config.ocrModel first, then served models declaring image, the DSH default first among them; config.textOnlyModels names a model whose provider claims image input but rejects it). Readings are cached per attachment in <home>/ocr.json, so later requests repeat the same words and the prompt cache still hits.

  • When no model can read an image, both places it becomes text — a Bot chat (imageAsText through withImagesAsText) and the group relay (imagesForGroup) — use the same imagePlaceholder.
  • That placeholder names the file when the Bot's own file tools can open it: attachmentFilePath takes the attachment store's imageHostPath through the file sandbox's processPathFromHostPath (the same mapping dsh-spill-policy applies to spooled tool output) and falls back to the local object layout <home>/attachments/v1/objects/<first two>/<sha256>. Either candidate must stat as a regular file, so a Bot is never sent after a file that is not there (a host path the sandbox cannot open is worse than no path), and only a sha256:<64 hex> id has an object path at all.
  • Nothing is copied into a Bot's working folder: a copy would need a lifecycle and a cleanup policy of its own, and the readable path already lets a Bot move or submit the bytes itself.
  • Covered by core/test/images.test.js and, through the Session hook and the group relay, core/test/session-identity.test.js.