| subsystem | core-host | ||||||||
|---|---|---|---|---|---|---|---|---|---|
| owned_paths |
|
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.
- Core server: roster/store, tools, turns/delivery, groups, conversation parts, search index, images, browser bridge,
api/botchannel. 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— standaloneautoweb-search provider.core/test/identity.test.js— the state identity schema tests (storestate.jsonv2, #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 chatpendingCountfromlistChatApprovals. 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,detailsthrough the channel);core/test/new-chat.test.jscoversungrantedLocalFixand 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 andbot.grants.
- 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.
| 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. |
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.
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.jsinstalls asrt.harness/rt.resolveHarness/rt.routeHarnessafterstoreinMODULES(core/index.js).rt.harnessis always thedefaultharness: 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 undercore/dev/harness/is edited here.- Tree exception:
@flybot/treekeeps its own marked prompt underplugins/tree(not one ofds-bot/·patches/ds-bot/·core/). Every harness lists that exception; injection stays outside the harness file so evalP3-tree-splitstill passes. - Usage ledger
callsis version 3 (LEDGER_VERSION): columns includeharness,issue_pr,tool_calls,wall_msbeside the existing token fields.withUsageFieldsfills defaults (harness→default, the rest empty/0);missingUsageFieldsfails a row that lacks any of the five.foldCallscountstool/callevents, computeswallMsfrom the attempt start, and takesissuePrfrom turn text (extractIssuePr) or fromrt.noteUsageIssuePrwhen a caller tags the session. Rows without those turn signals stay''/0by design, not as a silent shell that never wires turn data.foldCallsalso copiesfrom.harnessonto the row when a caller passes a known id, and leavesstateas{ provider, model, retry }. - Fixture for acceptance:
core/test/fixtures/usage-sample.sqliteholds one realcallsrow. Covered bycore/test/harness.test.jsandcore/test/usage-fields.test.js.
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 asdefaultplus a declared budget:prompt.maxChars,tools.maxCallsandtools.allow(nullon Heavy means the full set the host registers),model.maxTokens,stepRetry.maxStepsandstepRetry.maxRetries. The numbers rise from Lite to Heavy.appliedis false: prompt, tools, model, and turn code keep running as they do fordefault.- Triage selects a tier with exactly one label:
harness:lite,harness:medium,harness:heavy(catalogued in.github/labels.json; a maintainer creates them withsync-labels.mjs --apply). No such label, or any other label includingrisk:T*, routes todefault. Two tier labels, or an unknownharness:*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, itsverifiers.offlinelist isci,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, andnode --test .github/scripts/rsi-baseline.test.mjs.rt.noteUsageHarness(sessionId, idOrLabels)stores the chosen tier for that Session. LaterfoldCallsrows and live meter rows record it inharness. Clearing it recordsdefaultagain. Issue/PR, tool-call count, and wall time stay the Step 0 fields.
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>.jsonandcore/dev/experience/stacks/<techStack>/<id>.json. A repo file names thatowner/name. A shared stack file hasrepo: nulland lives only understacks/. A repo file may listtechStacks; those tags do not publish it to other repos. The shape is trigger, wrong, right, plus a date, the same spirit asLESSONS.md, scoped to the key. lookupExperiencereads 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 returnsrepo: nullentries from that stack. Passing both returns the repo's tagged entries plus the shared stack entries.queryfiltersid,trigger,wrong, andrightafter that scope is fixed. Sort is date descending, then id ascending.harnessExperience/rt.harnessExperiencecall that reader.resolveHarnessstill returns the Step 0 / Step 1 config.appliedstays false. Budgets stay as declared in Step 1.harnessProstill names CI,core/dev/eval, andrsi-baseline. The library does not replace those checks.- A new lesson is a JSON file in git.
node core/dev/experience/check.mjschecks the tree (path and fields must agree).addwrites one new file under that tree and refuses to overwrite. It does not edit harness configs.core/dev/is outside the npmfileslist, so a packaged install's default read returns no entries instead of failing the host. Covered bycore/test/experience.test.js.
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).
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.
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.
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.
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.
-
The saved team (
state.json) is versioned. Version 2 (#47) carries the identity schema —membersrecords, a per-BotownerId, the owner'sdesignatedChiefId. A legacy version-1 file is migrated on load with a one-timestate.json.v1backup; 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 modulesrc/host/member-auth.js, installed afterstoreinMODULES(core/index.js):invitationsandauthSessionsmaps live on state (noSTATE_VERSIONbump), the channel resolves the actor from thex-flybot-member-sessionheader, and endpoints areauth-invite/auth-redeem/auth-revoke/auth-whoami/auth-remove-member/auth-revoke-invitewithbody.actorIdignored;auth-invitealone also opens for an anonymous caller on a host with no sessions yet (bootstrap first invite),auth-revoke-invitekills an unredeemed invitation (redeeming it afterwards fails withrevoked),removeMemberrefuses a member that still owns a Bot (has_bots), and the auth mutations are serialized behind an in-process lock so concurrentremoveMembercalls 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 modulesrc/host/authz.js, installed after member-auth: when anyauthSessionsexist, the channelauthorize()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) withbot/blocked-sharedwhen more than one member exists, stampscreate-botwithactor.memberId, and filtersview()/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 viabotOf, current chats and group Sessions and earlier parts viaselfOf/groupOwners/pastOwners(a shared room Session also resolves for its current participants, #50) — and unresolvable ids deny.approval-answergates 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.ownedLivein 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-roomrefuses a request with neither a member Bot nor any participant. On top of it, room membership (#50) adds host modulesrc/host/room-membership.js, installed after groups: a room may carry typedparticipants(human/ localbot/ granted remotebotwithhandle+ owner fields, rolesadmin/member,joinedSeq/leftSeq); log lines carryauthorId+authorKind; history is the membership window (current members and the owner of an invited Bot seeseq >=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 fromsocial/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 sameparticipants/ membership-window / owner-badge presentation withorigin: 'face-to-face': room-only remotes may sit in the room without a person-grant or a friendship,presentRoomtreats the viewer as a member (not Shared room from), leave skips the two-Bot floor, andfanoutRoomLinegoes tort.faceRoomFanoutinstead of friendflybotSend. Still not team mode — see team. Per-Bot grants (#90) add host modulesrc/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 withrt.setGrants, exposed asBotView.granted,flybot.bot.hasGrant/hasAnyGrant,flybot.grantsand thebot.grantsevent.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 thebot.sendchain, again on approval release, and on every deliver with a sender or remote origin (resumed, retried, deferred, re-sent), so no verdict orbypasscan 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.approvalsrecords carryrequestId, a sha256versionover the request content, server-derivedownerIdandorigin(via/hop/audience) — claims in the message (meta, text) never confer authority.flybot/decideis allowed through authz for any signed-in actor and the per-record check happens in hooks (deciderequires the sender Bot's owner's session,bot/forbiddenotherwise,bot/unauthorizedwithout a session);release()revalidates before dispatch and marks the recordstalewhen the request changed, the sender or target Bot is gone (also invalidated eagerly ondeleteBot), or the Bot changed owners; a demoted Chief's sender-side holds are invalidated eagerly onsetMain(not insidestaleReason), andrelease()re-checks immediately before dispatch so an in-flight approve cannot go out after a concurrent invalidation. UnknowninvalidateForfield values throw.flybot/approvalslists 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 insrc/shared/approval-waiting.js(#754):decide(),heldOf()in the chat view, the in-flight filter that invalidates holds,PENDING_MAXand theAPPROVALS_KEPTcleanup all askisApprovalWaiting(), andchannel.jscounts a row's waiting replies with it, so apending_approvalhold (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~/.dshtakesstate.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, answeringbot/read-onlyand 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 insidesave()too, and a change made outside a request (a plugin, a scheduled task, a delivery) belongs inrt.mutate(fn), which takes over and adopts what the other host left before the change is made and written. The writers that changedrt.stateinline and then calledsave()are gone (#1048):delivery.js(a friend's first line and its unread mark, a team exchange) andagents.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()replacesrt.statewhole, 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>)— fromrefresh()and fromsave()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 rejectedmutate()(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 withN to approvewhile 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 incore/test/flybot.test.js. -
The Main Bot list is deployment-wide (#613):
set-mainrequires the actor to ownbody.idand, whenbody.replacenames 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 arebot/forbidden);roster.setMainkeeps the same invariant for every caller, refusing areplaceacross owners and leavingstate.mainBotIdsunchanged. 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 aspeer(notbot), and keeps cross-owner entries out of the team scope (teamAllowedFrom),list_botslists only the caller's owner's Bots (a granted child: that child'screatedBysubtree), Bots granted to the same owner, and Bots in the current room — another owner's folder and instructions stay private — and adsh-session:chip in actor-authoredsend/room-post/answer-question/schedule-createtext must resolve for the author (sessionOwnedBy;schedule-createauthz keys offbody.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.
src/host/memory-search.jsderives two recall aids from the memory files thatsrc/host/memory.jsalready reads; both are pure and rebuildable, and the Markdown files stay the only source of truth.- Hybrid ranking:
termsOf/buildIndex/searchIndexbuild 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.recalluses it only when called withsemantic: "true"; the default exact-word path is unchanged, so an existing call scores and prints exactly as before. - Graph:
buildGraph/relatedOfturn[[topic]]references into topic→topic edges and aby:metadata value into file→Bot edges;recallwithrelatedwalks 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 tworecallcases incore/test/memory.test.js.
- Hybrid ranking:
memory.jsexposesrt.botMemoryRead(botId)(a Bot's own entries, capped at 200) andrt.botMemoryAppend(botId, text, meta)(one entry through the ordinary path — secret-scanned, size-capped, journaled).hooks.jsgates them asflybot.memoryfor a plugin the host names inflybot.memoryPlugins(empty by default). The tree uses it to carry a merged Bot's memory into the kept Bot. Tests: theflybot.memorycase incore/test/flybot.test.js.
src/host/decisions.jsgives a Bot one small tool,record_decision({ option, choice, why })(choiceisusedorrejected), 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 asdecision. 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.
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-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.
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.
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.
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.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.
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.
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.
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.
- 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-SHAREenvelope (src/shared/room-share.js) throughflybotSend(via: 'room-share'for join/leave,via: 'room'for lines,meta.roomIdset) so the Bot's owner's FlyBot can project it immediately — including a room with no chat lines yet. Receive isrt.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-levelownerName/homethe social plugin keeps on that Bot's member record (read fromwhoami), so a group made from the sidebar no longer saysa friend; a human participant whose name is this home's own labelYou(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 ownsharedFrominviter 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):applyInboundRoomSharestill showsdata.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 byevent.fromHome/event.fromHandle— a share naming a room of mine while arriving from a home that room does not belong to is refusednot-inviter, and the recordedsharedFromidentity only ever follows the transport, so an envelope can be displayed but never re-write whose room it is. Onlyop: 'joinprojects a room: alineenvelope for an unknown remote room is refusedunknown-roominstead of opening one named by its own payload. A line keeps its ownwho, or the sender when it names nobody, never the inviter; itsauthorIdfollows 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 withunverified: 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 ajoinalone; alinefollows 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 nextjoinrecords the home and the room stops being legacy.reach()allows a local member to message a remote that already sits in that room whenroomIdis on the send. Still not team mode. #904: the projection now records the host's side too.recordInboundSharedRoomkeeps the guest's own Bot and one remote participant for the Bot that carried the join (draft.inviterHandle, withadmittedBy: 'ticket'/roomOnlywhen a link seated it) —invitedHandleon 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 thejoinasrosterandnotice:notifyRoomSharesends{ 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.applyInboundRoomSharereads 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,verifiedonly 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 ajointhat drops an entry marks it left (soft leave: the entry stays withleftSeq, history still resolves) and a re-sentjoinclears the mark; an emptyroster: []clears the list, while ajoinwithout the field changes nothing. The notice is replaced by the nextjointhat 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 ownadmittedBy: 'ticket'/ticket, and, when that record is older, through the link's ownhomes— 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 (sendTicketSharenames 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'scalls(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 anot-connected:id is never a token — #949). A fenced or quoted@namestays literal text. The envelope also carries the line'shop: a person's line is hop 0 and a Bot's answer is one hop on. The receivingapplyInboundRoomSharekeeps only the calls that name this room's own Bot (localCalls), opens one turn for each throughrt.startInboundTurn(groups.js:participant.open+participant.runTurn+appendRoom), and skips it when the line's hop has reached the room'smaxHops(kept on the room,DEFAULT_MAX_HOPS3) or when the same remote line was already answered — the last 200 keysremoteRoomId:seq:botIdlive inroom.answered. That turn's answer is an ordinary room line, sofanoutRoomLinesends it back to the inviter as anop: 'line'envelope under the inviter'sroomId, one hop on; an answer its owner's approval holds or refuses still shows as onenoticeline in the room instead of silence. Remote text stays inside thefencedUntrustedfence.
- 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.jsparseRoomInviteLinkreads both shapes in the one invite field and says which one it saw ({ kind: 'local', handle, ticket }/{ kind: 'face', ticket }), androomInviteUrlbuilds the local one. The ticket israndomBytes(16)in base64url — 22 characters of[A-Za-z0-9_-], inside the 64-character shapeFACE_INVITE_TICKETalready accepts. The handle is its own path segment becausesend_as_memberaddresses 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 oneacceptedBy/seats, read throughhomesOf), until the owner withdraws it or the room is deleted. A link minted with anexpiresAt(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) andrt.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 setswithdrawnAt(nobody new joins; the server ticket is revoked withoutendClaims), 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 existingjoin, whichaddRoomParticipantsends.applyInboundRoomSharehandles both ops before the branch that projects a room for an unmatchedjoin/line, so a knock never conjures a room;parseRoomShareacceptsaskwithout aroomIdand requires one oninvite. The asker isevent.fromHandle(the transport's sender), never a field of the payload. The accepting home is added tohomes: the same home retrying is idempotent success (the lostjoinreply 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 bycore/test/remote-room.test.js(#852). - The endpoints the UI calls (#876):
room-invitereads the live link (rt.localInviteOf, what the Members card copies and counts down),room-invite-mintandroom-invite-revokeare the room owner's or a human admin's call (authz.js), androom-invite-askis the invitee's side:rt.askLocalInvitereads 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 withaccept: true, and the answer arrives asinvitethroughapplyInboundRoomShare(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 noroomId, 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 withoutlocal_room_invitesanswersundefined, which keeps the friend-only local ticket). The link knocks oninvite.botId, the room lead when it has a Bot Social identity; a lead with none gets one first through the seam'sidentify(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, soidentifypromotes it, and when the seam is registeredrt.identifyInviteBotspromotes the Bot of every server link still open or holding a seat.room-invite-previewanswersroomIdandjoinednext toclaimed: a claim is not a seat and a seat is not a claim —joinedis true when this home holds the room, matched by that link's ticket or, since #1176, bysharedFrom.remoteRoomIdfor 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-askwithaccept: trueandbots, 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:seatByTicketreads the claims back with this home's own credential (verify→claimers, every home that joined; an older server's singleclaimer) and seats exactly the Bots of the claim that brought the asker, from that home, in this room, or nothing — refusalsunclaimed/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 (publishedRemoteOfstays empty, nothing in the sidebar), andnotifyRoomShare/fanoutRoomLinereach it fromhostBotIdonly, withmeta.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 passmayShareRoomfor 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 throughrt.startInboundTurn); alinefor 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 bydeleteRoom, revokes every link withendClaims). On the guest's side ajoinnaming 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, elseunclaimedand nothing is made. One projected room holds every Bot a ticket seated (sharedFrom.ticket; eachjoinadds 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 asYouor 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 itshostBotId), 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 undermeta.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, andreach.jsreads that record throughhomesOf(room-membership.js) — one reading, used by both halves, so a link accepted beforehomeswas written (acceptedBy+seats) enumerates the same pair and a room that predates the field does not lose it (#929);linkSeatsstays read-only, writes no state and sends nothing. A join also carrieshistory: 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 fromstartInboundTurn— is posted into the room Session withrt.showSharedLine(displayOnly). The conversation pane is that Session;room.logalone only feeds the sidebar preview, because the channel view stripslog. The followup is not appended again, so it does not fan out a second time, andopeningSpeakersreturns 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 callsrt.seatOpenClaims(flybot.rooms.seatOpenClaims), whichverifys every open server link and seats each claiming home the same way, then sendsjoinonly for a seat whose notice has not yet left (invite.told). A lost, denied or unread knock still seats the guest, and thejoinopens 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) keepswaitingon the link (reason, and fornot-addablethe local Bot names). The Members card says so, and Seat them callsroom-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 saysToo many attempts(or answersrate_limited/locked_out), waits out the minutes that sentence names or theRetry-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, ajoinand alinealike, 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 stillpending_approvalisholdand is not recorded as told; one whose hold is not listed yet is retried as not delivered. Covered bycore/test/room-ticket-seat.test.jsand the#923/#929cases incore/test/remote-room.test.js.
- Channel
sendon a face-to-face room (origin: 'face-to-face') alwayspostRoomMessages, then shows a quiet local notice (postToRoomquiet+notice). It does not callsessionController.promptunless a local Bot is @mentioned. Unsigned-in cloud UI usesimplicitRoomOwner(the local human) so the line still fans out overonFaceLine/FLYBOT-FACE-HUMANand the other FlyBot instance shows it; that path must not open a bot round (#124). Covered bycore/test/remote-room.test.jsandcore/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). upsertFaceRoomkeeps the six-digit number a room was joined by (spec.joinCode/spec.code) onroom.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 quietpostToRoompost carryingsource.human { authorId, name }.group_relayturns it into a round reply{ kind: 'user', other: true, name, authorId }(not the greynotice), so it draws as a left-hand bubble from that person; it asks no Bot to answer.postNoticestays for system events (joined, left, started). Covered bycore/test/rounds.test.js. - Lines from elsewhere mid-turn (#455): a
postHumanorpostRemotepost (source.human/source.remote) that arrives while the group Session isrunningis notfollowuped, which would park it in DSH's composer queue (raw[Group post from …], steerable).postToRoomholds it (heldPosts): the round that is running (rt.roomProgress) shows it at once, aspersonReplyor 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 throughapplyFaceLine); afteragent.whenIdle()it isfollowuped withshown: true, so its own round routes it by the usual rules butgroup_relayneither 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 apost_to_groupthat 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 andgroup_relaydraws it as that event once the turn is done. A room notice (theface-noticesender) keeps the queue path: it is not a Bot's line. Covered bycore/test/rounds.test.js. flybot.chatApprovals.changed()saves the team (moving the revision) sobot.pendingCountreaches the browser at once when a hold arrives or is decided (#226). Covered bycore/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 atFACE_PLAIN_SPEAKERS(6), one at a time and each after the earlier replies; that round issingle(isPlainFaceLine): replies' own@s start no follow-up turns inside it. Routed by intent (#299): a plain line that is not@all(routeon 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 quietname: textnotice), sodrainRoomInboxroutes 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):postHumanposts it from the quietface-noticesender withhuman, andisPlainFaceLinereads such a line by itsplainflag (friendLine), so it getsroute,singleand the last speaker first; a friend's line that asks my Bots (plainorcallIds,friendAsks) picks the speakers over a Bot post queued with it, while their@Botline (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),fallbackDueappends 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 cancelsfallbackDue; a Bot still asked (a line to everyone) is told inrouteNotethat<Bot> already answered this line or posted a plan; add only your role's part.everyonein the post's source marks a friend's@all(not routed). Covered bycore/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);@allis everyone; a line that only @s a friend's Bot starts none here; A friend's line (flybot.rooms.postHuman, only when the server atteststyped_by_owner, passed asattested) names my Bot by label or handle (callIdsin the post's source, read byopeningSpeakersbefore the quiet rule), or is plain or@alland then carriesplain: 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@Chieffrom 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 whoseface.statusisleftorclosedis refused bysendandroom-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 roomsleftwhen the server changes). One answer per Bot per line (#456): a person's plain line (the owner's, opened bydrainRoomInboxor the opener; a friend's attestedplainline once it is in the log) opensroom.faceLine(seq,answered,askedexchange ids); a Bot already inansweredis passed without a model call, a second answer from the same round is dropped, andlateRoomReplydrops 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):fanoutRoomLinerecords a local Bot's face line inroom.heldLines(seq,botId,text,status: sending, capped at 50) before the transport sends it; a transport answer{ held: true, approvalId }makes itpending, anything else drops the record.rt.settleHeldLine(approvalId, decision)(flybot.rooms.settleHeld) removes it on approve and marks itrejectedon reject; it is saved with the room, and the roster view carriesheldLines(notfaceLine). Covered bycore/test/rounds.test.js,core/test/face-mentions.test.jsandplugins/social/test/social.test.js. - New Bot model (#410):
roster.jscreateBotfixes a Bot's model at creation. A Bot made by another Bot (create_bot, a tree child viaflybot.bot.createwithcreatedBy) 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 bycore/test/roster.test.js. - Silent creation (#645):
createBotandcreate-roomsend 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.jssends it to the new Bot as one user turn and the card carries nobrief;roster.jsturns abriefintoWhy you exist: …instructions only when a Bot created the Bot (create_bot, a tree child), where the brief is a parent's context. Thekickoffrole, the[Bot team setup]text and the group-created cue are gone (an older transcript's kickoff line is still skipped byhistoryEntry), andcreate_botanswers that the new Bot starts no turn. Covered bycore/test/roster.test.js,core/test/rounds.test.jsandcore/test/new-chat.test.js. - Every new Bot is announced (
bot.created) through thert.createBotwrapper, including the first Main Botbootstrapcreates on the firststaterequest and aduplicate-botcopy. Creating those through the module-localcreateBotused 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 bycore/test/flybot.test.js. - Group Session models (#289, #1127):
applyModeldoes not trustappliedModelwhile a Bot ismodelStale. A failed model selection in a group Session (fromapplyModelor fromroom-participant.jsopen()) 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 (appliedModelmatches andmodelStaleis 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 bycore/test/models.test.js,core/test/room-participant.test.jsandcore/test/fresh-part-model.test.js.
core/src/host/face-lead.jsholds a face room's lead (总负责人):cleanFaceLead(whatupsertFaceRoomkeeps onroom.face.lead:{ handle, picked, canPick, supported }; a spec withoutleadkeeps 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 Socialmembers[].role, kept asroleLabelon the remote participant and cleaned like a name),FACE_LEAD_NOTEandfaceMemberNote.upsertFaceRoommakes the lead the room'sadminwhen it is one of this home's Bots (elseadmin: null), soplainSpeakersasks it first andadminNotegives it the lead's rules (plan once, assign parts by @Name); every other Bot getsfaceMemberNote(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:
updateRoomrefuses it member or admin changes in a face room, andmemory.jswritesTeamignores face rooms.MAIN_RULESsays 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'srouteNotecarriesFACE_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.fallbackDueforces 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.
syncFaceParticipants/faceRemoteRecordkeep optionalcolor/avatarfrom 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 toEnglishuntil 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.jslists every participant owner in stable handle order andisAutoFaceTitlemarks 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 callsdropFace), so Past rooms Open can reopen it (#171). - A member that left and joins again is the same participant record with
leftSeqcleared and a newjoinedSeq— not a second live row.upsertFaceRoomtreatsstartedAt(or an explicitopen) 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 notopen,closed, orleft, and that omitsstartedAt, does not demote a room that is already open, closed, or left back tocandidate(#1072).
- 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.jsstill readspackage.jsonname/repositoryfor the check and GitHub links.
rt.testModel(botId)(models.js, endpointtest-model, Bot-owner only inauthz.jsBOT_TARGETED) sends one real request on the route the Bot's next run takes (desiredModel): a one-line prompt,maxTokens16, the model's least thinking (off, else its first level), a 30 s timeout, tagged to the Bot's chat asotherusage. 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 isUNKNOWN({ ok: true, ref, ms }). A failure is{ ok: false, ref, code }, wheretestFailuremaps 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 answersNO_MODELwithout a request; a friend's Bot is refused.
turns.jskeepsendedView()(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 inturnFailure), as{ id: '<session>:<turn>:<at>', botId, sessionId | roomId, at, calls?, silent?, streak? }(callsis that turn's tool-call count, #471; left out for 0; a failure that produced no assistant text at all issilent: trueand carriesstreak, 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 anorigin(face-to-face, inbound-share). It rideslive().work.ended;rt.ownedLivekeeps the actor's own Bots' entries.set-prefstakesnotifications: false(kept) ortrue/null(cleared: on by default), andnotify: { approval | finished | failed: boolean }(#469: merged intoprefs.notify, only thefalseones kept, removed when empty). The client's use is in core-client.
- Channel
sendin 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 msSETTLE_MStimer on that Session (channel.jssettleLine), 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
delivernever 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), sosettleLineis only reached whenrt.roomOf(sessionId)is undefined. The pending timer is cleared on dispose. Covered bycore/test/settle.test.js.
- A reminder has to reach the owner with the desktop app closed, so its delivery cannot be a client toast.
src/host/notify.jsis the adapter that half needs, and nothing calls it yet:schedules.jswiring is the second half, once a real channel exists. createNotifier({ transport, channel })speaks Slack'schat.postMessageshape —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 anErrortoo), andrateLimited(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 answersduplicatewith the firsttsand never reaches the transport, and one handed over while it is in flight joins that attempt. A retry waitsmin(base · 2^(n-1), max)— 500 ms then 1 s by default — andretryAfterMsfrom 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 secondsend()for that key is answered without another call. A thrown error andnetworkare the same broken link: retryable. enabledis 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.jsdrives every retry with fake timers, needs no network and no key.
- Host diagnostics never depend on
ctx.logger: DSH may export no logger, and a test or plugin-local host may carry one withoutwarn.rt.warnstill writes stderr and the diagnostics log in that case (src/host/diagnostics.js). Plugins reach the same log throughflybot.warn(#68); see plugin-runtime.
authoredRoomLinefences 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, orline.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.stripUntrustedis one fixed-point pass over bothuntrusted-messageanduntrusted-file, and its pattern allows whitespace inside the tag (< / untrusted-message>), since a parser that ignores whitespace reads the same marker;stripMarkersdoes the same for one tag.core/test/fence-property.test.jsfuzzes a corpus of reconstitution attempts to keep this a fixed point. Inbound-share lines from the other instance areremote: trueso owner-typed text is fenced too. Face join/leave notices stay unfenced platform copy. Channel room previews useunwrapUntrustedso the sidebar never shows the wrap. A human line whosewhoisUseris 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.);failureLinestays on Bot-to-Bot deliver and in diagnostics, not in the human transcript.
- Channel
view()putspendingCounton a friend's chat (bot.remote) fromrt.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 asheldReplies(rt.heldRepliesOf, hooks.js: id, policy, status pending / approved / rejected / failed, orautofor a reply a middleware let go on the owner's Auto-send (#417,state.flybot.autoSent, last 200), the chatsessionIdandturnthe reply answers, the friend's handle andtoOwner; no text), for the Bot's owner only under enforcement.turns.jspasses the answered turn toflybotReplyRemote(replyIn), which puts it on the auto-reply'smeta.replyIn; a hold record superseded by a later hold (next) is left out. A middleware'sholdverdict may name itsrecipient(a person's name, at most 120 characters); the record keeps it asrecipientNamefor the card. A failed dispatch keeps its error, and an error the sender marksnoRetry: true— a refusal nothing can change, such as a friend's Bot no longer shared — is passed on asretry: falseso the chat draws no Retry (#954); every other failure keeps the Retry it always had. Which failures those are is one rule insrc/shared/held-retry.js(#968):canRetry(record)answers from the flags (retry: false,noRetry: true) or, for a record written before them, from the storederror/codenaming 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 codecapability_not_grantedis not one, #988), soheldOf()maps a hold'sretry: falsethrough it andapprovals.listmarks a failed terminal approval the same way — the two spellings cannot drift apart. The social plugin supplies the list viaflybot.chatApprovals.setProvider(plugin-runtime). The seam those two spellings cross is covered end to end bycore/test/held-reply-seam.test.js(#986): a real hold whose dispatch failsnoRetrylands in thestateview asretry: falseand 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 sendspluginTools: the names of plugin tools a per-Bot mask may take away (rt.pluginToolNames, fromhidePluginTools' gated set), so the client hides theirTool added/Tool removedrows (#419). A plugin reload changes them without a new revision, so they ride everystateanswer, anunchangedone too. - Channel
statenotes when a window on screen last read the team (#497): the client sendswatching: !document.hiddenwith every poll (a visible window asks every 1.5 s, a hidden one every 10 s), andwatching: falsedoes 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 asflybot.watched()(plugin-runtime). Process-local, never saved. Covered bycore/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 readsfriendLineAt,pendingCountandheldReplieswith 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);faceAliasesstill answers to the older<Bot> · <owner>'s Bot/<owner>'s Chieflabels. - A friend's DM that is waiting on a reply (
remoteAwaiting, #443) is told when the other home's turn dies (#1068).turns.jsremoteFailureNoticepicks one of two fixed sentences —Could not reply: this Bot has no model set up yet.forMISSING_CREDENTIAL,NO_KEY, or a provider line that says the route has no API key, andCould not reply: this Bot could not answer right now.for every otherreason.kind === 'error'. The provider's message is never copied (it may echo part of a key;AUTHis the generic sentence even when its text mentions a key).flybotReplyRemotecarriesfailure: 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.jsdeliver). remoteTurn(src/host/remote-chat.js) finds what the user typed on a friend's remote chat by skipping host-appendedrole:'user'context: formssnapshot,instructions(the AGENTS.md baseline on a chat's first turn) andnotice, 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 asbot.remote.goneand never as abot.hiddenof its own (#956). The two are separate:goneis the platform's fact,hiddenis 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 weregonebeforeopen()clears the marker, and takes back ahiddenthat a version before #956 wrote beside itsremote.hiddenByGonemarker 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#956cases incore/test/remote-chat.test.jsandcore/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 saysresend, 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 rawchat.publicrefusal that arrives before that mapping still gets that one sentence rather than the server's words. A barecapability_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
forwardsetsstate.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.jsexposes it asroster.remoteAwaiting, andauthz.jsnarrows it per owner likeremoteTypedTurns. - Remote-chat
opencopies optional publishedcolor/avatar/avatarUpdatedAtonto the chat record (cached byavatarUpdatedAt) so headers, the picker, and group lists match Friends' Bots. A missing look stays handle-seeded (#137). 0002-flybot-api-hostcleanRemotesanitizes those peer-controlledname/homeName/ownerlabels (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 rolemention) keeps only what went wrong (adeny, ahold) 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.jsopens) and outsideRELAY_ROLES, so the receipt opens no round and folds no turns. When that friend's Bot is already a current participant of the room,sendUserMentionsskips the 1:1bot.sendentirely (#143): the human line in the room is the delivery, and Chief's grant is not consulted.sendUserMentionsmatches 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 asremoteChats.deliver({ human: true, ownerName }): the message source carriestypedBy,turns.jsclaimTriggerrecords the turn instate.remoteTypedTurns[sessionId][turn](capped at 400 per Session, dropped with the part),channel.jspublishes it asroster.remoteTypedTurns(narrowed per owner inauthz.js), and the client draws that turn's reply as the person (typedByinsrc/client/text.js,PersonMessageincells.js). The flag is never derived from text. A failed 1:1 remote-chat send (remote-chat.jsforward/notSentLine) is one short English line with a next step, never a rawBot Social send_as_member: …preview. A local Bot's answer to a friend's Bot (turns.jsanswer→flybotReplyRemote,role: 'reply',via: 'auto-reply') that the send chain refuses is logged with its reason (#272): adenyresolves, it does not throw. deliver()(src/host/delivery.js) copies a cleaned, plugin-attestedremote.typedByonto the messagesource.remotefor 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 withfriendLineAt(published in the roster, so the save moves the revision) and keeps the senders seen per Bot instate.remoteContacts[botId][handle](host only, capped at 200, dropped with the Bot). A sender's first line carriessource.remote.firstContact. Group lines, and re-deliveries (deferred,resumed,retried,resent), stamp nothing. Covered bycore/test/flybot.test.js. - Pickup after a restart (
resume.js): every running Bot turn and every delivered-but-not-taken message is noted ininflight.json. At the next start a noted turn is picked up once only if a restart really cut it: the durable Session log'sturn/endfor that turn isinterrupted(or an abort caused bydisposed, 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; aturn/startseen after the start means the noted turn ended too. Notes from a build before the record carriedturnkeep the old behaviour. A pickup is confirmed before its note is dropped: the notes read at start are seeded into the liveturns/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 theexchangeIdthe 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 thetakenset 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 bycore/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 asdelivery: 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/resentcarry 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 bycore/test/repeated-delivery.test.js. - A checkpoint is judged by the prompt that is left (
parts.jspressureLeft): 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.jsmoveOn): the Bot'ssessionId/room.sessions, the new segment and therescuedmark 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 themoveSchedulespromise and waits for the move before archiving the earlier part; when the move failed and the refusal reportsscheduleas 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, becausestopActivitywould stop that too (#896). Covered bycore/test/work-dir.test.js,core/test/part-archive.test.jsandcore/test/bot-messages.test.js.
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).
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 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.
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 legacytruefrom #346 reads as on).bot-connectors { botId, connector, enabled }writes it; ascopean 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 onrt.whoseAgentforsecrets.js). - Enforcement is two layers. A
ctx.tools.guardrefuses everymcp__github__*/mcp__composio__*call that is not allowed, andagent.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 everyresolveAgent(delivery), when a switch changes, and ontools/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_TOKENwithghorgitin the shell only for what they cannot do, such asgit 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-connectorschannel endpoint, which Bots do not call; it is inSHARED_BLOCKED, so with more than one member it is refused like the other connector writes. - Whose request a turn answers (
turn-origin.js, recorded byturns.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.
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.
sourceOfgives a non-version ref asbranch(github:ThinkFlowLab/FlyBot#main&path:/core→main), and a 40-hex ref ascommit(#<sha40>: pinned, no branch, no tag; a shorter hex run stays a branch name, #379).commitOftakes the commit from, in order: the spec's own pinned hash; the profile'spnpm-lock.yaml(lockedCommit: theds-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 plainnode_modules/ds-botand holds no commit); theninstalledCommit, 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 carriesbuild: { branch?, commit?, pinned? }(commit is the short 7;pinnedwhen the spec names the commit) for git installs andnullotherwise. The lockfile format was checked against a realpnpm addinto 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 reportsbehind=ahead_byonly when the answer'sstatusisaheadoridentical(null on a failure, adivergedbranch, 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.packagePathoverrides the path read, for tests. - Version.
currentis 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.
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.jscallspluginManager.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 staticcordis.patch.ymlinsert 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.jsonin 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: falsein thebotconfig.
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-widecatalog()and keeps the Sessions that speak for it: its own chat (chatOwners), the archived parts of it (pastOwnerswith noroomId), its group Sessions, live and archived (groupOwners/pastOwnerswith aroomId, 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 inroom.members, so every member (the admin too) sees them and a Bot outside the group does not. Its paused tasks come fromstate.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 thatschedule_listis 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_deleteand 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).
- Edit (
schedule-update). On the same Session an active task goes through DSH's compare-and-update with the exact recordlistreturned, 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 asschedule-create. - Pause / Resume (
schedule-pause,schedule-resume). DSH has no pause (its README: "Pause ... not supported"). Pause writes the task's record tostate.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 withstatus: '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 timeincore/test/schedules.test.js.
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-createandschedule-updaterefuse before anyservice.create/service.update/service.delete, with the named codebot/quiet-hoursand 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)—fromrefuses,toallows — and a window whose end is earlier than its start crosses midnight. - What the clock is. A one-shot
atis 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.dailyandweeklycarry their wall clock and zone with the time itself, so the check reads the clock as written. everyandcronare allowed, and said so. DSH computes a repeat rule's next fire andcatalog()answers only for a stored task, so FlyBot cannot check one without duplicating DSH's scheduler. The view carriesquietUnchecked: truewhile 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
updateordeletecall. 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 nopause(ScheduleServicehascreate,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.
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.jshands everyuser/messagewhose source isscheduletort.scheduleRunStarted, which reads the task ids from DSH's framing (schedule_id_json, or eachreminders_jsonentry of a batch) and recordsrunning. The turn'sturn/endsettles it (rt.scheduleRunEnded):failedwith the turn's error line when the turn errored,stoppedwhen the user stopped it,ranotherwise. A group task's run is the group Session's turn; it fails when a member's turn in that round failed (rt.scheduleRoundFailed, fromgroup_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
schedulesread) 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.
schedulesaddslastRun: { at, result, reason? }to each task: FlyBot's record, ordeliveredwhen DSH'slastDeliveryis 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 aschedule-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, soschedules.jsadds 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…).
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: truein thebotconfig. Off (the default) nothing is opened or written, so every other test runs as before. - What.
src/host/journal.js. After each successfulstate.jsonwrite,store.jshands 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 };opisbase(the whole team: the first line, and any change only a key reorder explains),set,delete, orappend(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>);botIdis set on abots.<id>path;actorishostfor a save andretentionfor 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 inseq, a first line that is not a base, or aprevthat does not match what the earlier lines built; aseqoutside 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).
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.
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).
npm test in core/; smoke test for index.js / src/host/ changes.
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 (
imageAsTextthroughwithImagesAsText) and the group relay (imagesForGroup) — use the sameimagePlaceholder. - That placeholder names the file when the Bot's own file tools can open it:
attachmentFilePathtakes the attachment store'simageHostPaththrough the file sandbox'sprocessPathFromHostPath(the same mappingdsh-spill-policyapplies to spooled tool output) and falls back to the local object layout<home>/attachments/v1/objects/<first two>/<sha256>. Either candidate muststatas 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 asha256:<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.jsand, through the Session hook and the group relay,core/test/session-identity.test.js.