From de538cfc1b59e3b5d09ea92b1b26da96d21427a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Wed, 26 Aug 2026 07:13:57 +0000 Subject: [PATCH] =?UTF-8?q?/bots=20=E2=80=94=20one=20sentence=20arms=20a?= =?UTF-8?q?=20watch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit a bot is a chat whose working directory is bots/, armed through the background job any chat can already arm. no scheduler was written and none was needed: handoff already means "poll cheaply, wake the agent only when it fires", which is the expensive half of a commander without any of its ceremony. ONE directory, not one per bot. a commander earns its own because it is a job with procedures and a ledger; a bot is a sentence and a watch, and a directory each would mean a creation step before anything can be said. sharing it makes the list free — every bot is a chat in that cwd, so the header's picker is the list of bots. it earns NO permission of its own. /bots rides `chat`, because a bot adds no surface: same routes, same socket, same confinement. the registry says so rather than leaving the next reader to wonder. the brief is the feature. it tells the agent to arm on the FIRST message and not interview — pick a sensible interval, arm, say what you armed in one line, and let the owner correct it, which is cheaper for them than three questions. it says the owner will never say "notify" or "nudge" and must not have to. it forbids the loop that wakes on a timer to look for itself, which is the expensive mistake available here. and it draws the line: a bot watches and tells, it does not fix — a request for work is a commander, say so and stop. bots joins commanders and plugins in confineUserTree, third time: a member's bot runs as the member and a 700 service-user directory kills the turn on its own cwd. Co-Authored-By: Claude Opus 5 Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 --- src/apps/officer-web/App.tsx | 1 + .../Screens/Dashboard/Bots/BotsScreen.tsx | 47 ++++++++ .../Screens/Dashboard/Bots/defaultLayout.ts | 12 ++ .../Screens/Dashboard/Bots/index.tsx | 1 + .../officer-web/Screens/Dashboard/index.tsx | 1 + src/servers/api/bots/brief.ts | 104 ++++++++++++++++++ src/servers/api/chat/chat.ts | 4 +- src/servers/api/chat/websocket.ts | 13 +++ src/servers/data-path.ts | 20 ++++ src/servers/os-user.ts | 20 +++- src/servers/permissions/registry.ts | 5 +- 11 files changed, 224 insertions(+), 4 deletions(-) create mode 100644 src/apps/officer-web/Screens/Dashboard/Bots/BotsScreen.tsx create mode 100644 src/apps/officer-web/Screens/Dashboard/Bots/defaultLayout.ts create mode 100644 src/apps/officer-web/Screens/Dashboard/Bots/index.tsx create mode 100644 src/servers/api/bots/brief.ts diff --git a/src/apps/officer-web/App.tsx b/src/apps/officer-web/App.tsx index c52abe40..e3cbd8d7 100644 --- a/src/apps/officer-web/App.tsx +++ b/src/apps/officer-web/App.tsx @@ -108,6 +108,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> diff --git a/src/apps/officer-web/Screens/Dashboard/Bots/BotsScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Bots/BotsScreen.tsx new file mode 100644 index 00000000..300986c6 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Bots/BotsScreen.tsx @@ -0,0 +1,47 @@ +import type { LayoutNode } from 'officerdev'; +import { WorkspaceView, ChatPanelWrapper, ChatHeader } from 'officerdev'; +import { useAuthoredLayout } from 'state/useAuthoredLayout'; +import { defaultLayout } from './defaultLayout'; + +/** + * `/bots` — one sentence arms a watch, and the conversation stays open to widen it. + * + * ── Why it is a chat and nothing else ── + * + * A bot has no settings worth a form. What it watches, how often, and who hears about it are all things + * the owner says in a sentence, and the agent's job is to turn that into an armed background job without + * an interview. The instructions live in `$DATA_PATH//bots/CLAUDE.md`, written by the server on + * every turn — see `api/bots/brief.ts`. + * + * ── Why the panel is supplied here rather than named in the registry ── + * + * Every bot runs in one shared directory, which the server resolves from `context: 'bots'`. The registry's + * `officerdev/chat` derives its context from the workspace and would land on none. `components` is exactly + * the prop for a screen that supplies its own chat panel — `ChatPanelWrapper` says so — and it carries the + * header with it, so the picker lists bots rather than every chat on the machine. + */ + +const CHAT_CONTEXT = { context: 'bots' } as const; + +const BotsChat = ({ panelId }: { panelId: string }) => ( +
+ +
+); + +const BotsChatHeader = ({ panelId }: { panelId: string }) => ( + +); + +export const BotsScreen = () => { + const workspace = useAuthoredLayout('screens/bots', defaultLayout as LayoutNode); + + return ( + + ); +}; diff --git a/src/apps/officer-web/Screens/Dashboard/Bots/defaultLayout.ts b/src/apps/officer-web/Screens/Dashboard/Bots/defaultLayout.ts new file mode 100644 index 00000000..4dbb76cc --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Bots/defaultLayout.ts @@ -0,0 +1,12 @@ +import type { LayoutNode } from 'officerdev'; + +// One panel: the conversation. +// +// A bot is armed, corrected and widened by talking to it — there is no form that would do it better, and +// the header's own picker is the list of bots. A second pane listing them would repeat the header and take +// width from the only thing on this screen anybody reads. +export const defaultLayout: LayoutNode = { + type: 'panel', + id: 'bots-chat', + appType: 'bots-chat', +}; diff --git a/src/apps/officer-web/Screens/Dashboard/Bots/index.tsx b/src/apps/officer-web/Screens/Dashboard/Bots/index.tsx new file mode 100644 index 00000000..422420dd --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Bots/index.tsx @@ -0,0 +1 @@ +export { BotsScreen } from './BotsScreen'; diff --git a/src/apps/officer-web/Screens/Dashboard/index.tsx b/src/apps/officer-web/Screens/Dashboard/index.tsx index ac9eaac8..ac8e4fb9 100644 --- a/src/apps/officer-web/Screens/Dashboard/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/index.tsx @@ -24,3 +24,4 @@ export * from './Jobs'; export * from './QrTransfer'; export * from './SendOff'; export * from './Todos'; +export * from './Bots'; diff --git a/src/servers/api/bots/brief.ts b/src/servers/api/bots/brief.ts new file mode 100644 index 00000000..a0acecde --- /dev/null +++ b/src/servers/api/bots/brief.ts @@ -0,0 +1,104 @@ +import { join } from 'node:path'; +import { mkdirSync } from 'node:fs'; +import { getBotsDir } from '@@/data-path'; +import { writeAtomic } from '@@/write-atomic'; + +/** + * The instructions every bot runs under. + * + * Written into `$DATA_PATH//bots/CLAUDE.md`, which Claude Code reads from its working directory at + * session start. Rewritten on every bot turn, so a change here reaches every bot on the next message with + * nothing to migrate — the same arrangement as a plugin's chat brief. + * + * ── Why this file is short, and a commander's is not ── + * + * A commander is a job: it has procedures, a ledger, classification rules, a dispatch policy, and a setup + * conversation to settle all of it. A bot is one sentence and a watch. Everything a commander's template + * spends words on — proving the action on one item, arming only once the owner agrees, writing JOB.md — + * is ceremony a bot does not have. The whole point is that the first message arms it. + */ +const BRIEF = `# Bots + +Every conversation in this directory is a **bot**: one thing watched, one thing done when it changes. + +**This file is the platform's and is rewritten on every turn.** Anything you want to keep goes in a file +you create beside it. + +## Arm on the first message. Do not interview. + +The owner says what they want in one sentence. Work out what it means and **arm it in that same turn**. + +> "Watch the New York Times front page and tell me when a new headline appears." +> "Ping my site every few minutes and let the todo chat know if it goes down." + +Neither of those wants a conversation about it. Do not ask which selector, how often, or what to do on +failure unless you genuinely cannot proceed — pick a sensible answer, arm it, and say in one line what you +armed and how often it checks. The owner corrects you in the next message if you guessed wrong, which is +cheaper for them than answering three questions first. + +**Never ask the owner to phrase it differently.** They will not say "notify" or "nudge" and should not have +to. "Tell me", "let me know", "ping me", "message the todo guy" all mean the same two things below. + +## Arming is a background job, and nothing else + +Use the background job you already know, with the **hand-back-then-re-arm** behaviour. Its own instructions +tell you the rest; the parts that matter here: + +- The watch is a **cheap shell check that exits when the condition is true.** Polling costs nothing that + way, and you are woken only when something happened. Do not build a loop that wakes you on a timer to + look for yourself — that costs a turn every tick, forever, for a site that has been up all week. +- **Exit is the signal.** Print what changed on the way out, so the turn you are handed has the answer in + it and does not have to go and look again. +- **Re-arm before you end the turn**, exactly as you armed it the first time. Same condition, same command. + +## What to do when it fires + +Two things, and the owner will not name either of them: + +- **Tell the owner.** A notification reaches their phone. Send one when what fired is worth an interruption + — a site down, a headline they asked to know about. Not "I checked and nothing happened". +- **Tell another chat.** If they named one — "let the todo guy know", or an \`@Name [id]\` in their message — + deliver it there. The id in a mention is a prefix; resolve it rather than using it raw. + +Then re-arm, and stay quiet until the next fire. + +## Remembering things + +The interesting watches compare against last time — a headline, a hash, a status. Keep that in a file in +this directory named after this bot, and say in your first reply what you called it. Do not keep it only in +the conversation: you are woken into a turn that may be days later, and the file is what survives. + +## Adding to a bot + +The owner will come back and add more — another site to ping, another page to watch. That is the same bot: +widen the check, re-arm it, and confirm in one line what it now covers. Do not start a second watch running +beside the first unless the two genuinely need different intervals. + +## What a bot does not do + +It watches and it tells. It does not fix things, deploy things, or change anything it is watching. A +request that wants work done is a request for a commander or an ordinary chat — say so plainly and stop +rather than quietly becoming one. +`; + +/** + * Put the brief where a bot will read it, and make sure the directory exists. + * + * Best effort by contract: a turn must still start if this fails. The bot then knows less, which is where + * every chat was before this existed — worse than being briefed, better than not answering. + */ +export async function ensureBotsBrief(email: string): Promise { + const dir = getBotsDir(email); + mkdirSync(dir, { recursive: true }); + + try { + const target = join(dir, 'CLAUDE.md'); + // Skip the write when nothing changed, so the directory's mtimes do not churn on every message. + const file = Bun.file(target); + if (!(await file.exists()) || (await file.text()) !== BRIEF) await writeAtomic(target, BRIEF); + } catch (err) { + console.error('[bots] could not write the brief:', err instanceof Error ? err.message : err); + } + + return dir; +} diff --git a/src/servers/api/chat/chat.ts b/src/servers/api/chat/chat.ts index ddd51d69..b5559053 100644 --- a/src/servers/api/chat/chat.ts +++ b/src/servers/api/chat/chat.ts @@ -1,4 +1,4 @@ -import { getPluginChatsDir } from '@@/data-path'; +import { getPluginChatsDir, getBotsDir } from '@@/data-path'; import type { Context } from 'hono'; import { emitToSession, sessionKeyForTranscript } from './websocket'; import { pauseBackgroundTask, restartBackgroundTask } from './task-restart'; @@ -125,6 +125,8 @@ const cwdOf = (ctx: Context, who: { home: string; email: string }): string => { const context = ctx.req.query('context'); const contextId = ctx.req.query('contextId')?.trim(); if (context === 'plugin' && contextId) return getPluginChatsDir(who.email, contextId); + // Every bot shares one directory, so this needs no id — the context IS the address. + if (context === 'bots') return getBotsDir(who.email); return ctx.req.query('cwd')?.trim() || who.home; }; diff --git a/src/servers/api/chat/websocket.ts b/src/servers/api/chat/websocket.ts index 81522b19..f9a41973 100644 --- a/src/servers/api/chat/websocket.ts +++ b/src/servers/api/chat/websocket.ts @@ -43,6 +43,7 @@ import { claudeLoginState } from '@@/os-user-claude'; import { mkdirSync, existsSync } from 'node:fs'; import { snapshotPlugins } from '@@/plugins/mount'; import { writeAtomic } from '@@/write-atomic'; +import { ensureBotsBrief } from '../bots/brief'; import { logger } from './logger'; import { isSuperAdmin } from '@@/super-admin'; import { snapshotClaudeUsage } from './usage-store'; @@ -336,6 +337,18 @@ async function resolveChatCwd( return dir; } + /* + Every bot runs in ONE directory — `$DATA_PATH//bots` — rather than one each. + + A commander gets its own because it is a job with procedures and a ledger. A bot is a sentence and a + watch, and a directory per bot would mean a creation step before anything can be said. Sharing it also + means the list is free: `/chat/sessions?cwd=` already answers "what bots do I have", because Claude + groups transcripts by the directory they ran in. + + The brief is written here on every turn, so a change to it reaches every bot on the next message. + */ + if (msg.context === 'bots') return ensureBotsBrief(email); + if (msg.context === 'email') return resolveEmailCwd(email, msg.contextId); if (msg.context === 'chat') { if (msg.cwd?.trim()) return resolveCwd(home, msg.cwd); diff --git a/src/servers/data-path.ts b/src/servers/data-path.ts index 8f46ba1a..bdb8da0e 100644 --- a/src/servers/data-path.ts +++ b/src/servers/data-path.ts @@ -263,6 +263,26 @@ export const getCommandersDir = (email: string) => join(DATA_PATH, email, 'comma */ export const getPluginsDir = (email: string) => join(DATA_PATH, email, 'plugins'); +/** + * Where every bot runs — `$DATA_PATH//bots`. + * + * ── One directory, not one per bot ── + * + * A commander gets its own, because a commander is a job with procedures, a ledger and its own rules. A + * bot is one sentence and a watch, and giving each one a directory would mean a tree of near-empty + * folders and a creation step before anything can be said. + * + * Sharing it also gives the list for free: every bot is a chat whose cwd is this, so Claude groups their + * transcripts together and `/chat/sessions?cwd=` is already the answer to "what bots do I have". + * + * A bot that needs to remember something — the headline it saw last time — writes a file here named after + * itself. The `CLAUDE.md` in this directory says so; it is the platform's and is rewritten on every bot + * turn, the same arrangement as a plugin's chat brief. + * + * Confined like `commanders` and `plugins`: a member's bot runs as the member, so this must be theirs. + */ +export const getBotsDir = (email: string) => join(DATA_PATH, email, 'bots'); + export const getPluginChatsDir = (email: string, appName: string): string => { if (!appName || appName.includes('/') || appName.includes('\\') || appName === '.' || appName === '..') { throw new Error(`refusing to resolve a plugin directory for ${JSON.stringify(appName)}`); diff --git a/src/servers/os-user.ts b/src/servers/os-user.ts index f865eb66..1ff9067c 100644 --- a/src/servers/os-user.ts +++ b/src/servers/os-user.ts @@ -2,7 +2,15 @@ import { chmod, mkdir, readdir, stat } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { secretStorePath } from 'officerdb/secret-store'; -import { DATA_PATH, USER_DIRS, getOwnerHomeDir, getCommandersDir, getPluginsDir, toShellUsername } from './data-path'; +import { + DATA_PATH, + USER_DIRS, + getOwnerHomeDir, + getCommandersDir, + getPluginsDir, + getBotsDir, + toShellUsername, +} from './data-path'; // Real Linux accounts for members, so the surfaces that execute code can run as them. // @@ -347,7 +355,7 @@ export async function confineUserTree(params: { // dies on its own cwd before running anything, and it reads as the agent being broken. const entries = await readdir(accountDir, { withFileTypes: true }); for (const entry of entries) { - if (entry.name === 'home' || entry.name === 'commanders' || entry.name === 'plugins') continue; + if (['home', 'commanders', 'plugins', 'bots'].includes(entry.name)) continue; if (!entry.isDirectory()) continue; await chmod(join(accountDir, entry.name), 0o700); } @@ -450,6 +458,14 @@ export async function confineUserTree(params: { const closePlugins = await run(['sudo', '-n', 'chmod', '700', pluginsDir]); if (!closePlugins.ok) return { ok: false, error: `chmod of ${pluginsDir} failed: ${closePlugins.out}` }; + // And `bots`, for the same reason a third time: a member's bot runs as the member, in this directory. + const botsDir = getBotsDir(params.email); + if (!existsSync(botsDir)) await mkdir(botsDir, { recursive: true }); + const giveBots = await run(['sudo', '-n', 'chown', '-R', `${params.uid}:${params.gid}`, botsDir]); + if (!giveBots.ok) return { ok: false, error: `chown of ${botsDir} failed: ${giveBots.out}` }; + const closeBots = await run(['sudo', '-n', 'chmod', '700', botsDir]); + if (!closeBots.ok) return { ok: false, error: `chmod of ${botsDir} failed: ${closeBots.out}` }; + // ── One directory where container bind mounts can live ── // // Created for every account, not only the ones that get a daemon: it is two `install` calls, and diff --git a/src/servers/permissions/registry.ts b/src/servers/permissions/registry.ts index 356569d1..e85f98e9 100644 --- a/src/servers/permissions/registry.ts +++ b/src/servers/permissions/registry.ts @@ -319,7 +319,10 @@ const CORE_REGISTRY: Permission[] = [ // input. Riding this permission rather than a new one, because it is meaningless without `chat` // already granted. ws: ['chat', 'chat-notify'], - routes: ['/chat'], + // `/bots` is a chat with a context, not a second feature: a bot IS a conversation whose working + // directory is `bots/`, armed through the background job any chat can arm. It earns no permission of + // its own because it adds no surface — the same routes, the same socket, the same confinement. + routes: ['/chat', '/bots'], }, // Confined rather than execution since 2026-08-11. Every path under `/file-browser` resolves its root // through `resolveHomeDir(userId)` in a middleware that refuses the request outright when the account has