/bots — one sentence arms a watch

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 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
2026-08-26 07:13:57 +00:00
co-authored by Claude Opus 5
parent 2faf1fa4b2
commit de538cfc1b
11 changed files with 224 additions and 4 deletions
+3 -1
View File
@@ -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;
};
+13
View File
@@ -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/<email>/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);