thepopebot 1.2.76-beta.19 → 1.2.76-beta.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/api/CLAUDE.md CHANGED
@@ -4,9 +4,14 @@ This directory contains the route handlers for all `/api/*` endpoints. These rou
4
4
 
5
5
  ## Auth
6
6
 
7
- All routes (except `/telegram/webhook` and `/github/webhook`, which use their own webhook secrets) require a valid API key passed via the `x-api-key` header. API keys are stored in the SQLite database and managed through the admin UI — they are NOT environment variables.
7
+ Most routes require a valid API key passed via the `x-api-key` header. API keys are stored in the SQLite database and managed through the admin UI — they are NOT environment variables.
8
8
 
9
- Auth flow: `x-api-key` header -> `verifyApiKey()` -> database lookup (hashed, timing-safe comparison).
9
+ **Public routes** (no API key needed): `/ping`, `/telegram/webhook` (Telegram webhook secret), `/github/webhook` (GitHub webhook secret), `/oauth/callback` (validated via short-lived `state` token).
10
+
11
+ Auth flow: `x-api-key` header → `verifyApiKey()` → DB lookup (hashed, timing-safe comparison). Two key types exist:
12
+
13
+ - **User-owned API keys** — long-lived, created via the admin UI, used by external callers (cURL, GitHub Actions, Telegram register).
14
+ - **Per-job agent API keys** (`agent_job_api_key`) — short-lived, auto-created when an agent-job container launches (`createAgentJobApiKey()` in `lib/db/api-keys.js`), tied to the container name, and cleaned up by the maintenance cron after expiry. Only this key type is allowed to call `/api/get-agent-job-secret` (the route rejects other types).
10
15
 
11
16
  ## Do NOT use these routes for browser UI
12
17
 
@@ -25,10 +30,12 @@ Browser-facing data fetching uses **fetch route handlers** colocated with pages
25
30
  |--------|------|------|---------|
26
31
  | GET | `/api/ping` | None | Health check |
27
32
  | POST | `/api/create-agent-job` | `x-api-key` | Create agent job |
28
- | GET | `/api/get-agent-job-secret` | `x-api-key` | Get an agent job secret; oauth2 credentials return only the access_token (auto-refreshed) |
33
+ | GET | `/api/get-agent-job-secret` | `agent_job_api_key` only | Get an agent job secret. `oauth2` credentials return only the access_token (auto-refreshed under a lock; rotated refresh tokens are persisted back). Other secret types return the raw value. |
34
+ | POST | `/api/set-agent-job-secret` | `agent_job_api_key` only | Create or update an agent-job secret from inside the container (used by the `set-secret` skill). |
29
35
  | GET | `/api/agent-job-list-secrets` | `x-api-key` | List agent job secret keys (no values); returns `{secrets: [{key, isSet, updatedAt, secretType}]}` |
30
36
  | GET | `/api/agent-jobs/status` | `x-api-key` | Agent job status (query: `?agent_job_id=`) |
31
- | POST | `/api/telegram/webhook` | Telegram webhook secret | Telegram message handler |
37
+ | POST | `/api/telegram/webhook` | Telegram webhook secret | Telegram message handler (per-user routing via `user_channels`; verifies via `/verify <code>`, dispatches `/session` commands) |
32
38
  | POST | `/api/telegram/register` | `x-api-key` | Register bot token + webhook URL |
33
39
  | POST | `/api/github/webhook` | GitHub webhook secret | GitHub event handler |
34
40
  | POST | `/api/cluster/:clusterId/role/:roleId/webhook` | `x-api-key` | Trigger cluster role execution |
41
+ | GET/POST | `/api/oauth/callback` | `state` token | OAuth provider redirect target. Exchanges `code` for tokens, persists via `setAgentJobSecret(name, stored, 'oauth')`. |
package/bin/CLAUDE.md CHANGED
@@ -9,19 +9,23 @@ Entry point: `cli.js` (invoked via `npx thepopebot <command>`).
9
9
  | `init [--no-managed] [--no-install]` | Scaffold project from templates, sync managed files, create `.env`, install deps |
10
10
  | `setup` | Run interactive setup wizard (see `setup/CLAUDE.md`) |
11
11
  | `setup-telegram` | Reconfigure Telegram webhook |
12
+ | `setup-ssl` | Configure SSL with Let's Encrypt wildcard cert |
12
13
  | `upgrade [@beta\|version]` | Upgrade package, run init, rebuild, commit, push, restart Docker |
13
14
  | `reset [file]` | Restore a template file to defaults |
15
+ | `reset-all` | Nuclear reset — restore entire project to fresh init state |
16
+ | `audit` | Show project state vs. package templates (modified / missing / unknown) |
14
17
  | `diff [file]` | Show diff between user file and package template |
15
18
  | `reset-auth` | Regenerate `AUTH_SECRET` (invalidates all sessions) |
16
- | `set-var <KEY> [VALUE]` | Set GitHub repository variable |
19
+ | `set-var <KEY> [VALUE]` | Set GitHub repository variable (also reads piped stdin) |
17
20
  | `user:password <email>` | Change user password |
18
- | `sync <path>` | Dev helper — sync local package to test install |
21
+ | `sync <path>` | Dev helper — pack, build, upload local package to test install |
22
+ | `sync --fast <path>` | Fast variant — copy source into the running container and rebuild `.next` only |
19
23
 
20
24
  ## Managed Paths System
21
25
 
22
26
  `managed-paths.js` defines files auto-synced by `init`. These are overwritten on every init/upgrade — users should not edit them.
23
27
 
24
- **Managed paths**: `.github/workflows/`, `docker-compose.yml`, `.dockerignore`, `.gitignore`, `agent-job/CLAUDE.md`, `event-handler/CLAUDE.md`, `skills/CLAUDE.md`.
28
+ **Managed paths**: `.github/workflows/`, `docker-compose.yml`, `.dockerignore`, `.gitignore`.
25
29
 
26
30
  `isManaged(relPath)` — returns true if a path is managed (exact match or directory prefix).
27
31
 
package/config/CLAUDE.md CHANGED
@@ -1,9 +1,28 @@
1
- # config/ — Next.js Config Wrapper
1
+ # config/ — Next.js Config + Server Bootstrap
2
2
 
3
3
  ## Next.js Config Wrapper (index.js)
4
4
 
5
- `withThepopebot()` wraps user's `next.config.mjs`. Adds `transpilePackages` and `serverExternalPackages` for the npm package's dependencies that need special bundling.
5
+ `withThepopebot()` wraps the user's `next.config.mjs`. Adds `transpilePackages` and `serverExternalPackages` for npm-package dependencies that need special bundling (Drizzle, better-sqlite3, etc.).
6
6
 
7
- ## Instrumentation (instrumentation.js)
7
+ ## Instrumentation Hook (instrumentation.js)
8
8
 
