| subsystem | tree | |
|---|---|---|
| owned_paths |
|
Recursive Bot hierarchy plugin (Chief as root, children, send middleware tree) lives entirely under plugins/tree/.
Everything in plugins/tree/ (entry plugins/tree/index.js and any helpers).
- Plugin runtime hooks/slots → plugin-runtime.
- Core server/client → ds-bot-core.
- Cross-owner authority policy → team. Chief as tree root is the identity anchor for that contract; tree ownership itself stays on this page.
| File | Role |
|---|---|
plugins/tree/index.js |
Plugin entry. |
plugins/tree/src/organize.js |
Self-organization (#446): placement by role, role groups and leads, repair (over-full parents, orphans), daily tidy (merge, idle, archive), journal and undo. Exposes the shared primitives (shared) allocation uses. |
plugins/tree/src/allocate.js |
Forward allocation (#447): find_bots, reuse before create, create_child_bot(parent?) placement, create_team (lead plus children), on organize.js's shared primitives. |
plugins/tree/src/roles.js |
Role profiles and similarity (name, role label, description, own prompt, recent tools; deterministic, classify hook; reads a stored modelDomain). |
plugins/tree/src/load.js |
Runtime load (#507) and how long a turn has been open (#624): pure createLoadTracker/loadWord/loadLine for the open turns each Bot runs, fed from the activity feed. A deletion drops the removed Bot's records (clear(id), #732), so every record belongs to a live Bot. |
plugins/tree/src/classify-model.js |
Model-based clustering (#450 item 2): opt-in classification through the sanctioned plugin model call (#518). |
plugins/tree/index.js— loaded as@flybot/tree.
- Tree code stays inside
plugins/tree/; no edits tods-bot/orcore/src/. Runtime sources (index.js,client.js,src/) import only files in that package, Node builtins, or declared dependencies (ds-bot); they must not reachcore/orplugins/social/by relative path (#153). Tests may import fromcore/for fixtures.scripts/lint-plugins.mjsfails the leak and packs the plugin then imports it. - Uses documented plugin APIs only (hooks/slots).
- Reach (#90): tree never routes an outside sender through the root. A remote sender (friend's Bot) reaching a tree Bot is denied if
flybot.bot.hasAnyGrantis false (new and adopted Bots, and the Chief, start with no grant) and is not the tree's call if it is granted, so a granted Bot is reached directly, at any depth. Granted Bots on both sides are not the tree's call across trees or to non-tree Bots (the core's owner policy decides); inside one tree the parent/child rule is unchanged, andstopped/reportnotices are unchanged. On a core withouthasAnyGrantthe remote-sender rule gives no opinion.
- Every tree member (not only the Main/root) may call
create_child_bot. The new Bot goes under the caller or, withparent(#447), under one of the caller's descendants; never elsewhere (a Bot manages only its own downstream). maxChildrenand depth limits stay, checked at the target parent. Parallel creates are serialized per (target) parent.maxChildrenandmaxDepthapply to every create path while the tree plugin is loaded (#446). A Bot made without tree fields (corecreate_bot, the Bot list) is placed by role bysrc/organize.jsonbot.created: inside the create call when it fits a level or an existing lead, right after when a new lead has to be created first (it is markedorganizinguntil then). No parent goes overmaxChildrenat any moment.create_child_botkeeps its limit contract (limit reachedat a full target parent).- A newly created child starts with no person-grant (
bot.hasAnyGrantfalse, even when the parent is granted). Sharing with another person still needs an independent per-Bot grant (default deny). Tree never copies grants and never publishes.
adoptChief marks the current Main as the root and records it as the tree's adopted root; when the Main changes it clears that record, treeRole and parent on the previously adopted root (setting it back to an unattached member, which adoption then places by role), so the adopted root follows the Main and a second root another flow created is left alone. Adoption measures a Bot by the height of its subtree, not as a leaf (#748): a demoted root whose branch does not fit under the new root is left out of that pass instead of being pushed one level past maxDepth, the repair after it re-attaches the branch's children to the new root, and a second pass then places the demoted root itself. A Main change on a tree that already sits at maxDepth therefore keeps every Bot both inside the limit and inside the tree. A turn boundary checks this before the prompts are written, because roster.setMain fires no event the plugin sees. autoRoot: false turns the whole thing off: nothing is adopted or created, neither at the start nor at a turn boundary.
move_bot(bot, parent)(root only) moves an existing Bot with its subtree under another parent, found by id or by unique name, and refreshes the prompt of every Bot whose trace changed. It refuses a cycle (cannot move X under its own descendant Y), a no-op (already a child of), a duplicate sibling name,maxChildrenat the target, and a subtree that would end deeper thanmaxDepth— the same invariantscreate_child_botkeeps, applied to a move. It never creates a Bot: deletion already re-attaches a removed Bot's children to its parent, and this is the sideways counterpart.- A deletion re-runs adoption.
onBotRemovedre-attaches the deleted Bot's children first, then callsadoptUnattachedon the current root: a Bot that stayed outside the tree only because a level was full joins as soon as the deletion frees a slot, instead of waiting for the next startup. A Bot that still fits nowhere stays outside, logged, and is retried on the next pass. Removing the root hands its children to the team's current Main (adopted as the root now), or to a fresh Root when there is no Main, so the team stays one tree instead of children left withparent: nullthatisRootreads as extra roots. - Tests:
test/tree.test.js—#635: move_bot moves a bot with its subtree and refreshes the prompts,#635: move_bot refuses cycles, overflow, duplicates and a too deep subtree,#635: move_bot refuses an overflowing parent and refreshes the subtree, and#635: a Bot left outside because the tree was full joins when a deletion frees a slot.
Allocation puts a new Bot in a sensible place once; the self-organization below (#446) is the safety net that keeps the tree healthy afterwards. src/allocate.js uses organize.js's shared primitives (organizer.shared: role profiles, homes, fits, createLead, markLead, move, journal, the reorganization queue), so explicit allocation and automatic reorganization have one set of limit checks and one prompt / trace refresh.
- Reuse before create.
find_bots(query?)(read-only, every tree Bot) lists the Bots in the caller's branch (for Chief: the whole tree) withid,name,role,parent,depth,path,children,leadandvia(the caller's direct child on the path, whom it delegates to). With a query, only matches, best first: same role (sameRole), the query in the name or role, shared role words (stems: "test" finds "Tester"), the same project, the same domain. Empty query: an overview (first 100).create_child_botchecks the whole branch first: a Bot with the same role at any depth is returned as{ reused, note }and nothing is created. A Bot outside the caller's branch is not looked at (the daily tidy merges duplicates across branches). - Placement-aware creation.
create_child_bot(name, role_description, parent?):parent(id or unique name) must be the caller or a descendant, elseparent must be you or one of your descendants. Limits, depth and the sibling-name check apply at the target, under that parent's lock. Withoutparent, a marked group lead in the caller's branch whose group the new Bot belongs to takes it (most specific group first, then the shallower lead, when it has room): "FlyBot Docs" goes under "FlyBot Lead". A Bot that could lead that group itself, or a namesake, never goes under it; with no such lead the caller is the parent. The result names the parent and the tree path. - Teams: lead plus children.
create_team(team, members, parent?)withmembersas"Name: role"strings (2 tomaxChildren):"<team> Lead"(role "Lead of the group: coordinates …",lead { key, label }fromroles.js) under the caller orparent, and the members under it:Chief → FlyBot Lead → {Dev, Test, Maintenance, Repo}, one slot under Chief. An existing lead of that group in the caller's branch (marked for it, named "" / " Lead", or coordinating it) is reused and marked; a member with the same role already in the branch is reused, and moved under the lead when it sits flat next to it (that frees its slot, so a team fits at a full level when it reuses a flat member). Members that do not fit are listed as not created. A team is refused before anything is written when the lead's name or a member's name is already taken anywhere on the home — the host keeps names unique across the whole roster, not just under one parent — and the error names every clashing name with the path of the Bot that holds it; that is why a clash can no longer leave a childless<team> Leadbehind (#764). A team that moves or marks existing Bots is a journaled run (triggerteam, one-click undo); a fresh team is a plain create. - Concurrent burst (#810). Two
create_child_botcalls from the same caller that are in flight at the same time — the second started before the first returned — and share the first group key become one group: one lead for that key, reused whenleadIn()finds one and created once otherwise, with every member of the burst under it, so the second member does not fall into the broader pre-existing group the first one was heading for. The overlap is the definition: the seats are compared after one turn of the event loop, never after a timer or a settle window, and a call that starts after the previous one returned keeps the plain placement above. Members of a burst whose first keys differ are not forced together; an explicitparentkeeps the caller's own placement; a role the vocabulary cannot place (area:) is not a group. Two seats can be the same Bot-to-be, because the host keeps a name unique across the roster: a second call with an identical name and role finds the first one's Bot and reuses it, and the lead names that Bot once rather than once per seat (#840). A call that reuses an existing Bot creates nothing, so it takes no seat for the roster: the lead of a burst holds exactly the members that were created in it, and a burst of one new member plus one reused makes no lead (the seat count decides, as it does for a serial call) (#819).burstGrouping: falseturns it off. Limits are unchanged and each member is created through the lead's own per-parent lock, so a member that does not fit is refused with the existing message and one failure does not undo the others; no classifier and no model call takes part. - Plan card: not built. The codebase has no chat card for a model-proposed plan (approval cards belong to social's message holds), so forward allocation proceeds and names the resulting paths; deferral recorded on #447. Automatic reorganization never asks first.
- Prompts. Chief (and every coordinator) is told:
find_botsbefore creating and reuse what fits; a project with several roles is one lead with children (create_team); a new Bot of a group goes under its lead; atmaxChildren - 2children or more, group related children under a lead instead of adding another flat one; name the tree path of every new Bot; a full level, orphans and duplicates are fixed by the automatic reorganization, without asking the owner first. - Over capacity and orphans → #446. A full target refuses with
limit reached(pass a fittingparent, or let the tree regroup the level by role). A Bot made outsidecreate_child_botis placed by role (below). An orphan (itsparentno longer in the tree) is a reparent candidate, never parked outside for good: each repair pass (startup, everybot.created, after a deletion) moves it with its subtree to the home its role points to (its group's lead, else the root) where it fits; a journaled, undoable move. One that fits nowhere yet is retried on the next pass.
The tree organizes itself by role instead of leaving Bots outside it or a parent over the limit. Forward allocation (above, #447) is built on these primitives.
- Role profile (
src/roles.js, deterministic): name words (weighted most), the ds-bot role label, the tree role (description), the Bot's own prompt text (not the tree block) and the tools it used recently (activity.tools, from thetool.startfeed; common tools such asmessage_botignored). They give group keys, most specific first:project:<word>(a name word that is not a domain word, e.g. "flybot"),domain:<id>(dev, life, writing, research, finance, design, business, learning; English stems and Chinese terms),word:<w>(a shared role word) andarea:<work|life|other>(labels Work / Personal / General).config.classify(profile)may return{ domain, project }to override it. A stored model category (modelDomain, below) fills the domain only when the vocabulary found none. - Placement of a Bot outside the tree: its namesake, else the tree Bot that leads a group it belongs to (most specific key first, then the shallower lead; an area only through a lead made for it, e.g. "Work Lead"), else the root. At the first of those with room it is placed. A full level is regrouped: the most specific group of two or more children (the newcomer counted) moves under a suitable existing member, or under a new lead. Inside a full lead of its own group with no finer group, the member closest to its role takes it (all members share the group). There is no "least-loaded child" fallback: a Bot that fits no group while every level it matches is full stays outside, a warning names it and the sidebar tags it "outside the tree"; the next attempt (any
bot.created, startup) tries again. - Leads. A suitable existing lead is a member already marked as that group's lead, already coordinating that group (it has children and the key, project and domain only), or named for it ("Life", "FlyBot Lead") or as a lead ("Kitchen Manager", 负责人). It gets
lead { key, label }. Only when none fits a new lead is created with a clear role (<Label> Lead, role "Lead of the group: coordinates …"); its members move under it before it joins the level. A lead's own group key, and broad areas below any lead, are ignored inside it, so there is no second "Dev Lead" under "Dev Lead". - Repair: a parent over
maxChildren(after a deletion re-attached a deleted Bot's children) is regrouped the same way, at once when the lead exists, else in the queue. - Daily tidy (checked hourly, runs once a day; the first check after install only records the time;
dailyTidy: falseturns it off): (1) leaf Bots with no activity fortidy.idleDays(14) getidle(any turn or tool call clears it) and are archived aftertidy.archiveIdleDays(30) more days; Bots with children, granted Bots and the root are never idle; (2) duplicates: two Bots with essentially the same role (same name once generic words are dropped and no conflicting description, or near-identical descriptions in one domain), both at leasttidy.mergeGraceDays(1) in the tree, are merged: the newer one's children move to the kept Bot, which lists it inmergedFromand is told in its prompt; the old one is archived; (3) project and domain groups of three or more (or two with an existing lead) on every level, never a group of a whole level without a lead. - Archive, never delete: an archived Bot leaves the tree (
archived { at, reason, parent, treeRole, into? }, tree fields and block removed) but keeps its chat, memory and grants. It is hidden from the default Bot list (bot.hidden/ structureinclude; Search still finds it) (#452), and the sidebar'sflybot-tree-archivedsection is the way back to its chat (#810): collapsed by default, headerArchived (N), drawn only while N > 0, one row per archived Bot with the same tagstatusOf()gives (archived,archived · merged into <name>) opening the chat throughactions.openSession.include()still returns false, so the list andbotTree()never see it. Deleting it stays the owner's own click. That section is registered atorder: 88and the reorganization notice below atorder: 89; a slot renders ascending, so the archived Bots sit above the notice. On a merge (#450 item 1), the archived Bot's own memory is carried into the kept Bot throughflybot.memory(at most 20 entries, taggedsource: merged), so a merge loses nothing; without the host's memory seam the merge still stands. - Move, never rename-to-归档 + recreate (#452): grouping always reparents (
parent); it never renames a Bot to归档-…/Archive-…and never creates a replacement with the old name. A one-shot repair strips those prefixes when the bare name is free, or archives+hides the prefixed Bot when a live Bot already has that name. - Journal and undo: every move, new lead, lead mark, merge, archive and idle mark of a run is an action in the root's
reorg { runs (last 5), dismissed, dailyAt }and aflybot-tree: reorganized (<trigger>): …log line. The tree view's notice (sidebar sectionflybot-tree-reorg,reorgNotice: falsehides it) shows the last run with Undo and Dismiss (tree/reorg,tree/undo,tree/dismiss). Undo reverses the newest run: moves go back where the old parent has room and the Bot was not moved again, archived Bots come back, a lead the run created is archived once empty; what it cannot restore is listed as kept. Restored Bots are pinned fortidy.pinDays(14) so the daily tidy leaves them alone. - The owner is told afterwards, no plan card first: the notice in the tree view, and the Main Bot's prompt names the last run (within 7 days, not undone) so Chief can say what moved.
- No silent pull-up: freeing a slot moves nobody up; the next new Bot takes it. Activity is metadata only (when, which tool names), never message text.
A coordinator can see which of its children are busy right now, so it sends a new part to a free one instead of stacking work on a busy one.
src/load.js(pure) counts the open turns per Bot from the core activity feed (turn.start/turn.end, ids and times only).index.jssubscribes both events; nothing is persisted (a restart starts from zero). A removed Bot's turns can never end, soonBotRemovedcallsclear(id)and the count for that id reads 0 again, while a live Bot keeps its own records (#732).list_childrencarries aloadcount per child, read from the tracker live. The coordinator's prompt does not carry the load (#895): it once had aChild load right now — Work: busy (2 running), Travel: idle.line, which made the whole system prompt differ after every child turn start and end (and every minute for a stalled turn), and DSH then re-sent the full system prompt on the coordinator's next step (appended to the history on DeepSeek'sin-historyroute, a whole-history cache miss elsewhere). The coordinator rules say instead, in fixed words, to calllist_childrenbefore sending a part that more than one child fits, and to prefer an idle or working child to a busy one (load only breaks a tie between children whose scope fits, so a part with one fitting child costs no extra call). A child's turn boundary still rewrites its parent's prompt throughrefreshPrompt(the marked block only, andwriteBlockskips an unchanged block), and the running Bot's own prompt too, so a rename or role edit made in the core reaches its own prompt and its coordinator's child list on the next turn instead of only after a restart; with no load in the block, a boundary that changed nothing else writes nothing.
A count alone cannot tell a long turn from a stuck one: load: 1 read the same for a turn three seconds in and for one that had been open 47 minutes without consuming its queue.
- The tracker keeps each open turn's start time, so
openFor(id)answers the age of the oldest open turn and0when the Bot is idle. An end closes its own turn:index.jshands the feed'sturnIdtoend(id, turnId), because dropping whichever turn was oldest left the newer turn's age behind when a later turn ended first (#696). No match — an id never seen, or none at all — still drops exactly one turn, the oldest, so the count cannot drift. - Past
STALLED_AFTER_MS(12 minutes)loadLine(pure, kept for readers that want one line; the prompt no longer uses it, #895) says the age instead of just the count —Work: working (47 min, no turn ended), orWork: busy (2 running, oldest open 47 min).list_childrengainsopenForMs(andstalled: truepast the threshold) only for a child with a turn open, so an idle child keeps exactly its old shape and every existing reader ofloadkeeps a number. The tool's description names those fields too (#733): a model reads the description, not this code, and a coordinator that never learnsstalledexists cannot route around it. - A hint for routing, never a gate. The tree plugin cannot see a Bot's inbox, so a legitimately long turn ages too; nothing here refuses, ends or reassigns work.
loadLine's third argument is optional, so a caller that only has a count keeps the old line.
The rules above place a Bot by the words in its name, role label and description. When those words say nothing (a job title the small vocabulary does not hold, a brief in another language), the Bot fits no group and stays outside. src/classify-model.js asks the sanctioned plugin model call (#518) for one category and remembers it.
- Opt-in twice over. It runs only when the tree config sets
modelClassify: trueAND the host allow-lists this plugin inflybot.modelPlugins. With either missing,askModelanswersno-api/not-allowedand the Bot keeps its rules profile; nothing is asked. - One call per changed role. For each non-root tree Bot, the classifier sends the name, role label and description and asks for one of the domain ids (or
none). The answer is stored in the Bot's own fieldmodelDomain { domain, fp, at }, keyed by a fingerprint of the role text, so an unchanged Bot is asked once. A refused or unparsable answer stores nothing and never clears a good label. The host's own caps (input, output, calls-per-minute) bound every call. - Rules first, model fills the gap.
roles.jsreads the stored category and applies it only when the vocabulary found no domain, so the deterministic grouping stays primary and a model answer cannot override it. - Where it shows. The richer category feeds
find_bots(search by meaning) at once, and the next reorganization groups by it. It is a category id, never message text. - Tests:
plugins/tree/test/classify-model.test.js.
- The Bot list itself uses the core
.bt-tree-toggle(see core-client). WhensidebarSection: truebrings this plugin's separate Tree section back,.flybot-tree-togglematches that control: 28×28 hit target, 16px inner.flybot-tree-caret, hover, focus ring, and rotation of the glyph only. Leaf spacers (.flybot-tree-dot) keep the same 28px width.aria-expandedand Expand/Collapse{name}labels stay on the button. - The right-click menu entry from
menuItems({ bot })is a single Copy invite link (bare URL, shown whensocial.canInvite(bot.id)). The invite with instructions is offered only in the Bot's Sharing section, so the menu does not list the same invite twice (#173).
Both sidebar entries — the flybot.sidebar.structure entry ds-bot draws its Bot list from and the flybot-tree-archived list — need the same ctx.flybot.bots(). Each used to poll it on its own 10 s timer, so a mounted sidebar asked the host for the list twice per tick.
client.jskeeps onerosterFeed(flybot, { interval }): the first subscriber starts the poll, a later subscriber joins it and is handed the current list without a read of its own, a read already in flight setsagaininstead of stacking up behind the timer, and the last subscriber to leave clears it.apply()builds one feed and passes it toregisterStructureandmakeArchivedSection; called with no feed, each makes its own (tests and other callers keep working).- A field change that matters to either consumer (
archived,parent,treeRole,retired,idle) andbot.removedcallrefresh(), so a removal is still immediate instead of waiting for the next tick. - Tests:
plugins/tree/test/tree.test.js,#816: one roster read per tick…(one read per tick for two subscribers, the timer stops with the last one, and mounting the archived section insideapply()adds no read) and#816: the archived list is ordered above the reorganization notice.
The plugin's discarded .catch(() => {}) calls carry a one-line reason comment or void; check-empty-catch enforces it (see docs/code-standards.md).
Covered by the plugin's own tests under plugins/tree/ / core test suite as wired. The #447 tests cover reuse of a same-role Bot at depth 3 and find_bots (scope, fields, ranking), create_child_bot(parent) refusing a non-descendant (sibling, ancestor, outsider, unknown) and checking limits at the target under its lock, create_team (Chief → FlyBot Lead → four members, one slot; reuse of the lead and members; a flat member's slot at a full level, journal and undo; a name that is already taken anywhere refusing the whole team with no Bot created, no journal entry and the tree byte-identical), later Bots joining their group's lead (only down leads of groups they belong to), and orphan reattachment; test/host.test.js runs find_bots, create_team and create_child_bot(parent) on the real host. The #446 tests in test/tree.test.js cover role grouping (Life Lead, FlyBot Lead), existing leads, no unrelated fallback, the cap at every step, the daily tidy (merge, idle, archive), journal, undo and the client notice; test/host.test.js runs create_bot and the tree/reorg / tree/undo endpoints on the real host. #16 Option B in test/tree.test.js (and the matching host test) asserts a non-Main tree Bot can create a child under itself, that maxChildren/depth still refuse, and that the child is not auto-granted. Runtime load (#507) is covered by test/load.test.js (pure) and a test/tree.test.js case: a child's running turns show in list_children and drop to zero at turn.end, while the coordinator's prompt stays byte-identical across those boundaries (#895). The archived sidebar section and the concurrent burst (#810) are covered by test/archived-section.test.js (nothing at N=0, the collapsed header, a merged tag, a 归档- name, a row opening the chat, and the structure entry still hiding all of them) and by test/tree.test.js against a fake fb that holds its creates open (one lead and two children for one burst, never a second lead, an existing lead reused, different keys and the burstGrouping: false control landing as before, a serial call unchanged, an explicit parent opting out, a burst whose member was reused — no lead for one new member, and no lead naming a Bot it does not hold (#819), and a burst of two same-name same-role calls — one member, one lead, that name in the lead's role text once (#840)). The age of an open turn (#624) is covered in both: openFor reads the oldest start and falls back to the newer turn once one ends, an end that names its turn closes that one and not the oldest (#696, unknown and missing ids included), the line names the age only past the threshold and never calls an idle child stalled, and list_children adds openForMs/stalled for an open turn while an idle child keeps the old shape.
- Placing tree logic in
core/instead ofplugins/tree/. - Bypassing the plugin runtime for registration.
- Importing
../../../core/…from plugin runtime sources (breaks a packaged install, #153).