| 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/text.js |
The pure text helpers the host modules share: message text, names, model refs, cutting and estimating text, and the clock format of chat anchors. Since #1895 codePoints(text) (a step per code point, no array built since #1898) is how the host counts a limit — one character costs one whatever its UTF-16 width — and the bounds this change puts through it are: a memory entry of 300 and a MEMORY.md of 4,000 or a topic of 16,000 (the refusal and the size note print the code-point number), a scheduled task's name of 120, a message of 20,000, and the Project memory entry of 600 in both writers, so an emoji is never charged twice. Both cuts land on a code point boundary: clip and excerpt move back rather than keep half of a surrogate pair, so a clipped emoji reaches a UTF-8 encoder as the character and not as U+FFFD (#1907). Since #1912 that rule is one exported helper rather than a private detail: cut(text, max) answers { text, cut } with the longest prefix that stops before a split pair, and the archive writers that used to slice(0, max) go through it — the oneLine of decisions.js and meetings.js (into decisions.jsonl and decisions/<date>-<slug>.md), claimLine in groups.js, the report cap in diagnostics.js, cleanLabel in flybot-feed.js and clean in browser.js — so a decision or a meeting record holds the whole character instead of U+FFFD. Since #1916 the boundary rule itself lives in core/src/shared/text-boundary.js (codePointStart, codePointEnd, cut), and host/text.js builds its cut on it so a shared module can bound text too; the eight remaining slice(0, max) writers follow it — the claim cue's fallback reference (60), the task lead's name in a member prompt (80), a failed group Session's error text (300), read_own_chat's query (80) and around (40), the open-page address and title (2000/300), the diagnostics browser-log line and its code (500/40), and cleanPeerName in shared/remote-label.js (60) — each keeping the longest prefix that stops before a split pair, length <= max and a byte prefix of the input. Since #1918 the memory review's own two bounds go through the same rule: its 800-unit context clip cuts through cut, and its 3,000-unit two-ended ends takes the head through cut and moves the tail's start back with codePointStart, giving the head back the unit a stepped-back tail keeps, so the prompt and the log hold whole characters within the same budget; the exported clip's reason is optional there too, so a two-argument call reads … (cut) instead of … (cut; undefined) while a call with a reason is unchanged. Since #1923 the four bounds whose text crosses an owner boundary go through the same cut — cleanPublishedName in hooks.js (60, into state.flybot.remote), the group member line's displayName 60 and ownerName 40 in tools.js, label in reach.js (60, the othersInRoom labels), and the local clip in shared/room-share.js (its per-field caps included) — so a published, listed or relayed name keeps whole characters and never reaches a UTF-8 encoder as U+FFFD (#1923). The same rule now covers the host's own line bounds: the busy ack's 600-unit task and 1,500-unit message text in busy-ack.js, the group line's notice (100), a Bot's job line (120), the group answer echo (300) and the rework line's note (200) in tools.js, and cleanName's 60-unit peer name in delivery.js, which lands in fenceUntrusted's from="…" attribute — so none of them can end in half a surrogate pair. The five bounds turns.js keeps go through cut as well: a failure's 500-unit message and the 200-unit message of its line (turnFailure, failureLine), the 79-unit step a running call shows (the … is appended only when cut dropped something, never by a length guess), an approval card's 2,000-unit description and 4,000-unit command (the card stores the command "Allow once" runs rather than a preview of it, and one that still had to be cut ends with … inside that bound, so the card says what it abridged and never drops the fact, #1992), and the 160-unit reason a failed turn leaves for the inbox. Since #1963 cut also flattens its input before it bounds — String(text).toWellFormed() first, codePointEnd on the flat string — so a title, a note or a name that arrives already carrying an unpaired surrogate is stored as U+FFFD instead of the lone surrogate, and U+FFFD is one UTF-16 unit for the one it replaces, so a well-formed input keeps its exact prefix and its cut flag (#1963). Since #1979 the six remaining bounds on text another home or another person supplied go through it too — a connector item name (80, connectors.js), a role label another home set on a face-room seat (80, face-lead.js), a cross-home group name (200, project-team.js), a capability description from a peer server (2,000, capabilities.js), the remote home's own error text (157, remote-chat.js) and an abbreviated remote reply (80, same file) — so each keeps the longest prefix that stops before a split pair, is well formed and is a code-point prefix of the input, while remote-chat.js's … and trailing-period strip still apply to the cut value (#1979). The two bounds on a friend's Bot's own name in that file — the name its chat is created with (40) and the name a refresh writes (40) — go through cut as well, so a refresh cannot store a lone high surrogate and a create no longer hands roster.createBot half a pair to flatten into U+FFFD; the budget stays 40 and a shorter name is stored word for word. Since #1993 the last five bounds in core/src/host/ go through it too — the quoted first line of a checkpoint's kept tail (80, steward.js), an approval record's reason (500, approvals.js), a mention receipt's reason line (240, hooks.js), the title given to a DSH Agent's Session (80, AGENT_SESSION_TITLE_MAX in agents.js) and a schedule run's failure reason (200, schedules.js) — so each keeps the longest prefix that stops before a split pair, is well formed and is a code-point prefix of the input, and none of the five maxima changes (#1993). Since #2002 the connector's own error text goes through it too: the reason in core/src/host/connectors.js — the text an MCP server's failure leaves on the owner's connector card — keeps the longest prefix that stops before a split pair, so it is well formed and a code-point prefix of what the server sent; since #2016 that prefix is 2,000 units rather than 300, and one that had to be cut ends with … inside the bound, so the card says what it abridged (#2002, #2016). Since #2005 the five bounds the Project panels read go through it as well — the assistant card's 77-unit title in project-assistant.js, the board's 200-unit one-line value and a decision title's 200 in project-board.js, and capped's name, role, persona, model and color caps plus the dossier's 4,000-unit memory head in project-team.js, whose head is bounded where memoryDigest reads it (memory.js), because a cut at the dossier alone could only turn half a pair into U+FFFD and never drop the character whole — so a dossier row or a board card keeps whole characters (#2005). Since #2321 a bound also judges in code points, not only counts in them: fits(text, max) in core/src/shared/text-boundary.js is the one predicate — a character outside the BMP spends one of the budget and not two — and clip and excerpt judge with it while their cuts keep going through codePointEnd/cut, so a 300-character text is no longer cut by a 400-character limit on its way to read_own_chat and the search rows. Since #2339 the two bounds in core/src/host/ that still judged with text.length use it as well — the memory review's 3,000-unit two-ended ends in memory.js and room-context.js's oneLine (40/80/24/60) — so a user line or a board line of characters outside the BMP stays whole inside its own limit instead of being cut at half of it; both maxima and every cut call are unchanged, so an ASCII text judges exactly as length <= max did. The memory review's own third clip is the exported one now, so its cut reads … (cut), and a bound added later that judges with text.length fails core/test/text-bounds.test.js at its own file:line. |
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). A task name is at most 120 code points (#1895), so 120 emoji is a valid name and one more is refused. The list is a read of the caller's own rows: schedulesView(actor) reads each row's Session through the module's own sourceOf(sessionId, actor) (the rule #1321 uses for a copy's source and an edit's own Session), so another owner's task stays out and an actor-less call under scoping answers no rows, while an unenforced one-person install answers the whole host; the schedule-* writes keep their Session and target gates. A narrowed view also carries someHidden: true when a known row was dropped for the caller — the active and the paused loop set it alike — so an empty answer from a narrow caller is not read as a host with nothing scheduled; a row with no known owner hides nothing and sets no flag, and an unenforced install (no actor narrowing anything) never sets it either. |
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). Since #1899 the 600-character bound on trigger, wrong and right counts code points through codePoints (text.js), so an emoji costs one character, not two, and an entry written before this change whose text is over 600 UTF-16 units but within 600 characters still loads through the read path. |
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/real-dir.js |
The one "what is this directory entry, here" check (#2055), shared by the readers that must not follow a folder link. dirKind(path) (async) and dirKindSync(path) (sync) answer real — the entry itself is a directory and not a symlink; absent — nothing at that path (ENOENT); or other — a symlink, a plain file, a non-directory parent, or any other read error (fail closed). Three states rather than a boolean because a writer has to tell "nothing there, create it" from "a link or a file, refuse"; a reader only tests === 'real'. Both shells share one predicate (lstat + isDirectory() + !isSymbolicLink()), so the rule is written once; #2056 and #2057 consume it instead of a fourth hand-rolled copy. The leaf door is the same question asked of a file a writer appends to or truncates (#2079): fileKind(path) / fileKindSync(path) answer real (a plain file carrying one name), absent (nothing at that path) or other (a link, a hard link whose bytes are another name's, a directory, a non-directory parent, or a check that could not run). Since #2159 the leaf door and the temporary name a write lands through share one predicate, so the two answers for the same file cannot disagree (nlink === 1 in both); the five callers today are all writers — the readers ask dirKind — so a hard link refuses the write and never turns a snapshot's file into an unreadable one. Tests: core/test/real-dir.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. Since #2037 a decision name has exactly one shape — one path segment under decisions/, ending in .md — and isDecisionPath is the one rule for it: the listing, this reader and the board take a name only when it matches, and both this reader and the board open a file through describeFile, which lstats the name and refuses anything that is not a plain file, so a symlink answers absent — the shape a missing file already has — instead of the text it points at; since #2041 the bounded byte read is its own export, readListedFile(dir, path), because the board's opened decision reads through it too, so the 200,000-byte bound and the lstat rule cannot drift between the two readers (#2037, #2041). Since #2055 the decisions entry itself must be a directory this reader sees — dirKind in real-dir.js answers real — because readdir follows a folder link and the leaf lstat sees only the final component: a link at a real folder outside the Project, a link at a missing path and a plain file all read exactly like a folder with no decisions, so the listing contributes no row and a read of any decisions/<name>.md answers absent, never the text it points at (#2055). Since #2461 a listed name that is there but cannot be read is a row of its own: the listing adds unreadable: <path> (<reason>) after the readable rows (<reason> is the errno the read failed with, or not a plain file for a folder or other non-file entry), instead of catching every failure into the same silence that answers no Project State files in this folder; an absent name, and a symlink, which this reader already answers as absent (#2055), still add nothing, and the readable rows keep their order and their bytes (#2461). Tests: core/test/project-state.test.js, core/test/project-state-unreadable.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. Since #1847 that bound is enforced twice, from one value: MAX_ENTRY (600, in constants.js) is checked by the assistant writer and again by appendProjectMemory, the one write door, and since #1886 both count code points (codePoints in src/host/text.js, one helper since #1895), not UTF-16 units, so a 600-character emoji note is stored rather than refused as 1,200 units; the endpoint refuses an empty entry (A Project memory entry needs text) and one over the cap (A Project memory entry is at most 600 characters) instead of storing a blank row or cutting the text, and a refusal leaves the table and project.updatedAt untouched; the panel quotes either sentence, in Chinese under a zh locale (core/src/client/i18n.js). 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>. Since #1733 the memory and the digest are per (Project, owner) — state.projectMemory[projectId].owners[ownerId] with the primary owner mirrored on the row, behind rt.projectMemoryFor(id, ownerId), appendProjectMemory(id, kind, entry), setProjectDigest(id, note, ownerId) — and one projectBoard read plus one listProjectState read serve one render per owner, so the facts are shared and the personalisation is not; a read outside ownersOf(project) is refused naming that boundary. Its own schedule-create sends the assistant's owner as the actor, because a host with a member signed in answers the create with the caller's rows only and the host reads that answer back for the row id. Since #1846 the digest reads the Project's own board, not one member's page: rt.projectBoard(id, null, { internal: true }) is the one opt-in that skips the per-reader taskVisible rule, and the channel builds its options from the zone alone, so no request body can ask for it. Tests: core/test/project-assistant.test.js, core/test/project-digest-authz.test.js. |
core/src/host/project-chief.js |
The Project Chief (#1859): every Project gets its own Chief, created with the Project by project-create — never the user's main Chief acting part-time. rt.projectChief has the same shape as rt.projectAssistant (create(projectId), chiefOf(projectId), remove(projectId)), the record carries chief: { botId, sessionId, createdAt } beside assistant, and the spawned Bot is named Project Chief (<name>), bound with bindProject so it carries projectId, and given instructions that name that one Project and forbid answering for any other (another Project's name is never quoted). A host without the seam logs once and answers undefined, so project-create still creates the Project with chief: { error }; project-delete retires the Chief through deleteBot and clears its binding. Tests: core/test/projects.test.js. |
core/src/host/project-team.js |
The Project's team dossier (#1864), read-only. rt.projectTeam(projectId) answers { rows } in the board's order — the Project's Chief, its assistant, then roles.maintainers / roles.members — with each row { botId, name, role, persona, model, color, createdAt, memory: { head, topics }, skills, rooms }. persona is the Bot's instructions; memory.head is its own MEMORY.md (read through rt.memoryDigest in memory.js, which also supplies the topic names and never a topic body); skills is the attached capabilities the library records as skill (a pack contributes its skill children); rooms is only the rooms this Project owns. Every string and list is capped (DOSSIER_LIMITS: persona and memory head 4000, 64 topics, 64 skills, 32 rooms), a deleted Bot is a null row instead of an error for the whole answer, and an unknown Project answers { rows: [] }. The endpoint sits beside project-read in authz.js's TASK_ENDPOINTS, per the owner decision that persona and memory are shared by the Project's members. Tests: core/test/project-team.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. The workflows folder itself must be a real directory too (#2057, #2068): dirKindSync from src/host/real-dir.js is asked before every listing and read, so a link, a plain file or a check that cannot run lists nothing and opens nothing — own is empty, default is null, open answers workflow/not-found, definitions() answers the presets — which is exactly the answer a missing folder gives, and the linked folder is named once per folder and process in one warning (ds-bot: workflow folder is not a plain directory …; keeping it empty). The write side follows the same rule: save, customize and setDefault still create a missing folder as before and refuse a linked one instead of writing into it (workflow/not-a-folder), and a workflow file that does not parse is reported once per file and process. The file itself is asked as well (#2079): save and setDefault call fileKindSync on workflows/<id>.json and workflows/project.json, still create a missing file and write a plain one, and refuse a link, a hard link or a directory with workflow/not-a-file, named once per file and process. 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. Since #1916 cleanPeerName bounds a peer name through the shared cut (shared/text-boundary.js), so a 60-unit label never ends in half of an emoji. |
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. Every character bound in this module goes through shared/text-boundary.js cut, never a bare slice: the stub's hundred units (compactStatus), the transcript clip at room - 1 in fitRoomLines, and the card, board and presence fields oneLine caps. A bound that lands between the halves of a surrogate pair drops the pair whole, so neither room.log nor a rendered turn carries a lone surrogate the encoder would turn into U+FFFD (#1960). 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, plus the card's owntitle(nullwhen the record has none),priority(priorityOf,'normal'when the record stored none) andboard(the{ kind, id }the row opens,nullon aworkflowrow) so a row draws a card and opens the board it lives on: the opentasksread a page joins by id keeps open cards only, so a finished card in thedonehalf has no record there and would otherwise draw as#<id>with no priority and nowhere to go.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. Since #2021 the board's record halves say what their caps left out and cut the right end of the list: the decision read answerstotal— the files left after the one-row-per-path dedup, before the cut — and takes.slice(-DECISIONS_MAX)over the still-ascending sort, so a Project past 200 decisions keeps the newest instead of dropping the ones just written and the order on screen does not change, while the meetings read answersdecidedTotal/pendingTotalper list beside the 100 rows it already kept newest; a list at or under its cap answers the same rows as before, plus the count (#2021). Since #2033 the lookup a decided meeting uses for its own file falls back to the un-truncated dedupe set of that same read when the name is not among the kept rows, so a file that fell off the oldest end of the cut is still named by the meeting (pathset, and nosha256, because only the kept rows carry one) while a name the folder no longer holds still answerspath: ''; the fallback reads names from that dedupe set and never asks the filesystem, so a crafted../outside.mdyields no path either (#2033). Since #2037 the name is also checked where the listing is produced, by the sameisDecisionPaththe reader uses: a name with a..segment, an absolute path, a symlink standing in for the file, or a name this read never listed yields no row and no meetingpath(and nosha256), so the fallback past the cut cannot bring one back —archivedOfreads throughdescribeFile, the reader's ownlstat, neverstat(#2037). Since #2041 a decision row can be opened:rt.projectDecision(projectId, name)reads one name throughreadListedFileand answers the reader's own three states (text,absent,too large), but only for a name the lastprojectBoardread of that Project produced — that read's un-truncated dedupe set is kept per Project, each name with the folder it was read from, so a name past the 200-row cut still opens whiledecisions/../outside.md, an absolute path, a name one level down or a symlink answersnot listedwithout astat, an open or a byte of content; the endpoint isproject-decision, dispatched inchannel.jsbesideproject-boardand listed inauthz.js'sTASK_ENDPOINTS(#2041). A decided meeting that saved no folder keeps the cards it decided: its file is one of the Project folder's own rows, and binding those cards to that row is what keeps the name behind the same door as the cards, while a meeting that decided no card still answers through the role list (#2052). A record that named a folder whose file this read could not open is not that folder-less shape: it binds no cards, so the same relative name in the Project's own folder answers through the role list instead of inheriting the cards of a file in another folder. Since #2029 a blocked card also carries the age of its own question:blockedOf(task, now)answerssince(theinputRequest.requestedAtstamptasks.jswrote, preserved across a re-ask) andwaitedMs(now - since, the same shapewaitingOfanswers), bothnullwhen the ask wrote no stamp — never derived fromcreatedAt/updatedAt— andbuildQueuessortsblockedoldest-first with the comparatorwaiting.sortalready uses, so an undated card sorts last; the Waiting group's own output is unchanged. 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. Per-owner memory (#1733) keys those three fields by(Project, owner)underprojectMemory[id].owners, mirrors the primary owner onto the row itself so a single-owner Project reads as before, and migrates a pre-#1733 record onto its primary owner on the first write instead of replacing it; the boundary a memory read is checked against is the Project's own owner list, so an owner asking for another owner's entries is refused naming that list, while the assistant's own render walks every owner. A card needs an owner's decision when the Bot that owns the card belongs to that owner, never from the board'sassignee(a Bot name or null); the ask names the card's own title, at mostMAX_ACTIONABLE(5) per owner. A quiet window on the assistant record holds a due digest and releases it once, labelled, when the window ends; a window covering 09:00 delivers nothing that day; an owner's opt-out stops generation and pauses the shared 09:00 task once every owner is off, and a re-enable resumes it; delivery goes to each owner's own Session and falls back to the main Bot only for the primary owner. A held digest is released by the nextdigest()call, not by the 09:00 task, which does not make one after a window ends. 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 — except a Bot Social read naming abody.botIdthe actor does not own, whichbot/forbiddenrefuses before the plugin handler registers that Bot's identity or syncs its grants (#1788, an owner-decided tightening); 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. What such a window still answers ischannel.js's ownREADSwhitelist: besidestateit carries the reads a second window's screens draw — the Projects views (projects,project-read,project-board,project-team,project-of,project-decision,project-memory), the task boards (tasks), the folder rows and browser (folders,folder-list) and the connector row's app state (connector-app-status) — and the write beside each one (project-create,project-bind,task-create,folder-pick,connector-connect, ...) is still refused, so a missing read is an empty screen and never a change made through the reading window.diagnostics-clearis deliberately not on that list (#2302): it truncates<home>/logs/diagnostics.jsonl, the file a bug report is supposed to carry, so a reading window is refused withbot/read-onlyinstead of erasing the evidence, whilediagnosticsanddiagnostics-logstill read that same file. 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). A save never writes through a link at the team file's own name (P-556): the write lands on the temp name and therenamereplaces the name, link included, so the file the link pointed at keeps every byte and its last write time, and the path the person opened is a regular file of this home's own from that save on. A hard link is that same save for the person who opened the name:lstatreportsisSymbolicLink()falsewhile the inode carries a second name, so the name counts as linked only while a regular file'snlink > 1(the ruleplainLeafinreal-dir.jsalready holds), and a file of this host's own (nlink === 1) is never counted. The store counts that save —lstaton the name before therename, once per save, never for a save over the regular file that followed — and answersrt.stateLinkReplaced()as{ gen, count }: the count, plus the random generation stamp of the process that made it, because a restarted host starts counting at 0 again and a window holding the previous process's larger count would read the restart's first save as old news (Chief, on review). The pair rides the live part of everystateanswer (stateFileLinkReplaced), theunchangedone included, because a replaced link moves no revision;authz.js'sownedLivenames it in its whitelist, so a deployment where anyone has signed in receives it too — a field left out of that whitelist reaches no signed-in client at all (Security, on review). -
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 bot write tools keep the same owner rule (#1844): with authz on,
stop_bot,update_botandarchive_bot(src/host/tools.js,writableBot) accept a target only when itsownerIdis the caller's owner orhasGrant(bot.id, ownerId)holds, andinRoomtakes no part — a Bot that is only visible because it sits in the same room is not writable, andlisted()stays a read predicate. With authz off the predicate is the oldfindTeamBot, so a single-owner install is unchanged. All three answer an unknown and a foreign name with the same bareNo Bot named <name>.instead of aTeam:list, so the text neither confirms that a Bot the actor cannot see exists nor names one. Covered bycore/test/update-bot.test.js,core/test/archive-bot.test.jsandcore/test/bot-messages.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. Since #1709 the sameretiredBotpredicate (store.js, one copy, imported by every caller) is the one check the host paths outsidefindTeamBotapply, and each of them is listed here:track_tasks' assignee and a group@Nameresolve through it, so no card is written and no turn starts (no delivery, no Session);attach_capability/detach_capabilityand aremember/recallscope:treat a retired record exactly as an unknown name;schedule-create/schedule-update/schedule-copyrefuse it with the sameNo such Bot or group chatanswer, andschedule-resumerefuses a paused row of a retired Bot the same way;task-create(the endpoint the client calls), a plugin'srouteOpenTaskandtask-send-back(which opens a rework card on the same owner) refuse it withNo such Bot; a send held for approval whose target has been retired meanwhile is stale (the target Bot was retired) instead of delivered; and a restart neither picks up a retired Bot's interrupted turn nor sends again what was queued for it. The task drive never wakes a retired owner:drivingTasksreturns nothing for it, so a card still on the board is not delivered, and a card that arrives through none of these doors is left exactly where it is. Archiving also disarms the target's own chat and the group Sessions it speaks through, every part of each:rt.pauseBotSchedules(installed byschedules.js) takes the Bot's current part plus each earlier part inbot.segments, and eachroom.sessions[botId]plus each earlier part inroom.segments[botId], writes each active row — with the part it came from — intostate.pausedSchedulesbefore DSH drops it, the way pausing one task does, so a task left on an old Session by a part hand-off is not still armed after retirement (#1799), and neither is one armed where it speaks in a group; a room's own Session is left alone, since its rows belong to the whole group, andarchive_bot's answer saysPaused N task(s)—or how many are still armed—instead of staying silent. Retiring means to the board what deleting means:archiveBotcallsrt.taskBotDeletedwiththe responsible Bot was retired, so the cards a retired Bot still holds inopen,claimed,workingorblockedfail instead of waiting for a Bot that will not run again, while a card inresult-readystays for its requester to accept or cancel. Still not covered: an un-retire path (there is none), the memory and chat the record keeps, and the connectors a retired Bot's own Session no longer starts. Deleting remains the UI'sdelete-botcall (roster.deleteBot), which archives the Bot's memory first. Since #1801 retirement also ends the reach a share gave:archiveBotdeletesstate.grants[id]and callsrt.reachChanged(id), the way deletion clears sharing, so a friend's Bot granted the record no longer resolves it from outside and nothing is delivered to a Bot that will not run. Covered bycore/test/archive-bot.test.jsandcore/test/retired-bot-paths.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), a retired Bot is never reviewed — neither a lagging group member is brought along nor a review queued before the archive runs, so its archivedMEMORY.mdis not rewritten and no model call is billed for it (#1786),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:
- The memory folders are entries of their own (#2057, #2068): one resolver,
folderKind()insrc/host/memory.js, walks every segment from<home>/memorydown — the root,memory/botsfor a Bot scope, the scope folder — and answersreal/absent/other;readFolder()andwriteFolder()are its two doors. A reader (scopeDir, the snapshot read,savedSummary,reviewed.json) takes onlyreal, so a link on any of those segments cannot feed another folder'sMEMORY.md, topics, frozen prompt snapshot or summary into a prompt, arecall, the memory panel or the Project dossier;absentandotheranswer what a missing folder answers (the one emptyMEMORY.mdentry and no topics). A host write or unlink (the snapshot, the summary,reviewed.json, the archive, the journal — and since #2068 the entry writers behindremember/edit/forget) still creates a folder that isabsent, as it always did, and refusesotherinstead of writing or deleting through the link;workflow-store.jsdoes the same for a workspace'sworkflows/on the read and write side.otheris named once per folder and process in one warning (ds-bot: memory folder is not a plain directory …; keeping it empty, or theworkflow folderform), because a folder that reads as empty and a folder that cannot be read look alike. The journal's own file is asked as a file too (#2079):journal.jsonlmust beabsent(created) or a plain file of its own before a record is appended — not a symbolic link, and not a hard link, whose bytes are another name's and whose append would land in that other file — and a link, a hard link, a directory or a check that cannot run drops that one record with a warning once per file and process, while the memory write itself still lands — the journal is a side channel. The temporary name a write lands through is asked the same way (#2097):<file>.<pid>.tmpfor an entry write and<snapshot>.tmpfor a frozen snapshot must beabsentor a plain non-link file, becausewriteFilefollows a link there and therenameafter it would put the link where the memory file belongs — a symbolic or hard link refuses the write with one warning, nothing is renamed into place and the file keeps what it had, while a check that cannot run refuses the same way. A hard link is a plain file tolstatalthough its bytes are also a name outside this home, so the guard (tempWritable()insrc/host/real-dir.js) also requires the file to carry only this one name (nlink === 1): a shared inode is refused exactly like a link, and the plain temp a crashed host leaves behind keeps its single name and stays replaceable. The same temp-name guard covers the team state (state.json.<pid>.tmp), the OCR cache (ocr.json.<pid>.tmp) and the Automation-tasks defaults (dsh-defaults.json.<pid>.tmp, refused asunrecordedwith one warning): a symbolic link, a hard link, a folder or a check that cannot run refuses the write before its first byte, while the destination itself needs no check becauserenamereplaces the name, link included. The panel can say which of the two it is looking at (#2101):memory-viewcarriesblocked, one{ scope, path }per scope the host refused (otheronly —absentis an ordinary empty home), and the memory panel prints one line naming that folder above its empty title, while an empty or not-yet-created folder renders exactly the copy it rendered before. Out of scope, deliberately: a single*.md/*.jsonleaf that is a link is still read, and a local process that can write inside the home can still win a redirect race. recallshows at most 30 entries (RECALL_MAXinsrc/host/memory.js) and now says when it cut the list (#2001): the exact-word path printsN more entries match; narrow the query or read a topicwith the real count it already holds, andsemantic: "true"askssearchIndexfor one past the cap and printsmore entries are close in meaning than are shown; narrow the query or read a topicbecause that index returns the top k only. The cap stays 30; a reply that shows every match adds neither line.
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. The log is one leaf file beside the home, so a write asks the leaf first (#2089): whendecisions.jsonlis a link, a hard link or anything other than a plain file of its own — the line is dropped, the refusal is one warning, and the tool answersNot recorded: the decision log is not a plain file of its own.instead of reporting a line that is not on disk, while an absent file is still created. The temp name the housekeeping lands through is asked the same way (#2103):<home>/decisions.jsonl.tmpis written and then renamed over the log, so when it is a link, a hard link (nlink > 1, whose bytes another name also holds) or anything other than a plain file of its own, the trim is refused instead of carrying the retained log out through it and putting the link where the log belongs; the line just appended stays, the log simply grows past its bound, the refusal is one warning, and the tool answersWritten: … — the log was not trimmed: …rather thanRecorded:. A normal temp name trims exactly as before.- 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. The three 2,000-unit text bounds — the summary a Done move copies from the card's note, the criteria stored at create, and the summary a return writes — cut on a code point boundary (cut in src/shared/text-boundary.js) rather than with slice, so a bound that lands inside a surrogate pair drops the pair whole instead of storing a lone surrogate the card would show as U+FFFD (#1956). The 300-unit oneLine bound those cards share with a room dispatch (tasks.js and work-dispatch.js) goes through the same cut, so a title, a note or a dispatch line that lands inside a pair keeps whole characters rather than half of one. 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).
Six additive fields sit beside the record above; they include brief, the card's own facts — multi-line, at most 8000 characters, stored by writeContract and read back whole, so the run that picks the card up receives the facts from the card rather than from the dispatch call that happens to start it (#2468). The other five are: none of them 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. These bounds count code points, not UTF-16 units (#1901), so a 300-character emoji reason is stored rather than refused as 600. 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 contract fields on the row it already emits when the card carries them, and brief is read from the task itself through the board's task read, 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, counted in code points (#1901). 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 a result-ready card waits on its requester, so neither counts (occupiesSlot in src/shared/task-board.js counts only claimed and working, #1744). 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 (the same occupiesSlot rule, so a member whose only card is delivered or blocked counts as free, #1744), 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. A notice the host edits or clears in the room's settings now pushes that samejointoo (roster.jsupdateRoom, after the save): a guest already seated holds the old notice and no seat change is coming, so the envelope carries the same roster snapshot with only the notice field changed, and a settings save that leaves the notice alone — or writes back the value the room already holds — sends nothing. A rename is that same push (#1949):updateRoomsends the onejoinwhen the name changed too, so a guest already seated holds the new name without waiting for the next line, and a save that changes the name and the notice together still sends one envelope. A member's rename moves an unnamed group's title the same way (#1968):followMemberNamesrecomputes the name after a Bot is renamed or removed and sends that onejointoo, because the guests already seated hold the name they were given at join and nothing else tells them; the guard that skips a room whose computed name did not move is what keeps it to real changes. A seat changing moves it as well (#1995): every path that seats or unseats somebody —addRoomParticipant,removeRoomParticipant, a room link's seats claimed into the room, a seat's own leave, and the grant eviction — ends insettleRoomName, which recomputes an unnamed room's name through the same wide formula and hands it to the room's own Session, so a guest who joins is in the title and a guest who leaves drops out of it instead of staying instate.json; a group made with a guest is opened before its seats are known, socreateRoomretitles that Session once the participants are applied. The other homes learn it too (#2004):settleRoomNameanswers whether the name moved, and the path that moved it sends onejointhroughreshareRosterto the guests already seated, so their copy of the name is the same text the host holds; the seat that just changed is left out of that broadcast, because its own targetedjoinis the one it is owed, On the settings page that broadcast is once per change (#2011):updateRoomalready sends the final name itself after its save, so the seats it adds or removes are taken with{ broadcast: false }and the seat path adds no secondjoin— a guest already seated hears exactly one, the arriving seat keeps its own targetedjoinplus that settings-page one, and a name or notice the patch asks for is still told in one envelope. A human seat is this home's own and never changes the roster a guest holds, but adding or removing one still moves an unnamed group's name (#2009): those two seat paths send the same onejointhroughreshareRoster, and only whensettleRoomNameanswers that the name moved, so a chosen name is still sent nothing. A room with a chosen name, a mirror of another home's room (inbound-share) and a face-to-face room keep the title they have. The boot's own migration tells the guests too (#2010):adopt()collects an unnamed room whose stored name no longer matches the formula,load()writes the migrated file and then sends the same onejoinfor each such room after that save, so a guest already seated holds the migrated name instead of the one it arrived with; the send is not awaited, a failure warns and never fails the boot, and a room with no remote guest, a mirror and a face-to-face room answer nothing. The same save also leaves one line in the room itself (#1962):updateRoomposts a single quietface-noticeevent naming who changed the notice (updated the group notice/cleared the notice), the room name (renamed the room to “X”), the reply mode, the admin or the task lead, so everyone already holding the room learns it without opening the settings. It is one line for the whole patch however many of these fields it changes, it opens no Bot turn, a seat change keeps its own joined/left line and draws no settings line, and a patch that writes back the values the room already holds — or an unnamed room's title following its members — says nothing. The name on that line is the signed-in member, else the Bot that acted through its own tool, else the room's implicit owner (#1967): the web UI keeps no member session, so without that step every save the local person makes would readSomeone; only a room with no owner at all falls back tothe user.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 share envelope's body is found by scanning from the first
{after the marker, and that scan tracks JSON string literals and\escapes (#2447):encodeRoomSharewritestextwithJSON.stringify, which leaves braces raw, so a chat line's own{or}is text and never ends the envelope early. One unbalanced brace used to failJSON.parseand drop the whole envelope — a sent line never arrived, and ajointhat copied such a line lost its history with it.parseRoomShareis the parser the host trusts; the client'senvelopeTail(#855) already scanned this way. - 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, then roster entries, and only then the notice 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", and an overflow a roster causes never empties the notice or deletes a present empty one, which is the host's own clear (#1932); since #1946 the roster also pays the 12 units the notice field costs on the wire even when it is empty, so a roster that stopped at a length where restoring the notice pushed the envelope back overROOM_SHARE_TEXT_MAXno longer leaves the notice branch with nothing to cut and deletes the field — the host's clear now reaches the guest as a present empty notice instead of reading as "change nothing" (#1946). 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).- A face room's
hiddenbelongs to the person who hid its row (Hide from sidebar,set-flags→roster.jssetFlags, which stampsroom.hiddenAt):upsertFaceRoomruns on every poll, so without that stamp the write would delete the hide and the switch would spring back a poll later. Ahiddencarrying no stamp — the stale shape #1605 is about — is still cleared by an open or waiting snapshot, and a closed or left room keeps whatever it holds. Covered bycore/test/face-room-hidden.test.js. - 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. The two-Bot floor runs only for a patch that changes membership, and never in a face-to-face or inbound-share room whose seats are synced (#1764), so a rename goes through while an ordinary room still refuses a member list of one.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: it is delivered into that run as a steer (
mode: 'steer', #707) so the running turn reads it at its next step and can change direction. The window flushes what it holds first, in arrival order, and those held lines steer too once a turn is running, so a line can never overtake an older one into the same run; the new line opens its own turn only when no turn runs (mode: 'queue'). A Bot-to-Botdelivernever 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.
read_own_chatshows at mostOWN_CHAT_SEARCH(20) search hits, andOWN_CHAT_READ(12) latest messages on a bare read. Both caps stay where they are, but the answer now says what it left out: a capped search ends withN more not shown; search with narrower words, or read around one of these anchors., and a capped bare read's header readsYour own chat (newest 12 of 15, oldest first). Exactly at the cap nothing was left out, so nothing is added.chat-index.jsreturns the count next to the lines —searchOwnChatanswers{ lines, every, more },latestOwnChat{ lines, more }— instead of the lines alone. A search counts the same match in all only once its limit was filled (termCountQuery; the per-term fallback usesanyTermCountQuery, which unions one branch per term so a line matching two counts once), and a latest read counts the part's visible lines (countAll/countVisible, the latter leaving out the asking turn). Without the SQLite index the log scan counts every match while it reads, because that count is the only way to say what was left out.- Covered by
core/test/group-work.test.js;core/test/history.test.jsstill pinstermQuery's SQL.
- 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. - The
Read-onlyrow of a report repeats this host's own answer rather than one constant:rt.readOnly()isundefinedwhile the window writes, a sentence about a livestate.lockwhen another dsh holds the team, and a sentence about the team file when it isstuck(a newer version, a file that will not parse, an unreadable path). The report shows that sentence —clean(cut(answer, 200)), so saved keys, the home folder and|are handled like any other report text — the same one the sidebar shows, so a newer team file readsupdate FlyBotand an unreadable one readscould not be read, neveranother dsh holds the team; a writable window still adds no row (#2167). - The log itself is one leaf file under the home, and a write asks it before it appends (#2089): when
logs/diagnostics.jsonlis a link, a hard link or anything other than a plain file of its own — the line is dropped with one line on stderr that carries no home folder, the entries stay readable in memory, andclearleaves a file behind such a link untouched rather than truncating it. Past 1 MB the leaf moves aside todiagnostics.jsonl.1, andclearremoves that name as well — in the same write chain, behind the same leaf guard — so a cleared log leaves no rotated lines behind. The truncate runs first and the removal only after it: a leaf that cannot be emptied keeps the rotated copy where it is, because those lines are then the only copy left of them. The removal keeps atryof its own: a copy the host cannot remove (a directory under that name, a folder without its write bit) keeps its bytes, is named on one stderr line that carries no folder, and never turns a Clear that emptied the leaf into one that leaves every line on disk. The truncate keeps atryof its own for the same reason (#2364): the leaf guard asks the file's kind, not its mode, so a plain0444leaf passes it and the write fails withEACCES— that failure is named on one stderr line,[ds-bot] diagnostics log not truncated: <code>, which carries no folder, the same wayappendnames its own; the answer it leaves behind isleaf: 'kept'.clearanswers with what it did —{ leaf: 'truncated' | 'kept' | 'absent', rotated: 'removed' | 'kept' | 'none' }— anddiagnostics-clearpasses that result through instead of a constant, so the Feedback page's sentence matches it: the unchanged one, one that names the leftoverlogs/diagnostics.jsonl.1, or one that does not claim a cleared log (#2360).rotatedsays what this call did to that name:removedwhen it deleted one,keptwhen one was there and still is — the removal failed, or the truncate failed first and nothing was removed — andnonewhen there was none; a leaf the guard refuses never reaches the probe, so it answersnone. What the window reads moves only with an emptied leaf:entries()answers the old lines throughclearedplus the live ones infresh, andlogText()behind the Download reads the same, so a Clear that leaves the leaf full changes neither of them — the page can no longer show "not cleared" next to "0 recent errors" while every byte is still on disk. Only a leaf that really was truncated clears both — plus a host that keeps no log file at all (diagnosticsFile: false), which has no file and no list to keep; anENOENTwrite leaves both alone like every other refusal (#2364). That is the answer, one stderr line and nothing else that moves: the truncate, thefileKindguard, the removal rule and thetrykeep the behaviour they had, and a leaf that is not there yet still gets the empty file the write always landed there.absenttherefore means nothing was on disk to clear — this host keeps no log file (diagnosticsFile: false), or the folder is not there so the write found nothing to truncate — not a leaf the call refused. The report says whether that same file is being written, in the shape the journal line already has:diagnostics log: recordingwhile the appends land,not written (<code>)after an append the host could not make, andnot written (not a plain file of its own)when the leaf guard refused the write, so a bundle that lists recent errors also says when the file holding them is missing or short and why. The value follows the last append attempt, not the first refusal: a host that puts a plain file of its own back writes the next entry into it and saysrecordingagain, and a leaf that has become a plain0444file saysnot written (EACCES)on the next attempt, while the guard's own stderr line stays one per host (#2089); a host with the log off (diagnosticsFile: false) adds no line at all, the rule the journal line keeps (#2383). - The report also names the team file itself, right after the table that counts the team (#2394):
state file: <path, home as ~> (<size> bytes, last written <ISO UTC>)for a plain file with this one name,state file: none yet (this computer has not saved the team)before this computer has one, andstate file: not a plain file (described only; the store reads the team file through the link and the next save replaces the name)when the path is a link to a plain file or a file with a second name (nlink > 1) — the store's own shape, which reads the team file through the name and renames a temp name over it on the next save, so the line must not claim nothing is read or written (#2404). A directory, a link to a directory, a link to nothing or a check that cannot run keeps the journal leaf'sstate file: not a plain file (nothing is read or written), which is true of them: no read of those lands, and for a folder behind the link the store's read fails as one (EISDIR), so it lands instuck,writable()stays false and no save ever renames over the name. A link to a plain file this host may not read keeps that leaf sentence too (#2428):statalone answersisFile(), so the permission is asked as well withaccess(path, R_OK)— no open, no byte — because otherwise the line promised the read above theRead-onlyrow that reports the same read failing withEACCES. Both sentences are metadata only —lstatfor the name, plusstaton a link to ask what it points at, never open, read or stream the target; that is the point:state.jsonholds the members, the messages and the saved keys, so the report may describe the file and must never open it — a report can say how large the team file is and when it was last written without reading a byte of it. Beside that line the report names the backups the store leaves next to the team file (#2434):state backups: none, or thestate.json.v1migration backup and thestate.json.broken-<stamp>copies of a team file that could not be read, each asname (12 KB, 2 h ago)— the broken copies newest first, three at most, withand k morecounting the rest — andunreadable: <code>only whenlstatitself fails, since a name thatlstatanswers for is drawn from its own size and age however a read of it would end (a folder or a link is such a name, and neither is opened). The path goes through the same home redaction, the line is read information rather than a write action so a read-only window draws it too, and the wording ofstate journal:,journal file:and theRead-onlyrow is unchanged. - The report names the file the journal records into, in the journal's own shape and without opening it (#2388):
journal file: <path, home as ~> (<size> bytes, last written <ISO UTC>)for a plain file with this one name,journal file: none yetbefore the first save creates it, andjournal file: not a plain file (nothing is read or written)when the path is a link, a file with a second name (nlink > 1) or the check cannot run.lstatis the only call — neveropen,readFileorcreateReadStream— so the line needs no read permission on the file and touches nothing another name holds; the path goes through the same home redaction the rest of the report uses, and a host without a journal adds no line, like thestate journal:sentence it follows. The line says what the file is, not that a write happened:state journal:keeps answering whether the host is recording.
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, since #2409 the record's owncreatedAt, so the bubble can draw how long it has waited, and since P-538 the hold's ownreasonwhen it has one, so the card can show why it waits; 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. - An earlier page names every line it hands the chat (
parts.jsdisplayItems, P-393): a bot line of an earlier part now carriessessionId(the part it was said in),turnandseqbeside its text and time, because the chat stores a reaction under the message rather than under a place in a merged row — a page is prepended as the user scrolls up, so the same line's index moved while its seq did not. The fields are additive:earlierPageanswers them on every bot item it produces, and a caller that reads onlytextandtime(the existing one included) is unchanged. Covered bycore/test/earlier-message-actions.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. Opening the writer is itself a write — it createsjournal/(mode 0700) and repairs a half-written last line by replacing the file with its prefix — sostore.jsopens it on the first save that got pastwritable(), not when the host starts: a window another dsh keeps read-only creates no folder and leaves the holder's lines as they are, and the report still sayspaused (this window is read-only). - 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 the first save 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 with a warning and leaves the file as it is. A last line left half written by a crash is the one shape the writer repairs instead of refusing: when the complete prefix before it replays, the file is replaced with that prefix (the same atomic temp-and-rename as a rotation) with one warning,dropped an incomplete last line left by a crash, and recording continues from it (#2166) — a broken line above the last one, aseqgap, aprevmismatch or an unknown op still stops recording and leaves the file as it is. A writer that stopped is asked once more on the next save: it runs the start-upopen()again, so a folder or file repaired meanwhile resumes recording — appending the change that save carried, or starting a new file with a base when the journal was removed — while a journal that still refuses stays stopped and that save still lands; the asking is silent, so a refused start-up or write is warned once, not once per save (#2154). Saving the team never depends on the journal. The folder above the journal must be a real directory too (#2056): a link, a plain file or a check that cannot run refuses before themkdir/chmodthat would follow it, so neither the permissions nor a write reach the folder a link names; a folder that does not exist yet is still this writer's to create, as before. Since #2074 the same question is asked again when the write runs: a journal folder swapped for a link is refused with one warning and recording stops, and astate.jsonlswapped for a link is never written through. A hard link reads as a plain file tolstatwhile its bytes are also a name outside this home, so the start-up read, the append and — since #2146 — the rotation also require the file to carry only this one name (nlink === 1): a shared inode is refused exactly like a link — one warning, nothing read, chmod-ed or appended — and a journal with one name records as before. The append path refuses the write, and so does the rotation, before it reads: a link or a second name there stops recording with one warning, so another file's lines are never read as the team's history and therenamenever drops the other name. A writer that stopped says so off the host too:rt.journal.status()answers{ recording, opened, because? }—opened: falsewhile no save has opened the writer yet, andopenednever claimed otherwise — and the diagnostics report adds one line —state journal: recording,state journal: not started (opens with the first save)while the writer has not opened,state journal: stopped (<reason>), orstate journal: paused (this window is read-only)in a window another dsh keeps read-only, which never reaches the write that records (#2153) — while a host without the journal adds none (#2134). - 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).
A card carries its own route. handoff.to names the role that owns the next step, and that role is the card's pool: writeContract calls routeToPool (src/host/tasks.js) on every handoff it records, so queueRole becomes handoff.to in the same write. A card created with a handoff starts in that pool (the Chief's one dispatch), and a handoff recorded on a card a member is holding (claimed, working or blocked) hands the card on through requeue in the same write — the producer lets go of it, claimedBy and the lease are cleared, and the card is open in the target pool. Recording a handoff is therefore the whole handover: no supervisor step moves the card afterwards.
task-drive's queue pass offers the pool to one idle member of the role. A card with a room is offered to that room's members of the role, as the room queue always was; a card with no room is offered to every local Bot carrying the role, so a chain between roles needs no shared group and no graph. The offer is the existing Take <id> … wake, inflight and the per-Session hold are unchanged, and a role with no idle member (holdsCard, busy, or a wake already waiting) leaves the card in the pool: the next pass offers it again, so a card is never lost for want of a worker.
A claim works the same way (#1247's compare-and-set and WIP = 1, unchanged): track_tasks { claim: true } without a group takes the highest-priority card of the caller's role whose handoff.to matches, and { claim: true, id } takes a named one; a card in another role's pool is refused by name. The target role's own admission is either the claim (accept) or track_tasks { rework: { task, reason } } (return): only the member that claimed the card may return it, and the write opens one child card with taskClass: 'rework', reworkOf naming the card it fixes and handoff.to the producing role — the same openRework task-send-back uses, now also reachable from the role that received the card. The child is queued for the producer's role, so the producer takes it from its own pool, and the original stays working until the rework child is accepted (closeRework). A return with no producer role (handoff.from missing or requester) is refused rather than handed nowhere. Tests: core/test/task-handoff-route.test.js.
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, assistantMine }, where canManage is
true only for the owner — or for every caller on a Project whose record names no owner — and
assistantMine ({ off, quiet }, #1845) is the caller's own row on project.assistant.owners, the
same record project-assistant-owner writes, so the panel can read back the digest opt-out its
owner set; a Project record that does not exist answers without the field. 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. The same check covers the record itself since #1740: project-delete, project-memory,
project-memory-write and project-assistant-owner ask requireProjectOwner first, so a
non-owner gets bot/forbidden and the Project, its memory and the other owner's digest settings
are unchanged afterwards — a refusal, never a silent no-op. project-assistant-owner also refuses
a body whose ownerId is not the caller's own record, so even a Project's owner cannot rewrite
another owner's quiet window or opt-out; an omitted ownerId means the caller's own. Since #1759
project-assistant-owner is dispatched by channel.js alongside the other project-* endpoints
and listed in authz.js's TASK_ENDPOINTS, so the owner reaches the store case through the host
channel instead of only in-process; the two landed together because a dispatch without the
allow-list entry would reach store.js with the owner check bypassed. The gate returns early on a missing id, so project-memory keeps the not-found
refusal it answered before rather than turning an unknown id into a 403. Tests:
core/test/project-owner-gate.test.js.
The rows are the work still open (#1855). A card in the done column — accepted, cancelled,
failed — is a finished record, so projectBoard returns it under done (the same row shape, the same
order, deduped with the rows) instead of in rows/tasks; counts.done keeps counting every finished
card, so the header and the #1829 metrics read exactly as they did. Nothing else is filtered: a rework, a
blocked and a waiting card stay rows. The project-board endpoint carries the archive beside tasks as
done, which is what a page needs to fold months of history into one closed group instead of painting it.
An unreadable store still answers counts of null. Tests: core/test/project-board.test.js.
The team dossier (#1864) is the same class of read: project-team takes { id }, is dispatched by
channel.js beside project-board, and sits in authz.js's TASK_ENDPOINTS next to
project-read — the owner decided that a Project's persona and memory are shared by its members.
core/src/host/project-team.js builds the rows from the Project record and the Bot store, reads the
memory head and topic names through rt.memoryDigest, and writes nothing; a Bot that was deleted is
a null row, so one dead Chief does not take the rest of the answer down. Tests:
core/test/project-team.test.js.
Beside the columns the board reads three groups (#1768), from the same pass and never a
second store: waiting is an open card with a queueRole and no claim, oldest wait first, carrying
the role and the milliseconds since its own createdAt; inflight is the card a claim (or the lease
that claim wrote) holds, its holder named, a live lease reading live and an expired one stalled
without the card record changing; blocked is a blocked card with the one-line reason it already
stored (inputRequest.question, else queueReason, else its title). projectBoard(projectId, actor)
keeps answering the record it answered before — rows, decisions, meetings — and now carries the
groups and the header counts (waiting/inflight/blocked/done) from the same read; the
project-board endpoint answers them beside tasks, decisions and meetings, and
rt.projectBoardQueues is the same read for a caller that wants only the queue. A store the host
cannot read answers counts of null, never 0 (owner rule: an unreadable queue is unknown).
Nothing here writes, and the queue semantics in tasks.js are untouched. Tests:
core/test/project-board.test.js.
A meeting is the decision card: open and decide accept level (ordinary | major | severe,
default ordinary, an unknown value refused with meeting/invalid) and recipients (names, deduped,
at most 32). severe is the one level the host refuses to advance on its own: advanceRefusal(meeting)
in core/src/host/meetings.js answers the reason (how many participants dissented, else that the Owner
confirms it) and nothing for ordinary/major. The board projects that record through
decisionCardOf in core/src/host/project-board.js, so every pending and decided row carries level,
recipients and advance: { allowed, reason }; a record written before the slice reads as an ordinary
card with no recipients. The same read separates the three identities the record already holds: a row
carries holder (the claim, never the assignee label) and speaksFor (the user a card is written for),
while a decision file keeps its own Written-by:. Tests: core/test/project-board-levels.test.js.
A participant may be invited with speaksFor and a position may record it, so a Project Chief or an owner's agent can hold a position on a person's behalf; the participant and its position both carry the id, decide copies it into the archived record, and the file's ## Participants line reads (speaks for <id>) beside that position. A proxy position counts once in positions n/m. proxyRefusal(meeting) in core/src/host/meetings.js runs inside governanceRefusal: a severe decision whose only position on one side is a proxy is refused before anything is written unless the spoken-for person is invited and holds their own position or is recorded silent. decisionCardOf exposes participants: [{ name, speaksFor, stance, silent }] and the client meeting row draws the proxy with the existing for {user} words. Tests: core/test/meetings.test.js.
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.
Projects carry three roles since #1828, and only two of them are stored. roles: { maintainers, members } on the record holds the two assignable ranks; the Owner is derived every time from
ownerUserIds (plus ownerUserId), so a record written before this slice reads owner-only with no
migration and the stored lists can never disagree with the owner. rolesOf / roleOf /
roleChoices / changeRole in project-board.js are the whole policy: nobody promotes themselves,
only an Owner re-roles someone else, the Owner rank is never assignable here, and the last owner
cannot be demoted. changeRole answers { ok: true, roles } or a machine-readable reason
(not-owner, cannot-self-promote, last-owner, unknown-role, owner-not-assignable) and the
record is untouched on a refusal. The project-role endpoint runs that function, persists only
maintainers/members (setProjectRoles), and answers { project, changed, reason }; the
projects listing now also carries each entry's role and choices for the caller, both read
from the same functions, so the sidebar menu cannot offer a switch the host would refuse.
project-role is listed in authz.js's TASK_ENDPOINTS since #1836 — the third of the family
after project-board (#1713) and project-assistant-owner (#1759): channel.js dispatched it and
store.js implemented it, and a signed-in host still answered bot/not-found: Unknown endpoint project-role before the handler ran. core/test/project-role-authz.test.js holds the invariant for
every one of them: each case of the channel's switch (endpoint) must get a gate answer that is
not Unknown endpoint. Tests: core/test/project-roles.test.js.
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. A card that has an openreworkchild is not a queue item its holder abandoned (#1804): the rework decides where the card goes, so the tick walks past it before it clears anything — thecontinuethat skips it sits abovedelete task.lease,dropWorkerand the recovery entry — and the card keeps its holder, its worker and itsworkingstatus, in no pool, with the expired lease still on it. That is deliberate: the dead lease is the one thing the recovery watches for, so clearing it while the card waits would lose the only signal, and once the child is cancelled or fails, no tick would come back to the card and it would sitworkingfor good;rt.taskExpireLeasesalso saves only when a tick recovered something, so a clear made here would not even reach disk. Two exits follow. Accepting the child runscloseRework— its only caller, on the accept path — which moves the original toresult-ready, and the next tick then drops only that dead lease. Cancelling or failing the child, so that noreworkchild is left open, hands the card back to the ordinary recovery on the next tick: the lease is cleared, the dead worker dropped,lease expired (held by <name>)written to the card'sevidencewithin the existing eight-entry cap, and theworkingoriginal requeued to the role queue its handoff names — the exit that keeps an abandoned rework from stranding the original. A card that is not a queued item only loses the dead lease. Once the lease's own expiry has passed,rt.taskLease(id).livereads false while the card still sitsworkingand still waits for its rework: that is intentional — the card has not given up its holder — and never an invitation to take it. 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.
Stage 4.1 gives an unresolved conflict a record instead of a chat. core/src/host/meetings.js
keeps meetings in the existing store (state.meetings) and registers one meeting tool; there is
no second store, no UI-only path and no cron.
- Lifecycle.
open → deliberating → decided | blocked, bound to one question and, when there is one, the issue and Project that triggered it. Recording the first position movesopentodeliberating. At most one meeting per question may be open or deliberating: a second one is refusedduplicate, comparing the question with casing and whitespace flattened. Ablockedmeeting records one blocking question and is not a dead end:resumetakes the answer, keeps the positions, options and evidence, writes theblockingAnswer, clears the blocked stamp and moves the meeting back todeliberating.decidedis terminal —resumeon it is refusedclosed. - Positions. An invited participant records
agree,dissentorabstainwith a line of text. A name that was not invited is refusednot-invited; a dissent with text is also kept as the meeting's standing dissent, owned by the participant who recorded it. A participant who never answers and is not recordedsilentkeeps the meeting from closing. - The governance check is code.
governanceRefusal(meeting)is the one refusaldecideruns before it writes anything: every invited participant has a position or is recorded silent, the decision names anowner, a dissent carries its ownowner, and the decision states what was decided. It answers the reason, orundefinedwhen the meeting may close.blockedneeds one one-sentence blocking question, writes no decision file and produces no task. - Archived decision.
decidedwrites onedecisions/<YYYY-MM-DD>-<slug>.md(Stage 1 shape: Date, Decision, Class, Evidence, Participants, Dissent, Status, Links,Written-by:), in the Session's working folder. A day that already holds that name gets the next free suffix (…-2.md,…-3.md): an earlier conclusion is never rewritten, so the same question can be decided twice in one day and two questions that share a slug both keep a record. The meeting keeps the file path and its sha256; every evidencesha/pr/checkand every participant name is in the file's text. Since #1809 the record also names the folder it wrote to (decisionDir), because the file does not live in the Project's own folder:decidearchives in the Session's working folder, and on a default install nobody names a folder at all, so the team folder is the one the file lands in. - The board reads both sources (#1809). The Decisions section is the union of the Project
folder read whole and, for each decided meeting of that Project, the one file the record names,
deduplicated by path and sorted, so one Project never lists a file it did not archive even when
two folder-less Projects share the team folder; a record written before this slice names no
folder and resolves through the Project folder as it did. The board's own read of a folder takes
no repository gate —
listProjectState(dir, { repository: false })— because a decision file is the board's artifact and a Project folder need not be a git repository; an absent or unreadable named file leaves the meeting without a row rather than failing the read.project_stateand the Project Assistant keep thenot a repositoryanswer, unchanged by this slice. - Linked tasks. Follow-up cards go through
rt.taskEndpoint('task-create', …)— the one task write path — astaskClass: 'work', each carrying the decision file path in itsevidence. A meeting never keeps tasks of its own. The name and every produced task are settled first: each task's title, its Bot and the authorization the create path applies (rt.taskCanCreate, the same gate the write uses) are checked before a card is written, so a refused task leaves no card, no temp file and no decision file. A host that carries no such gate refuses the produced taskforbiddenrather than creating it ungated. The decision body is rendered once, after the cards exist, so the- Tasks:line names the ids the meeting keeps and the recorded sha256 is the sha256 of the bytes archived. The archive folder is claimed (a temp file opened) before any card exists, so a folder that refuses the write fails the decide with no card created, and an attempt that died after the cards leaves their ids on the meeting record so a retry keeps them rather than creating a second card. - Who may close. A Project Assistant (
role: 'Project Assistant') may be invited and record a position under its own name; it may not write the decision file or close the meeting, which is refusedforbidden. - Tests:
core/test/meetings.test.js.
Stage 4.2 of #1550 turns the Project Assistant from one owner to every owner of the same Project, on
the host side only, so the tier stays T2 and core/src/shared/ is untouched.
- Owners. A Project lists its primary owner in
ownerUserIdand further owners inownerUserIds;rt.projectAssistant.ownersOf(project)is that list and it is the memory boundary.read(projectId, ownerId)anddigest(projectId, { ownerId })refuse an id outside it withbot/refused, naming the owners and the boundary — never an empty answer that would read as "this Project has no memory". - One read, two renders.
digest()readsrt.projectBoard(project.id)once andlistProjectState(project.workDir)once, then renders once per owner with that owner's memory and that owner's actionable block. Shared facts are identical in both texts; a card assigned to somebody else never appears in the "Needs 's decision:" block, andMAX_ACTIONABLE(5) caps that block, each item carrying the task id and a one-line ask. A sixth line,… and N more waiting on you, counts what the cap left out; five or fewer print exactly the lines they printed before (#P-197). The block holds the cards that wait on the reader and no others:blocked("needs your call") andresult-ready("ready for your acceptance"), read from the card's own status throughwithTitles, so a card stillopen,claimedorworkingstays only in the shared "In progress, waiting on:" section, and a row that carries no status keeps the older answer rather than being dropped (#P-195). A prompt-injected line cannot cross: digest A carries no entry from B's memory because A's render is handed only A's entries. - Quiet window per owner.
rt.projectAssistant.setOwner(projectId, ownerId, { quiet, zone, off })stores{ quiet: { on, from, to }, zone, off }onproject.assistant.owners[ownerId], never instate.prefs(the host-wide #1166 window keeps its own job, refusing a new or edited task). The window is wall clock in the owner's zone, so it is compared with the due instant — today's 09:00 Asia/Shanghai, read back as a clock in the owner's zone withclockIn(#P-194) — never with the string09:00, which is another zone's wall clock. Then: a digest due inside it is held — still generated and stored, then delivered once when the window ends, labelledheld from <the clock it was held at> to <clock> by quiet hours <from>–<to>; a window that covers the due instant delivers nothing that day, and that is the expected outcome, not a failure (the note is still written, so the next day is still one a day).setOwnerhas no channel case yet, so no product-level path reaches these controls today; the window matters once that endpoint is wired (P-198). - Opt-out.
off: trueon the owner record stops generation for that owner (no note, no message) and the 09:00 task is paused throughrt.scheduleEndpoint('schedule-pause')once every owner is off — the task is shared, so one owner turning back on resumes it (schedule-resume), and the setting is restored from the record, not by creating a second task. - Failure isolation. Each owner's render is wrapped: a throw is that owner's failure, recorded
as
digest failed: <message>on their own note and warned once, while the other owners' digests are still built and delivered. A failed render never costs the other owner their 09:00 message. - Identity. The 2.5 GitHub gate is a write gate and no longer a delivery gate: the digest is
delivered to each owner's own Session through
deliver()with no connector signed in, and the refusal is recorded on that digest's note (GitHub write refused: …) instead of suppressing the message.Written-by:/Posted-for:appear only when an identity was resolved. - Endpoint
project-assistant-owner({ id, ownerId, settings }) exposes the controls: dispatched bychannel.jsand inauthz.js'sTASK_ENDPOINTSsince #1759, withrequireProjectOwnerrefusing a non-owner withbot/forbiddenbefore any record is written.
core/src/host/project-metrics.js answers the Project board's seven numbers: PRs opened and merged today, issues opened and closed today, and the seven-day medians of PR merge, issue close and task completion durations. The four day counts compare created_at / merged_at / closed_at against the reader's own time zone (Intl.DateTimeFormat), not the host clock — the zone arrives in the request body, so resolveTimeZone validates it once and an unknown name answers the named UTC instead of throwing out of the whole board read, rows, queues and decisions with it (#1849) — and the local task half reads createdAt and the last terminal actors[].at (falling back to completedAt, then updatedAt) and counts only accepted cards. The running table and the task-done median read one board's own cards, not the deployment's task store: project-board.js hands readMetrics the same mine list its rows were built from (#1848), so a card of another Project, or one this reader is refused, appears in neither, and every branch (no credential, no fetch, a failed read, a cache hit, a success) reproduces both from that same list. The five-minute cache is shared by every Project on the repository, so an entry holds only the repository-level numbers (ok, the time zone, the stamp and the six GitHub counts and medians); running and taskDoneMedianMs are re-projected from the caller's cards on a hit exactly as on a miss (#1880). The numbers ride on the existing project-board endpoint (store.js carries the metrics section beside the rows), which passes the host secret store's GITHUB_TOKEN and the reader's time zone from the request, requires the Project, and answers ok: false with null values when the credential is missing or a read fails — the client paints —, never 0. One five-minute in-process cache keys on repository, time zone and window, so a hit spends no source call; a failed read is not cached. runningOf projects a claimed card into the running table's row (who, claim age, source, last activity).
fb.projects is the one seam a workflow run's Project binding goes through. read(id) answers the Project record or undefined; bindRun(runId, projectId) writes state.projectRuns[runId] through bindProject({ kind: 'run', … }) — the path the project-bind endpoint already uses — and saves. An unknown Project throws the store's own refusal, so the caller (the workflow plugin) treats it as a refused binding and keeps the run. A host without either function answers undefined and never throws, which is what an older plugin instance sees.