9
- Server startup hook loaded by Next.js. Sequence: loads `.env`, initializes database, starts cron scheduler, starts cluster runtime. Skipped during `next build` (checks `NEXT_PHASE`).
9
+ Loaded by Next.js once on server start. The user's project re-exports it from their own `instrumentation.js`:
10
+
11
+ ```js
12
+ export { register } from 'thepopebot/instrumentation';
13
+ ```
14
+
15
+ ### Boot sequence
16
+
17
+ 1. **Skip during `next build`** — checks `process.argv` for `'build'` to avoid keeping the event loop alive during build output.
18
+ 2. **Load `.env`** — `dotenv.config()` from project root.
19
+ 3. **Default `AUTH_URL` from `APP_URL`** — so NextAuth redirects to the correct host on sign-out.
20
+ 4. **Validate `AUTH_SECRET`** — throws if unset (required for session encryption).
21
+ 5. **`initDatabase()`** — `lib/db/index.js`. Opens SQLite, runs Drizzle migrations.
22
+ 6. **`migrateEnvToDb()`** — `lib/db/config.js`. Idempotent first-run migration of `.env` values into the settings table.
23
+ 7. **`loadCrons()`** — `lib/cron.js`. Reads `agent-job/CRONS.json`, schedules user-defined crons.
24
+ 8. **`startBuiltinCrons()`** — `lib/cron.js`. Starts internal crons (e.g., npm version check). Then warms the in-memory update flag from `lib/db/update-check.js`.
25
+ 9. **`startClusterRuntime()`** — `lib/cluster/runtime.js`. Registers cluster role triggers (cron + file watch + webhook).
26
+ 10. **`startMaintenanceCron()`** — `lib/maintenance.js`. Hourly cleanup of expired agent-job API keys and other housekeeping.
27
+
28
+ `initialized` is module-scoped so the sequence runs exactly once even if `register()` is called more than once.
@@ -27,6 +27,16 @@ Abstract interface for platform integrations. Methods:
27
27
  ## Telegram Adapter (telegram.js)
28
28
 
29
29
  - **Authorization**: per-user via the `user_channels` table. Unverified chats only accept `/verify <code>`; all other messages are dropped. See `lib/db/user-channels.js` and `lib/channels/commands/verify.js`.
30
- - **Session commands**: post-auth messages may be slash commands (`/session`, `/session list`, `/session <id>`) dispatched from `lib/channels/commands/`. Resolution chat.id → userId → activeThreadId lives in `api/index.js` `processChannelMessage`.
31
30
  - **Webhook auth**: Validates `x-telegram-bot-api-secret-token` header against `TELEGRAM_WEBHOOK_SECRET`.
32
- - **Streaming**: `supportsStreaming` returns `false` — sends complete responses only.
31
+ - **Streaming**: `supportsStreaming` returns `false` — text + tool calls accumulate during streaming and are sent as complete messages once the turn ends. Progressive tool-call rendering (commit 740c734 / d9bf19a) inserts intermediate "→ used X" lines as tool calls land.
32
+
33
+ ## Slash Commands (`lib/channels/commands/`)
34
+
35
+ Post-auth messages starting with `/` are dispatched here before reaching the LLM. Resolution chat.id → userId → activeThreadId happens in `api/index.js` `processChannelMessage`.
36
+
37
+ | Command | Purpose | Source |
38
+ |---------|---------|--------|
39
+ | `/verify <code>` | Verify a Telegram account against a one-time code generated in the web UI (`/profile/telegram`). Code expires in 10 minutes. Sets `verifiedAt`. | `commands/verify.js` |
40
+ | `/session` | List the user's recent chat threads (active thread marked) | `commands/session.js` |
41
+ | `/session list` | Same as `/session` | `commands/session.js` |
42
+ | `/session <id>` | Switch the user's `activeThreadId` so subsequent messages route to that chat | `commands/session.js` |
@@ -38,10 +38,10 @@ export { getRepositoriesHandler as GET } from 'thepopebot/chat/api';
38
38
  ## Chat Streaming Flow
39
39
 
40
40
  1. Client sends message via AI SDK `DefaultChatTransport` → `POST /stream/chat`
41
- 2. Handler validates session, extracts text + file attachments from message parts
42
- 3. Calls `chatStream()` from `lib/ai/` which handles DB persistence and LLM invocation
43
- 4. Streams response chunks (text deltas, tool calls, tool results) via `createUIMessageStream`
44
- 5. After first message, client calls `/chat/finalize-chat` to generate auto-title
41
+ 2. Handler validates session, extracts text + file attachments from message parts. Images and PDFs pass through as vision content; text files are inlined into the prompt.
42
+ 3. Calls `chatStream()` from `lib/ai/` which handles DB persistence and LLM invocation. Two paths: SDK adapter (in-process, e.g. Claude Agent SDK) or direct headless container (other agents).
43
+ 4. Streams response chunks (text deltas, tool calls, tool results, thinking blocks) via `createUIMessageStream`. Tool call/tool result pairs and `{ type: 'error' }` chunks are persisted as JSON message parts.
44
+ 5. After the first user message streams, the client calls `/chat/finalize-chat` to generate the auto-title (helper LLM with truncated-description fallback).
45
45
 
46
46
  ## Server Actions (actions.js)
47
47
 
@@ -3,7 +3,7 @@ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
3
  import { useState, useEffect, useCallback, useRef } from "react";
4
4
  import { createPortal } from "react-dom";
5
5
  import { PageLayout } from "./page-layout.js";
6
- import { SpinnerIcon, RefreshIcon, StopIcon, PlayIcon, TrashIcon, XIcon } from "./icons.js";
6
+ import { SpinnerIcon, RefreshIcon, StopIcon, PlayIcon, TrashIcon, XIcon, FileTextIcon } from "./icons.js";
7
7
  import { ConfirmDialog } from "./ui/confirm-dialog.js";
8
8
  import { CodeLogView } from "./code-log-view.js";
