Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/2220-tree-readonly-reorg.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The reorganization notice in the sidebar no longer offers `Undo` and `Dismiss` in a read-only window: the card still shows what the last run changed and what an undo kept, but the two buttons are writes the host refuses, so they are not drawn while `roster.readOnly` is set ([#2220](https://github.com/ThinkFlowLab/FlyBot/issues/2220)).
2 changes: 1 addition & 1 deletion docs/subsystems/tree.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ The tree organizes itself by role instead of leaving Bots outside it or a parent
- **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` / structure `include`; Search still finds it) (#452), and the sidebar's `flybot-tree-archived` section is the way back to its chat (#810): collapsed by default, header `Archived (N)`, drawn only while N > 0, one row per archived Bot with the same tag `statusOf()` gives (`archived`, `archived · merged into <name>`) opening the chat through `actions.openSession`. `include()` still returns false, so the list and `botTree()` never see it. Deleting it stays the owner's own click. That section is registered at `order: 88` and the reorganization notice below at `order: 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 through `flybot.memory` (at most 20 entries, tagged `source: merged`), so a merge loses nothing; without the host's memory seam the merge still stands.
- **Retire from the core (#638):** the Main Bot's `archive_bot` writes `fields['flybot-tree'].retired = true` (never `archived`, which is the tidy's own record) and sets `bot.hidden`, so the Bot leaves the tree and the default list the way an archived one does while its history stays readable; adoption skips a retired record and `forestOf` no longer counts it, which frees its `maxChildren` slot. The tree panel (`treeOf`) skips a retired Bot the same way (#1793), and the `flybot-tree-archived` section lists it with the `retired` tag so its chat stays reachable; a record that also carries `archived` keeps the archived tag.
- **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 a `flybot-tree: reorganized (<trigger>): …` log line. The tree view's notice (sidebar section `flybot-tree-reorg`, `reorgNotice: false` hides 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 for `tidy.pinDays` (14) so the daily tidy leaves them alone.
- **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 a `flybot-tree: reorganized (<trigger>): …` log line. The tree view's notice (sidebar section `flybot-tree-reorg`, `reorgNotice: false` hides it) shows the last run with **Undo** and **Dismiss** (`tree/reorg`, `tree/undo`, `tree/dismiss`); a read-only window (`roster.readOnly`, trimmed) keeps the card and everything it reports but draws neither button, because both are writes the host refuses (#2220). 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 for `tidy.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.

Expand Down
2 changes: 1 addition & 1 deletion plugins/tree/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Recursive bot tree plugin for FlyBot (built on ds-bot / DeepSeek Harness).
- **`create_child_bot` is serialized per parent:** parallel calls never exceed `maxChildren` or create duplicate-name siblings. Two calls that are **in flight at the same time** and belong to the same project or domain become one group instead of each landing in whatever broader group it fits: one lead for that key (reused when it exists, created once otherwise) with both new Bots under it (#810). The overlap is the definition: one turn of the event loop, no timer, and a call that starts after the previous one returned keeps the ordinary placement.
- Delegation is level by level: coordinators delegate to direct children whose scope fits and aggregate upward; a bot with no children does the work itself. A one-line trace like `Chief → Work → Dev` is added only to answers a tree bot sends to its parent tree bot; replies to the user or to a remote (friend's, Bot Social) bot never carry it, and the root (Chief) never adds one.
- Tools (visible to every tree bot, not only Main/root — #16 Option B): `list_children()`, `find_bots(query?)`, `create_child_bot(name, role_description, parent?)`, `create_team(team, members, parent?)` and — root only — `move_bot(bot, parent)` (#635), which moves an existing Bot with its subtree under another parent after checking for cycles, `maxChildren`, `maxDepth` and duplicate sibling names, then refreshes every prompt whose trace changed. Unlike `create_child_bot` it never creates a Bot; use it to reorganise a team, and note that deleting a Bot still re-attaches its children to its parent. A bot creates only in its own downstream: `parent` (id or name) must be the caller or one of its descendants, and limits are checked at that parent. **Forward allocation (#447, `src/allocate.js`):** `find_bots` (read-only) lists the bots in the caller's branch (Chief: the whole tree) with role, parent, depth and path, matches first; `create_child_bot` reuses a bot with the same role anywhere in the caller's branch instead of creating a duplicate (`{ reused, note }`), and without `parent` puts the new bot under a marked group lead in the branch that its role belongs to ("FlyBot Docs" under "FlyBot Lead"), else under the caller; `create_team` makes a project team as a lead plus children (`Chief → FlyBot Lead → {Dev, Test, …}`, `members` as `"Name: role"` strings), one slot under the parent, reusing an existing lead and same-role members (a flat one moves under the lead, journaled and undoable). Allocation uses the self-organization's primitives (role profiles, leads, moves, journal); a concurrent burst of two or more `create_child_bot` calls in one group is placed as one leader plus members (#810), a call that only reuses an existing Bot is not counted as a member of that group (#819), two seats with the same name and role are one member, named once in the lead's role text (#840), and an explicit `parent` keeps the caller's own placement; a full level, orphans (a bot whose parent is gone is reattached by role on the next repair pass) and duplicates are left to the self-organization, which never asks first. There is no plan card. Limits: depth 5 (root = 1), 8 children per node; duplicate sibling names (case/space-insensitive) are rejected; names are 1–40 characters (ds-bot's roster limit). Every new child gets the same recursive system prompt template (`src/prompt.js`). A new child starts with no person-grant even if its parent is granted; sharing it still needs an independent Sharing → Copy invite link (default deny).
- Frontend (`client.js`, bundle id `@flybot/tree`, injects `flybot`): the tree is drawn by ds-bot's own Bot list, so this plugin's separate "Tree" sidebar section is **off by default** (`sidebarSection: true` in the client config brings it back). It always exposes the synchronous helper `ctx.flybot.tree.forest(bots)` (also `globalThis.__flybot__.tree.forest(bots)`), see **Tree data** below. It also registers `flybot.sidebar.structure` (`{ parentOf(botId) }`, parents from `forestOf` over `ctx.flybot.bots()`, refreshed on field changes and every 10 s); ds-bot's sidebar uses it to draw its Bot list as one expandable tree with Chief on top. Retired, archived and outside Bots and other Main Bots have no parent there, so ds-bot shows them directly under Chief; the entry's `nodeActions` draws a tag after such a row ("archived", "archived · merged into …", "outside the tree") and after an idle Bot ("idle"), so the sidebar and the tree model agree. A `flybot.sidebar.section` entry `flybot-tree-archived` lists the archived Bots the Bot list hides (#810): collapsed by default, header `Archived (N)`, nothing at all while N is 0, one row per Bot carrying the same tag (`archived`, or `archived · merged into …` after a merge) that opens that Bot's chat through `actions.openSession`; expanding is local state, so the 10 s refresh re-renders the rows without closing it (`archivedSection: false` hides it). It is registered with `order: 88` and the reorganization notice with `order: 89`: a slot renders in ascending order, so the archived list sits **above** the notice (a smaller number is higher) instead of the card pushing it down. The structure entry and the archived list read `ctx.flybot.bots()` through **one** shared feed (`rosterFeed`, `{ interval }`): the first subscriber starts the poll, later ones join it without a read of their own, a field change or a removal asks for an immediate read (`refresh()`), and a mounted sidebar asks the host once per 10 s tick — not once per consumer (#816). A `flybot.sidebar.section` entry `flybot-tree-reorg` shows the last reorganization (what moved) with Undo and Dismiss, through the host endpoints `tree/reorg`, `tree/undo`, `tree/dismiss`; it draws nothing when there is none in the last 7 days (`reorgNotice: false` in the client config hides it). The entry also has `menuItems({ bot })`, which ds-bot adds to each Bot's right-click menu: one item, `{ id: 'copy-invite', label: 'Copy invite link' }` (with `visible`, `run`); the invite with instructions is offered in the Bot's Sharing section only, so the menu does not list the same invite twice (#173). `visible` is `social.canInvite(bot.id) === true` (false without the social plugin); `run` calls `social.createInvite(botId)` (or `flybot.call('social.createInvite', { botId })`). **Copy invite link** copies only the bare URL (`link`); `copyInvite(flybot, botId, 'text')` copies the full message `text` for other callers; a missing field is an error, never swapped for the other. The clipboard write starts inside the click via `ClipboardItem` with the text as a promise, so it keeps the click's permission while the invite is created; `writeText` then a textarea are the fallbacks. It returns "Invite link for <name> copied", which ds-bot shows as a toast; a failure throws and shows as the error toast. Right-clicking never opens the Bot's chat.
- Frontend (`client.js`, bundle id `@flybot/tree`, injects `flybot`): the tree is drawn by ds-bot's own Bot list, so this plugin's separate "Tree" sidebar section is **off by default** (`sidebarSection: true` in the client config brings it back). It always exposes the synchronous helper `ctx.flybot.tree.forest(bots)` (also `globalThis.__flybot__.tree.forest(bots)`), see **Tree data** below. It also registers `flybot.sidebar.structure` (`{ parentOf(botId) }`, parents from `forestOf` over `ctx.flybot.bots()`, refreshed on field changes and every 10 s); ds-bot's sidebar uses it to draw its Bot list as one expandable tree with Chief on top. Retired, archived and outside Bots and other Main Bots have no parent there, so ds-bot shows them directly under Chief; the entry's `nodeActions` draws a tag after such a row ("archived", "archived · merged into …", "outside the tree") and after an idle Bot ("idle"), so the sidebar and the tree model agree. A `flybot.sidebar.section` entry `flybot-tree-archived` lists the archived Bots the Bot list hides (#810): collapsed by default, header `Archived (N)`, nothing at all while N is 0, one row per Bot carrying the same tag (`archived`, or `archived · merged into …` after a merge) that opens that Bot's chat through `actions.openSession`; expanding is local state, so the 10 s refresh re-renders the rows without closing it (`archivedSection: false` hides it). It is registered with `order: 88` and the reorganization notice with `order: 89`: a slot renders in ascending order, so the archived list sits **above** the notice (a smaller number is higher) instead of the card pushing it down. The structure entry and the archived list read `ctx.flybot.bots()` through **one** shared feed (`rosterFeed`, `{ interval }`): the first subscriber starts the poll, later ones join it without a read of their own, a field change or a removal asks for an immediate read (`refresh()`), and a mounted sidebar asks the host once per 10 s tick — not once per consumer (#816). A `flybot.sidebar.section` entry `flybot-tree-reorg` shows the last reorganization (what moved) with Undo and Dismiss, through the host endpoints `tree/reorg`, `tree/undo`, `tree/dismiss`; in a read-only window (`roster.readOnly`, trimmed) the card still shows what moved but draws no Undo and no Dismiss, because those two are writes the host refuses (#2220); it draws nothing when there is none in the last 7 days (`reorgNotice: false` in the client config hides it). The entry also has `menuItems({ bot })`, which ds-bot adds to each Bot's right-click menu: one item, `{ id: 'copy-invite', label: 'Copy invite link' }` (with `visible`, `run`); the invite with instructions is offered in the Bot's Sharing section only, so the menu does not list the same invite twice (#173). `visible` is `social.canInvite(bot.id) === true` (false without the social plugin); `run` calls `social.createInvite(botId)` (or `flybot.call('social.createInvite', { botId })`). **Copy invite link** copies only the bare URL (`link`); `copyInvite(flybot, botId, 'text')` copies the full message `text` for other callers; a missing field is an error, never swapped for the other. The clipboard write starts inside the click via `ClipboardItem` with the text as a promise, so it keeps the click's permission while the invite is created; `writeText` then a textarea are the fallbacks. It returns "Invite link for <name> copied", which ds-bot shows as a toast; a failure throws and shows as the error toast. Right-clicking never opens the Bot's chat.

### Config
```yaml
Expand Down
14 changes: 9 additions & 5 deletions plugins/tree/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,7 @@
try { next = typeof flybot.bots === 'function' ? await flybot.bots() : [] } catch { reading = false; return }
reading = false
bots = next ?? []
for (const fn of [...subs]) { try { fn(bots) } catch { /* one subscriber must not stop the feed */ } }

Check warning on line 336 in plugins/tree/client.js

View workflow job for this annotation

GitHub Actions / plugins (tree)

unicorn(no-useless-spread)

Using a spread operator here creates a new array unnecessarily.
if (again) { again = false; void read() }
}
return {
Expand Down Expand Up @@ -427,26 +427,30 @@
if (!run || overview.dismissed === run.id || now - run.at > NOTICE_DAYS * 24 * 60 * 60 * 1000) return null
return run
}
/** The notice body (pure, for tests): title, what moved, Undo / Dismiss. */
function ReorgCard({ run, busy = false, note = null, onUndo, onDismiss }) {
/** The notice body (pure, for tests): title, what moved, Undo / Dismiss (neither while locked). */
function ReorgCard({ run, busy = false, note = null, locked = false, onUndo, onDismiss }) {
const lines = run.summary ?? []
const undone = Boolean(run.undone)
return h('div', { className: 'flybot-reorg', role: 'status', 'aria-label': 'Tree reorganized' },
h('div', { className: 'flybot-reorg-title' }, undone ? 'Tree reorganization undone' : 'Tree reorganized', h('span', { className: 'flybot-reorg-time' }, clock(run.at))),
h('ul', { className: 'flybot-reorg-list' }, ...lines.slice(0, NOTICE_LINES).map((line, i) => h('li', { key: i }, line)),
lines.length > NOTICE_LINES ? h('li', { key: 'more' }, `and ${lines.length - NOTICE_LINES} more`) : null),
undone && run.undone.kept?.length ? h('div', { className: 'flybot-reorg-note' }, `Kept: ${run.undone.kept.join('; ')}`) : null,
h('div', { className: 'flybot-reorg-actions' },
locked ? null : h('div', { className: 'flybot-reorg-actions' },
undone ? null : h('button', { type: 'button', className: 'flybot-reorg-btn', disabled: busy, onClick: onUndo }, 'Undo'),
h('button', { type: 'button', className: 'flybot-reorg-btn', disabled: busy, onClick: onDismiss }, 'Dismiss')),
note ? h('div', { className: 'flybot-reorg-note', role: 'alert' }, note) : null)
}
function makeReorgNotice(flybot) {
return function ReorgNotice() {
return function ReorgNotice({ roster }) {
useReorgStyles()
const [overview, setOverview] = useState(null)
const [busy, setBusy] = useState(false)
const [note, setNote] = useState(null)
// The notice is a read (`tree/reorg`), so a read-only window keeps it; `tree/undo` and
// `tree/dismiss` are writes the host refuses there, so it offers neither (#2220). Same
// trimmed signal the workflow page uses for its own write entries (#2200).
const locked = (typeof roster?.readOnly === 'string' ? roster.readOnly.trim() : '') !== ''
const load = useCallback(() => { Promise.resolve(flybot.call('tree/reorg', {})).then(v => setOverview(v ?? null), () => { /* an older host has no endpoint: no notice */ }) }, [])
useEffect(() => {
load()
Expand All @@ -466,7 +470,7 @@
setBusy(false)
load()
}
return h(ReorgCard, { run, busy, note, onUndo: act('tree/undo'), onDismiss: act('tree/dismiss') })
return h(ReorgCard, { run, busy, note, locked, onUndo: act('tree/undo'), onDismiss: act('tree/dismiss') })
}
}
// The tag after a Bot's row: 'idle' (no activity for a long time) or 'archived' (out of the tree;
Expand Down
Loading
Loading