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