9
9
  import {
@@ -195,81 +195,99 @@ function ContainerRow({ container, onRequestStop, onShowLogs, isStopping, isStar
195
195
  /* @__PURE__ */ jsx("td", { className: "py-2.5 pr-3 text-xs text-right hidden md:table-cell whitespace-nowrap", children: isRunning && container.stats ? /* @__PURE__ */ jsx("span", { children: formatBytes(container.stats.memUsage) }) : /* @__PURE__ */ jsx("span", { className: "text-muted-foreground", children: "\u2014" }) }),
196
196
  /* @__PURE__ */ jsx("td", { className: "py-2.5 pr-3 text-xs text-muted-foreground hidden lg:table-cell whitespace-nowrap", children: container.status }),
197
197
  /* @__PURE__ */ jsx("td", { className: "py-2.5 text-right whitespace-nowrap", children: /* @__PURE__ */ jsxs("div", { className: "inline-flex items-center gap-1.5", children: [
198
- isRunning && (isStopping ? /* @__PURE__ */ jsxs(
198
+ /* @__PURE__ */ jsx(
199
+ "button",
200
+ {
201
+ onClick: () => onShowLogs(container.name),
202
+ title: "Logs",
203
+ "aria-label": "Logs",
204
+ className: "inline-flex items-center justify-center rounded-md p-1.5 border border-border text-muted-foreground hover:bg-accent hover:text-foreground transition-colors",
205
+ children: /* @__PURE__ */ jsx(FileTextIcon, { size: 14 })
206
+ }
207
+ ),
208
+ isRunning && (isStopping ? /* @__PURE__ */ jsx(
199
209
  "button",
200
210
  {
201
211
  disabled: true,
202
- className: "inline-flex items-center gap-1 rounded-md px-2.5 py-1.5 text-xs font-medium border border-border text-muted-foreground disabled:opacity-50 disabled:pointer-events-none transition-colors",
203
- children: [
204
- /* @__PURE__ */ jsx(SpinnerIcon, { size: 12 }),
205
- "Stopping..."
206
- ]
212
+ title: "Stopping...",
213
+ "aria-label": "Stopping",
214
+ className: "inline-flex items-center justify-center rounded-md p-1.5 border border-border text-muted-foreground disabled:opacity-50 disabled:pointer-events-none transition-colors",
215
+ children: /* @__PURE__ */ jsx(SpinnerIcon, { size: 14 })
207
216
  }
208
- ) : /* @__PURE__ */ jsxs(
217
+ ) : /* @__PURE__ */ jsx(
209
218
  "button",
210
219
  {
211
220
  onClick: () => onRequestStop(container.name),
212
- className: "inline-flex items-center gap-1 rounded-md px-2.5 py-1.5 text-xs font-medium border border-border text-muted-foreground hover:bg-accent hover:text-foreground transition-colors",
213
- children: [
214
- /* @__PURE__ */ jsx(StopIcon, { size: 12 }),
215
- "Stop"
216
- ]
221
+ title: "Stop",
222
+ "aria-label": "Stop",
223
+ className: "inline-flex items-center justify-center rounded-md p-1.5 border border-border text-muted-foreground hover:bg-accent hover:text-foreground transition-colors",
224
+ children: /* @__PURE__ */ jsx(StopIcon, { size: 14 })
217
225
  }
218
226
  )),
219
- isStopped && (isStarting ? /* @__PURE__ */ jsxs(
227
+ isStopped && (isStarting ? /* @__PURE__ */ jsx(
220
228
  "button",
221
229
  {
222
230
  disabled: true,
223
- className: "inline-flex items-center gap-1 rounded-md px-2.5 py-1.5 text-xs font-medium border border-border text-muted-foreground disabled:opacity-50 disabled:pointer-events-none transition-colors",
224
- children: [
225
- /* @__PURE__ */ jsx(SpinnerIcon, { size: 12 }),
226
- "Starting..."
227
- ]
231
+ title: "Starting...",
232
+ "aria-label": "Starting",
233
+ className: "inline-flex items-center justify-center rounded-md p-1.5 border border-border text-muted-foreground disabled:opacity-50 disabled:pointer-events-none transition-colors",
234
+ children: /* @__PURE__ */ jsx(SpinnerIcon, { size: 14 })
228
235
  }
229
- ) : /* @__PURE__ */ jsxs(
236
+ ) : /* @__PURE__ */ jsx(
230
237
  "button",
231
238
  {
232
239
  onClick: () => onRequestStop(container.name, "start"),
233
- className: "inline-flex items-center gap-1 rounded-md px-2.5 py-1.5 text-xs font-medium border border-border text-muted-foreground hover:bg-accent hover:text-foreground transition-colors",
234
- children: [
235
- /* @__PURE__ */ jsx(PlayIcon, { size: 12 }),
236
- "Start"
237
- ]
240
+ title: "Start",
241
+ "aria-label": "Start",
242
+ className: "inline-flex items-center justify-center rounded-md p-1.5 border border-border text-muted-foreground hover:bg-accent hover:text-foreground transition-colors",
243
+ children: /* @__PURE__ */ jsx(PlayIcon, { size: 14 })
238
244
  }
239
245
  )),
240
246
  /* @__PURE__ */ jsx(
241
- "button",
242
- {
243
- onClick: () => onShowLogs(container.name),
244
- className: "inline-flex items-center gap-1 rounded-md px-2.5 py-1.5 text-xs font-medium border border-border text-muted-foreground hover:bg-accent hover:text-foreground transition-colors",
245
- children: "Logs"
246
- }
247
- ),
248
- /* @__PURE__ */ jsxs(
249
247
  "button",
250
248
  {
251
249
  onClick: () => handleAction("remove"),
252
250
  disabled: removingContainer,
253
- className: `inline-flex items-center gap-1 rounded-md px-2.5 py-1.5 text-xs font-medium border transition-colors disabled:opacity-50 disabled:pointer-events-none ${confirmingRemove ? "border-destructive text-destructive hover:bg-destructive/10" : "border-border text-muted-foreground hover:bg-accent hover:text-foreground"}`,
254
- children: [
255
- removingContainer ? /* @__PURE__ */ jsx(SpinnerIcon, { size: 12 }) : /* @__PURE__ */ jsx(TrashIcon, { size: 12 }),
256
- confirmingRemove ? "Confirm" : "Remove"
257
- ]
251
+ title: confirmingRemove ? "Confirm remove" : "Remove",
252
+ "aria-label": confirmingRemove ? "Confirm remove" : "Remove",
253
+ className: `inline-flex items-center justify-center rounded-md p-1.5 border transition-colors disabled:opacity-50 disabled:pointer-events-none ${confirmingRemove ? "border-destructive text-destructive hover:bg-destructive/10" : "border-border text-muted-foreground hover:bg-accent hover:text-foreground"}`,
254
+ children: removingContainer ? /* @__PURE__ */ jsx(SpinnerIcon, { size: 14 }) : /* @__PURE__ */ jsx(TrashIcon, { size: 14 })
258
255
  }
259
256
  )
260
257
  ] }) })
261
258
  ] });
262
259
  }
263
260
  function DockerContainersSection({ containers, loading, onRequestStop, onShowLogs, pendingStop, pendingStart }) {
261
+ const [activeTab, setActiveTab] = useState("running");
264
262
  if (loading) {
265
263
  return /* @__PURE__ */ jsxs("div", { className: "space-y-4", children: [
266
264
  /* @__PURE__ */ jsx("h2", { className: "text-base font-medium", children: "Docker Containers" }),
267
265
  /* @__PURE__ */ jsx("div", { className: "flex flex-col gap-3", children: [...Array(3)].map((_, i) => /* @__PURE__ */ jsx("div", { className: "h-14 animate-pulse rounded-md bg-border/50" }, i)) })
268
266
  ] });
269
267
  }
268
+ const runningContainers = containers.filter((c) => c.state === "running");
269
+ const exitedContainers = containers.filter((c) => c.state !== "running");
270
+ const visibleContainers = activeTab === "running" ? runningContainers : exitedContainers;
271
+ const tabs = [
272
+ { id: "running", label: `Running (${runningContainers.length})` },
273
+ { id: "exited", label: `Exited (${exitedContainers.length})` }
274
+ ];
270
275
  return /* @__PURE__ */ jsxs("div", { className: "space-y-4", children: [
271
276
  /* @__PURE__ */ jsx("h2", { className: "text-base font-medium", children: "Docker Containers" }),
272
- containers.length === 0 ? /* @__PURE__ */ jsx("div", { className: "rounded-lg border border-dashed border-border p-8 text-center text-sm text-muted-foreground", children: "No containers found on the compose network." }) : /* @__PURE__ */ jsx("div", { className: "overflow-x-auto", children: /* @__PURE__ */ jsxs("table", { className: "w-full text-left", children: [
277
+ /* @__PURE__ */ jsx("div", { className: "flex gap-1.5 overflow-x-auto scrollbar-hide max-w-full", children: tabs.map((tab) => {
278
+ const isActive = activeTab === tab.id;
279
+ return /* @__PURE__ */ jsx(
280
+ "button",
281
+ {
282
+ type: "button",
283
+ onClick: () => setActiveTab(tab.id),
284
+ className: `rounded-full px-3 py-1.5 min-h-[36px] inline-flex items-center text-xs font-medium transition-colors shrink-0 whitespace-nowrap ${isActive ? "bg-foreground text-background" : "text-muted-foreground hover:text-foreground hover:bg-accent"}`,
285
+ children: tab.label
286
+ },
287
+ tab.id
288
+ );
289
+ }) }),
290
+ visibleContainers.length === 0 ? /* @__PURE__ */ jsx("div", { className: "rounded-lg border border-dashed border-border p-8 text-center text-sm text-muted-foreground", children: activeTab === "running" ? "No running containers." : "No exited containers." }) : /* @__PURE__ */ jsx("div", { className: "overflow-x-auto", children: /* @__PURE__ */ jsxs("table", { className: "w-full text-left", children: [
273
291
  /* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsxs("tr", { className: "text-xs text-muted-foreground", children: [
274
292
  /* @__PURE__ */ jsx("th", { className: "pb-2 pr-3 font-medium", children: "State" }),
275
293
  /* @__PURE__ */ jsx("th", { className: "pb-2 pr-3 font-medium", children: "Container" }),
@@ -278,7 +296,7 @@ function DockerContainersSection({ containers, loading, onRequestStop, onShowLog
278
296
  /* @__PURE__ */ jsx("th", { className: "pb-2 pr-3 font-medium hidden lg:table-cell", children: "Status" }),
279
297
  /* @__PURE__ */ jsx("th", { className: "pb-2 font-medium text-right", children: "Actions" })
280
298
  ] }) }),
281
- /* @__PURE__ */ jsx("tbody", { children: containers.map((c) => /* @__PURE__ */ jsx(
299
+ /* @__PURE__ */ jsx("tbody", { children: visibleContainers.map((c) => /* @__PURE__ */ jsx(
282
300
  ContainerRow,
283
301
  {
284
302
  container: c,
@@ -3,7 +3,7 @@
3
3
  import { useState, useEffect, useCallback, useRef } from 'react';
4
4
  import { createPortal } from 'react-dom';
5
5
  import { PageLayout } from './page-layout.js';
6
- import { SpinnerIcon, RefreshIcon, StopIcon, PlayIcon, TrashIcon, XIcon } from './icons.js';
6
+ import { SpinnerIcon, RefreshIcon, StopIcon, PlayIcon, TrashIcon, XIcon, FileTextIcon } from './icons.js';
7
7
  import { ConfirmDialog } from './ui/confirm-dialog.js';
8
8
  import { CodeLogView } from './code-log-view.js';
9
9
  import {
@@ -263,22 +263,32 @@ function ContainerRow({ container, onRequestStop, onShowLogs, isStopping, isStar
263
263
  </td>
264
264
  <td className="py-2.5 text-right whitespace-nowrap">
265
265
  <div className="inline-flex items-center gap-1.5">
266
+ <button
267
+ onClick={() => onShowLogs(container.name)}
268
+ title="Logs"
269
+ aria-label="Logs"
270
+ className="inline-flex items-center justify-center rounded-md p-1.5 border border-border text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
271
+ >
272
+ <FileTextIcon size={14} />
273
+ </button>
266
274
  {isRunning && (
267
275
  isStopping ? (
268
276
  <button
269
277
  disabled
270
- className="inline-flex items-center gap-1 rounded-md px-2.5 py-1.5 text-xs font-medium border border-border text-muted-foreground disabled:opacity-50 disabled:pointer-events-none transition-colors"
278
+ title="Stopping..."
279
+ aria-label="Stopping"
280
+ className="inline-flex items-center justify-center rounded-md p-1.5 border border-border text-muted-foreground disabled:opacity-50 disabled:pointer-events-none transition-colors"
271
281
  >
272
- <SpinnerIcon size={12} />
273
- Stopping...
282
+ <SpinnerIcon size={14} />
274
283
  </button>
275
284
  ) : (
276
285
  <button
277
286
  onClick={() => onRequestStop(container.name)}
278
- className="inline-flex items-center gap-1 rounded-md px-2.5 py-1.5 text-xs font-medium border border-border text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
287
+ title="Stop"
288
+ aria-label="Stop"
289
+ className="inline-flex items-center justify-center rounded-md p-1.5 border border-border text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
279
290
  >
280
- <StopIcon size={12} />
281
- Stop
291
+ <StopIcon size={14} />
282
292
  </button>
283
293
  )
284
294
  )}
@@ -286,38 +296,35 @@ function ContainerRow({ container, onRequestStop, onShowLogs, isStopping, isStar
286
296
  isStarting ? (
287
297
  <button
288
298
  disabled
289
- className="inline-flex items-center gap-1 rounded-md px-2.5 py-1.5 text-xs font-medium border border-border text-muted-foreground disabled:opacity-50 disabled:pointer-events-none transition-colors"
299
+ title="Starting..."
300
+ aria-label="Starting"
301
+ className="inline-flex items-center justify-center rounded-md p-1.5 border border-border text-muted-foreground disabled:opacity-50 disabled:pointer-events-none transition-colors"
290
302
  >
291
- <SpinnerIcon size={12} />
292
- Starting...
303
+ <SpinnerIcon size={14} />
293
304
  </button>
294
305
  ) : (
295
306
  <button
296
307
  onClick={() => onRequestStop(container.name, 'start')}
297
- className="inline-flex items-center gap-1 rounded-md px-2.5 py-1.5 text-xs font-medium border border-border text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
308
+ title="Start"
309
+ aria-label="Start"
310
+ className="inline-flex items-center justify-center rounded-md p-1.5 border border-border text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
298
311
  >
299
- <PlayIcon size={12} />
300
- Start
312
+ <PlayIcon size={14} />
301
313
  </button>
302
314
  )
303
315
  )}
304
- <button
305
- onClick={() => onShowLogs(container.name)}
306
- className="inline-flex items-center gap-1 rounded-md px-2.5 py-1.5 text-xs font-medium border border-border text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
307
- >
308
- Logs
309
- </button>
310
316
  <button
311
317
  onClick={() => handleAction('remove')}
312
318
  disabled={removingContainer}
313
- className={`inline-flex items-center gap-1 rounded-md px-2.5 py-1.5 text-xs font-medium border transition-colors disabled:opacity-50 disabled:pointer-events-none ${
319
+ title={confirmingRemove ? 'Confirm remove' : 'Remove'}
320
+ aria-label={confirmingRemove ? 'Confirm remove' : 'Remove'}
321
+ className={`inline-flex items-center justify-center rounded-md p-1.5 border transition-colors disabled:opacity-50 disabled:pointer-events-none ${
314
322
  confirmingRemove
315
323
  ? 'border-destructive text-destructive hover:bg-destructive/10'
316
324
  : 'border-border text-muted-foreground hover:bg-accent hover:text-foreground'
317
325
  }`}
318
326
  >
319
- {removingContainer ? <SpinnerIcon size={12} /> : <TrashIcon size={12} />}
320
- {confirmingRemove ? 'Confirm' : 'Remove'}
327
+ {removingContainer ? <SpinnerIcon size={14} /> : <TrashIcon size={14} />}
321
328
  </button>
322
329
  </div>
323
330
  </td>
@@ -330,6 +337,8 @@ function ContainerRow({ container, onRequestStop, onShowLogs, isStopping, isStar
330
337
  // ─────────────────────────────────────────────────────────────────────────────
331
338
 
332
339
  function DockerContainersSection({ containers, loading, onRequestStop, onShowLogs, pendingStop, pendingStart }) {
340
+ const [activeTab, setActiveTab] = useState('running');
341
+
333
342
  if (loading) {
334
343
  return (
335
344
  <div className="space-y-4">
@@ -343,12 +352,42 @@ function DockerContainersSection({ containers, loading, onRequestStop, onShowLog
343
352
  );
344
353
  }
345
354
 
355
+ const runningContainers = containers.filter((c) => c.state === 'running');
356
+ const exitedContainers = containers.filter((c) => c.state !== 'running');
357
+ const visibleContainers = activeTab === 'running' ? runningContainers : exitedContainers;
358
+
359
+ const tabs = [
360
+ { id: 'running', label: `Running (${runningContainers.length})` },
361
+ { id: 'exited', label: `Exited (${exitedContainers.length})` },
362
+ ];
363
+
346
364
  return (
347
365
  <div className="space-y-4">
348
366
  <h2 className="text-base font-medium">Docker Containers</h2>
349
- {containers.length === 0 ? (
367
+
368
+ <div className="flex gap-1.5 overflow-x-auto scrollbar-hide max-w-full">
369
+ {tabs.map((tab) => {
370
+ const isActive = activeTab === tab.id;
371
+ return (
372
+ <button
373
+ key={tab.id}
374
+ type="button"
375
+ onClick={() => setActiveTab(tab.id)}
376
+ className={`rounded-full px-3 py-1.5 min-h-[36px] inline-flex items-center text-xs font-medium transition-colors shrink-0 whitespace-nowrap ${
377
+ isActive
378
+ ? 'bg-foreground text-background'
379
+ : 'text-muted-foreground hover:text-foreground hover:bg-accent'
380
+ }`}
381
+ >
382
+ {tab.label}
383
+ </button>
384
+ );
385
+ })}
386
+ </div>
387
+
388
+ {visibleContainers.length === 0 ? (
350
389
  <div className="rounded-lg border border-dashed border-border p-8 text-center text-sm text-muted-foreground">
351
- No containers found on the compose network.
390
+ {activeTab === 'running' ? 'No running containers.' : 'No exited containers.'}
352
391
  </div>
353
392
  ) : (
354
393
  <div className="overflow-x-auto">
@@ -364,7 +403,7 @@ function DockerContainersSection({ containers, loading, onRequestStop, onShowLog
364
403
  </tr>
365
404
  </thead>
366
405
  <tbody>
367
- {containers.map((c) => (
406
+ {visibleContainers.map((c) => (
368
407
  <ContainerRow
369
408
  key={c.id}
370
409
  container={c}
@@ -41,9 +41,15 @@ Roles support multiple concurrent triggers. All paths use `canRunRole()` as a sh
41
41
 
42
42
  ## Concurrency & Validation
43
43
 
44
- `canRunRole(roleIdOrData)` is the shared gate function. It checks cluster enabled status and concurrency limits. Returns `{ allowed, reason?, roleData? }`. All trigger paths call this before `runClusterRole()`.
44
+ `canRunRole(roleIdOrData)` is the synchronous validation function. It checks cluster enabled status and concurrency limits. Returns `{ allowed, reason?, roleData? }`. Reasons: `disabled` (cluster off), `concurrency` (at max), `not_found`.
45
45
 
46
- Each role has `maxConcurrency` (default 1). `canRunRole()` counts running instances via `listContainers()`. Reasons: `disabled` (cluster off), `concurrency` (at max), `not_found`.
46
+ `acquireAndRunRole(roleId, payload?)` is the **atomic gate** that all trigger paths actually use. It calls `canRunRole()` and `runClusterRole()` together so two simultaneous triggers can't both pass the concurrency check before either container is observable to `listContainers()`. Manual UI triggers, webhooks, cron, and file-watch all funnel through this.
47
+
48
+ Each role has `maxConcurrency` (default 1). `canRunRole()` counts running instances via `listContainers()`.
49
+
50
+ ## Plan Mode (Roles)
51
+
52
+ `cluster_roles.planMode` (default `0`) gates the worker into Claude's plan-mode (read-only). When set, the worker is launched with `PERMISSION=plan` so it cannot execute mutating tools. Useful for review/analysis roles.
47
53
 
48
54
  ## Prompt Architecture
49
55
 
@@ -75,6 +81,6 @@ Built by `buildTemplateVars()` → `buildWorkerSystemPrompt()` + `resolveCluster
75
81
  ## DB Tables
76
82
 
77
83
  - `clusters` — cluster metadata (name, system_prompt, folders, enabled)
78
- - `cluster_roles` — role definitions scoped to a cluster (role_name, role, prompt, trigger_config, max_concurrency, cleanup_worker_dir, folders)
84
+ - `cluster_roles` — role definitions scoped to a cluster (role_name, role, prompt, trigger_config, max_concurrency, plan_mode, cleanup_worker_dir, folders)
79
85
 
80
86
  Workers are ephemeral containers, not database entities.
@@ -2,7 +2,9 @@
2
2
 
3
3
  ## Data Flow
4
4
 
5
- Chat agent's `coding_agent` tool → `runInteractiveContainer()` in `lib/tools/docker.js` → Docker container runs `coding-agent-claude-code` image (interactive runtime) with ttyd on port 7681 → browser navigates to `/code/{id}` → `TerminalView` (xterm.js) opens WebSocket → `ws-proxy.js` authenticates and proxies to container.
5
+ Chat agent's synthetic `coding_agent` tool wrapper → `runInteractiveContainer()` in `lib/tools/docker.js` → Docker container runs `coding-agent-{agent}` image (interactive runtime) ttyd on port 7681 fronts a tmux session that runs the agent CLI → browser navigates to `/code/{id}` → `TerminalView` (xterm.js) opens WebSocket → `ws-proxy.js` authenticates and proxies to container.
6
+
7
+ The interactive container's entrypoint launches a tmux session via the per-agent `docker/coding-agent/scripts/agents/<agent>/start-coding-session.sh` script. Multiple terminal tabs can attach to the same tmux session and survive WebSocket reconnects. Tab close + reopen reattaches; container restart re-creates the session with the prior agent session resumed (see "Session Continuity" below).
6
8
 
7
9
  ## WebSocket Auth
8
10
 
@@ -23,12 +25,18 @@ All actions use `requireAuth()` with ownership checks: `getCodeWorkspaces()`, `c
23
25
 
24
26
  ## Multi-Agent Backends
25
27
 
26
- Code workspaces support multiple coding agent backends. Agent selection is **global** via the `CODING_AGENT` config key (DB-backed, defaults to `claude-code`). There is no per-workspace agent selection all workspaces use the globally configured agent.
28
+ Code workspaces support multiple coding agent backends. Selection is **per-workspace** via the `codingAgent` column on `code_workspaces`, falling back to the global `CODING_AGENT` config key, then to `claude-code`. See `lib/code/actions.js:410`: `const agent = workspace.codingAgent || getConfig('CODING_AGENT') || 'claude-code';`. The same fallback chain is used by `lib/ai/index.js` for chat-mode streaming.
27
29
 
28
- **Supported agents**: `claude-code`, `pi`, `gemini-cli`, `codex-cli`, `opencode`. Each uses a different Docker image variant (`docker/coding-agent/Dockerfile.*`) and agent-specific setup/auth scripts in `docker/coding-agent/scripts/`.
30
+ **Supported agents**: `claude-code`, `pi-coding-agent`, `gemini-cli`, `codex-cli`, `opencode`, `kimi-cli`. Each uses a different Docker image variant (`docker/coding-agent/Dockerfile.*`) and agent-specific setup/auth scripts in `docker/coding-agent/scripts/`.
29
31
 
30
32
  **Configuration**: Users configure agents via `/admin/event-handler/coding-agents` — enable/disable agents, set per-agent auth mode (OAuth vs API key), provider, and model. `setCodingAgentDefault()` sets the global default. `buildAgentAuthEnv()` in `lib/tools/docker.js` resolves credentials from the settings DB at container launch time.
31
33
 
32
34
  **Container streaming**: `lib/containers/stream.js` provides an SSE endpoint (`/stream/containers`) that polls Docker for container stats every 3 seconds. Used by the Containers admin page for live monitoring.
33
35
 
34
36
  **Backend API in messages**: When an agent produces output, the `backendApi` field in message chunks identifies which agent backend generated the response.
37
+
38
+ ## Session Continuity
39
+
40
+ Code workspaces, chat-mode (SDK adapter), and headless agent jobs share session continuity through `lib/ai/session-manager.js`. Session IDs are written to per-port files inside the workspace volume — `~/.{agent}-ttyd-sessions/${PORT}` (or scope-prefixed when `SCOPE` is set). The agent CLI captures its session ID via per-agent hooks (see `docker/coding-agent/CLAUDE.md` § Session Tracking for the 5 patterns). On the next launch the entrypoint reads the saved ID and passes the agent's resume flag (`--continue`, `--resume`, `--session`, depending on agent).
41
+
42
+ Headless `run.sh` always reads from port `7681` — so SDK chat, manual chat tools, and code workspaces all converge on the same conversation when they share a workspace.
@@ -1,9 +1,19 @@
1
1
  # lib/containers/ — Container Streaming
2
2
 
3
- `stream.js` exports `GET(request)`an SSE route handler for real-time Docker container monitoring.
3
+ Two SSE endpointsboth authenticated via `auth()` session.
4
4
 
5
- **Endpoint**: `/stream/containers` (actual SSE streaming, stays in `/stream/`)
6
- **Auth**: `auth()` session check
7
- **Events**: `containers` (every 3s with full container list + CPU/memory stats), `ping` (keepalive every 15s)
8
- **Data source**: `listNetworkContainers()` + `getContainerStats()` from `lib/tools/docker.js`
9
- **Client**: `ContainersPage` connects via `new EventSource('/stream/containers')`
5
+ ## `stream.js` Container Status
6
+
7
+ - **Endpoint**: `/stream/containers`
8
+ - **Events**: `containers` (every 3s with full container list + CPU/memory stats), `ping` (keepalive every 15s)
9
+ - **Data source**: `listNetworkContainers()` + `getContainerStats()` from `lib/tools/docker.js`. Filtered to containers on the event-handler's Docker network (auto-detected at boot).
10
+ - **Client**: `ContainersPage` connects via `new EventSource('/stream/containers')`
11
+
12
+ ## `logs.js` — Container Log Tail
13
+
14
+ - **Endpoint**: `/stream/containers/logs?name=<containerName>`
15
+ - **Events**: raw log lines as SSE `data:` frames
16
+ - **Data source**: Docker `containers/{id}/logs?follow=true&stdout=1&stderr=1` via the multiplexed-stream parser in `lib/tools/docker.js`
17
+ - **Client**: `ContainerLogsView` (used from the Containers admin page when a row's logs are expanded)
18
+
19
+ Both endpoints share the same network filter and frame-decoding logic so all SSE consumers see the same view.
package/lib/db/CLAUDE.md CHANGED
@@ -25,11 +25,12 @@ Key files: `schema.js` (source of truth), `drizzle/` (generated migrations), `dr
25
25
  | `users` | Admin accounts (email, bcrypt password hash, role) |
26
26
  | `chats` | Chat sessions (user_id, title, starred, chat_mode, code_workspace_id, timestamps) |
27
27
  | `messages` | Chat messages (chat_id, role, content) |
28
- | `code_workspaces` | Code workspace containers (user_id, container_name, repo, branch, feature_branch, title, last_interactive_commit, starred, has_changes) |
28
+ | `code_workspaces` | Code workspace containers (user_id, container_name, repo, branch, feature_branch, title, last_interactive_commit, coding_agent, scope, starred, has_changes) |
29
29
  | `notifications` | Job completion notifications (notification text, payload, read status) |
30
30
  | `subscriptions` | Channel subscriptions (platform, channel_id) |
31
+ | `user_channels` | Per-user channel linking (user_id, channel, channel_chat_id, code, code_expires_at, verified_at, active_thread_id) — Telegram verification + active thread |
31
32
  | `clusters` | Worker clusters (user_id, name, system_prompt, folders, enabled, starred) |
32
- | `cluster_roles` | Role definitions scoped to a cluster (cluster_id, role_name, role, trigger_config, max_concurrency, cleanup_worker_dir, folders) |
33
+ | `cluster_roles` | Role definitions scoped to a cluster (cluster_id, role_name, role, trigger_config, max_concurrency, plan_mode, cleanup_worker_dir, folders) |
33
34
  | `settings` | Key-value configuration store (also stores API keys and OAuth tokens via type/key/value) |
34
35
 
35
36
  ## OAuth Token Storage
@@ -64,3 +65,5 @@ OAuth tokens for coding agent backends are stored as `config_secret` with LRU ro
64
65
  - `chats.chatMode` — `'agent'` (default) or `'code'`. Determines which agent singleton and tools are used.
65
66
  - `codeWorkspaces.featureBranch` — tracks the git feature branch for the workspace session.
66
67
  - `codeWorkspaces.hasChanges` — flag set when workspace has uncommitted changes.
68
+ - `codeWorkspaces.codingAgent` — per-workspace coding-agent override. Falls back to global `CODING_AGENT` config, then `claude-code` (`lib/code/actions.js:410`).
69
+ - `codeWorkspaces.scope` — subdirectory scope within the repo (e.g., `agents/gary-vee`). Resolves the agent's working directory and skills (`lib/ai/scope.js`).
@@ -12,7 +12,16 @@ Calls Docker Engine API directly through `/var/run/docker.sock` using Node's `ht
12
12
 
13
13
  **Image pull on demand**: Checks if image exists locally before pulling. Avoids pre-pulling at startup.
14
14
 
15
- **`buildAgentAuthEnv(agent)`**: Resolves coding agent type → auth environment variables from the settings DB. Handles OAuth vs API key auth modes, multi-provider resolution (builtin + custom), and model overrides. Each agent type (claude-code, pi, gemini-cli, codex-cli, opencode) has its own credential resolution path. OAuth tokens use LRU rotation via `getNextOAuthToken()`. All credentials come from the DB, not `.env` or GitHub secrets.
15
+ **`buildAgentAuthEnv(agent)`**: Resolves coding agent type → auth environment variables from the settings DB. All credentials come from the DB (`getConfig`, `getCustomProvider`), never `.env` or GitHub secrets. Returns `{ env: string[], backendApi: string }`.
16
+
17
+ Per-agent resolution paths:
18
+
19
+ - **`claude-code`** — `CODING_AGENT_CLAUDE_CODE_BACKEND` selects backend. If `anthropic`, picks OAuth (`CLAUDE_CODE_OAUTH_TOKEN` via LRU rotation) or API key (`ANTHROPIC_API_KEY`). For Anthropic-compatible third parties (DeepSeek, MiniMax, Kimi, OpenRouter) sets `ANTHROPIC_AUTH_TOKEN` + `ANTHROPIC_BASE_URL` to the provider's `anthropicEndpoint`. For OpenAI-only builtins or custom providers, routes through the LiteLLM sidecar at `http://litellm:4000` and prefixes the model with the provider key.
20
+ - **`pi-coding-agent`, `opencode`, `kimi-cli`** — share a multi-provider pattern. `CODING_AGENT_{AGENT}_PROVIDER` picks anthropic/openai/google/deepseek/minimax/mistral/xai/openrouter/nvidia or a custom provider. Sets the matching `*_API_KEY` (or `CUSTOM_OPENAI_BASE_URL` + `CUSTOM_API_KEY` for custom).
21
+ - **`gemini-cli`** — `GOOGLE_API_KEY` only. Backend is always `google`.
22
+ - **`codex-cli`** — OAuth (`CODEX_OAUTH_TOKEN`) or API key (`OPENAI_API_KEY`). Backend always `openai`.
23
+
24
+ OAuth tokens use LRU rotation via `getNextOAuthToken()` (in `lib/db/oauth-tokens.js`) — distributes load across multiple stored tokens and updates `lastUsedAt` on each pick. Refresh-token rotation is handled at retrieval time in `/api/get-agent-job-secret` (under a per-token lock).
16
25
 
17
26
  ## create-agent-job.js — Agent Job Creation
18
27
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thepopebot",
3
- "version": "1.2.76-beta.19",
3
+ "version": "1.2.76-beta.20",
4
4
  "type": "module",
5
5
  "description": "Create autonomous AI agents with a two-layer architecture: Next.js Event Handler + Docker Agent.",
6
6
  "bin": {
package/setup/CLAUDE.md CHANGED
@@ -4,13 +4,19 @@ Entry point: `setup.mjs` (invoked via `thepopebot setup`).
4
4
 
5
5
  ## Wizard Steps
6
6
 
7
- 1. **Prerequisites**Checks Node.js (>=18), git, gh CLI (authenticated), Docker. Initializes git repo and GitHub remote if needed.
8
- 2. **GitHub PAT** — Validates fine-grained token with required scopes (Actions, Admin, Contents, PRs, Secrets, Workflows).
9
- 3. **App URL** — Prompts for public HTTPS URL (ngrok, VPS, PaaS). Generates webhook secret.
10
- 4. **Sync Config** — Writes secrets/variables to GitHub and local DB via `syncConfig()`.
11
- 5. **Build** — Runs `npm run build` with retry.
7
+ 1. **Load `.env`** `dotenv.config()` runs first so existing values are available to subsequent steps.
8
+ 2. **Prerequisites** — Checks Node.js (>=18), git, gh CLI (authenticated), Docker. Initializes git repo and GitHub remote if needed.
9
+ 3. **GitHub PAT** — Validates fine-grained token with required scopes (Actions, Admin, Contents, PRs, Secrets, Workflows).
10
+ 4. **App URL** — Prompts for public HTTPS URL (ngrok, VPS, PaaS). Generates webhook secret.
11
+ 5. **Sync Config** — Writes secrets/variables to GitHub and local DB via `syncConfig()`.
12
12
  6. **Start Server** — Starts Docker containers, polls `/api/ping` to confirm.
13
13
 
14
+ The setup wizard does NOT run `npm run build` — `.next` is baked into the event-handler Docker image at publish time.
15
+
16
+ ## Database
17
+
18
+ Settings DB defaults to `data/db/thepopebot.sqlite` (relative to project root). Override via `DATABASE_PATH` in `.env`. Schema migrations run automatically on server start (`lib/db/index.js`).
19
+
14
20
  ## Sync Target Types
15
21
 
16
22
  Config values are synced to different targets via `lib/sync.mjs`:
@@ -20,16 +20,14 @@ This is a [thepopebot](https://github.com/stephengpope/thepopebot) project.
20
20
 
21
21
  ## Managed Files
22
22
 
23
- Some files are auto-synced by `npx thepopebot init` and will be overwritten on upgrade. Do not edit these:
23
+ Some files are auto-synced by `npx thepopebot init` and will be overwritten on every init/upgrade. Do not edit these:
24
24
 
25
25
  - `.github/workflows/` — CI/CD workflows
26
26
  - `docker-compose.yml`
27
27
  - `.dockerignore`
28
28
  - `.gitignore`
29
- - `agent-job/CLAUDE.md`
30
- - `agents/CLAUDE.md`
31
- - `event-handler/CLAUDE.md`
32
- - `skills/CLAUDE.md`
29
+
30
+ The `CLAUDE.md` files scattered through the project tree (e.g. `agent-job/CLAUDE.md`, `agents/CLAUDE.md`, `event-handler/CLAUDE.md`, `skills/CLAUDE.md`) are scaffolded by `init` from `*.template` sources but are **not** in the managed-paths list — they will not be overwritten if you have edited them. Run `npx thepopebot reset <path>` to restore one to its template default, or `npx thepopebot diff <path>` to see your local changes.
33
31
 
34
32
  ## Agent Scoping
35
33
 
@@ -2,15 +2,20 @@
2
2
 
3
3
  ## Adding an Agent
4
4
 
5
- Each subdirectory defines an agent. Create a folder with a `SYSTEM.md` file:
5
+ Each subdirectory defines an agent. At minimum create a folder with a `SYSTEM.md` file:
6
6
 
7
7
  ```
8
8
  agents/
9
9
  └── my-agent/
10
- └── SYSTEM.md # System prompt — identity, instructions, constraints
10
+ ├── SYSTEM.md # System prompt — identity, instructions, constraints (required)
11
+ ├── CLAUDE.md # Optional — guidance for AI assistants editing this agent's files
12
+ ├── skills/ # Optional — agent-specific skills (overrides root skills/ for this scope)
13
+ └── jobs/ # Optional — reusable task prompts referenced from CRONS.json
11
14
  ```
12
15
 
13
- `SYSTEM.md` is the agent's system prompt. Write it in markdown addressed to the agent (e.g. "You are a code reviewer...").
16
+ `SYSTEM.md` is the agent's system prompt. Write it in markdown addressed to the agent (e.g. "You are a code reviewer..."). `CLAUDE.md` (if present) is read by AI assistants working in this directory — use it for non-obvious context only.
17
+
18
+ **Skills resolution**: when a job runs with `scope: "agents/my-agent"`, the runtime checks `agents/my-agent/skills/` first; missing skills fall back to root `skills/`. To override a built-in skill for one agent, drop a same-named SKILL.md under that agent's `skills/`.
14
19
 
15
20
  > **Important:** `agents/<name>/SYSTEM.md` **replaces** `agent-job/SYSTEM.md` when the agent is scoped — it doesn't extend it. Use `agent-job/SYSTEM.md` as a starting template and adapt it: keep the runtime environment notes, the `/tmp` scratch directive, and add the `{{skills}}` token if you want skill descriptions injected. Then add the agent's identity-specific instructions on top.
16
21
 
@@ -1,5 +1,5 @@
1
1
  # Data Directory
2
2
 
3
- Runtime data including the SQLite database (`thepopebot.sqlite`) and cluster state. Created automatically on server start.
3
+ Runtime data including the SQLite database (`db/thepopebot.sqlite`) and cluster state. Created automatically on server start. Override the DB path with `DATABASE_PATH` in `.env`.
4
4
 
5
5
  Not checked into git.
@@ -0,0 +1,79 @@
1
+ # event-handler/ — Event Handler Configuration
2
+
3
+ This directory holds configuration for the event handler: webhook triggers, chat system prompts, cluster role definitions, LiteLLM proxy routing, and the job-summary prompt. Edit these to shape how your handler reacts to events.
4
+
5
+ ## Files
6
+
7
+ | File | Purpose |
8
+ |------|---------|
9
+ | `TRIGGERS.json` | Webhook trigger definitions — fires actions when matching paths receive HTTP requests |
10
+ | `agent-chat/SYSTEM.md` | System prompt for the chat agent (default chat mode) |
11
+ | `code-chat/SYSTEM.md` | System prompt for code mode chat (workspace-aware) |
12
+ | `clusters/SYSTEM.md` | System prompt prepended to every cluster worker |
13
+ | `clusters/ROLE.md` | Role-template snippet referenced by cluster role definitions |
14
+ | `litellm/main.yaml` | LiteLLM proxy config — routes Claude Code through other providers |
15
+ | `SUMMARY.md` | System prompt for the auto-summary that runs after agent jobs complete |
16
+
17
+ ## TRIGGERS.json
18
+
19
+ JSON array of webhook trigger definitions. Loaded at server boot by `lib/triggers.js`.
20
+
21
+ ```json
22
+ {
23
+ "name": "review-github-event",
24
+ "watch_path": "/github/webhook",
25
+ "actions": [
26
+ { "type": "agent", "job": "Summarize this GitHub event:\n{{body}}" }
27
+ ],
28
+ "enabled": true
29
+ }
30
+ ```
31
+
32
+ | Field | Required | Notes |
33
+ |-------|----------|-------|
34
+ | `name` | Yes | Unique within the file |
35
+ | `watch_path` | Yes | Path to match (`/github/webhook`, `/webhook`, custom paths) |
36
+ | `actions` | Yes | Array — runs in order, fire-and-forget after auth |
37
+ | `enabled` | No | Defaults `true`. Set `false` to keep the entry but skip it |
38
+
39
+ ### Action types
40
+
41
+ Three types — `agent`, `command`, `webhook`. `agent` accepts optional `scope`, `agent_backend`, `llm_model` overrides:
42
+
43
+ ```json
44
+ { "type": "agent", "job": "Process: {{body}}", "scope": "agents/triage", "agent_backend": "claude-code", "llm_model": "claude-sonnet-4-5-20250929" }
45
+ { "type": "command", "command": "echo 'webhook received: {{body}}' >> logs/webhook.log" }
46
+ { "type": "webhook", "url": "https://example.com/hook", "method": "POST", "headers": {}, "vars": { "source": "github" } }
47
+ ```
48
+
49
+ `scope` resolves to a directory under `agents/` (see `agents/CLAUDE.md`). The agent runs with that directory as its working dir, picks up its `SYSTEM.md`, and uses the skills under `agents/<scope>/skills/` falling back to root `skills/`.
50
+
51
+ ### Template tokens (in `job` and `command` strings)
52
+
53
+ `{{body}}`, `{{body.field}}`, `{{query}}`, `{{query.field}}`, `{{headers}}`, `{{headers.field}}`. Tokens are expanded when the trigger fires.
54
+
55
+ ## Chat System Prompts
56
+
57
+ `agent-chat/SYSTEM.md` and `code-chat/SYSTEM.md` are the system prompts injected into chat sessions. They support `{{include path/to/file.md}}`, `{{datetime}}`, and `{{skills}}` variables (resolved by `lib/utils/render-md.js`).
58
+
59
+ - **Agent chat** (`agent-chat/`) — default chat mode. Workspace-agnostic.
60
+ - **Code chat** (`code-chat/`) — code-mode chat. Knows about the active code workspace, repo, branch, and feature branch.
61
+
62
+ Both can be scoped: a chat tied to a workspace with `scope: "agents/foo"` will load `agents/foo/SYSTEM.md` instead of these defaults.
63
+
64
+ ## Cluster Configuration
65
+
66
+ `clusters/SYSTEM.md` is prepended to every cluster worker's system prompt. `clusters/ROLE.md` is a reusable role-template snippet that individual cluster role definitions can reference. Cluster behavior is configured in the Admin UI; these files supply the prompt context.
67
+
68
+ ## LiteLLM Proxy
69
+
70
+ `litellm/main.yaml` is read by the optional LiteLLM sidecar (`docker-compose.litellm.yml`). It routes Claude Code's Anthropic-protocol calls to other providers (OpenAI, Gemini, custom OpenAI-compatible endpoints). Edit when you want to use Claude Code with a non-Anthropic backend. Refer to [LiteLLM docs](https://docs.litellm.ai/) for the schema.
71
+
72
+ ## Summary Prompt
73
+
74
+ `SUMMARY.md` is the prompt for the helper LLM call that auto-summarizes agent job results (PR link, status, files changed). Tweak its tone or formatting here.
75
+
76
+ ## Notes
77
+
78
+ - These files are user-owned — edits are preserved across `thepopebot upgrade`.
79
+ - The Admin UI (`/admin/event-handler/`) configures runtime defaults (LLM provider, coding agent backend, OAuth tokens). Trigger-level `agent_backend` / `llm_model` overrides those defaults.
@@ -69,9 +69,14 @@ If a skill needs an API key, add it via the admin UI (Settings > Agent Jobs > Se
69
69
 
70
70
  Skills are active when present in the `skills/` directory. To deactivate, remove or move the skill directory out.
71
71
 
72
- All coding agents discover skills from the same `skills/` directory via symlink bridges:
73
- - `.claude/skills → ../skills`
74
- - `.pi/skills → ../skills`
72
+ All coding agents discover skills from the same `skills/` directory via symlink bridges created by `npx thepopebot init`:
73
+
74
+ - `.claude/skills → ../skills` (Claude Code)
75
+ - `.pi/skills → ../skills` (Pi)
76
+ - `.codex/skills → ../skills` (Codex CLI)
77
+ - `.gemini/skills → ../skills` (Gemini CLI)
78
+ - `.kimi/skills → ../skills` (Kimi CLI)
79
+ - `.agents/skills → ../skills` (OpenCode and other plugin-style agents)
75
80
 
76
81
  ## Creating a Skill
77
82