| 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/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, copy, pause/resume, retry and delete for Settings → Scheduled tasks, each task's last run, and moving a Bot's tasks to its fresh part, which rewrites the stored row in place so the task keeps its id, its delivery history and the instant it was already due (#354, #473, #1245, #1287, see Scheduled tasks: edit, pause, missed runs, Scheduled tasks: copy 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). The every selector takes seconds (60 to 604800) as well as the older minutes form, so a 90-second repeat survives an edit; a value below 60s or above 7 days is refused with the same sentence (#1651). |
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/host/project-state.js |
The project_state tool (#1702, #1550 Stage 2.4): the Project State files of the Session working folder (PROJECT.md, ROADMAP.md, docs/governance.md, decisions/*.md, LESSONS.md), listed with byte size, sha256 content hash and last-modified time, or one file handed over whole up to 200,000 bytes. A caller compares the hash with the copy it already had, so a stale copy is visible instead of assumed. Answers absent, too large (with the size) or not a repository rather than an empty document, refuses any path outside that list, and reads nothing at install time: no prompt, system message or Session context is written by a call. Tests: core/test/project-state.test.js. |
core/src/host/project-assistant.js |
The Project Assistant (#1712, #1550 Stage 2.5): one hidden Bot and Session per Project, created by project-create through the roster and refusing an unknown or archived id, with exactly one 09:00 Asia/Shanghai task on that Session through the host's own schedule-create path and removed with the Project by project-delete — never a cron inside the plugin, and no backfill for a Project made before this slice. Memory lives in state.projectMemory[id] ({decisions, questions, digest}, behind rt.projectMemoryOf / appendProjectMemory / setProjectDigest and the project-memory / project-memory-write endpoints) keyed by Project id, so one Project cannot read another's entries. buildDigest answers, in order, what merged since the previous digest, what closed, what is still in progress and on whom, and the next slice, using only board rows (source/column/assignee/updatedAt) and the Project State file list; a day with no change is one line, one day has one note and one deliver(), and the second build answers skipped. Identity is the install's GitHub connector account (the owner's own on a single-person install): with no connector signed in, or on a multi-member host whose projects.ownerUserId is not this host's own user, digest refuses and escalates instead of posting as the shared app, and every write carries Written-by: Project Assistant (<name>) and Posted-for: <login>. Tests: core/test/project-assistant.test.js. |
core/src/shared/artifact.js |
The typed facts a workflow node takes in and hands out (#1272): parseArtifactSpec reads check:tests-ok / issue.ready / text:topic into { kind, name } and refuses a spelling the vocabulary does not have; artifact(spec, revision, { value }) is one instance, and REVISION_RULES says what the revision of each kind is (a verdict its SHA, a check the SHA it ran on, a PR its number and headRefOid). freshness(input, currentRevision) answers present-and-current separately from present, and a fact nobody could read is unreadable, never a match. Tests: core/test/workflow.test.js. |
core/src/shared/preview.js |
What a stored line may show a person (#130, #1347): visibleText(text) unwraps a complete <untrusted-message> wrap, then removes leftover marker fragments until neither changes the text, then folds to one line. stripFence is the marker half alone. The sidebar rows read it (previewOf in src/client/text.js), and so does the preview this channel puts on a room in the state payload, which used to be a plain unwrapUntrusted and so leaked from="User"> … > on a line whose opener had already been cut. Tests: core/test/ui-clickthrough.test.js. |
core/src/shared/failure-copy.js |
A failed turn's sentence a person can act on (#1347): failureSentence(raw, translate) keys on the provider's own code field in the stored text — not on the message, which is not stable — and returns the mapped sentence (naming the feature in an UnsupportedModel refusal, and where to change the model), or the text unchanged when the code is not mapped, because a failure nobody has mapped reads better as it came than as a guess. src/shared/turn-failures.js and src/client/inbox.js both read it, so the chat's dead-turn lines and the Inbox card say the same thing. Tests: core/test/ui-clickthrough.test.js. |
core/src/shared/board-card.js |
Task-card identity for the per-Bot board (#1363): taskCardKey(id) is task:<id> and taskCard(task) builds the model the client's BoardCard draws (source: 'task', data-key). Tests: core/test/board-card.test.js. |
core/src/shared/issue-ref.js |
The one issue/PR matcher (#1254, #1331, #1360): issueRefs, namesIssue and cardNamesIssue. Own fields (title, target.href) name the card; a note counts only when those name no issue, and a repository written in that note does not count either. work-dispatch.js re-exports the first three and tasks.js assignFromDispatch uses cardNamesIssue to pick the newest open record. Tests: core/test/work-dispatch.test.js. |
core/src/shared/workflow.js |
The workflow engine's pure half (#1272): a workflow is data — nodes (each with a required role and typed inputs / outputs), edges (<node>.<artifact> -> <node>.<artifact>) and declared loops — and every function below is a function of that file. validateWorkflow refuses each way a definition cannot run, with its own sentence (an input nothing produces, an output nothing consumes and that is not a deliverable, two producers of one fact, an undeclared cycle, a node with no role, a role not on the team, an unknown artifact kind, a misaligned edge, a guard whose is is not one of present / fresh / equals, a column the board does not have); workflowColumns is the board's columns in dependency order, so the node ids are the state and no built-in list exists, and a node carrying column: false draws no column at all while a definition that states none keeps the node id as its column (#1265); layoutWorkflow is the flowchart (one box per node carrying its role and rails, one edge per edges[] row, ranks left to right); enterNode / inputStates / renderRefusal answer whether a card may start and, when it may not, say which input is missing or stale with both revisions, in the node's own refusal wording when it has one and in the guard's own wording when that guard carries one; both read a fact by the same parsed key the rest of the engine uses, so the spelling on the card and the spelling on the producer cannot disagree (#1298); holderOf is the node's role, which is the card's assignee. #1291's four debts are closed here: one word for one thing (role, never bot), an unknown guard is refuses instead of being read as present, producersOf / consumersOf key on the parsed spec so text: draft and text:draft are one fact, and resolvedEdges reports the artifact it carries once. Tests: core/test/workflow.test.js. |
core/src/shared/workflow-blank.js |
The blank preset (#1272): three nodes (draft / review / publish, roles Writer / Editor / Publisher), no guards, artifacts text:topic -> text:draft -> text:edited -> link:published. Data, not logic — the same JSON is what a workspace workflows/blank.json holds — and the proof that the engine special-cases no node id. Tests: core/test/workflow.test.js. |
core/src/shared/workflow-repo-dev.js |
The repo-dev preset (#1272 step 2): one repository change as data — triage → build → verify → review → gate → merge → release, roles Triage / Dev / SelfTest / Reviewer / system / Release, and the artifacts issue → issue.ready → pr → check:tests-ok → verdict → risk:tier → merge-commit → release. Two roles say what they are rather than who: verify is SelfTest and CI, the run being the check:tests-ok fact it hands out, and gate is held by the Reviewer that judged the pull request, read from the verdict's own Written-by through a guard. The gate's guards refuse in the merge gate's own words against .github/scripts/fixtures/workflow-cases.json, which no file imports and both sides read. column per node: not-started / in-progress / done, with review on false — a review is the pull request's state, not a third lane (#1265). Tests: core/test/workflow-repo-dev.test.js. |
core/src/shared/workflow-doc.js |
The definition as a file (#1272 step 3): workflowBytes(def) is canonical — fixed key order, two spaces, a trailing newline, a field a definition does not state left out — so a load → save round trip is byte-identical, a definition diffs in git, and column: false is written while an absent column is not (they are two different decisions, #1265). isWorkflowId is the file-name rule (lowercase, digits, hyphens) and PRESET_IDS names the two ids a workspace file may not claim; copyOfPreset(preset, { id, name }) is what Customize writes, and differingFields(a, b) is how the copy is held to "differs only in id/name" (acceptance 6). No I/O. Tests: core/test/workflow-doc.test.js. |
core/src/shared/workflow-preview.js |
The guard preview the editor shows (#1272 step 3, acceptance 5): previewNode(def, nodeId, answer) asks workflow.js's guardRows for one row per guard in the definition's order — holds (read and passing, no sentence), refuse (the producer's own sentence) or unreadable — and when the reader answered nothing ({ ok: false, why }) every row says it could not read the fact, with the reason named and no pass anywhere. It reads the same function enterNode reads, so the preview and a card's refusal cannot disagree. Tests: core/test/workflow-preview.test.js. |
core/src/shared/workflow-run.js |
The running layer's pure half (#1316, #1272 step 4). planJob({ definition, job, cards, facts, hold }) answers which node cards a job may create now: every decision is enterNode's, so a node whose input is missing, stale or unreadable opens nothing and its refusal is that function's own sentence byte for byte, and a node that already has a card is in neither list — a card parented to another job is not this job's card, so cardsOfJob is what makes (job, node) one card across a replayed tick or a restarted host, even when a caller hands in the whole board. An open entry is the card the board already places (parentId the job card, workflow, node) plus the fields the pull queue reads (role, queueRole, assignee: null), so the hand-off is the claim and never an @. A held job opens nothing on any node, in the definition's own sentence: the planner renders that definition's no-hold guard (pr equal to none, refused for hold) through renderRefusal, which is the merge gate's wording byte for byte, and a definition guarding no held pull request gets refusal: null and the holder's name instead of a sentence this layer wrote. jobDone answers pin 6 — a job is done when its terminal node's outputs are present and current, never because a card moved — and completions is step (c)'s pure half: a done card whose declared outputs are current, append-only, an entry already in history never returned again. No fs, no clock, no timer, no network, no host import: task-drive.js driveOnce stays the only beat. Tests: core/test/workflow-run.test.js. |
core/src/host/workflow-store.js |
Where a definition lives (#1272 step 3): the presets read from their modules plus the owner's workflows/<id>.json in the workspace, and the only writer. save validates with the engine's own validateWorkflow (the roster's roles are the team; an empty roster is no team to check), refuses a preset id (workflow/preset-read-only — Customize is the only way to start from one), writes workflow-doc.js's bytes only when they changed, and answers changed: false for an unedited definition (acceptance 7). customize, setDefault (the project's workflows/project.json), list, open (definition + bytes + layout + preview) and definitions() (the presets plus the workspace files) round it out; install wires rt.workflowEndpoint for workflows (read) and workflow-save (write, in authz.js's TASK_ENDPOINTS), so the client writes a file through the host and never to GitHub. A file that does not parse is skipped with one warning instead of breaking the list. Tests: core/test/workflow-store.test.js. |
core/src/host/workflow-facts.js |
The fact providers (#1272 step 2, acceptance 10): one function per fact a guard asks about — github.labels, github.checkAtSha, github.verdictAtTip, github.mergeable, github.prBodyTip, task.progress, manual.tick — each taking the answer a reader already got and returning one artifact (spec, revision, value), never fetching. No GITHUB_TOKEN, no answer, or an answer missing the field is { ok: false, reason: 'unreadable', why }, never a guess: a fact nobody could read refuses the node it feeds instead of passing it. workflowFact(name, args) is the one spelling for a caller that has the name as data, and an unknown name is unreadable with the name in the reason. Tests: core/test/workflow-repo-dev.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.
A workflow definition — node ids, the role on each node, the typed artifacts each one takes in and
hands out, the edges and any declared loop — is read from the file and from nothing else:
core/src/shared/workflow.js special-cases no node id, and core/src/shared/artifact.js names no
producer. A five-node content workflow, the blank preset and the repo-dev preset pass through
the same validation, the same column order, the same layout and the same input check.
A node refuses to start on an input that is missing or stale, and the sentence names the revision the node read and the one the source holds now; the engine never decides a fact, and a fact it could not read refuses instead of passing. Validation and refusal live in the pure half, so a flowchart can be asserted against a definition with no browser, no network and no clock.
Two rules keep that from drifting:
- One word for one thing. A node names a
role; there is no second spelling for it, and no definition can state both and disagree. A guard'sisis likewise kept as written — an unknown check refuses with its own sentence instead of being read aspresent, because a guard that can never refuse is worse than a guard that is missing: it looks like a check. A fact is keyed by its parsed spec, sotext: draftandtext:draftare one fact (canonicalSpec), in the producers, in the consumers and in the declared input and deliverable lists alike — and inenterNode/inputStatestoo, so a definition that spells an inputtext: draftreads thetext:draftartifact it has instead of refusing a fact that is there (#1298). - The board's columns are the definition's, and a node may draw none.
workflowColumnsreturns the nodes in dependency order with thecolumneach card sits in (not-started,in-progress,done); a node carryingcolumn: falsestays in the definition and out of the board, which is howreviewis a pull request's state rather than a third lane (#1265), and a definition that states no column keeps the node id, so nothing that already read these columns moves.
Refusal sentences are not re-typed per surface. The three the merge gate emits for a held pull
request, a moved head and a conclusion that is not a PASS are written once in
.github/scripts/fixtures/workflow-cases.json;
merge-gate.mjs's judge (which enqueue-guard.mjs calls, and whose joined reasons it prints) and
the repo-dev preset's gate guards are both asserted against it, byte for byte, and neither file
imports the other (#1272 acceptance 9). The facts those guards read come from
core/src/host/workflow-facts.js, one function per fact, where a fact nobody could read is
unreadable and never a guess (acceptance 10) — a check run that ran but reported no conclusion
included, since "still running" is not a reading (#1298).
Step 3 is the surface (#1299). A definition is a file — workflows/<id>.json in the workspace —
that the host reads and writes (workflows, workflow-save) and the page edits; the two presets are
copied by Customize and never written, and workflowBytes is canonical, so saving an unedited
definition produces identical bytes. The per-Bot task board does not draw a column per workflow node (#1363). A definition still
names column on each node, and the running layer opens a task card for a node that may start.
That card sits on the task board by its task status (Not started / In progress / Done).
Step 4 is the running layer (#1316). workflow-run.js is the one place a job becomes node cards,
and it decides nothing: enterNode says whether a node may open on the facts just read, and a
refusal is that sentence unchanged — the runtime writes none about a node's inputs. A card is queued
for node.role and created unassigned, so the hand-off is the queue's own claim (#1247) and no second
dispatch path exists; the cards a job already has are the record that a node opened, so a replayed
tick, a restarted host or a re-read of the same facts opens nothing twice. Opening the next node is a
fact and not a follow-up action: node n+1 opens when its inputs are present and current, and a
stale one refuses with both revisions. A held job opens nothing on any node and is refused in the
definition's own sentence — the no-hold guard, rendered with the fact it expects (pr: hold), which
is the merge gate's wording and not a second copy of it; a definition that guards no held pull request
gets the holder's name and no sentence, because this layer writes none. A hold names its holder or it is no hold at all (#1337): a blank string — the shape “the pull-request query answered nothing” arrives in — and a hold no name can be read from plan exactly like no hold, so a job is never refused whole with no sentence to show, while a named holder still refuses every card-less node in the definition's own sentence. A job is done when its terminal
node's outputs are current, never because a card was moved or a merge was claimed. Hanging that card under a GitHub issue was the delivery board (#1265) and is not
built (#1363). The planner still answers which node cards may open.
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.
A group turn does not hand the model the raw ring buffer. src/host/room-context.js fitRoomLines takes the lines the count caps already chose (ROOM_FIRST_LOOK 12, ROOM_DELTA_MAX 24, and ROOM_EARLIER_MAX 8 of those from before the round) and keeps a newer-first slice of at most ROOM_CONTEXT_BUDGET characters (4000). The budget is characters because the measured essays are mostly CJK, about one token per character. A long bot line that reads as CI, merge-queue, or review narration (statusShaped: at least 400 characters and two of the status hints) is collapsed to a one-line stub that keeps the opening and up to four @ names. Since #1251 every writer of room.log stores that same stub rather than only the turn: groups.js appendRoom (a group round) and room-membership.js append (postRoomMessage, a seat's line op, a face-room event) both pass the stamped line through prepareRoomLine, so the stored line and the next turn cannot disagree. A person's line (botId absent), a kind-carrying event line from this home and a short handoff come back unchanged and are stored verbatim. A fenced peer line (#98) is compacted inside its fence and wrapped again with the same from name, because compacting the fenced string would strip the markers and store a guest's text as this home's own; the fence decides before the kind/botId exemptions, because both travel from the other home (room-membership.js copies the peer's kind), so a peer could otherwise mark its essay notice or type it as a person and store it whole. A long Bot line that is not status-shaped is still stored whole — the store gate reuses #1204's rule, it does not widen it. When lines are left out, the turn says so and points at read_group_chat for an exact earlier line; read_group_chat returns the stored line, which for a Bot essay is the stub. Covered by core/test/group-work.test.js, core/test/room-membership.test.js and core/test/remote-room.test.js (the stores) and core/test/room-context.test.js (the rule).
The open task board for that room is a compact snapshot in the same turn (formatRoomBoard): the newest overall task, then its subtasks and any room card that is not under a different overall task, up to eight cards. Done cards stay off. Titles and notes are one line, capped, with < and > removed. An unclaimed queue item reads unassigned [open 3h Dev] — its role and its wait on the card itself — and the snapshot ends with one line per role that has something waiting (- Queue Dev: 2 waiting, oldest 3h), so starvation is visible without reading every card (#1247). A role with nothing waiting is omitted. A face-to-face room has no snapshot and keeps @Name as how its lead gives a part. The standing section does not carry the snapshot or the transcript, so it still changes only when the group settings change. In a group that has a board, ROOM_REPLY_BOARD and the task-lead note say assigning, claiming, and progress are track_tasks writes (overall task, subtask, assignee, column, progress), @Name only wakes, and a status the board already shows is [PASS]. A task-drive wake in the group says to set that card's column and progress, and appends the same snapshot after its cue. Covered by core/test/room-context.test.js, core/test/rounds.test.js, and core/test/task-drive.test.js.
The board says what is open; the turn also says which member holds it, because a member that claims a card and stays quiet used to be indistinguishable from an idle one. room-context.js formatRoomPresence(tasks, { roomId, members, now }) reads the same rt.state.tasks at the moment the turn is built — no write, no message, no heartbeat — and groups.js roomPrompt renders it just before formatRoomBoard, for a room with a board only (a face-to-face room gets neither). One line per member that holds an unfinished card in that room: - Dev2 — #1081 <title> (in progress 15%, open 27 min), the number from the card's target, the column word from columnOf, progress from progressOf, age from createdAt (open N min), and , moved N min ago when updatedAt is at least a minute newer; a card waiting on an input reads waiting on an answer, a 0% card just claimed, and a second card on the same owner is +N more. Every member that holds no card gets - <Name> — idle (no open card), so "nobody is working" is readable too; when no member holds a card the block is the single line Who is working: nobody (no open card). At most eight member lines and ROOM_PRESENCE_BUDGET (400) characters, then - +N more member(s); titles are flattened and cut, and a card's note prose never rides along. Only card state is rendered — never CI, the queue or a verdict, which #1203 keeps out of the chat. Covered by core/test/room-context.test.js and core/test/rounds.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.
A person's line sent into a group while a member is speaking now reaches that member inside its turn, instead of waiting behind the speaker. groups.js runRound starts watchSpeakerLines around the member's runTurn: every ROOM_MID_LINE_POLL_MS (100ms) it reads the same Session queue the round drains at its loop head (waitingLines: queued or steered user lines) and hands each new one to speakMidLine. That appends the line to the room log at once, puts it in the running round's replies, marks it with notePostedLine and drops the relay's pending copy, so the next relay neither logs nor answers it again. The member gets it through rt.deliver with steer: true and the note ROOM_MID_LINE ([Group chat "..." · the user sent this while you were speaking; answer it now]), so its next model step sees that the line arrived after it started and never answers it as if it had been there from the start. The one exception is a line that only says stop (ROOM_STOP: 停 / 停下 / 停止 / 别做了 / 不要做了 / stop / stop it): the speaking slot is marked superseded (slotLabel: (stopped by the user)), participant.cancel cancels that turn, a stopped reply replaces its answer, and nothing the stopped turn said is posted. That line stays in the Session queue, and the round's next head drain opens it as a fresh line for the room to answer, admin first; round.midLineIds keeps the drain from showing it in the replies a second time. 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.
An admin's @ used to be the room's control plane: every @Dev #1234 rebase and push opened a group round for that member, so dispatch stayed chat and the board only recorded what chat had already said. Now the line is the assignment. work-dispatch.js workDispatch(text, { callees }) answers only the shape — exactly one named member, an issue/PR id (#1234 or a github.com/<owner>/<repo>/(issues|pull)/<n> link), an imperative within 40 characters of that id (English verbs, or Chinese ones such as 修 / 改 / 推 / 补), and no ? — and groups.js dispatchOf adds the room's rules: a Bot's own post, no @all, not quiet or an event, a room with a board, and the poster is rt.taskLeadOf(room) (the picked lead, else the admin). rt.assignFromDispatch in tasks.js then updates the open card that member already holds for that issue (cardNamesIssue in src/shared/issue-ref.js, re-exported from work-dispatch.js: matched on title or target; a card whose own fields name no issue is matched on its note, so a note that merely mentions another issue no longer reclaims it — #1331) or opens one owned by it, still open — the host assigns, and the member claims on its own turn, where #1253's claim line is posted in its own voice. The save runs the drive, so the member is woken in its own chat (#1205). A card that is not driving — result-ready or blocked — is restarted through an edge the state machine already has (result-ready → working, blocked → working), because task-drive wakes only open/claimed/working and rewriting the note alone would leave the member unwoken; a card blocked on an input is reused rather than duplicated, so one issue stays one card, and a stored card with no actors list has one started instead of throwing after the note moved. The relay's speakers is empty for that opener and the round records no part or pass; the same dispatch posted while a round runs is written from showInRound and adds no round.calls, so the member is not called twice — but a card write that answers undefined is never a silent drop: the opener's speakers fall back to the ordinary ones and a mid-round dispatch falls back to round.calls, so the member is called in the round instead of not at all. A question, a remark with no imperative, two named members, @all, a poster that does not divide the board, and a face-to-face room (no board) keep the group turn they had. The line is still in the room log, and the card shows on the board. Tests: core/test/work-dispatch.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, a durable Project record (#1691):state.projectsholds{id, name, repository, defaultBranch, ownerUserId, createdAt, updatedAt, status},bot.projectId/room.projectIdandstate.projectRuns[runId]bind a member (a group, a Bot or a workflow run) to at most one Project, and theapi/botendpointsprojects,project-create,project-read,project-bind,project-ofandproject-boardsit in the task allow list. An unbound member resolvesnull, so an install with zero Projects behaves exactly as before. The read-only board over those bindings isproject-board(#1696): Project id in,{ tasks: [...] }out, one row per core task id withsource(bot/group/workflow),column,assignee,updatedAtandrunId/nodeIdwhen a run holds it.core/src/host/project-board.jsreadsstate.tasksand the bindings and asks the registered providers throughrt.projectRunRefs(flybot.tasks.onProjectProvider); it never readsworkflows.jsonand writes nothing. Tests:core/test/project-board.test.js. On top of the record and the board sits the Project Assistant (#1712, Stage 2.5):project-deleteremoves the Project, its bindings and its memory; the assistant's digest is one stored note plus onedeliver()to the owner, idempotent per Asia/Shanghai day, and its identity is the connector account and never the shared app — no connector signed in, or a multi-member host whose Project owner is not this host's own user, refuses and escalates with nothing written. 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. The write chain is also readable:rt.savesQueued()is a monotonic watermark — every save this host has accepted since it started, never decremented — andrt.savesSettled()resolves once the chain is empty, so a caller that must readstate.jsonafter a save it did not await — a rollback's own write, in the tests — waits on the store instead of on a delay, where a wait long enough on an idle machine reads a half-written team on a busy one (#1368). -
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. -
A Main Bot retires a Bot; it never deletes one (#638).
archive_bot(tools.js, Main Bot only) callsroster.archiveBot: it writesfields['flybot-tree'].retired = truethroughrt.setBotField— the same path andchangeevent a plugin'sfields.setuses, so the tree and the sidebar refresh — and setsbot.hidden = true, the flag the tree's own archive uses to keep a Bot out of the default list while Search still finds it (#452). The record, its chat, parts and memory stay where they are, so its history stays readable; retirement has no un-retire path, and the restore entries in the sidebar and the palette only clearhidden(core/src/client/new-chat.js:104,core/src/client/palette.js:60), which opens the chat again but leaves the record retired. The tree'sforestOfskips a retired record, so its slot undermaxChildrenis free again and adoption leaves it alone. A Main Bot, the caller itself and an unknown name are refused, nothing reparents, andlist_botsleaves a retired Bot out (the record is still found by name). Retirement is terminal (#1700): the tools that resolve a Bot throughfindTeamBot—message_bot,stop_bot,update_bot,create_group,update_group(add_members,admin,task_lead) andarchive_bot— refuse a retired record exactly as they refuse an unknown name, somessage_botsends nothing and starts no turn, andarchive_botaimed at an already-retired name answers the same "No Bot named …" line. Other host paths that resolve a Bot by name — task assignee, group@Name, capability attach, memory scope — are outside this change and tracked in #1709. Deleting remains the UI'sdelete-botcall (roster.deleteBot), which archives the Bot's memory first. Covered bycore/test/archive-bot.test.js. -
A deleted Bot's Sessions keep their owner (#1453):
state.retiredSessionsmaps a Session id to the Bot id that owned it, written byroster.deleteBotfor the own chat and its parts and for each group Session and its parts before the Bot leaves the state, soindexSessionscan put them inpastOwnersagain on the next rebuild. The record only fills what the live indexes do not claim — a live Bot or room holding the same id wins — and it only grows, one entry per Session of each Bot ever deleted, with no pruning in this change.selfOfthen names a Bot that no longer exists, soconnector-access.jswhoseAgentanswers{ unknown: true }and every connector is refused withthe Bot this Agent works for could not be determined, in the process that deleted the Bot and after a reload, because the team file carries the record. The owner's own Session, an id no Bot ever had and a call with no Agent keep the connectors. The schedule tools are not covered by this change: they carry no connector id and are refused or allowed before that branch, and widening them is the #1237 decision. -
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. After task-create, task-update and track_tasks save, the host asks rt.onTaskWrite (hooks exposes it as flybot.tasks.onWrite, plugin-runtime). A returned ownerBotId that names a different local Bot is stored and saved once more; that second save does not ask again. A missing, remote or identical id leaves the owner (core/test/task-route.test.js, #1318). track_tasks routes every card it saved, after its own save and before it answers, so a card a Bot records itself reaches the same listeners as a board write made through the channel API (#1462).
Five additive fields sit beside the record above; none changes what an existing field means, and a record stored before this slice reads back with them absent and nothing rewritten. taskClass is the typed kind (work | rework | release | triage | meeting) — it is not kind, which keeps its group-card meaning, so a group card is kind: 'group' with taskClass: 'work'. reworkOf names the card a rework card fixes and is not parentId, which stays the one-level part-of-overall link (settleOverall is untouched); a rework card therefore fires no overall-card settle. handoff is {from, to, at, reason} with both roles named (a role is known when a Bot carries it, or it is requester; an unknown role is refused with the role quoted back), reason at most 300 characters. evidence is at most eight {sha, pr, check, at} pointers, and lease is {holder, takenAt, expiresAt} on a claimed card. Every value is validated on write by one function, writeContract in src/host/tasks.js, reached from track_tasks, task-create and task-update; it reads every field before it assigns one, so a refusal (bot/invalid, or bot/unknown-role for a role no Bot carries) leaves the card exactly as it was. A review rejection (task-send-back) records the handover on the original, moves it to working and opens exactly one child card with taskClass: 'rework' and reworkOf naming the original — the single write that returns the original to result-ready is the acceptance of that child, guarded by the original still being working, so a replay creates no second child and moves the original once. A claim takes a lease (30 minutes); a claim on a live lease is refused as bot/lease-held with the holder named, and an expired lease returns a queued card to the queue. The read model does not copy: project-board.js puts the five fields on the row it already emits when the card carries them, so a pre-slice card keeps its old row shape. Tests: core/test/task-contract.test.js.
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. The tasks read lists active work only; { archive: true } is the same read including the finished rows, and { archive: true, limit: N } returns the newest N finished rows by updatedAt (tiebreak createdAt) and no active rows — the default read is what carries active work — so the board's poll carries the slice and not the history. A botId or roomId read filters before that cut, so the page is that Bot's or room's finished rows and not the whole visible scope. A read without limit still returns every settled row on purpose: it is the id-lookup and tooling path (statusOf, a Bot reading a settled row), not what the board polls (#1252).
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, and wakes that assignee in its own chat (#1205); the assignment does not open a group round. The assignee then sets the column to in-progress with track_tasks; a chat line does not assign, claim, or record progress. The group board shows that above the columns. 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. When every child has ended, the overall card completes through the same transition a person's move uses: accepted with progress 100 when each child ended accepted or cancelled, failed when any child ended failed, so a failed part is never hidden behind a green overall card; an overall card already blocked or waiting on a result is left to its owner (#1252). A child also ends when its Bot is deleted (failed) or when the owner rejects the send it was waiting on (cancelled); both doors settle the overall card the same way, so the card a person sees is closed rather than left working.
A lead can put work in a pull queue instead of naming an assignee (#1247). track_tasks { tasks: [{ queue: true, role: 'Dev', priority, title }] } records one item for that role: queueRole names it, queueBy is the lead, and ownerBotId stays the enqueuer until a claim, so an unclaimed item wakes nobody — task-drive skips it. Only the room's lead enqueues, and an item whose role no live member of that room carries is refused (no-member-with-role) instead of sitting for ever for a role nobody has. track_tasks { claim: true } takes the highest-priority item of the caller's own role, oldest first on a tie (compareForQueue, not compareForWork, whose tiebreak is newest first), and id claims a named item; a member of another role is refused by name (not-in-role), never silently. One member holds one card in progress, so a member already holding one gets nothing — a blocked card waits on a person and does not count. The claim is compare-and-set: the item is read again after the pick yields, and only one still open is taken, so two claims leave exactly one owner. track_tasks { release: { task, reason, retryInSec } } returns a claimed item (blocked → open included) with retryAt, and it is not offered again before then. A claim sets ownerBotId and claimedBy to the member and keeps queueRole, so the card the board and room presence read is the same card: claiming is a board write, not a second row. Tests: core/test/task-queue.test.js. 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 call ask_user for the user, and to post one short question in the group only when a person there has to decide. answer resumes that wait. The question is the same record the inbox already shows for a blocked task.
The claim is public (#1253). When a card in a room moves out of open into claimed or working — a member claims it from the queue, or the lead assigns it — groups.js postClaimLine appends one short line to the room log in that member's voice, the one the room notice asks for: 接了 #1253,第一步是 <the card's note>. The reference is the issue number the title carries (the title itself when it has none), the first step is the card's own note and never an invented one, and a card with no note still names itself. The line is appended to the log and not posted into the room's Session: no followup, no model turn, no round — the member's own work still arrives as its own-chat wake (#1205), and the two never merge. An identical line for the same card inside CLAIM_CUE_REPEAT_MS (30 minutes) is not posted again, so a re-claim stays quiet, and progress writes on a card the member already holds never reach the room: the claim is the beacon, the card is the progress. The record keeps the last cue as claimCue ({ seq, at, text }), which view leaves out. Tests: core/test/claim-cue.test.js.
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.
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. An unclaimed queue item is offered, not driven (#1247): one idle member of its role that does not already hold a card in progress is woken in that member's own chat, one item per room and role per pass, one Session gets at most one task wake in a pass, and the cue carries the item's id, priority and title — not its note, which is the body. A claimed item is then a normal card above. 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 group subtask wakes that same own chat (#1205). It does not open the Bot's group Session and it does not start a round. The cue says to set the card's column and progress with track_tasks, not to post a status in the group, and not to wait for a confirmation. A question a person has to decide is one short post in the group; the wake's own reply stays in the chat and is not posted into the room. Completing or updating the card is what wakes the next assignee. The wake text also carries that room's compact board snapshot (#1203), the same one a round turn shows, so the Bot reads progress from the board. A Bot @ in the group whose open card already shows that line is a pass (boardCoversCue in room-context.js, applied in the round before a member turn): the round records it as passed and does not start a turn. A question, a line the card does not already say, and a person's @ still get a turn.
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 does not defer that wake: it is not a group turn, so it does not sit beside the round. The parallel wake is the assignment pass. 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 queue offer keeps its own wait: the work pass and the queue pass compare different fingerprints, so one does not clear the other's backoff. 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.
A provider refusal that clears on its own — the requests-per-window 429 (Limit type: requests, Remaining: 0) — is not the turn's answer. turns.js turn/end asks the same trigger again after 2 s, 8 s and 30 s (RATE_LIMIT_BACKOFF_MS), through delivery.js deliver({ rateLimited: true }), so a group round keeps its waiter and a sender keeps its exchange; each attempt logs one line, and the attempt that outlasts them ends the turn once, in the ended failure line the chat already draws (#1044), carrying rateLimited with the attempt count. The provider's truncated reset is never parsed. A budget or quota refusal (budget_exceeded, QUOTA, 402) is not asked again. A round the person stopped while one of those waits was pending is not asked again either: the stop is noted before the backoff ends, its turn/end schedules no retry (stoppedRoomTurns), and the callback drops the wait when it carries that exchange, so no model call and no tool run happens behind a stopped round (#1679). rateLimitRetries is keyed by Session and holds one timer, so that drop forgets only the wait it belongs to: a newer turn in the same Session keeps its own retry, and a stop that removes a queued delivery keeps the exchange marked while a retry for it is still pending. A stop that lands after the retry went out is the running-turn path (stopRoomTurn cancels that turn), unchanged. Tests: core/test/rate-limit-retry.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). A run Bot is not a maker's child (#1582): itscreatedBy, when a caller still passes one, is the connector ceiling and not a parent, so with no model named it starts on the profile default like the board, the canvas and the API — the same graph must not run on a different model because another Bot pressed start. Covered bycore/test/roster.test.jsandcore/test/runbot-model.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. A run Bot is the exception:createBotstoreshiddenandephemeral: { plugin }before the first save, and the wrapper does not firebot.createdfor it. Covered bycore/test/flybot.test.js. - A run Bot (#1373) is left out of the channel
stateview, so the sidebar, search and Settings → Bots never list it.list_bots,message_bot,stop_bot,update_botand group member lookup skip it too.findBotstill sees the name, so a later Bot cannot reuse it. It is not a permissionless shell:flybot.bot.createmay passtoolsandpermissionfor an ephemeral Bot only.stepToolsis what connector-access and the approval listener read.githubgrantsmcp__github__*; a connector the step did not name is hidden and a call is refused (not granted to this run). A run Bot is also capped by the Bots whose connector switches apply to it (#1544):cappedBynames every Bot whose switches cap it — the Bot whose tool call started the run and the Bot that saved the workflow graph — andconnector-access.jsrefuses a connector turned off for any of them withturned off for <name>; the step's tools list cannot turn it back on, dropping it from the run Bot's own list as well. The caller travels incappedByand not increatedBy(#1582), so a run Bot never reads as a Bot's child; a record written withcreatedBybefore that still caps the run. A Bot that no longer exists, and a run recorded before this with neither field, leaves the answer as it was: there is no switch list to intersect with. A save with no Bot behind it (the canvas, the API, a template copy) records no author, which is the owner's own edit and lifts that ceiling again. A hidden run Bot cannot show an approval card, so a granted call isallowed-onceand an ungranted connector or an ungranted full-access shell escalation isrejected. It is not a team member either (#1445): a node'stoolscovers connectors and the shell, not the team surface, soEPHEMERAL_DENIED_TOOLSintools.jsnames every tool whose effect reaches past the run Bot's own Session —message_bot/stop_bot/create_bot/update_bot, groups and posts,ask_user, the capability writes,request_secret/list_secrets, the schedule writes,send_message, the social and workflow tools,read_browser,recall(its default scope reads the team memory) andcreate_child_bot.connector-access.jsrefuses each call withA running Bot is not a team member.,approvals.jsanswersrejectedfor the same names (also for a subagent child Session, by following the parent chain throughrt.whoseAgent), and the run Bot's own list loses them throughrestrict({ deny }); a name the profile serves but the mask cannot enforce stays listed with a warn line, and the guard is what refuses it.EPHEMERAL_ALLOWED_TOOLSis the rest: the read-only tools (list_bots,list_models,list_capabilities,bot_schedules,schedule_list,read_own_chat,read_group_chat) andremember/forget, whichwriteDeniedkeeps inside the run Bot's own scope. Ordinary Bots, friend's Bots and group rooms are unchanged, andplugins/workflow/**is not edited: the node'stoolsstill decides connectors and the shell. Covered bycore/test/flybot.test.js,core/test/connector-access.test.jsandcore/test/run-bot-tools.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.
When a person's line reaches a Bot's own chat while that Session already has a live turn, the host records a short spoken acknowledgment on the live payload (busyAcks) — at the line's arrival, which DSH appends as agent/inbox/spliced whose inserted[].source.kind is user; the line's user/message follows only at the next step's start, when it is claimed, which production logs put up to 768 s later, so the claim is the fallback for a splice that was not seen — except for the line that opens a turn: DSH appends turn/start before the step's user/message (agent-loop index.js:943 then :1061), so that line is claimed while the Session already reads as running, and the host skips the first claim after turn/start instead of acknowledging a message that started the turn (#1698). The line's own message id is what keeps one line from earning a second ack and a second model call: the host keeps a per-Session ledger of the ids it has acknowledged, beside the ack entry rather than inside it, because the entry dies at turn/end and the claim of a next-turn line always lands after it (#1684). The ledger is bounded on both axes. Per Session it keeps LINE_IDS_KEPT ids exactly, folds anything older into a fixed-size 1 KiB filter (three salted positions per id), and rebuilds that filter from the exact set every LEDGER_FILTER_REBUILD_AFTER folds — a filter that only ever filled would answer "acked" for 3.6% of brand-new lines after 1000 lines and 97.9% after 12000, which silences the ack again, so the guarantee is the last 16 lines exactly plus under 1% worst-case false positives on older ones, and a pruned id still answers "already acked" and cannot resurrect a line, while a stale claim finds no ack entry to reopen (#1678). The Session map is bounded too (LEDGER_SESSIONS_KEPT Sessions, the one that wrote longest ago dropped), so an archived Session cannot hold its memory for the life of the process (#1684). An own-chat next-step splice — a steer, admitted at the running turn's next step — is not ack'd. A person's line relayed into a running member's group Session (role: 'room', no Bot sender) earns the same one, keyed by that member Session and published per member on the room as busyAcks so the room draws it on the member's row (#1646); a mid-line there is relayed as a steer (groups.js steer: true), so it is spliced for next-step, and the member's row is acknowledged at arrival like an own-chat line rather than waiting for a claim (#1684). The line is written by one short model call on the host (#1665), replacing the templates of #1629 that quoted a clipped piece of the message inside a fixed phrase: the ack is published at once with an empty text (the client draws the avatar with blinking dots), then a separate call — config.flybot.modelFor['busy-ack'] when set, else the chat's own model, reasoning off when the model offers it, ACK_MAX_TOKENS output — is asked for one natural sentence in the message's language that shows what the new message is about and what the turn is doing (its running tool and the request it works on), with no quoting and no stock phrases. A hard ACK_TIMEOUT_MS (3 s) bounds it; a timeout, an error, an empty or overlong answer, or no model route leaves the text empty, so no sentence is ever sent and the dots clear with the turn. A late answer never revives a cleared ack. The client draws it as one bot bubble (and also from a local pending queue/steer submission) so the chat is not silent while a blocking job wait holds the next step. Related but separate: #1509 (Steer does not abort a tool wait). Host: core/src/host/busy-ack.js. Client: core/src/client/busy-ack.js. Tests: core/test/busy-ack.test.js, core/test/busy-ack-arrival.test.js, core/test/busy-ack-ledger.test.js, core/test/busy-ack-client.test.js.
Steer alone admits a human line only at the next step boundary, so a foreground bash/job wait that blocks the step keeps the line waiting for the wall clock. core/src/host/steering-interrupt.js watches the Session events itself: a tool/call (or a program sub-call) is an open wait from its event.time, and tool/result / turn/end close it — a result names its call on data.message.toolCallId, the shape the host writes (#1644), and data.callId only serves older payloads, so reading it alone left every wait open and turned the 60s line into the turn's age. A person's line arrives as agent/inbox/spliced the moment DSH queues it, and its own user/message only follows at the next step, after the blocking tool returned (#1666); both arrivals are judged, and the let-go clears the open wait, so the later one is never a second interruption. When such a line from source user lands in that Session while it is the Bot's own chat, a turn is live, and the oldest open wait is more than INTERRUPT_AFTER_MS (60 000 ms) old, the host cancels the turn the way Stop does — agent.cancel({ kind: 'hook', reason: 'ds-bot/interrupt-long-wait' }, { keepInbox: true }) — exactly once per wait. keepInbox is what makes the send safe: the abort ends the turn, and the human line survives in the inbox for the turn that starts right after, so it is claimed at once and never lost. Waits of 60s and shorter are left alone, a finished wait is not a wait, and a group room or an idle Session is never touched. The threshold is fixed; no settings key. Tests: core/test/steering-interrupt.test.js.
A group room takes the same 60s line, through the room's own path (#1650). A person typing in a room lands on the room's own Session (room.id), not in the member's, so the own-chat gate never saw it and the member's turn held the round for the whole wait. Now, when such a line (the splice, or the user/message that follows it) lands on a room's Session, the member turn the round is waiting on is live, and that member's oldest open wait is more than INTERRUPT_AFTER_MS old, the host lets the turn go through stopRoomTurn(sessionId, exchangeId) — the very cancel Stop uses, { kind: 'hook', reason: 'ds-bot/stopped' } with no keepInbox, so nothing the member had begun reaches the room, exactly as after a Stop. The line is safe because it waits in the room's queued inbox, and the next round answers it. A room id never takes reason: 'ds-bot/interrupt-long-wait'; that reason stays the own-chat one. Only the member the round is waiting on is let go — another member running in the same room is untouched — and waits of 60s or less, a finished wait, and an idle Session are all left alone.
- 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. - The check line is 45% of the usable window, not 80% (
steward.jslinesFor): a Bot's conversation is condensed once its estimated size passesDEFAULT_CHECK_RATIOof the model's declared window less the room its reply needs, so the conversation is condensed earlier than upstream ds-bot does. The share is a floor on the prompt of a long turn, because every call re-sends the fixed block and the history is most of each prompt (#796); the trade is a checkpoint sooner and less detail kept (#1233).checkRatioin the profile still sets the share, and the hard line stays at 95% of the usable window, so a share at or above 0.95 can never lift the check line above it. Covered bycore/test/steward.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). - The steward is asked again before a part moves (
parts.jsdecide, #1447): its one answer decides whether the part moves, and a rate limit on that request — a per-minute count on the same key the conversation uses, recovered in seconds — used to read as "no answer" and moved a part that was still working: a fresh Session, the latest messages re-sent, a note written through the same limited key, and a handoff whose reason said "the previous part grew long". A route reports a failed request as a terminal finish chunk, not as a thrown value, soaskStewardcarries that chunk'sfailure— itscode,statusandproviderRetryAfterMs, not only its message — onto the Error it throws (stewardFailure), and the classification below reads exactly those. A failure that can pass (rate limit, timeout, 5xx, byRATE_LIMIT/SERVER/TRANSPORT/TIMEOUTor by a 429/5xx status) is now retried up toSTEWARD_ATTEMPTS(3), waiting theproviderRetryAfterMsthe provider names (capped atSTEWARD_RETRY_MAX_MS, 5 s; the raw adapters'retryAfteris seconds and still read) or a short backoff that grows by attempt; a success at any attempt is the answer. Below the hard line a failure that outlasts the attempts spends the round instead of the part: the part stays, and the next round asks again only afterSTEWARD_COOLDOWN_MS(60 s, then 300 s, counting failures in a row). Over the hard line, and for a failure that retrying cannot change, the part still moves on;segment.stewardthen records the failure and the handoff saysthe context steward could not be asked whether to keep it (…)rather than the default reason. A note's own retry (NOTE_ATTEMPTS,NOTE_COOLDOWN_MS) is a separate policy and unchanged. Covered bycore/test/steward-retry.test.js. - Making room before a step has its own deadline (
parts.jscondense, #1285): the pre-step overflow compaction used to carry only the turn's own signal, so a summary request stuck in retries — a 429 storm retries each request five times with its backoff — parked the turn before its first step: nostep/start, noturn/end,load: 1and the roster'sstalledhint until a human calledstop_bot.COMPACT_DEADLINE_MS(config.compactDeadlineMs, 300 000 ms) now bounds that call throughAbortSignal.any([signal, deadline]). When the deadline is what ended the wait,condensewarnsds-bot: making room for %s ran out of %d ms before a step; the turn ends here and the next one tries again: …and rethrows, so DSH's ownfinallyappendsturn/endand the turn fails normally. DSH claimed that step's input before the pre-step hook ran and does not hand it back for a turn that ends in an error, so before the throw the deadline branch queues the batch again (keepClaimed,agent.inbox.append('next-turn', …), one ordinary queued message each, a copy the inbox already holds refused): nothing the turn was woken for is lost, and the next turn — which the loop starts straight away while input is pending — reads it and tries to make room again. A provider that stays down therefore fails one turn per deadline, back to back, each ending normally instead of parking atload: 1; no backoff is added here, the queue is the thing that carries the work forward. A turn the user aborted keeps the old path (warn,false, move on), and the!signal?.abortedhalf of the guard keeps a turn the user stopped in charge even when the deadline was already reached. The idle pass is unchanged onNOTE_TIMEOUT_MS(600 000 ms, one shared signal over both note attempts). The turn-ending waits stay where they were: steward 120 s, idle condense and the handoff note each at that same 600 s budget. Covered bycore/test/pre-step-compaction.test.js. - 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 never answered withstopActivity, because that would stop it too (#896): the id is remembered instead and the archive is asked for once more, with no options, when that holder is reported ended — the job registry'ssettledevent for a job, the Session's own nextagent/statusidle otherwise — keeping the id while a refusal still names one of them, and holding at most 32, oldest dropped (#1433). 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).
Feishu (Lark) started as the first sender connector (#1263), and its webhook mode still is one: a group's custom-bot webhook is one HTTPS POST into the instance's own group, so nothing is mounted, no Bot's tool list changes, and connector-access.js's CONNECTOR_IDS does not list it. The definition is a channel today, not a sender (#1266, below): the view reports the kind and the client reads it instead of hard-coding an id, and the channel kind is what makes the row offer Choose Bots and reconcile() open the socket, while webhook mode is the same row with nothing to dial. A webhook stops at the status: a saved webhook reads ready without posting, because the test POST belongs to the connect step, so a restart never messages the group; connector-retry ("Try again") is the one path that proves it again. The webhook is FEISHU_WEBHOOK, saved through secret-set before the probe — so a refusal stays on the row as error with the reason and redactSecrets can mask it, the way a failed mount does for GitHub — and it must be https on the configured feishu.api origin, else A Feishu webhook must be https on <origin>: the URL is the credential, so it may never be posted to another host and never appears in state.json, the view or a warn line. connector-send { id, text } posts one message on demand and records lastOkAt / lastError in state.connectors.feishu; nothing posts room traffic by itself. Feishu answers HTTP 200 with the verdict in the body (code: 0 success, 9499 a rate limit, 19021 a refused signature), so feishuPost maps the body into notify.js's vocabulary and the retry policy stays notify.js's createNotifier (3 attempts, backoff, honour retry-after, structural errors not retried) rather than a second one written here; connector-send uses a fresh idempotency key per call, so two messages with the same text are two messages. The connect probe and a send both carry a deadline (15 s and 60 s) and withDeadline races the fetch, so a fetch that ignores its abort signal cannot hang either. feishu: false hides the row; connector-send is in SHARED_BLOCKED like the other connector writes.
The same row carries a Feishu message coming in (#1266): inbound reports off|connecting|ready|error, the last event, the last error and whether the app pair is set or missing — never a value. src/host/feishu-inbound.js owns the policy: off unless the row has app mode connected with both halves saved (mode: 'app' on the record and an app pair appOf() can assemble, below — no config switch of its own), so an off connector dials nothing. The owner is the only sender that may start a turn: feishu.ownerOpenId from config is one half of that gate and the record's allowed guest list (below) the other, and an empty open_id never compares equal to an unset owner. A bounded ring of 512 event_ids makes a replay land once — the id enters it only after deliver has returned, so a handover that threw is retried instead of being refused as a duplicate for ever — and one socket per connector shares a start. The socket itself is injected as rt.feishuTransport (connect(onEvent) -> { close }) so the client behind it is one adapter, and it is read again on every start rather than copied while the modules install — an adapter that arrives later comes up on the row's Try again, which retries the sender and the socket together; a stop that lands while a socket dials hangs up the one that arrives instead of adopting it. src/host/feishu-sdk.js is the default adapter over the official @larksuiteoapi/node-sdk, imported only when a socket is dialled and reading the app pair at that moment, so a host that never turns the long connection on pays nothing for the package; the message lands through remote-chat.deliver, the seam a friend's Bot already uses, and the answer leaves again through the sender this module registers in that seam under the feishu plugin, posting on the same FEISHU_WEBHOOK feishuPost has used since phase 1 (a chat an older build opened without the plugin is refreshed on its next message) — one path in, one path out. FEISHU_APP_SECRET is the only half read as a secret through rt.secretValue, the App ID travels in the connector record (below), and a half-saved pair reports missing and dials nothing.
The connector row is where Feishu is configured (#1266, owner's word 「飞书是做成一个连接器,左下角点击连接应用的时候可以连接」), so the definition is no longer one secret and one mode: feishu is kind: 'channel', declares credentials (FEISHU_MODES — a custom-bot webhook with one masked URL, outbound only as phase 1, or a self-built app with App ID + App Secret) instead of the singular secret, and reconcile() branches on kind — a channel is a socket whose lifecycle is the row's status (off|connecting|ready|error), while every other connector is still an MCP client. App mode proves the pair with one POST /open-apis/auth/v3/tenant_access_token/internal that is then discarded (the socket dials with its own credentials, so the token never reaches a row, a log line or an error), keeps app_id in the record because it is not a secret, and puts app_secret only in secrets.json; a half-saved pair is missing and dials nothing. The record also carries the guest list the inbound gate reads — allowed: [{ openId, name? }], nobody by default, changed only through connector-allow / connector-block (both SHARED_BLOCKED like the rest of the connector-* writes, both routed in channel.js), so a stranger's message starts no turn and appears on the row as one refused sender. Disconnect closes the socket and clears both credentials. feishu joins github and composio in RESERVED_NAMES, so a saved capability cannot take the row's id.
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"; one exception, #1237, is the group room's composio and schedule tools, below). 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. - A run Bot started by a friend's Bot reaches no connector (#1555).
run_workflowanddefine_workflowhave novisibleTo, so a friend's Bot can call them; the run Bot names that remote Bot among its caps (cappedBy; a record written before #1582 named it increatedBy), which has no switch table of ours to intersect with. The ephemeral branch reads that as "every connector switched off":allowed()requires the step tools to name the connector and a caller that is not remote, so the guard refuses withThe github connector is not available to a run started by a friend's Bot.and the samedenied()takes the connectors out of the run Bot's tool list. A run with no cap (the owner, the board, an older run) and a cap that names a Bot the team no longer has keep the answer they had, and an in-team caller whose own switch is on, whose node names the connector, still gets it.visibleToon the two tools stays backlog. - 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). - A mask that cannot be rebuilt keeps the previous one (#1271).
hideConnectorToolslifts the old mask before it readsschemas()again, so arestrict()that refuses a name would otherwise leave the Agent with no mask and every tool listed again. The rebuild now names only what the global view (schemas()with no Agent) can restrict, and if a restriction still fails the catch puts the previous deny list back: the guard refuses a call either way, and a list that widens by itself is a wrong answer. A denied name the Agent itself serves stays listed —restrict()cannot name a scope's own tool — and the warn line says so. - A group room turn carries no composio tools and no schedule tools (#1237). The eight tools —
mcp__composio__*andschedule_create/schedule_list/schedule_update/schedule_delete— are 20,355 characters of schema (≈5.2k tokens at the measured 3.92 chars/token) on every call, and a room round uses neither: an outside app is reached from the Bot's own chat, and a schedule belongs to a Session (#628), so managing it from a member's room Session would write to the wrong place. The rule is one function inconnector-access.js: the samedenied()that answers the per-Bot switches also names these eight whenrt.groupOwners— the store's Session → room index — has the Agent's Session, so the mask rides the existing refresh points (Agent created,resolveAgenton every delivery, a switch,tools/changefor a plugin that mounts later) and the guard refuses a call with… not available in a group room— the room answer comes before the friend's-Bot one, and a friend's Bot has no composio tools in its own chat either, so the refusal does not send it there. A room still reads GitHub: judging a pull request is what a room round does, so its read tools stay. - A group room keeps github's read half only (#1234, the first step of #1237's 「不做」). The 43 github tools were 47,413 characters of schema on every call of every room turn (#796:
request/header115,020 → ~47,300 characters once the writes go, ≈12.1k tokens a call). A room round reads a pull request and never creates, merges or pushes one — a Bot that means to change GitHub does it from its own chat — so the rule is a whitelist on the sameroomHidden()the composio rule uses: undermcp__github__onlylist_*,get_*andsearch_*stay, and every other name (create/update/delete/merge/push/comment/…, and a new tool nobody classified) is dropped from that Session's list and refused by the guard. The refusal names the tool —The mcp__github__create_pull_request tool is not available in a group room (#1234): a room gets GitHub's read tools (list/get/search) only. Change GitHub from your own chat.— because a silent disappearance reads as a broken tool. Composio and the schedule tools keep their #1237 sentences, the Bot's own chat, its earlier parts and its sub-agents outside a room are unchanged, andcore/test/connector-access.test.jspins each family: the reads stay, each write leaves, an unclassified tool fails closed, a chat Session is untouched, and the room's list is the chat's minus exactly the hidden names. - 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, so a failure keeps the old task (a part move needs no such create — it rewrites the stored row, below).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), its text gets the same chip check asschedule-create, and its source must belong to the caller's own user (#1321, the rule in Scheduled tasks: copy) — authz admits a room's current member, so that last check lives in the data layer. - Part move (
moveSchedules, #1245). A handoff is not an edit, so the earlier part's active tasks are rewritten in place in DSH's storage domain, insideservice.serialize: the row keeps its id, record and delivery history and onlysessionIdchanges to the fresh Session. The next run therefore stays the instant it was already due — creating the task again would setnow + interval(the slip and reset history #1245 documents) — and a one-shot whose time has passed stays where it is. The due set is unchanged, so the runtime needs no re-arm: it re-reads the table before every delivery. A failed write leaves the task on the earlier part, with nothing half-deleted, which is the property #901 relies on. The write reaches DSH's storage domain directly —getDomain(),serialize(),table('tasks'),putand thetasksrow shape are private in@deepseek-ai/dsh-schedule@0.2.0-rc.2, so re-check them on every DSH upgrade. A missing or renamedgetDomain(),serialize()ortable('tasks')throws before the loop: nothing moves andmoveScheduleswarnsds-bot: tasks of %s not moved. A missing or renamedrecordthrows inside the loop, on the first row that passes the filter, so rows already written in that call stay moved — a uniform drift moves nothing, a partial one leaves it half done (the sameds-bot: tasks of %s not movedwarn fires). A missing or renamedputthrows per row: the per-row catch warnsds-bot: task %s stayed with the earlier part, and only that row stays behind while the loop continues. A renamed row field the filter reads (sessionId,status) throws nothing and warns nothing — every row is skipped silently, so a fake row carryingstateinstead ofstatusis skipped and grepping either warning finds nothing, and those tasks silently stop firing rather than crash. A presentrecordwith a renamedkindreads askind: undefined, so the one-shot test is false and an expired one-shot is moved to the fresh part; a renamedscheduledAtreads as undefined, soDate.parsegives NaN and the expiry check passes — those rows do not stop firing, they move and can fire there, an expired one-shot late. The tests model the same private shape, so they will not catch any of it. - 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 task can be copied to another Bot or group chat: the same title, prompt and timing, on the target's current part (a Bot's current chat, an earlier part's task included anyway), keeping the source's next run. schedule-create takes copyFrom: { sessionId, id } — no second endpoint — and the source is resolved through the same ownership check schedule-update uses, or from state.pausedSchedules when it is paused.
- A new row, not
create. An active source is read in DSH's storage domain and written back as a new row insideservice.serialize: a fresh id in DSH's own shape (schedule-<uuid>, retried on collision rather than overwriting), the source's record under it,status: 'active', and an emptydeliveryHistorywith nolastDelivery. Copying throughcreatewould anchor aneverytask tonow + interval— #1245 with a new name — and would carry the source's delivery record, whichcheckMissedreads as "this run was already delivered", so the copy would never fire. The private API is the one Part move already documents (getDomain(),serialize(),table('tasks'),get,put, the row shape), so re-check it on every DSH upgrade; this path sits outside themoveScheduleswarn-and-continue loop, so a renamedputorgetrejects the copy and writes nothing. - No re-arm. The copy's due instant is the source's, and the source's own row keeps it in the driver's global minimum, so the row is covered before it exists.
- A paused source has no next run to keep, so its copy goes through
createwith the stored record's timing, as Resume does. A one-time task whose time has passed is refused withPick a time in the future, the same sentence Edit uses, because such a copy could never run. - Refusals. An unknown source Session, an unknown id, the same Bot or group chat as the source, and an unknown target each get their own sentence; a copy to the same chat never falls through to
No such task. Quiet hours are checked as on create (a repeat or cron rule is let through, as the view says). - Same user only (#1321). The source is the caller's to read only while it belongs to the caller's own user.
copyScheduleandupdateScheduleresolve it through onesourceOf(sessionId, actor), and whereauthzEnforced()is true an owner that is not the actor's answers exactly as an unknown source does — the existingNo such task, no second sentence and nothing about the other member's task. Ownership here is the Bot'sownerId, orrt.roomOwnedByfor a room's own Session (every member Bot belongs to that user); it is deliberately notrt.sessionOwnedBy, which also admits a room's current human member, so being seated in a room is not owning its task and a shared room's task is nobody's single user's. The actor reaches the data layer asscheduleEndpoint(endpoint, body, actor)from the channel, because authz cannot see acopyFromor an edit's source. A deployment with nobody signed in is untouched: the legacy single-human path still copies across Bots. - Tests: four in
core/test/schedules.test.js— the copy's id, next run, empty history and untouched source; the four refusals; a daily rule's time and zone plus an earlier part's task going to the target's current part; and the five owner-scope cases of #1321 (a peer's task refused by copy and by edit; a refusal that writes nothing and carries nothing of the source; the owner's own copy still landing; and the unsigned single-human copy still allowed).
A window that a new or edited task must fall outside — not a mute. The window itself is a wall clock, but the time a task would be due is read in that task's own zone (the next bullet), so the two are compared as instants rather than in one team-wide clock. state.prefs.quietHours holds { on, from, to } beside the other team preferences, and its absence or on: false is off, which is the default (src/shared/quiet-hours.js, and setPrefs in channel.js).
- Checked once, at the create/edit moment.
schedule-create(a copy throughcopyFromincluded, #1287) andschedule-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.
projectEndpoint takes the authenticated actor. project-create stamps the caller's member id as
ownerUserId (an ownerUserId in the body cannot mint a Project someone else manages); an install
with no signed-in session falls back to the first member, the same rule tasks.js uses for
requesterOf. project-read answers { project, canManage }, where canManage is true only for
the owner — or for every caller on a Project whose record names no owner. project-bind is refused
by the host with bot/forbidden unless the caller may manage the Project the binding names (or the
one it is leaving, when unbinding); the refusal writes nothing, so hiding a button is never the
gate. Reading the board (project-board) stays open to a non-owner: the same rows, read-only.
Those rows are the ones the caller could open through tasks (#1723): store.js passes the
authenticated actor into projectBoard(projectId, actor), and the board drops every card
rt.taskVisible refuses — a card is visible to its requester, to the owner of its Bot or an
ancestor of it, and to the admin of the room it came from. The predicate stays in tasks.js, one
copy of the rule tasks itself answers with, so the board cannot paint a card the same member is
refused there; a board with nothing to paint is still [], not an error.
project-board is listed in authz.js's TASK_ENDPOINTS, so a signed-in deployment does not
refuse it as an unknown endpoint.
Stage 3.2 of the task pool. One queue per role, one worker per claim, one owner for an expired lease; all of it reads the cards already on the board, so there is no second store.
- Per-role cap.
ROLE_QUEUE_CAP(8) is the default ceiling for one role's queue in one group. A graph states its own throughcapon the enqueued item, kept on the card asqueueCap; a re-offer keeps the card's id and is never counted against itself. An enqueue over the ceiling is refusedqueue-fullwith a sentence naming the role and the cap (the Dev queue already holds 8 items, its cap is 8), so the caller knows which queue is full rather than seeing a silent null. - Isolated workers. A claim creates a worker (
worker-<hex>, kept intasks.js's own map, capped at 64, oldest dropped), and the card names it inworker.rt.taskWorkerState(id)is the only read: an unknown id answersundefined, so a second claim cannot see the first'sfilesorvars.requeueand an expired lease drop the worker, so the next claim starts clean. - One owner for an expired lease.
expireLeasesreads the lease once, clears it, then returns a queued card to its role queue — a second read in the same tick finds no lease and answers 0. The recovery is written to the card'sevidence(lease expired (held by <name>)) within the existing eight-entry cap, the dead worker is dropped, and a card that is not a queued item only loses the dead lease. A live lease is untouched, so a restart with a live lease is a no-op; a claim on a live lease is refusedlease-heldnaming the holder.rt.taskExpireLeasesexposes the same read, andrt.taskLease(id)gives{ holder, takenAt, expiresAt, live, heldBy }; the workflow plugin reads the lease throughfb.tasks.lease(id)and never keeps a copy. - Measured deliveries.
rt.taskCounters({ role?, run? })countsqueued,claimed,working,blocked,accepted,failed,cancelledper role and per run id from the rows, plusdeliveriesandworkerSeconds(accepted cards with a lease, measured fromtakenAtto the card's last update). A rework chain is one accepted delivery (#1730): an acceptedreworkchild whosereworkOfparent is also accepted folds into the parent's delivery, so a rejection→rework→accept cycle is two records and one delivery; a child whose parent never landed stays a delivery of its own, and a replay writes no rows so the count cannot double.fb.tasks.counters(query)is the plugin seam. No metric store is added.
Audit projection (#1730). rt.taskAudit(id) renders one text block from the typed fields already on the card — handoff from→to/reason/at, each evidence entry (sha, pr, check, at), the reworkOf chain oldest→newest, and the current lease holder with its takenAt→expiresAt window. It is a read: nothing is written, minted or cached, so the same rows render the same text twice. A record stored before the slice carries none of the fields and answers the empty string, not a throw. The Project board puts the same text on its row as audit (only when non-empty) and the client shows it inside the existing card detail — no new endpoint and no new page. Tests: core/test/task-audit.test.js.
- Tests:
core/test/task-queue-lease.test.js— the cap refusal names role and cap, a re-offer keeps its id, two concurrent claims share no worker state, two expired reads requeue once and record the holder, a live lease refuses a claim naming the holder, a restart with a live lease changes nothing, and the counters read from the rows.