From c0561f4d69e335406226ac89bbceedf232bd9323 Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Thu, 22 Oct 2026 09:44:17 +0800 Subject: [PATCH 1/3] List a Bot's scheduled tasks across all of its Sessions (#628) --- AGENTS.md | 4 +- changelog.d/628-bot-schedules.added.md | 1 + core/src/host/constants.js | 1 + core/src/host/schedules.js | 51 +++++++++ core/test/install.test.js | 1 + core/test/schedules.test.js | 57 +++++++++- docs/subsystems/core-host.md | 18 ++- patches/ds-bot/0001-core-fixes.patch | 127 +++++++++++++++++++++- patches/ds-bot/0002-flybot-api-host.patch | 9 +- 9 files changed, 255 insertions(+), 14 deletions(-) create mode 100644 changelog.d/628-bot-schedules.added.md diff --git a/AGENTS.md b/AGENTS.md index d1b3a7b60..0b3564e2e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -87,6 +87,8 @@ Decided by the owner (Danyang, GitHub `Birfy`) on 2026-10-18/19. Four scheduled **An empty `schedule_list` is not evidence that a schedule does not exist; never delete or recreate a schedule from it, and never report "no timers" as a finding.** The owner's UI shows the real list; ask the owner instead of the tool when the run depends on it. `schedule_delete` and `schedule_update` share the same scope, so a Bot can otherwise "clean up" a schedule it cannot see. The DSH-side fix is tracked in #628. +**`bot_schedules` (no arguments) is the Bot-wide list.** It reads DSH's host-wide catalog and keeps every Session that speaks for the calling Bot — its own chat, the earlier parts of it, and the group chats it speaks in — plus its paused tasks, with each task's id, title, next run, state and owning Session. Read that tool before saying anything about this Bot's timers; DSH's `schedule_list` stays per Session, and the DSH-side scoping fix is still tracked in #628. + ### Issue labels - **Type** (exactly one): `bug`, `enhancement`, `documentation`, `accessibility`. @@ -141,7 +143,7 @@ The standing "file no new issues" rule covers design proposals only: a reproduci 3. A routine PR is merged by the Bot that gated it (owner decision 2026-10-21, [self-gate](#owner-decision-the-bot-gates-and-merges-its-own-prs-2026-10-21-owner-authorized)). A typed chat reply is still not an approval for anything else, and an owner-only decision (scope, security surface, policy) still waits for the owner's word through the normal path (#349). 4. Connector tools are on for every turn of the owner's own Bots, whoever started it, unless the owner turns a connector off for one Bot; a friend's Bot never gets them (#359, owner decision 2026-10-22 on #601). For GitHub work a Bot uses the GitHub connector tools first and `$DSH_SECRET_GITHUB_TOKEN` with `gh`/`git` only as the fallback (for example `git push`). 5. Bot Social has no keyword commitment scan. -6. A schedule is per session, not per Bot: an empty `schedule_list` is not evidence that a schedule does not exist; never delete or recreate a schedule from it, and never report "no timers" as a finding (see [A schedule is per session](#a-schedule-is-per-session-not-per-bot-628)). +6. A schedule is per session, not per Bot: an empty `schedule_list` is not evidence that a schedule does not exist; never delete or recreate a schedule from it, and never report "no timers" as a finding; `bot_schedules` is the Bot-wide list for this (see [A schedule is per session](#a-schedule-is-per-session-not-per-bot-628)). ## What this project is diff --git a/changelog.d/628-bot-schedules.added.md b/changelog.d/628-bot-schedules.added.md new file mode 100644 index 000000000..3846646dc --- /dev/null +++ b/changelog.d/628-bot-schedules.added.md @@ -0,0 +1 @@ +`bot_schedules` lists every scheduled task of the calling Bot across all of its sessions — its own chat, the earlier parts of it, and the group chats it speaks in — with each task's next run, state and owning session, so a task the owner sees in the UI can no longer look missing from the one session a Bot happens to be in ([#628](https://github.com/ThinkFlowLab/FlyBot/issues/628)). diff --git a/core/src/host/constants.js b/core/src/host/constants.js index a0089f48a..0f5b40321 100644 --- a/core/src/host/constants.js +++ b/core/src/host/constants.js @@ -49,6 +49,7 @@ export const AGENT_SESSION_TITLE_MAX = 80 // Desktop profile, so a restriction filters this list down to registered names. export const TEAM_TOOLS = [ 'list_bots', 'message_bot', 'stop_bot', 'create_bot', 'update_bot', 'list_models', + 'bot_schedules', 'create_group', 'update_group', 'delete_group', 'post_to_group', 'ask_user', 'request_secret', 'list_secrets', 'group_relay', 'read_own_chat', 'read_group_chat', 'remember', 'forget', 'recall', 'read_browser', diff --git a/core/src/host/schedules.js b/core/src/host/schedules.js index 601fdcc54..184ce807e 100644 --- a/core/src/host/schedules.js +++ b/core/src/host/schedules.js @@ -24,7 +24,9 @@ // A failure also joins the ended turns the notifications and the Agent inbox read (#353). import { createUserMessage } from '@deepseek-ai/dsh-llm' +import { defineTool } from '@deepseek-ai/dsh-tools' import { textOf } from './text.js' +import { output } from './tools.js' const MAX_TITLE = 120 // How long a due task may stay undelivered while the app runs before it counts as failed. @@ -409,6 +411,55 @@ export function install(rt) { return schedulesView() } + // Which Session a task lives in, named as the Bot reads it: its own chat (now, or an + // earlier part), or a group chat it speaks in (now, or an earlier part). + const placeOf = (sessionId) => { + const chat = rt.chatOwners.get(sessionId) + if (chat !== undefined) return { botId: chat, place: sessionId === rt.chatOf(chat) ? 'own chat' : 'own chat, earlier part' } + const live = rt.groupOwners.get(sessionId) + const past = rt.pastOwners.get(sessionId) + const owner = live ?? past + if (owner === undefined) return undefined + // An archived part of a Bot's own chat has no group: it is the same chat, earlier. + if (live === undefined && past.roomId === undefined) return { botId: past.botId, place: 'own chat, earlier part' } + const name = rt.roomOf(owner.roomId)?.name + const place = name === undefined ? 'a group chat' : `group chat "${name}"` + return { botId: owner.botId, place: live === undefined ? `${place}, earlier part` : place } + } + + // DSH's own `schedule_list` answers for the Session the call runs in, and its empty + // answer is indistinguishable from "this Bot has nothing scheduled" (#628): a task the + // owner sees in the UI is invisible from another Session of the same Bot. This tool + // answers for the Bot instead, from the host-wide catalog. + ctx.tools.register(defineTool({ + name: 'bot_schedules', + description: "List every scheduled task this Bot has, in every Session of its own — its own chat, the earlier parts of it, and the group chats it speaks in — with id, title, next run, state and owning Session. DSH's own `schedule_list` answers for one Session only; this one answers for the Bot.", + parameters: {}, + output, + async execute(_args, exec) { + await rt.load() + const botId = rt.selfOf(exec.agent?.id) + const bot = rt.botOf(botId) + if (bot === undefined) return 'This Session does not belong to a Bot, so there are no Bot-wide scheduled tasks.' + const head = `Scheduled tasks of ${bot.name} — every Session of this Bot (DSH's own schedule_list answers for one Session only):` + if (service === null) return `${head}\nnone — Automation tasks are off in DSH, so no scheduled task is visible.` + const rows = [] + const add = (entry, status, when) => { + const at = placeOf(entry.sessionId) + if (at?.botId !== botId) return + rows.push({ + at: when ?? '', + line: `- "${entry.title ?? 'untitled'}" (${entry.id}) — ${when === undefined ? 'no next run' : `next ${when}`}, ${status}, ${at.place}, session ${entry.sessionId}`, + }) + } + for (const entry of await service.catalog()) add(entry, entry.status ?? 'active', entry.scheduledAt) + for (const entry of Object.values(paused())) add({ ...entry.record, id: entry.id, sessionId: entry.sessionId }, 'paused', undefined) + if (rows.length === 0) return `${head}\nnone — no scheduled task in any of its Sessions, so this is the whole list for the Bot.` + rows.sort((a, b) => a.at.localeCompare(b.at) || a.line.localeCompare(b.line)) + return [head, ...rows.map(row => row.line)].join('\n') + }, + })) + const endpoints = { schedules: () => schedulesView(), async 'schedule-create'(body) { diff --git a/core/test/install.test.js b/core/test/install.test.js index e76e10756..add152242 100644 --- a/core/test/install.test.js +++ b/core/test/install.test.js @@ -97,6 +97,7 @@ test('apply registers listeners, tools, and effects in a fixed order', (t) => { 'on agent/created', 'effect ds-bot: capabilities', 'effect ds-bot: missed scheduled runs', + 'tool bot_schedules', 'effect ds-bot: automation tasks default', 'effect ds-bot: usage ledger', 'on llm/stream', diff --git a/core/test/schedules.test.js b/core/test/schedules.test.js index 040e15bc8..6dec09846 100644 --- a/core/test/schedules.test.js +++ b/core/test/schedules.test.js @@ -70,9 +70,9 @@ function scheduleService() { } } -function setup(services = {}) { +function setup(services = {}, team = TEAM) { const home = mkdtempSync(join(tmpdir(), 'dsb-schedules-')) - writeFileSync(join(home, 'state.json'), JSON.stringify(TEAM)) + writeFileSync(join(home, 'state.json'), JSON.stringify(team)) const ctx = fakeContext({ services }) const register = ctx.connection.fetch.register ctx.connection.fetch.register = (route) => { ctx.route = route; return register(route) } @@ -393,3 +393,56 @@ test('a due task DSH does not deliver while the app runs is a failed run, told o task.record = { ...task.record, scheduledAt: '2030-01-02T01:00:00.000Z' } assert.deepEqual(await lastRuns(call), [['failed', 'DSH could not deliver it']]) }) + +// #628: DSH's own `schedule_list` answers for one Session, so a task the owner sees in the +// UI is invisible from another Session of the same Bot, and its `[]` reads the same as +// "nothing scheduled". `bot_schedules` answers for the Bot. +test('#628: bot_schedules answers for the Bot, never for the Session the call runs in', async (t) => { + const schedule = scheduleService() + const team = JSON.parse(JSON.stringify(TEAM)) + team.rooms.g1.sessions = { w1: 'g1-w1', m1: 'g1-m1' } + team.rooms.g1.segments = { w1: [{ sessionId: 'g1-w1-old', endedAt: 5 }] } + team.pausedSchedules = { 's-paused': { id: 's-paused', sessionId: 'w1', record: { title: 'Paused brief', prompt: 'Sum up' }, pausedAt: 6 } } + const { ctx, home } = setup({ schedule }, team) + t.after(async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) }) + const add = (sessionId, title, id, status = 'active') => schedule.tasks.set(id, { sessionId, status, record: { id, title, prompt: `${title} prompt`, scheduledAt: '2030-01-01T01:00:00.000Z' } }) + add('w2', 'Writer current part', 's-w2') + add('w1', 'Writer earlier part', 's-w1') + add('g1-w1', 'Writer group', 's-g1') + add('g1-w1-old', 'Writer group earlier part', 's-g1-old') + add('m1', 'Chief own chat', 's-m1') + add('g1-m1', 'Chief group session', 's-g1m') + const listed = (sessionId) => ctx.registeredTools.get('bot_schedules').execute({}, { agent: { id: sessionId } }) + const answer = await listed('w2') + // Every Session of this Bot: its own chat, the earlier part of it, the group chats it speaks in. + for (const name of ['Writer current part', 'Writer earlier part', 'Writer group', 'Writer group earlier part', 'Paused brief']) { + assert.ok(answer.includes(name), `${name} is the Bot's schedule`) + } + assert.match(answer, /own chat, earlier part/) + assert.match(answer, /group chat "Launch"/) + assert.match(answer, /paused/) + // The same answer whichever Session of the Bot asks — that is what "Bot-wide" means. + assert.equal(await listed('w1'), answer, 'an earlier part gets the same answer') + assert.equal(await listed('g1-w1'), answer, 'so does a group Session it speaks in') + // Another Bot's tasks stay out, own chat and group alike. + assert.ok(!answer.includes('Chief own chat')) + assert.ok(!answer.includes('Chief group session')) + // The answer names its scope, so an empty one cannot be read as "nothing is scheduled". + assert.match(answer, /every Session of this Bot/) + assert.match(answer, /schedule_list answers for one Session only/) +}) + +test('#628: without the schedule service the Bot-wide answer says why it is empty', async (t) => { + const { ctx, home } = setup() + t.after(async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) }) + const answer = await ctx.registeredTools.get('bot_schedules').execute({}, { agent: { id: 'w2' } }) + assert.match(answer, /Automation tasks are off in DSH/) + assert.match(answer, /every Session of this Bot/) +}) + +test('#628: a Bot with no task in any Session gets an answer that is about the Bot', async (t) => { + const { ctx, home } = setup({ schedule: scheduleService() }) + t.after(async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) }) + const answer = await ctx.registeredTools.get('bot_schedules').execute({}, { agent: { id: 'w2' } }) + assert.match(answer, /none — no scheduled task in any of its Sessions, so this is the whole list for the Bot/) +}) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index d0e3fc1a3..ba5153a5a 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -42,7 +42,7 @@ The server side of the patched `ds-bot` core (`core/`): the part of [ds-bot-core | `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 `'s Chief` / ` · 's Bot`, ` · `, 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). | -| `core/src/host/schedules.js` | Scheduled tasks through DSH's schedule service: list, create, edit, pause/resume, retry and delete for Settings → Scheduled tasks, each task's last run, and moving a Bot's tasks to its fresh part (#354, #473, see **Scheduled tasks: edit, pause, missed runs** and **Scheduled tasks: last run and Retry**). Tests: `core/test/schedules.test.js`. | +| `core/src/host/schedules.js` | Scheduled tasks through DSH's schedule service: list, create, edit, pause/resume, retry and delete for Settings → Scheduled tasks, each task's last run, and moving a Bot's tasks to its fresh part (#354, #473, see **Scheduled tasks: edit, pause, missed runs** and **Scheduled tasks: last run and Retry**). Also the `bot_schedules` tool: the Bot-wide list DSH's own `schedule_list` cannot give (#628, see **Scheduled tasks: the Bot-wide list**). Tests: `core/test/schedules.test.js`. | | `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/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). `remoteBotName` / `isAutoOwnerName` / `seatOwnerName` keep group and face-room labels in that format too (#526): Bot Social's ` · ` 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 sidebar, the **发给** picker, mentions, and `list_bots` friends lines; history stays on disk. | @@ -67,7 +67,8 @@ external backend or task-specific session mapping; see the ### Team state and access -- The saved team (`state.json`) is versioned. Version 2 (#47) carries the identity schema — `members` records, a per-Bot `ownerId`, the owner's `designatedChiefId`. A legacy version-1 file is migrated on load with a one-time `state.json.v1` backup; 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](team.md). On top of it, member auth (#48) adds host module `src/host/member-auth.js`, installed after `store` in `MODULES` (`core/index.js`): `invitations` and `authSessions` maps live on state (no `STATE_VERSION` bump), the channel resolves the actor from the `x-flybot-member-session` header, and endpoints are `auth-invite` / `auth-redeem` / `auth-revoke` / `auth-whoami` / `auth-remove-member` / `auth-revoke-invite` with `body.actorId` ignored; `auth-invite` alone also opens for an anonymous caller on a host with no sessions yet (bootstrap first invite), `auth-revoke-invite` kills an unredeemed invitation (redeeming it afterwards fails with `revoked`), `removeMember` refuses a member that still owns a Bot (`has_bots`), and the auth mutations are serialized behind an in-process lock so concurrent `removeMember` calls cannot both pass the last-owner check. Sign-in surface only — still not team mode; details in [team](team.md). On top of it, owner-scoped access (#49) adds host module `src/host/authz.js`, installed after member-auth: when any `authSessions` exist, the channel `authorize()` 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) with `bot/blocked-shared` when more than one member exists, stamps `create-bot` with `actor.memberId`, and filters `view()`/`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 via `botOf`, current chats and group Sessions and earlier parts via `selfOf`/`groupOwners`/`pastOwners` (a shared room Session also resolves for its current participants, #50) — and unresolvable ids deny. `approval-answer` gates on the approval's own Bot or room (resolved by approval id), never on caller-supplied session fields. `flybot/*` plugin endpoints (src/host/hooks.js) reach any signed-in actor for reads; plugin writes follow the shared rule. `view()`/`live()` narrowing (`rt.ownedView`/`rt.ownedLive` in 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-room` refuses a request with neither a member Bot nor any participant. On top of it, room membership (#50) adds host module `src/host/room-membership.js`, installed after groups: a room may carry typed `participants` (`human` / local `bot` / granted remote `bot` with `handle` + owner fields, roles `admin` / `member`, `joinedSeq` / `leftSeq`); log lines carry `authorId` + `authorKind`; history is the membership window (current members and the owner of an invited Bot see `seq >=` 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 from `social/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 \**. Face-to-face rooms (#83) reuse the same `participants` / membership-window / owner-badge presentation with `origin: 'face-to-face'`: room-only remotes may sit in the room without a person-grant or a friendship, `presentRoom` treats the viewer as a member (not Shared room from), leave skips the two-Bot floor, and `fanoutRoomLine` goes to `rt.faceRoomFanout` instead of friend `flybotSend`. Still not team mode — see [team](team.md). Per-Bot grants (#90) add host module `src/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 with `rt.setGrants`, exposed as `BotView.granted`, `flybot.bot.hasGrant` / `hasAnyGrant`, `flybot.grants` and the `bot.grants` event. `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 the `bot.send` chain, again on approval release, and on every deliver with a sender or remote origin (resumed, retried, deferred, re-sent), so no verdict or `bypass` can 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.approvals` records carry `requestId`, a sha256 `version` over the request content, server-derived `ownerId` and `origin` (`via`/`hop`/audience) — claims in the message (`meta`, text) never confer authority. `flybot/decide` is allowed through authz for any signed-in actor and the per-record check happens in hooks (`decide` requires the sender Bot's owner's session, `bot/forbidden` otherwise, `bot/unauthorized` without a session); `release()` revalidates before dispatch and marks the record `stale` when the request changed, the sender or target Bot is gone (also invalidated eagerly on `deleteBot`), or the Bot changed owners; a demoted Chief's sender-side holds are invalidated eagerly on `setMain` (not inside `staleReason`), and `release()` re-checks immediately before dispatch so an in-flight approve cannot go out after a concurrent invalidation. Unknown `invalidateFor` field values throw. `flybot/approvals` lists 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. +- The saved team (`state.json`) is versioned. Version 2 (#47) carries the identity schema — `members` records, a per-Bot `ownerId`, the owner's `designatedChiefId`. A legacy version-1 file is migrated on load with a one-time `state.json.v1` backup; 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](team.md). On top of it, member auth (#48) adds host module `src/host/member-auth.js`, installed after `store` in `MODULES` (`core/index.js`): `invitations` and `authSessions` maps live on state (no `STATE_VERSION` bump), the channel resolves the actor from the `x-flybot-member-session` header, and endpoints are `auth-invite` / `auth-redeem` / `auth-revoke` / `auth-whoami` / `auth-remove-member` / `auth-revoke-invite` with `body.actorId` ignored; `auth-invite` alone also opens for an anonymous caller on a host with no sessions yet (bootstrap first invite), `auth-revoke-invite` kills an unredeemed invitation (redeeming it afterwards fails with `revoked`), `removeMember` refuses a member that still owns a Bot (`has_bots`), and the auth mutations are serialized behind an in-process lock so concurrent `removeMember` calls cannot both pass the last-owner check. Sign-in surface only — still not team mode; details in [team](team.md). On top of it, owner-scoped access (#49) adds host module `src/host/authz.js`, installed after member-auth: when any `authSessions` exist, the channel `authorize()` default-denies cross-owner Bot/room/session access, blocks shared non-isolatable resources (secrets, connectors, folder picks, browser bridge, update-install, diagnostics, set-prefs, and the DSH Agent with its Sessions — `add-agent`, `agent-session`, `agent-adopt`, `agent-archive`, `agent-rename`, `remove-agent`, which name an ownerless `agentId` and are one per deployment) with `bot/blocked-shared` when more than one member exists, stamps `create-bot` with `actor.memberId`, and filters `view()`/`live()` to the actor — every answer that returns a view, `delete-bot`, `set-main`, `remove-agent` and `delete-room` included, passes the actor so it stays scoped; with no sessions the single-human behavior is unchanged. Session targets resolve through the store's owner maps — a Bot id via `botOf`, current chats and group Sessions and earlier parts via `selfOf`/`groupOwners`/`pastOwners` (a shared room Session also resolves for its current participants, #50) — and unresolvable ids deny. `approval-answer` gates on the approval's own Bot or room (resolved by approval id), never on caller-supplied session fields. `flybot/*` plugin endpoints (src/host/hooks.js) reach any signed-in actor for reads; plugin writes follow the shared rule. `view()`/`live()` narrowing (`rt.ownedView`/`rt.ownedLive` in 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-room` refuses a request with neither a member Bot nor any participant. On top of it, room membership (#50) adds host module `src/host/room-membership.js`, installed after groups: a room may carry typed `participants` (`human` / local `bot` / granted remote `bot` with `handle` + owner fields, roles `admin` / `member`, `joinedSeq` / `leftSeq`); log lines carry `authorId` + `authorKind`; history is the membership window (current members and the owner of an invited Bot see `seq >=` 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 from `social/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 \**. Face-to-face rooms (#83) reuse the same `participants` / membership-window / owner-badge presentation with `origin: 'face-to-face'`: room-only remotes may sit in the room without a person-grant or a friendship, `presentRoom` treats the viewer as a member (not Shared room from), leave skips the two-Bot floor, and `fanoutRoomLine` goes to `rt.faceRoomFanout` instead of friend `flybotSend`. Still not team mode — see [team](team.md). Per-Bot grants (#90) add host module `src/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 with `rt.setGrants`, exposed as `BotView.granted`, `flybot.bot.hasGrant` / `hasAnyGrant`, `flybot.grants` and the `bot.grants` event. `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 the `bot.send` chain, again on approval release, and on every deliver with a sender or remote origin (resumed, retried, deferred, re-sent), so no verdict or `bypass` can 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.approvals` records carry `requestId`, a sha256 `version` over the request content, server-derived `ownerId` and `origin` (`via`/`hop`/audience) — claims in the message (`meta`, text) never confer authority. `flybot/decide` is allowed through authz for any signed-in actor and the per-record check happens in hooks (`decide` requires the sender Bot's owner's session, `bot/forbidden` otherwise, `bot/unauthorized` without a session); `release()` revalidates before dispatch and marks the record `stale` when the request changed, the sender or target Bot is gone (also invalidated eagerly on `deleteBot`), or the Bot changed owners; a demoted Chief's sender-side holds are invalidated eagerly on `setMain` (not inside `staleReason`), and `release()` re-checks immediately before dispatch so an in-flight approve cannot go out after a concurrent invalidation. Unknown `invalidateFor` field values throw. `flybot/approvals` lists 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. +- The Main Bot list is deployment-wide (#613): `set-main` requires the actor to own `body.id` and, when `body.replace` names 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 are `bot/forbidden`); `roster.setMain` keeps the same invariant for every caller, refusing a `replace` across owners and leaving `state.mainBotIds` unchanged. A swap inside one owner's team, the plain promotion of a Bot the caller owns, and the deployment-wide list itself are unchanged. - Context sharing (#53) is specified in [team](team.md), **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 as `peer` (not `bot`), and keeps cross-owner entries out of the team scope (`teamAllowedFrom`), `list_bots` lists only the caller's owner's Bots (a granted child: that child's `createdBy` subtree), Bots granted to the same owner, and Bots in the current room — another owner's folder and instructions stay private — and a `dsh-session:` chip in actor-authored `send` / `room-post` / `answer-question` / `schedule-create` text must resolve for the author (`sessionOwnedBy`; `schedule-create` authz keys off `body.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. @@ -182,7 +183,7 @@ Work a member hands a Bot with `message_bot` from its group Session runs in a hi ### Untrusted fences in every shared room -- `authoredRoomLine` fences 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, or `line.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. `stripUntrusted` is one fixed-point pass over both `untrusted-message` and `untrusted-file`. Inbound-share lines from the other instance are `remote: true` so owner-typed text is fenced too. Face join/leave notices stay unfenced platform copy. Channel room previews use `unwrapUntrusted` so the sidebar never shows the wrap. A human line whose `who` is `User` is rewritten from the signed-in display name before it is stored or fenced. +- `authoredRoomLine` fences 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, or `line.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. `stripUntrusted` is one fixed-point pass over both `untrusted-message` and `untrusted-file`, and its pattern allows whitespace inside the tag (`< / untrusted-message>`), since a parser that ignores whitespace reads the same marker; `stripMarkers` does the same for one tag. `core/test/fence-property.test.js` fuzzes a corpus of reconstitution attempts to keep this a fixed point. Inbound-share lines from the other instance are `remote: true` so owner-typed text is fenced too. Face join/leave notices stay unfenced platform copy. Channel room previews use `unwrapUntrusted` so the sidebar never shows the wrap. A human line whose `who` is `User` is 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.`); `failureLine` stays on Bot-to-Bot deliver and in diagnostics, not in the human transcript. ### Remote chats and mentions @@ -192,7 +193,6 @@ Work a member hands a Bot with `message_bot` from its group Session runs in a hi - Channel `state` notes when a window on screen last read the team (#497): the client sends `watching: !document.hidden` with every poll (a visible window asks every 1.5 s, a hidden one every 10 s), and `watching: false` does 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 as `flybot.watched()` ([plugin-runtime](plugin-runtime.md)). Process-local, never saved. Covered by `core/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 reads `friendLineAt`, `pendingCount` and `heldReplies` with no reload. - A face-to-face room's presented Bot participants carry `label` = `remoteBotTitle`, ` ()`, for remote and local seats alike (`room-membership.js`, #384); `faceAliases` still answers to the older ` · 's Bot` / `'s Chief` labels. - `remoteTurn` (`src/host/remote-chat.js`) finds what the user typed on a friend's remote chat by skipping host-appended `role:'user'` context: forms `snapshot`, `instructions` (the AGENTS.md baseline on a chat's first turn) and `notice`, 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`) mirrors a chat whose Bot left the published list (`gone`) into `bot.hidden`, so it leaves the sidebar without deleting its history, and drops that hide when the Bot is published again. It remembers which chats were `gone` before `open()` clears the marker, and only a real change of `gone` touches `hidden`: a chat the user hid himself (`roster.setFlags`, "Hide from sidebar") while its Bot is still published stays hidden instead of returning on the next publish. The marker `bot.remote.hiddenByGone` records a hide the sync itself imposed (and only when the chat was not already hidden), so returning drops that hide alone and a hide the user set survives the Bot leaving the list and coming back (#631). - `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 says `resend`, 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 chat waits on its reply (#443): a successful `forward` sets `state.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.js` exposes it as `roster.remoteAwaiting`, and `authz.js` narrows it per owner like `remoteTypedTurns`. - Remote-chat `open` copies optional published `color` / `avatar` / `avatarUpdatedAt` onto the chat record (cached by `avatarUpdatedAt`) so headers, the picker, and group lists match Friends' Bots. A missing look stays handle-seeded (#137). @@ -244,6 +244,14 @@ DSH 0.2 offers the schedule service only through its optional bundle `@deepseek- - **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: false` in the `bot` config. +### Scheduled tasks: the Bot-wide list (#628) + +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-wide `catalog()` and keeps the Sessions that speak for it: its own chat (`chatOwners`), the archived parts of it (`pastOwners` with no `roomId`), and its group Sessions, live and archived (`groupOwners` / `pastOwners` with a `roomId`, named by the group chat), plus its paused tasks from `state.pausedSchedules`. 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 that `schedule_list` is 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_delete` and 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…`, plus the service-off and empty-list cases). + ### Scheduled tasks: edit, pause, missed runs (#354) - **Edit** (`schedule-update`). On the same Session an active task goes through DSH's compare-and-update with the exact record `list` returned, so its id, next run and delivery records stay; the timing is sent only when it changed, so renaming a task does not move its next run. Moved to another Bot or group chat, or a finished one-time task, the new task is created first and the old one deleted after, the same order as a part move (`moveSchedules`), so a failure keeps the old task. `when: { kind: 'keep' }` keeps the timing (a cron rule the form cannot show). The edit needs the task's Session **and** its new target (`authz.js`), and its text gets the same chip check as `schedule-create`. @@ -275,7 +283,7 @@ A local, replayable record of every change to the saved team, for multi-Bot bugs ### Standing tool approvals (#540) -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 :`, 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, a room with other people in it, or a turn with no verdict still asks), 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. `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`. +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 :`, 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 was not used: the turn is ""`. `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`. ### Discarded promise `.catch` (#213) diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 249eb74cf..e4554a3ee 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -3216,7 +3216,7 @@ diff --git a/src/host/resume.js b/src/host/resume.js diff --git a/src/host/schedules.js b/src/host/schedules.js --- a/src/host/schedules.js +++ b/src/host/schedules.js -@@ -5,8 +5,32 @@ +@@ -5,8 +5,34 @@ // them. A group chat's task lives on the group's own Session, so the reminder reaches // the group like a message from the user (see reminderText). DSH 0.2.0 mounts the service only with its Automation tasks bundle on, so // the service is optional and every endpoint reports whether it is there. @@ -3239,7 +3239,9 @@ diff --git a/src/host/schedules.js b/src/host/schedules.js +// A failure also joins the ended turns the notifications and the Agent inbox read (#353). + +import { createUserMessage } from '@deepseek-ai/dsh-llm' ++import { defineTool } from '@deepseek-ai/dsh-tools' +import { textOf } from './text.js' ++import { output } from './tools.js' const MAX_TITLE = 120 +// How long a due task may stay undelivered while the app runs before it counts as failed. @@ -3307,7 +3309,7 @@ diff --git a/src/host/schedules.js b/src/host/schedules.js // The settings form's timing as a create request selector. function selectorOf(when) { const zone = typeof when?.timeZone === 'string' && when.timeZone !== '' ? when.timeZone : 'UTC' -@@ -89,36 +89,278 @@ export function install(rt) { +@@ -89,36 +89,327 @@ export function install(rt) { return rt.groupOwners.get(sessionId) ?? rt.pastOwners.get(sessionId) } const known = owner => owner !== undefined && (owner.botId === undefined ? state.rooms[owner.roomId] !== undefined : state.bots[owner.botId] !== undefined) @@ -3563,6 +3565,55 @@ diff --git a/src/host/schedules.js b/src/host/schedules.js + } + return schedulesView() + } ++ ++ // Which Session a task lives in, named as the Bot reads it: its own chat (now, or an ++ // earlier part), or a group chat it speaks in (now, or an earlier part). ++ const placeOf = (sessionId) => { ++ const chat = rt.chatOwners.get(sessionId) ++ if (chat !== undefined) return { botId: chat, place: sessionId === rt.chatOf(chat) ? 'own chat' : 'own chat, earlier part' } ++ const live = rt.groupOwners.get(sessionId) ++ const past = rt.pastOwners.get(sessionId) ++ const owner = live ?? past ++ if (owner === undefined) return undefined ++ // An archived part of a Bot's own chat has no group: it is the same chat, earlier. ++ if (live === undefined && past.roomId === undefined) return { botId: past.botId, place: 'own chat, earlier part' } ++ const name = rt.roomOf(owner.roomId)?.name ++ const place = name === undefined ? 'a group chat' : `group chat "${name}"` ++ return { botId: owner.botId, place: live === undefined ? `${place}, earlier part` : place } ++ } ++ ++ // DSH's own `schedule_list` answers for the Session the call runs in, and its empty ++ // answer is indistinguishable from "this Bot has nothing scheduled" (#628): a task the ++ // owner sees in the UI is invisible from another Session of the same Bot. This tool ++ // answers for the Bot instead, from the host-wide catalog. ++ ctx.tools.register(defineTool({ ++ name: 'bot_schedules', ++ description: "List every scheduled task this Bot has, in every Session of its own — its own chat, the earlier parts of it, and the group chats it speaks in — with id, title, next run, state and owning Session. DSH's own `schedule_list` answers for one Session only; this one answers for the Bot.", ++ parameters: {}, ++ output, ++ async execute(_args, exec) { ++ await rt.load() ++ const botId = rt.selfOf(exec.agent?.id) ++ const bot = rt.botOf(botId) ++ if (bot === undefined) return 'This Session does not belong to a Bot, so there are no Bot-wide scheduled tasks.' ++ const head = `Scheduled tasks of ${bot.name} — every Session of this Bot (DSH's own schedule_list answers for one Session only):` ++ if (service === null) return `${head}\nnone — Automation tasks are off in DSH, so no scheduled task is visible.` ++ const rows = [] ++ const add = (entry, status, when) => { ++ const at = placeOf(entry.sessionId) ++ if (at?.botId !== botId) return ++ rows.push({ ++ at: when ?? '', ++ line: `- "${entry.title ?? 'untitled'}" (${entry.id}) — ${when === undefined ? 'no next run' : `next ${when}`}, ${status}, ${at.place}, session ${entry.sessionId}`, ++ }) ++ } ++ for (const entry of await service.catalog()) add(entry, entry.status ?? 'active', entry.scheduledAt) ++ for (const entry of Object.values(paused())) add({ ...entry.record, id: entry.id, sessionId: entry.sessionId }, 'paused', undefined) ++ if (rows.length === 0) return `${head}\nnone — no scheduled task in any of its Sessions, so this is the whole list for the Bot.` ++ rows.sort((a, b) => a.at.localeCompare(b.at) || a.line.localeCompare(b.line)) ++ return [head, ...rows.map(row => row.line)].join('\n') ++ }, ++ })) + const endpoints = { schedules: () => schedulesView(), @@ -5392,7 +5443,7 @@ diff --git a/test/home-lock.test.js b/test/home-lock.test.js diff --git a/test/install.test.js b/test/install.test.js --- a/test/install.test.js +++ b/test/install.test.js -@@ -91,12 +91,21 @@ test('apply registers listeners, tools, and effects in a fixed order', (t) => { +@@ -91,12 +91,22 @@ 'on llm/stream', 'effect ds-bot: load secrets', 'effect ds-bot: connectors', @@ -5402,6 +5453,7 @@ diff --git a/test/install.test.js b/test/install.test.js 'on agent/created', 'effect ds-bot: capabilities', + 'effect ds-bot: missed scheduled runs', ++ 'tool bot_schedules', + 'effect ds-bot: automation tasks default', 'effect ds-bot: usage ledger', 'on llm/stream', @@ -5414,7 +5466,7 @@ diff --git a/test/install.test.js b/test/install.test.js 'effect ds-bot: api route', 'route POST /api/bot', 'effect ds-bot: load state', -@@ -137,6 +137,17 @@ test('the client channel answers from the saved state', async (t) => { +@@ -137,6 +147,17 @@ assert.equal((await call('set-prefs', { motion: 'lively' })).value.motion, 'lively') assert.equal('motion' in (await call('set-prefs', { motion: 'normal' })).value, false) assert.deepEqual((await call('set-prefs', { motion: 'wild' })).error, { code: 'bot/invalid', message: 'Motion must be quiet, normal or lively', details: {} }) @@ -6137,7 +6189,19 @@ diff --git a/test/schedules.test.js b/test/schedules.test.js if (tasks.get(id)?.sessionId !== sessionId) return { id, deleted: false, code: 'schedule_not_found' } tasks.delete(id) return { id, deleted: true } -@@ -121,3 +121,249 @@ test('a due reminder reaches a group as its prompts', () => { +@@ -44,9 +44,9 @@ function scheduleService() { + } + } + +-function setup(services = {}) { ++function setup(services = {}, team = TEAM) { + const home = mkdtempSync(join(tmpdir(), 'dsb-schedules-')) +- writeFileSync(join(home, 'state.json'), JSON.stringify(TEAM)) ++ writeFileSync(join(home, 'state.json'), JSON.stringify(team)) + const ctx = fakeContext({ services }) + const register = ctx.connection.fetch.register + ctx.connection.fetch.register = (route) => { ctx.route = route; return register(route) } +@@ -121,3 +121,302 @@ test('a due reminder reaches a group as its prompts', () => { assert.equal(reminderText(batch), '[Scheduled task] One\n[Scheduled task] Two') assert.equal(reminderText('plain text'), 'plain text') }) @@ -6387,6 +6451,59 @@ diff --git a/test/schedules.test.js b/test/schedules.test.js + task.record = { ...task.record, scheduledAt: '2030-01-02T01:00:00.000Z' } + assert.deepEqual(await lastRuns(call), [['failed', 'DSH could not deliver it']]) +}) ++ ++// #628: DSH's own `schedule_list` answers for one Session, so a task the owner sees in the ++// UI is invisible from another Session of the same Bot, and its `[]` reads the same as ++// "nothing scheduled". `bot_schedules` answers for the Bot. ++test('#628: bot_schedules answers for the Bot, never for the Session the call runs in', async (t) => { ++ const schedule = scheduleService() ++ const team = JSON.parse(JSON.stringify(TEAM)) ++ team.rooms.g1.sessions = { w1: 'g1-w1', m1: 'g1-m1' } ++ team.rooms.g1.segments = { w1: [{ sessionId: 'g1-w1-old', endedAt: 5 }] } ++ team.pausedSchedules = { 's-paused': { id: 's-paused', sessionId: 'w1', record: { title: 'Paused brief', prompt: 'Sum up' }, pausedAt: 6 } } ++ const { ctx, home } = setup({ schedule }, team) ++ t.after(async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) }) ++ const add = (sessionId, title, id, status = 'active') => schedule.tasks.set(id, { sessionId, status, record: { id, title, prompt: `${title} prompt`, scheduledAt: '2030-01-01T01:00:00.000Z' } }) ++ add('w2', 'Writer current part', 's-w2') ++ add('w1', 'Writer earlier part', 's-w1') ++ add('g1-w1', 'Writer group', 's-g1') ++ add('g1-w1-old', 'Writer group earlier part', 's-g1-old') ++ add('m1', 'Chief own chat', 's-m1') ++ add('g1-m1', 'Chief group session', 's-g1m') ++ const listed = (sessionId) => ctx.registeredTools.get('bot_schedules').execute({}, { agent: { id: sessionId } }) ++ const answer = await listed('w2') ++ // Every Session of this Bot: its own chat, the earlier part of it, the group chats it speaks in. ++ for (const name of ['Writer current part', 'Writer earlier part', 'Writer group', 'Writer group earlier part', 'Paused brief']) { ++ assert.ok(answer.includes(name), `${name} is the Bot's schedule`) ++ } ++ assert.match(answer, /own chat, earlier part/) ++ assert.match(answer, /group chat "Launch"/) ++ assert.match(answer, /paused/) ++ // The same answer whichever Session of the Bot asks — that is what "Bot-wide" means. ++ assert.equal(await listed('w1'), answer, 'an earlier part gets the same answer') ++ assert.equal(await listed('g1-w1'), answer, 'so does a group Session it speaks in') ++ // Another Bot's tasks stay out, own chat and group alike. ++ assert.ok(!answer.includes('Chief own chat')) ++ assert.ok(!answer.includes('Chief group session')) ++ // The answer names its scope, so an empty one cannot be read as "nothing is scheduled". ++ assert.match(answer, /every Session of this Bot/) ++ assert.match(answer, /schedule_list answers for one Session only/) ++}) ++ ++test('#628: without the schedule service the Bot-wide answer says why it is empty', async (t) => { ++ const { ctx, home } = setup() ++ t.after(async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) }) ++ const answer = await ctx.registeredTools.get('bot_schedules').execute({}, { agent: { id: 'w2' } }) ++ assert.match(answer, /Automation tasks are off in DSH/) ++ assert.match(answer, /every Session of this Bot/) ++}) ++ ++test('#628: a Bot with no task in any Session gets an answer that is about the Bot', async (t) => { ++ const { ctx, home } = setup({ schedule: scheduleService() }) ++ t.after(async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) }) ++ const answer = await ctx.registeredTools.get('bot_schedules').execute({}, { agent: { id: 'w2' } }) ++ assert.match(answer, /none — no scheduled task in any of its Sessions, so this is the whole list for the Bot/) ++}) diff --git a/test/tool-cells.test.js b/test/tool-cells.test.js --- a/test/tool-cells.test.js +++ b/test/tool-cells.test.js diff --git a/patches/ds-bot/0002-flybot-api-host.patch b/patches/ds-bot/0002-flybot-api-host.patch index 21b32d0c2..e8ab45306 100644 --- a/patches/ds-bot/0002-flybot-api-host.patch +++ b/patches/ds-bot/0002-flybot-api-host.patch @@ -75,7 +75,14 @@ diff --git a/index.js b/index.js diff --git a/src/host/constants.js b/src/host/constants.js --- a/src/host/constants.js +++ b/src/host/constants.js -@@ -55,8 +55,6 @@ export const TEAM_TOOLS = [ +@@ -49,14 +49,13 @@ export const AGENT_SESSION_TITLE_MAX = 80 + // Desktop profile, so a restriction filters this list down to registered names. + export const TEAM_TOOLS = [ + 'list_bots', 'message_bot', 'stop_bot', 'create_bot', 'update_bot', 'list_models', ++ 'bot_schedules', + 'create_group', 'update_group', 'delete_group', 'post_to_group', 'ask_user', + 'request_secret', 'list_secrets', 'group_relay', 'read_own_chat', + 'read_group_chat', 'remember', 'forget', 'recall', 'read_browser', 'list_capabilities', 'save_capability', 'delete_capability', 'attach_capability', 'detach_capability', ] From 787d8571b2fcbcf58f20f7edb26c48111f8f4118 Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Thu, 22 Oct 2026 09:53:40 +0800 Subject: [PATCH 2/3] Keep a group chat's own scheduled tasks in the Bot-wide list (#628) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A group chat keeps its own Session (its id is the room id), and a task the owner set for the group lives there. `placeOf` only knew the Bot's own chat and the members' group Sessions, so the group's own task was dropped from `bot_schedules` while the answer still said "this is the whole list for the Bot" — the wrong conclusion #628 exists to prevent, for group tasks. Read the group's own Session through `rt.roomOf` and keep it for members of that room only, with the tests: visible to both members, hidden from a Bot outside the group, and a paused group task still listed. --- AGENTS.md | 2 +- core/src/host/schedules.js | 10 +++-- core/test/schedules.test.js | 41 ++++++++++++++++++- docs/subsystems/core-host.md | 4 +- patches/ds-bot/0001-core-fixes.patch | 59 ++++++++++++++++++++++++---- 5 files changed, 101 insertions(+), 15 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0b3564e2e..2ac0802dd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -87,7 +87,7 @@ Decided by the owner (Danyang, GitHub `Birfy`) on 2026-10-18/19. Four scheduled **An empty `schedule_list` is not evidence that a schedule does not exist; never delete or recreate a schedule from it, and never report "no timers" as a finding.** The owner's UI shows the real list; ask the owner instead of the tool when the run depends on it. `schedule_delete` and `schedule_update` share the same scope, so a Bot can otherwise "clean up" a schedule it cannot see. The DSH-side fix is tracked in #628. -**`bot_schedules` (no arguments) is the Bot-wide list.** It reads DSH's host-wide catalog and keeps every Session that speaks for the calling Bot — its own chat, the earlier parts of it, and the group chats it speaks in — plus its paused tasks, with each task's id, title, next run, state and owning Session. Read that tool before saying anything about this Bot's timers; DSH's `schedule_list` stays per Session, and the DSH-side scoping fix is still tracked in #628. +**`bot_schedules` (no arguments) is the Bot-wide list.** It reads DSH's host-wide catalog and keeps every Session that speaks for the calling Bot — its own chat, the earlier parts of it, the group chats it speaks in, and a group chat's own Session for the tasks the owner set for that group (to its members only) — plus its paused tasks, with each task's id, title, next run, state and owning Session. Read that tool before saying anything about this Bot's timers; DSH's `schedule_list` stays per Session, and the DSH-side scoping fix is still tracked in #628. ### Issue labels diff --git a/core/src/host/schedules.js b/core/src/host/schedules.js index 184ce807e..f34c91a1c 100644 --- a/core/src/host/schedules.js +++ b/core/src/host/schedules.js @@ -413,9 +413,13 @@ export function install(rt) { // Which Session a task lives in, named as the Bot reads it: its own chat (now, or an // earlier part), or a group chat it speaks in (now, or an earlier part). - const placeOf = (sessionId) => { + const placeOf = (sessionId, botId) => { const chat = rt.chatOwners.get(sessionId) if (chat !== undefined) return { botId: chat, place: sessionId === rt.chatOf(chat) ? 'own chat' : 'own chat, earlier part' } + // A group chat's own Session (its id is the room id): the tasks the owner set for the + // group itself. Only a member is told about them. + const room = rt.roomOf(sessionId) + if (room !== undefined) return room.members?.includes(botId) === true ? { botId, place: room.name === undefined ? 'this group chat' : `group chat "${room.name}"` } : undefined const live = rt.groupOwners.get(sessionId) const past = rt.pastOwners.get(sessionId) const owner = live ?? past @@ -433,7 +437,7 @@ export function install(rt) { // answers for the Bot instead, from the host-wide catalog. ctx.tools.register(defineTool({ name: 'bot_schedules', - description: "List every scheduled task this Bot has, in every Session of its own — its own chat, the earlier parts of it, and the group chats it speaks in — with id, title, next run, state and owning Session. DSH's own `schedule_list` answers for one Session only; this one answers for the Bot.", + description: "List every scheduled task this Bot has, in every Session of its own — its own chat, the earlier parts of it, the group chats it speaks in, and the tasks set for those group chats themselves — with id, title, next run, state and owning Session. DSH's own `schedule_list` answers for one Session only; this one answers for the Bot.", parameters: {}, output, async execute(_args, exec) { @@ -445,7 +449,7 @@ export function install(rt) { if (service === null) return `${head}\nnone — Automation tasks are off in DSH, so no scheduled task is visible.` const rows = [] const add = (entry, status, when) => { - const at = placeOf(entry.sessionId) + const at = placeOf(entry.sessionId, botId) if (at?.botId !== botId) return rows.push({ at: when ?? '', diff --git a/core/test/schedules.test.js b/core/test/schedules.test.js index 6dec09846..11cf8dd02 100644 --- a/core/test/schedules.test.js +++ b/core/test/schedules.test.js @@ -412,10 +412,12 @@ test('#628: bot_schedules answers for the Bot, never for the Session the call ru add('g1-w1-old', 'Writer group earlier part', 's-g1-old') add('m1', 'Chief own chat', 's-m1') add('g1-m1', 'Chief group session', 's-g1m') + // A task the owner set for the group chat itself lives on the group's own Session (#628). + add('g1', 'Launch group task', 's-g1-own') const listed = (sessionId) => ctx.registeredTools.get('bot_schedules').execute({}, { agent: { id: sessionId } }) const answer = await listed('w2') // Every Session of this Bot: its own chat, the earlier part of it, the group chats it speaks in. - for (const name of ['Writer current part', 'Writer earlier part', 'Writer group', 'Writer group earlier part', 'Paused brief']) { + for (const name of ['Writer current part', 'Writer earlier part', 'Writer group', 'Writer group earlier part', 'Launch group task', 'Paused brief']) { assert.ok(answer.includes(name), `${name} is the Bot's schedule`) } assert.match(answer, /own chat, earlier part/) @@ -432,6 +434,43 @@ test('#628: bot_schedules answers for the Bot, never for the Session the call ru assert.match(answer, /schedule_list answers for one Session only/) }) +// A group chat keeps its own Session, and a task the owner sets for the group lives on it +// (#628): its id is the room id, so it belongs to every member of that group and to nobody +// else — including a Bot that only sees the group's members' Sessions. +test('#628: a task set for the group chat is listed for its members, not for a Bot outside it', async (t) => { + const schedule = scheduleService() + const team = JSON.parse(JSON.stringify(TEAM)) + team.bots.w3 = { id: 'w3', sessionId: 'w3', name: 'Outsider', role: '', instructions: '', createdAt: 7 } + const { ctx, home, call } = setup({ schedule }, team) + t.after(async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) }) + const made = await call('schedule-create', { target: 'g1', title: 'Standup', prompt: 'Where are we?', when: { kind: 'daily', time: '10:00', timeZone: 'UTC' } }) + assert.equal(made.ok, true, JSON.stringify(made)) + assert.equal([...schedule.tasks.values()][0].sessionId, 'g1', 'the task lives on the group Session') + const listed = (sessionId) => ctx.registeredTools.get('bot_schedules').execute({}, { agent: { id: sessionId } }) + const writer = await listed('w2') + assert.ok(writer.includes('Standup'), 'a member sees the group chat’s own task') + assert.match(writer, /group chat "Launch"/) + assert.ok((await listed('m1')).includes('Standup'), 'the admin is a member and sees it too') + const outsider = await listed('w3') + assert.ok(!outsider.includes('Standup'), 'a Bot outside the group is not told about it') + assert.match(outsider, /none — no scheduled task in any of its Sessions/, 'and its answer says so plainly') +}) + +test('#628: a paused task on the group chat’s own Session is listed too', async (t) => { + const schedule = scheduleService() + const team = JSON.parse(JSON.stringify(TEAM)) + team.bots.w3 = { id: 'w3', sessionId: 'w3', name: 'Outsider', role: '', instructions: '', createdAt: 7 } + team.pausedSchedules = { 's-gpaused': { id: 's-gpaused', sessionId: 'g1', record: { title: 'Paused group brief', prompt: 'Sum up' }, pausedAt: 6 } } + const { ctx, home } = setup({ schedule }, team) + t.after(async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) }) + const listed = (sessionId) => ctx.registeredTools.get('bot_schedules').execute({}, { agent: { id: sessionId } }) + const answer = await listed('w2') + assert.ok(answer.includes('Paused group brief'), 'a paused group task is still a task of the Bot') + assert.match(answer, /paused/) + assert.match(answer, /group chat "Launch"/) + assert.ok(!(await listed('w3')).includes('Paused group brief')) +}) + test('#628: without the schedule service the Bot-wide answer says why it is empty', async (t) => { const { ctx, home } = setup() t.after(async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) }) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index ba5153a5a..67cfb3188 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -248,9 +248,9 @@ DSH 0.2 offers the schedule service only through its optional bundle `@deepseek- 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-wide `catalog()` and keeps the Sessions that speak for it: its own chat (`chatOwners`), the archived parts of it (`pastOwners` with no `roomId`), and its group Sessions, live and archived (`groupOwners` / `pastOwners` with a `roomId`, named by the group chat), plus its paused tasks from `state.pausedSchedules`. 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 that `schedule_list` is per Session, so an empty answer cannot be read as a claim about another Session. +- **`bot_schedules`** (no arguments) answers for the calling Bot. It reads the host-wide `catalog()` and keeps the Sessions that speak for it: its own chat (`chatOwners`), the archived parts of it (`pastOwners` with no `roomId`), its group Sessions, live and archived (`groupOwners` / `pastOwners` with a `roomId`, named by the group chat), and the group chat's own Session (`rt.roomOf`, whose id is the room id) for the tasks the owner set for that group itself — listed to its members only, never to a Bot outside the group. Its paused tasks come from `state.pausedSchedules`, on any of those Sessions. 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 that `schedule_list` is 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_delete` and 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…`, plus the service-off and empty-list cases). +- Tests: `core/test/schedules.test.js` (`#628: bot_schedules answers for the Bot…`, the two group-chat cases, plus the service-off and empty-list cases). ### Scheduled tasks: edit, pause, missed runs (#354) diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index e4554a3ee..1796293b5 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -3309,7 +3309,7 @@ diff --git a/src/host/schedules.js b/src/host/schedules.js // The settings form's timing as a create request selector. function selectorOf(when) { const zone = typeof when?.timeZone === 'string' && when.timeZone !== '' ? when.timeZone : 'UTC' -@@ -89,36 +89,327 @@ export function install(rt) { +@@ -89,36 +89,331 @@ export function install(rt) { return rt.groupOwners.get(sessionId) ?? rt.pastOwners.get(sessionId) } const known = owner => owner !== undefined && (owner.botId === undefined ? state.rooms[owner.roomId] !== undefined : state.bots[owner.botId] !== undefined) @@ -3568,9 +3568,13 @@ diff --git a/src/host/schedules.js b/src/host/schedules.js + + // Which Session a task lives in, named as the Bot reads it: its own chat (now, or an + // earlier part), or a group chat it speaks in (now, or an earlier part). -+ const placeOf = (sessionId) => { ++ const placeOf = (sessionId, botId) => { + const chat = rt.chatOwners.get(sessionId) + if (chat !== undefined) return { botId: chat, place: sessionId === rt.chatOf(chat) ? 'own chat' : 'own chat, earlier part' } ++ // A group chat's own Session (its id is the room id): the tasks the owner set for the ++ // group itself. Only a member is told about them. ++ const room = rt.roomOf(sessionId) ++ if (room !== undefined) return room.members?.includes(botId) === true ? { botId, place: room.name === undefined ? 'this group chat' : `group chat "${room.name}"` } : undefined + const live = rt.groupOwners.get(sessionId) + const past = rt.pastOwners.get(sessionId) + const owner = live ?? past @@ -3588,7 +3592,7 @@ diff --git a/src/host/schedules.js b/src/host/schedules.js + // answers for the Bot instead, from the host-wide catalog. + ctx.tools.register(defineTool({ + name: 'bot_schedules', -+ description: "List every scheduled task this Bot has, in every Session of its own — its own chat, the earlier parts of it, and the group chats it speaks in — with id, title, next run, state and owning Session. DSH's own `schedule_list` answers for one Session only; this one answers for the Bot.", ++ description: "List every scheduled task this Bot has, in every Session of its own — its own chat, the earlier parts of it, the group chats it speaks in, and the tasks set for those group chats themselves — with id, title, next run, state and owning Session. DSH's own `schedule_list` answers for one Session only; this one answers for the Bot.", + parameters: {}, + output, + async execute(_args, exec) { @@ -3600,7 +3604,7 @@ diff --git a/src/host/schedules.js b/src/host/schedules.js + if (service === null) return `${head}\nnone — Automation tasks are off in DSH, so no scheduled task is visible.` + const rows = [] + const add = (entry, status, when) => { -+ const at = placeOf(entry.sessionId) ++ const at = placeOf(entry.sessionId, botId) + if (at?.botId !== botId) return + rows.push({ + at: when ?? '', @@ -5443,7 +5447,7 @@ diff --git a/test/home-lock.test.js b/test/home-lock.test.js diff --git a/test/install.test.js b/test/install.test.js --- a/test/install.test.js +++ b/test/install.test.js -@@ -91,12 +91,22 @@ +@@ -91,12 +91,22 @@ test('apply registers listeners, tools, and effects in a fixed order', (t) => { 'on llm/stream', 'effect ds-bot: load secrets', 'effect ds-bot: connectors', @@ -5466,7 +5470,7 @@ diff --git a/test/install.test.js b/test/install.test.js 'effect ds-bot: api route', 'route POST /api/bot', 'effect ds-bot: load state', -@@ -137,6 +147,17 @@ +@@ -137,6 +137,17 @@ test('the client channel answers from the saved state', async (t) => { assert.equal((await call('set-prefs', { motion: 'lively' })).value.motion, 'lively') assert.equal('motion' in (await call('set-prefs', { motion: 'normal' })).value, false) assert.deepEqual((await call('set-prefs', { motion: 'wild' })).error, { code: 'bot/invalid', message: 'Motion must be quiet, normal or lively', details: {} }) @@ -6201,7 +6205,7 @@ diff --git a/test/schedules.test.js b/test/schedules.test.js const ctx = fakeContext({ services }) const register = ctx.connection.fetch.register ctx.connection.fetch.register = (route) => { ctx.route = route; return register(route) } -@@ -121,3 +121,302 @@ test('a due reminder reaches a group as its prompts', () => { +@@ -121,3 +121,341 @@ test('a due reminder reaches a group as its prompts', () => { assert.equal(reminderText(batch), '[Scheduled task] One\n[Scheduled task] Two') assert.equal(reminderText('plain text'), 'plain text') }) @@ -6470,10 +6474,12 @@ diff --git a/test/schedules.test.js b/test/schedules.test.js + add('g1-w1-old', 'Writer group earlier part', 's-g1-old') + add('m1', 'Chief own chat', 's-m1') + add('g1-m1', 'Chief group session', 's-g1m') ++ // A task the owner set for the group chat itself lives on the group's own Session (#628). ++ add('g1', 'Launch group task', 's-g1-own') + const listed = (sessionId) => ctx.registeredTools.get('bot_schedules').execute({}, { agent: { id: sessionId } }) + const answer = await listed('w2') + // Every Session of this Bot: its own chat, the earlier part of it, the group chats it speaks in. -+ for (const name of ['Writer current part', 'Writer earlier part', 'Writer group', 'Writer group earlier part', 'Paused brief']) { ++ for (const name of ['Writer current part', 'Writer earlier part', 'Writer group', 'Writer group earlier part', 'Launch group task', 'Paused brief']) { + assert.ok(answer.includes(name), `${name} is the Bot's schedule`) + } + assert.match(answer, /own chat, earlier part/) @@ -6490,6 +6496,43 @@ diff --git a/test/schedules.test.js b/test/schedules.test.js + assert.match(answer, /schedule_list answers for one Session only/) +}) + ++// A group chat keeps its own Session, and a task the owner sets for the group lives on it ++// (#628): its id is the room id, so it belongs to every member of that group and to nobody ++// else — including a Bot that only sees the group's members' Sessions. ++test('#628: a task set for the group chat is listed for its members, not for a Bot outside it', async (t) => { ++ const schedule = scheduleService() ++ const team = JSON.parse(JSON.stringify(TEAM)) ++ team.bots.w3 = { id: 'w3', sessionId: 'w3', name: 'Outsider', role: '', instructions: '', createdAt: 7 } ++ const { ctx, home, call } = setup({ schedule }, team) ++ t.after(async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) }) ++ const made = await call('schedule-create', { target: 'g1', title: 'Standup', prompt: 'Where are we?', when: { kind: 'daily', time: '10:00', timeZone: 'UTC' } }) ++ assert.equal(made.ok, true, JSON.stringify(made)) ++ assert.equal([...schedule.tasks.values()][0].sessionId, 'g1', 'the task lives on the group Session') ++ const listed = (sessionId) => ctx.registeredTools.get('bot_schedules').execute({}, { agent: { id: sessionId } }) ++ const writer = await listed('w2') ++ assert.ok(writer.includes('Standup'), 'a member sees the group chat’s own task') ++ assert.match(writer, /group chat "Launch"/) ++ assert.ok((await listed('m1')).includes('Standup'), 'the admin is a member and sees it too') ++ const outsider = await listed('w3') ++ assert.ok(!outsider.includes('Standup'), 'a Bot outside the group is not told about it') ++ assert.match(outsider, /none — no scheduled task in any of its Sessions/, 'and its answer says so plainly') ++}) ++ ++test('#628: a paused task on the group chat’s own Session is listed too', async (t) => { ++ const schedule = scheduleService() ++ const team = JSON.parse(JSON.stringify(TEAM)) ++ team.bots.w3 = { id: 'w3', sessionId: 'w3', name: 'Outsider', role: '', instructions: '', createdAt: 7 } ++ team.pausedSchedules = { 's-gpaused': { id: 's-gpaused', sessionId: 'g1', record: { title: 'Paused group brief', prompt: 'Sum up' }, pausedAt: 6 } } ++ const { ctx, home } = setup({ schedule }, team) ++ t.after(async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) }) ++ const listed = (sessionId) => ctx.registeredTools.get('bot_schedules').execute({}, { agent: { id: sessionId } }) ++ const answer = await listed('w2') ++ assert.ok(answer.includes('Paused group brief'), 'a paused group task is still a task of the Bot') ++ assert.match(answer, /paused/) ++ assert.match(answer, /group chat "Launch"/) ++ assert.ok(!(await listed('w3')).includes('Paused group brief')) ++}) ++ +test('#628: without the schedule service the Bot-wide answer says why it is empty', async (t) => { + const { ctx, home } = setup() + t.after(async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) }) From 86228c470e77ee5cd230a4608ac3e418cdc6b011 Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Thu, 22 Oct 2026 11:46:37 +0800 Subject: [PATCH 3/3] Re-apply the #628 doc section onto current main (#628) --- docs/subsystems/core-host.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 67cfb3188..494830060 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -42,7 +42,7 @@ The server side of the patched `ds-bot` core (`core/`): the part of [ds-bot-core | `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 `'s Chief` / ` · 's Bot`, ` · `, 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). | -| `core/src/host/schedules.js` | Scheduled tasks through DSH's schedule service: list, create, edit, pause/resume, retry and delete for Settings → Scheduled tasks, each task's last run, and moving a Bot's tasks to its fresh part (#354, #473, see **Scheduled tasks: edit, pause, missed runs** and **Scheduled tasks: last run and Retry**). Also the `bot_schedules` tool: the Bot-wide list DSH's own `schedule_list` cannot give (#628, see **Scheduled tasks: the Bot-wide list**). Tests: `core/test/schedules.test.js`. | +| `core/src/host/schedules.js` | Scheduled tasks through DSH's schedule service: list, create, edit, pause/resume, retry and delete for Settings → Scheduled tasks, each task's last run, and moving a Bot's tasks to its fresh part (#354, #473, see **Scheduled tasks: edit, pause, missed runs** and **Scheduled tasks: last run and Retry**). Also the `bot_schedules` tool: the Bot-wide list DSH's own `schedule_list` cannot give, group chats' own Sessions included (#628, see **Scheduled tasks: the Bot-wide list**). Tests: `core/test/schedules.test.js`. | | `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/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). `remoteBotName` / `isAutoOwnerName` / `seatOwnerName` keep group and face-room labels in that format too (#526): Bot Social's ` · ` 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 sidebar, the **发给** picker, mentions, and `list_bots` friends lines; history stays on disk. | @@ -67,8 +67,7 @@ external backend or task-specific session mapping; see the ### Team state and access -- The saved team (`state.json`) is versioned. Version 2 (#47) carries the identity schema — `members` records, a per-Bot `ownerId`, the owner's `designatedChiefId`. A legacy version-1 file is migrated on load with a one-time `state.json.v1` backup; 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](team.md). On top of it, member auth (#48) adds host module `src/host/member-auth.js`, installed after `store` in `MODULES` (`core/index.js`): `invitations` and `authSessions` maps live on state (no `STATE_VERSION` bump), the channel resolves the actor from the `x-flybot-member-session` header, and endpoints are `auth-invite` / `auth-redeem` / `auth-revoke` / `auth-whoami` / `auth-remove-member` / `auth-revoke-invite` with `body.actorId` ignored; `auth-invite` alone also opens for an anonymous caller on a host with no sessions yet (bootstrap first invite), `auth-revoke-invite` kills an unredeemed invitation (redeeming it afterwards fails with `revoked`), `removeMember` refuses a member that still owns a Bot (`has_bots`), and the auth mutations are serialized behind an in-process lock so concurrent `removeMember` calls cannot both pass the last-owner check. Sign-in surface only — still not team mode; details in [team](team.md). On top of it, owner-scoped access (#49) adds host module `src/host/authz.js`, installed after member-auth: when any `authSessions` exist, the channel `authorize()` default-denies cross-owner Bot/room/session access, blocks shared non-isolatable resources (secrets, connectors, folder picks, browser bridge, update-install, diagnostics, set-prefs, and the DSH Agent with its Sessions — `add-agent`, `agent-session`, `agent-adopt`, `agent-archive`, `agent-rename`, `remove-agent`, which name an ownerless `agentId` and are one per deployment) with `bot/blocked-shared` when more than one member exists, stamps `create-bot` with `actor.memberId`, and filters `view()`/`live()` to the actor — every answer that returns a view, `delete-bot`, `set-main`, `remove-agent` and `delete-room` included, passes the actor so it stays scoped; with no sessions the single-human behavior is unchanged. Session targets resolve through the store's owner maps — a Bot id via `botOf`, current chats and group Sessions and earlier parts via `selfOf`/`groupOwners`/`pastOwners` (a shared room Session also resolves for its current participants, #50) — and unresolvable ids deny. `approval-answer` gates on the approval's own Bot or room (resolved by approval id), never on caller-supplied session fields. `flybot/*` plugin endpoints (src/host/hooks.js) reach any signed-in actor for reads; plugin writes follow the shared rule. `view()`/`live()` narrowing (`rt.ownedView`/`rt.ownedLive` in 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-room` refuses a request with neither a member Bot nor any participant. On top of it, room membership (#50) adds host module `src/host/room-membership.js`, installed after groups: a room may carry typed `participants` (`human` / local `bot` / granted remote `bot` with `handle` + owner fields, roles `admin` / `member`, `joinedSeq` / `leftSeq`); log lines carry `authorId` + `authorKind`; history is the membership window (current members and the owner of an invited Bot see `seq >=` 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 from `social/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 \**. Face-to-face rooms (#83) reuse the same `participants` / membership-window / owner-badge presentation with `origin: 'face-to-face'`: room-only remotes may sit in the room without a person-grant or a friendship, `presentRoom` treats the viewer as a member (not Shared room from), leave skips the two-Bot floor, and `fanoutRoomLine` goes to `rt.faceRoomFanout` instead of friend `flybotSend`. Still not team mode — see [team](team.md). Per-Bot grants (#90) add host module `src/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 with `rt.setGrants`, exposed as `BotView.granted`, `flybot.bot.hasGrant` / `hasAnyGrant`, `flybot.grants` and the `bot.grants` event. `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 the `bot.send` chain, again on approval release, and on every deliver with a sender or remote origin (resumed, retried, deferred, re-sent), so no verdict or `bypass` can 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.approvals` records carry `requestId`, a sha256 `version` over the request content, server-derived `ownerId` and `origin` (`via`/`hop`/audience) — claims in the message (`meta`, text) never confer authority. `flybot/decide` is allowed through authz for any signed-in actor and the per-record check happens in hooks (`decide` requires the sender Bot's owner's session, `bot/forbidden` otherwise, `bot/unauthorized` without a session); `release()` revalidates before dispatch and marks the record `stale` when the request changed, the sender or target Bot is gone (also invalidated eagerly on `deleteBot`), or the Bot changed owners; a demoted Chief's sender-side holds are invalidated eagerly on `setMain` (not inside `staleReason`), and `release()` re-checks immediately before dispatch so an in-flight approve cannot go out after a concurrent invalidation. Unknown `invalidateFor` field values throw. `flybot/approvals` lists 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. -- The Main Bot list is deployment-wide (#613): `set-main` requires the actor to own `body.id` and, when `body.replace` names 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 are `bot/forbidden`); `roster.setMain` keeps the same invariant for every caller, refusing a `replace` across owners and leaving `state.mainBotIds` unchanged. A swap inside one owner's team, the plain promotion of a Bot the caller owns, and the deployment-wide list itself are unchanged. +- The saved team (`state.json`) is versioned. Version 2 (#47) carries the identity schema — `members` records, a per-Bot `ownerId`, the owner's `designatedChiefId`. A legacy version-1 file is migrated on load with a one-time `state.json.v1` backup; 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](team.md). On top of it, member auth (#48) adds host module `src/host/member-auth.js`, installed after `store` in `MODULES` (`core/index.js`): `invitations` and `authSessions` maps live on state (no `STATE_VERSION` bump), the channel resolves the actor from the `x-flybot-member-session` header, and endpoints are `auth-invite` / `auth-redeem` / `auth-revoke` / `auth-whoami` / `auth-remove-member` / `auth-revoke-invite` with `body.actorId` ignored; `auth-invite` alone also opens for an anonymous caller on a host with no sessions yet (bootstrap first invite), `auth-revoke-invite` kills an unredeemed invitation (redeeming it afterwards fails with `revoked`), `removeMember` refuses a member that still owns a Bot (`has_bots`), and the auth mutations are serialized behind an in-process lock so concurrent `removeMember` calls cannot both pass the last-owner check. Sign-in surface only — still not team mode; details in [team](team.md). On top of it, owner-scoped access (#49) adds host module `src/host/authz.js`, installed after member-auth: when any `authSessions` exist, the channel `authorize()` 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) with `bot/blocked-shared` when more than one member exists, stamps `create-bot` with `actor.memberId`, and filters `view()`/`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 via `botOf`, current chats and group Sessions and earlier parts via `selfOf`/`groupOwners`/`pastOwners` (a shared room Session also resolves for its current participants, #50) — and unresolvable ids deny. `approval-answer` gates on the approval's own Bot or room (resolved by approval id), never on caller-supplied session fields. `flybot/*` plugin endpoints (src/host/hooks.js) reach any signed-in actor for reads; plugin writes follow the shared rule. `view()`/`live()` narrowing (`rt.ownedView`/`rt.ownedLive` in 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-room` refuses a request with neither a member Bot nor any participant. On top of it, room membership (#50) adds host module `src/host/room-membership.js`, installed after groups: a room may carry typed `participants` (`human` / local `bot` / granted remote `bot` with `handle` + owner fields, roles `admin` / `member`, `joinedSeq` / `leftSeq`); log lines carry `authorId` + `authorKind`; history is the membership window (current members and the owner of an invited Bot see `seq >=` 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 from `social/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 \**. Face-to-face rooms (#83) reuse the same `participants` / membership-window / owner-badge presentation with `origin: 'face-to-face'`: room-only remotes may sit in the room without a person-grant or a friendship, `presentRoom` treats the viewer as a member (not Shared room from), leave skips the two-Bot floor, and `fanoutRoomLine` goes to `rt.faceRoomFanout` instead of friend `flybotSend`. Still not team mode — see [team](team.md). Per-Bot grants (#90) add host module `src/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 with `rt.setGrants`, exposed as `BotView.granted`, `flybot.bot.hasGrant` / `hasAnyGrant`, `flybot.grants` and the `bot.grants` event. `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 the `bot.send` chain, again on approval release, and on every deliver with a sender or remote origin (resumed, retried, deferred, re-sent), so no verdict or `bypass` can 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.approvals` records carry `requestId`, a sha256 `version` over the request content, server-derived `ownerId` and `origin` (`via`/`hop`/audience) — claims in the message (`meta`, text) never confer authority. `flybot/decide` is allowed through authz for any signed-in actor and the per-record check happens in hooks (`decide` requires the sender Bot's owner's session, `bot/forbidden` otherwise, `bot/unauthorized` without a session); `release()` revalidates before dispatch and marks the record `stale` when the request changed, the sender or target Bot is gone (also invalidated eagerly on `deleteBot`), or the Bot changed owners; a demoted Chief's sender-side holds are invalidated eagerly on `setMain` (not inside `staleReason`), and `release()` re-checks immediately before dispatch so an in-flight approve cannot go out after a concurrent invalidation. Unknown `invalidateFor` field values throw. `flybot/approvals` lists 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. - Context sharing (#53) is specified in [team](team.md), **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 as `peer` (not `bot`), and keeps cross-owner entries out of the team scope (`teamAllowedFrom`), `list_bots` lists only the caller's owner's Bots (a granted child: that child's `createdBy` subtree), Bots granted to the same owner, and Bots in the current room — another owner's folder and instructions stay private — and a `dsh-session:` chip in actor-authored `send` / `room-post` / `answer-question` / `schedule-create` text must resolve for the author (`sessionOwnedBy`; `schedule-create` authz keys off `body.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. @@ -183,7 +182,7 @@ Work a member hands a Bot with `message_bot` from its group Session runs in a hi ### Untrusted fences in every shared room -- `authoredRoomLine` fences 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, or `line.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. `stripUntrusted` is one fixed-point pass over both `untrusted-message` and `untrusted-file`, and its pattern allows whitespace inside the tag (`< / untrusted-message>`), since a parser that ignores whitespace reads the same marker; `stripMarkers` does the same for one tag. `core/test/fence-property.test.js` fuzzes a corpus of reconstitution attempts to keep this a fixed point. Inbound-share lines from the other instance are `remote: true` so owner-typed text is fenced too. Face join/leave notices stay unfenced platform copy. Channel room previews use `unwrapUntrusted` so the sidebar never shows the wrap. A human line whose `who` is `User` is rewritten from the signed-in display name before it is stored or fenced. +- `authoredRoomLine` fences 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, or `line.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. `stripUntrusted` is one fixed-point pass over both `untrusted-message` and `untrusted-file`. Inbound-share lines from the other instance are `remote: true` so owner-typed text is fenced too. Face join/leave notices stay unfenced platform copy. Channel room previews use `unwrapUntrusted` so the sidebar never shows the wrap. A human line whose `who` is `User` is 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.`); `failureLine` stays on Bot-to-Bot deliver and in diagnostics, not in the human transcript. ### Remote chats and mentions @@ -193,6 +192,7 @@ Work a member hands a Bot with `message_bot` from its group Session runs in a hi - Channel `state` notes when a window on screen last read the team (#497): the client sends `watching: !document.hidden` with every poll (a visible window asks every 1.5 s, a hidden one every 10 s), and `watching: false` does 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 as `flybot.watched()` ([plugin-runtime](plugin-runtime.md)). Process-local, never saved. Covered by `core/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 reads `friendLineAt`, `pendingCount` and `heldReplies` with no reload. - A face-to-face room's presented Bot participants carry `label` = `remoteBotTitle`, ` ()`, for remote and local seats alike (`room-membership.js`, #384); `faceAliases` still answers to the older ` · 's Bot` / `'s Chief` labels. - `remoteTurn` (`src/host/remote-chat.js`) finds what the user typed on a friend's remote chat by skipping host-appended `role:'user'` context: forms `snapshot`, `instructions` (the AGENTS.md baseline on a chat's first turn) and `notice`, 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`) mirrors a chat whose Bot left the published list (`gone`) into `bot.hidden`, so it leaves the sidebar without deleting its history, and drops that hide when the Bot is published again. It remembers which chats were `gone` before `open()` clears the marker, and only a real change of `gone` touches `hidden`: a chat the user hid himself (`roster.setFlags`, "Hide from sidebar") while its Bot is still published stays hidden instead of returning on the next publish. The marker `bot.remote.hiddenByGone` records a hide the sync itself imposed (and only when the chat was not already hidden), so returning drops that hide alone and a hide the user set survives the Bot leaving the list and coming back (#631). - `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 says `resend`, 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 chat waits on its reply (#443): a successful `forward` sets `state.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.js` exposes it as `roster.remoteAwaiting`, and `authz.js` narrows it per owner like `remoteTypedTurns`. - Remote-chat `open` copies optional published `color` / `avatar` / `avatarUpdatedAt` onto the chat record (cached by `avatarUpdatedAt`) so headers, the picker, and group lists match Friends' Bots. A missing look stays handle-seeded (#137). @@ -248,9 +248,9 @@ DSH 0.2 offers the schedule service only through its optional bundle `@deepseek- 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-wide `catalog()` and keeps the Sessions that speak for it: its own chat (`chatOwners`), the archived parts of it (`pastOwners` with no `roomId`), its group Sessions, live and archived (`groupOwners` / `pastOwners` with a `roomId`, named by the group chat), and the group chat's own Session (`rt.roomOf`, whose id is the room id) for the tasks the owner set for that group itself — listed to its members only, never to a Bot outside the group. Its paused tasks come from `state.pausedSchedules`, on any of those Sessions. 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 that `schedule_list` is per Session, so an empty answer cannot be read as a claim about another Session. +- **`bot_schedules`** (no arguments) answers for the calling Bot. It reads the host-wide `catalog()` and keeps the Sessions that speak for it: its own chat (`chatOwners`), the archived parts of it (`pastOwners` with no `roomId`), its group Sessions, live and archived (`groupOwners` / `pastOwners` with a `roomId`, 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 in `room.members`, so every member (the admin too) sees them and a Bot outside the group does not. Its paused tasks come from `state.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 that `schedule_list` is 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_delete` and 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 two group-chat cases, plus the service-off and empty-list cases). +- 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). ### Scheduled tasks: edit, pause, missed runs (#354) @@ -283,7 +283,7 @@ A local, replayable record of every change to the saved team, for multi-Bot bugs ### Standing tool approvals (#540) -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 :`, 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 was not used: the turn is ""`. `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`. +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 :`, 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, a room with other people in it, or a turn with no verdict still asks), 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. `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`. ### Discarded promise `.catch` (#213)