647 lines
46 KiB
Markdown
647 lines
46 KiB
Markdown
# DisClaw — Entwicklungsplan
|
||
|
||
Autor: Senior Developer
|
||
Datum: 2026-04-08
|
||
Basis: `ARCHITECTURE.md`, `CLAUDE.md`, `docs/ai-engineer-analyse.md`, `docs/cli-feature-answers.md`, aktueller Stand unter `src/`.
|
||
|
||
Der Plan ist umsetzungsnah. Er legt fest, was gebaut wird, wie, in welcher Reihenfolge, und wo der Code erweiterbar bleiben muss. Zeitangaben gibt es bewusst nicht — nur logische Reihenfolge und Abhängigkeiten.
|
||
|
||
## Revision-Log
|
||
|
||
- 2026-04-08: Initialversion.
|
||
- 2026-04-08: AI-Engineer-Antworten eingearbeitet (siehe `docs/cli-feature-answers.md`). Workspace-Location auf `~/.disclaw/workspaces/` (außerhalb DisClaw-Repo) geändert, Windows-Spawn-Fix via `cross-spawn` ergänzt, Phase 2 (`--resume`) freigegeben, `.claude/settings.json`-Schema und Hooks-Layout präzisiert, Abschnitt 6 durch Verweis auf Antworten-Dokument ersetzt.
|
||
|
||
---
|
||
|
||
## 1. Status Quo
|
||
|
||
### Was existiert und funktioniert (vermutlich)
|
||
|
||
- `src/index.ts` — Bootstrap, lädt Env und Config, initialisiert DB, startet Bot, Graceful Shutdown.
|
||
- `src/config/loader.ts` — Lädt `.env` und `disclaw.yaml`.
|
||
- `src/db/database.ts` — `better-sqlite3`-Wrapper, `workspaces` und `conversations` Tabellen mit Prepared Statements, Migrationen via `IF NOT EXISTS`, WAL und Foreign Keys aktiv.
|
||
- `src/db/schema.sql` — SQL-Schema (separat vorhanden, aber Migration läuft aktuell aus dem Inline-String in `database.ts`, Duplikation).
|
||
- `src/bot.ts` — Discord Client mit Intents `Guilds`, `GuildMessages`, `MessageContent`. Auto-Erstellung/Adoption des Management-Channels. Slash-Command-Registrierung pro Guild. Basic `messageCreate` Dispatch.
|
||
- `src/router.ts` — Channel→Workspace Lookup, naives 2000-Zeichen-Splitting an Line-Breaks, Typing-Indikator alle 5 s, schreibt User- und Assistant-Messages in `conversations`.
|
||
- `src/commands/new-agent.ts` — Slash-Command `/new-agent` mit Name-Validierung (Regex), Channel-Create, Workspace-Erstellung, DB-Insert.
|
||
- `src/agent/identity.ts` — `agent.yaml`, `CLAUDE.md` und `.claude/settings.json` Templates werden beim Create geschrieben.
|
||
- `src/agent/runner.ts` — Spawnt `claude -p "<prompt>" --output-format json`, baut History als Freitext, parst JSON-Output in drei Formaten, Timeout, AbortController-Support.
|
||
|
||
### Was kaputt oder problematisch ist
|
||
|
||
1. **P0 Security**: `src/agent/runner.ts:187` setzt `shell: true` — direkter Command-Injection-Vektor über Discord-User-Input (siehe AI-Engineer-Analyse §4.1).
|
||
2. **P0 Secret Leak**: `src/agent/runner.ts:181` vererbt `process.env` vollständig an den Kindprozess → `DISCORD_BOT_TOKEN` und andere Secrets sind im Claude-Prozess sichtbar (§4.5).
|
||
3. **Kein Queueing pro Channel**: Mehrere gleichzeitige Nachrichten im selben Channel starten parallele `claude`-Prozesse im gleichen `cwd`. Race Conditions bei Tool-Calls und Session-Updates sind garantiert (§2 "Per-Channel-FIFO-Queue").
|
||
4. **Agent-Name-Regex unzureichend gegen Path Traversal**: Kein expliziter `path.resolve`-Containment-Check in `new-agent.ts`. Regex akzeptiert `a-a` aber kein explizites Path-Check-Gate existiert (§4.3).
|
||
5. **Message-Splitting zerreißt Codeblöcke**: `router.ts:splitMessage()` respektiert keine ``` ``` ``` Fences (§2).
|
||
6. **History-Injektion statt `--resume`**: `runner.ts:buildPrompt()` konkateniert Historie als Freitext — kein Prompt-Caching, falsche Rollenzuordnung (§1).
|
||
7. **`.claude/settings.json` Template ist zu permissiv**: `allow: ["Read", "Write", "Edit", "Bash", ...]` ohne Pfad-Restriktionen und ohne `deny`-Liste. Kein Hook-Guard.
|
||
8. **`CLAUDE.md` Template hat keine Prompt-Injection-Resistenz-Klausel**.
|
||
9. **Keine User-Rate-Limits, kein globales Concurrency-Cap**.
|
||
10. **Keine Tests**. Kein `devDependencies`-Eintrag für ein Test-Framework.
|
||
11. **Keine strukturierten Logs**. Nur `console.log`. Schwierig zu filtern/parsen.
|
||
12. **Keine `.env.example`** im Repo (zu verifizieren bei `ls`).
|
||
13. **Doppelte Schema-Quelle**: `src/db/schema.sql` und Inline-`SCHEMA_SQL` in `database.ts` können divergieren.
|
||
14. **Typing-Refresh auf 5 s**: Discord-Typing läuft ~10 s, 5 s ist okay aber unnötig häufig; 8 s reicht (§2).
|
||
|
||
### Was komplett fehlt
|
||
|
||
- `/list-agents`, `/delete-agent` Commands.
|
||
- Session-ID-Persistenz (`--resume`).
|
||
- Streaming-Output.
|
||
- Attachment-Handling.
|
||
- PreToolUse-Hook / Audit-Log.
|
||
- Profile-basiertes Workspace-Scaffolding (researcher/developer/writer/ops).
|
||
- Per-Channel-Queue, globaler Concurrency-Semaphore.
|
||
- Structured Logger (pino o. ä.).
|
||
- Zod-Validation für Config und CLI-JSON-Output.
|
||
- Tests (Unit und Integration).
|
||
- `docs/cli-feature-probe.md` — Test-Script, das das tatsächliche Verhalten der installierten `claude` CLI gegen unsere Annahmen verifiziert.
|
||
|
||
---
|
||
|
||
## 2. Technische Entscheidungen (locked in)
|
||
|
||
### 2.1 Context-Persistenz: Hybrid (`--resume` primär, DB-Rehydrierung Fallback)
|
||
|
||
**Was**: Session-ID pro Workspace in der DB persistieren. Runner ruft `claude -p "<msg>" --resume <session-id> --output-format json --bare` auf. Wenn keine Session-ID existiert oder `--resume` fehlschlägt, wird aus den letzten N DB-Einträgen ein Bootstrap-Prompt gebaut und eine neue Session gestartet. Die neue `session_id` aus dem JSON-Output wird **nach jedem Run** in der DB aktualisiert (defensiv gegen Fork-Verhalten älterer CLI-Versionen).
|
||
|
||
**Warum**: Prompt Caching von Anthropic greift nur bei stabilen Präfixen innerhalb einer Session. Neue Session pro Nachricht = 0 % Cache-Hit, jede CLAUDE.md wird neu tokenisiert. Mit `--resume` erwartete Einsparung 60–90 % Input-Tokens (siehe ai-engineer-analyse.md §1). Bestätigt über `cli-feature-answers.md` Frage 1+2: `-p` + `--resume` ist offiziell dokumentiert, `session_id` liegt **top-level** im JSON-Output.
|
||
|
||
**Wie**:
|
||
- Neue DB-Spalten in `workspaces`: `claude_session_id TEXT`, `session_updated_at TEXT`. Migration additiv (`ALTER TABLE ... ADD COLUMN`).
|
||
- Runner-API bekommt einen `SessionStore`-Typ injiziert (damit der Runner testbar bleibt).
|
||
- Session-ID lesen: `JSON.parse(stdout).session_id` (top-level). Nach **jedem** Run per `store.set(ws.id, json.session_id)` persistieren, auch wenn die ID identisch zur vorigen ist — fängt Fork-Verhalten (`--fork-session`, CLI-Upgrades, `cleanupPeriodDays`-Expiry) transparent auf.
|
||
- Fallback bei `--resume` Fehler: Error-String auf `session .* not found` oder ähnlich matchen → `claude_session_id = NULL` setzen und erneut starten ohne `--resume` (optional mit `--continue` als zweiter Fallback-Stufe).
|
||
- Die `conversations`-Tabelle bleibt Source of Truth für Rehydrierung nach Bot-Neustart oder vollständigem Session-Verlust.
|
||
- Metriken: `usage`-Block aus JSON-Output parsen (`cache_read_input_tokens`, `cache_creation_input_tokens`, `input_tokens`, `output_tokens`) und pro Run loggen. Exakter Pfad (`result.usage.*` oder top-level `usage.*`) wird empirisch via Probe-Script verifiziert.
|
||
|
||
```ts
|
||
// src/agent/runner.ts (Skizze)
|
||
export interface SessionStore {
|
||
get(workspaceId: number): string | null;
|
||
set(workspaceId: number, sessionId: string): void;
|
||
clear(workspaceId: number): void;
|
||
}
|
||
|
||
async function runOnce(ws: Workspace, msg: string, store: SessionStore): Promise<RunResult> {
|
||
const sid = store.get(ws.id);
|
||
const args = [
|
||
"-p", msg,
|
||
"--output-format", "json",
|
||
"--bare",
|
||
"--append-system-prompt-file", path.join(ws.workspace_path, "CLAUDE.md"),
|
||
];
|
||
if (sid) args.push("--resume", sid);
|
||
const res = await spawnClaude(args, ws.workspace_path);
|
||
if (res.ok) {
|
||
// Immer updaten — fängt Fork/Cleanup-Edge-Cases ab
|
||
if (res.sessionId) store.set(ws.id, res.sessionId);
|
||
return res;
|
||
}
|
||
if (sid && isResumeFailure(res.error)) {
|
||
store.clear(ws.id);
|
||
return runOnce(ws, msg, store); // einmaliger Retry ohne --resume
|
||
}
|
||
throw res.error;
|
||
}
|
||
```
|
||
|
||
**Status**: Freigegeben. `cli-feature-answers.md` Frage 1+2 bestätigt, dass `-p "..." --resume <id> --output-format json` offiziell dokumentiert ist und `session_id` als Top-Level-Feld im JSON-Output liegt. Kein Blocker mehr. Das Probe-Script verifiziert lediglich noch die genauen Pfade des `usage`-Blocks und bleibt als Regression-Check im Repo.
|
||
|
||
### 2.2 Prozessmodell: Spawn pro Nachricht (beibehalten)
|
||
|
||
**Was**: Ein Bot-Prozess, ein `claude`-Kindprozess pro Nachricht, terminiert nach Antwort.
|
||
|
||
**Warum**: Persistente Prozesse sind im MVP nicht vertretbar (150–300 MB RSS pro idle Agent, Supervisor nötig, Windows-Signal-Semantik fragil, interaktiver Modus ist TTY-first). Spawn-pro-Nachricht ist einfacher, isolierter, crash-resistenter. Der Kaltstart-Overhead wird durch `--resume` + Prompt Caching teilweise kompensiert.
|
||
|
||
**Wie**: Bestehender `runner.ts`-Ansatz, aber mit `shell: false`, sanitizedEnv, Windows-Pfad-Auflösung, SessionStore, und abstrahiertem Event-Stream-Return-Type, damit Phase 5 (Streaming) ohne API-Bruch nachgerüstet werden kann.
|
||
|
||
**Windows-Spawn-Entscheidung (locked in)**: `cross-spawn` als Dependency. Grund: `claude` wird auf Windows als `claude.cmd`-Wrapper installiert (`%APPDATA%\npm\claude.cmd`), nicht als `.exe`. Seit Node 20.12 (CVE-2024-27980) blockt Node `child_process.spawn("foo.cmd", args, { shell: false })` bzw. verlangt explizites Arg-Quoting — `spawn("claude", args)` scheitert außerdem, weil Node ohne `shell: true` keine `.cmd`-Extension im PATH-Lookup anhängt. `cross-spawn` abstrahiert beides (löst `.cmd`-Wrapper auf, quoted Args korrekt, identisches Verhalten Linux/Mac/Windows).
|
||
|
||
**Trade-off**: Eine Mini-Dependency (~30 kB, keine transitiven Deps) gegen ~50 Zeilen Windows-Workaround-Code (where-lookup + `.cmd`-Wrapper-Parsing + direkter `node cli.js`-Spawn). Die Eigenbau-Variante bleibt als Fallback dokumentiert, falls `cross-spawn` jemals ausfällt, aber ist für den MVP nicht die erste Wahl — der Pragmatik-Gewinn wiegt die Dependency auf.
|
||
|
||
**Zusätzlich**: Beim Bot-Start einmalig `CLAUDE_PATH` aus `.env` lesen; falls leer, `where claude` (Windows) / `which claude` (Unix) automatisch ausführen, Ergebnis in-memory cachen. Fehlermeldung mit klarem Hinweis auf `CLAUDE_PATH`, falls nicht auffindbar. `windowsHide: true`, `stdio: ["pipe", "pipe", "pipe"]`, `child.stdout.setEncoding("utf8")`.
|
||
|
||
### 2.3 Per-Channel-Queue
|
||
|
||
**Was**: FIFO-Queue pro `channel_id`, die alle Runs im gleichen Channel serialisiert. Parallele Channels bleiben parallel.
|
||
|
||
**Warum**: Siehe §1.3 Status Quo — ohne Queue laufen race-anfällige parallele Runs im selben Workspace.
|
||
|
||
**Wie**: In-Memory `Map<channelId, Promise<void>>` im Router. Neue Tasks werden an die letzte Promise der Queue angehängt. Kein externer Queue-Service, kein Redis — MVP-tauglich. Zusätzlich: globaler Semaphore (max. 4 parallele `claude`-Prozesse) über ein simples Counting-Primitive.
|
||
|
||
```ts
|
||
// src/runtime/channel-queue.ts
|
||
export class ChannelQueue {
|
||
private queues = new Map<string, Promise<unknown>>();
|
||
enqueue<T>(channelId: string, task: () => Promise<T>): Promise<T> {
|
||
const prev = this.queues.get(channelId) ?? Promise.resolve();
|
||
const next = prev.catch(() => undefined).then(task);
|
||
this.queues.set(channelId, next);
|
||
next.finally(() => {
|
||
if (this.queues.get(channelId) === next) this.queues.delete(channelId);
|
||
});
|
||
return next;
|
||
}
|
||
}
|
||
```
|
||
|
||
### 2.4 Message-Splitting: Eigenbau, codeblock-aware
|
||
|
||
**Was**: Eigene Funktion `splitForDiscord(text, limit=1900)`, die Fences (``` ```) erkennt, offene Blöcke beim Split schließt und im nächsten Chunk wieder öffnet. Bei Ausgaben >8 k Zeichen: stattdessen als `.md`-Attachment senden.
|
||
|
||
**Warum**: Keine externe Library erfüllt das 1:1 (die üblichen "split-to-discord"-Pakete sind unmaintained oder fehlerhaft bei Fences). Eigenbau ist ~60 Zeilen und testbar. Limit 1900 statt 2000 lässt Puffer für Prefix `(1/3)`.
|
||
|
||
**Wie**: Siehe Algorithmus-Skizze in `docs/ai-engineer-analyse.md` §2 "2000-Zeichen-Splitting". Ablegen unter `src/discord/split.ts` mit Unit-Tests.
|
||
|
||
### 2.5 Workspace-Isolation: Permissions + PreToolUse-Hook
|
||
|
||
**Was**: `.claude/settings.json` bekommt ein Default-Deny für Pfade außerhalb `./` und für gefährliche Bash-Muster. Zusätzlich PreToolUse-Hook, der jeden Tool-Call mit absolutem Pfad gegen `workspace_abs_path` matched und abbricht, wenn außerhalb.
|
||
|
||
**Warum**: Permissions allein reichen nicht, weil `Read(./**)` sich mit `cd ..` via Bash aushebeln lässt. Der Hook ist die zweite Verteidigungslinie und gleichzeitig der Audit-Log-Hook.
|
||
|
||
**Wie**:
|
||
- `src/agent/templates/settings.default.json` als Template mit Platzhalter `{{workspace_abs}}`.
|
||
- Schema-Struktur wie in `cli-feature-answers.md` Frage 3 bestätigt: Permissions unter `permissions.allow` / `permissions.ask` / `permissions.deny`, mit zusätzlich `permissions.defaultMode` und `permissions.additionalDirectories`. Hooks als dreistufige Struktur: `hooks.<EventName>[].matcher` + `hooks.<EventName>[].hooks[].{type, command, if, timeout}`.
|
||
- Hook-Script als Node-File: `src/agent/hooks/guard-tool.ts`, das beim Workspace-Create nach `./.claude/hooks/guard-tool.cjs` gerendert wird (compile-to-CJS damit der Hook ohne Build-Step ausführbar ist).
|
||
- Template-Beispiel (das tatsächliche Schema):
|
||
```json
|
||
{
|
||
"$schema": "https://json.schemastore.org/claude-code-settings.json",
|
||
"permissions": {
|
||
"allow": ["Read", "Write(./**)", "Edit(./**)", "Bash(git diff *)"],
|
||
"ask": ["Bash(git push *)"],
|
||
"deny": [
|
||
"Read(../**)", "Write(../**)", "Edit(../**)",
|
||
"Read(./.env)", "WebFetch",
|
||
"Bash(rm -rf *)", "Bash(curl * | sh)",
|
||
"Bash(ssh *)", "Bash(scp *)",
|
||
"Bash(* /etc/*)", "Bash(* ~/.ssh/*)",
|
||
"Bash(powershell -Command *)"
|
||
],
|
||
"defaultMode": "acceptEdits"
|
||
},
|
||
"hooks": {
|
||
"PreToolUse": [
|
||
{
|
||
"matcher": "Bash|Read|Write|Edit",
|
||
"hooks": [
|
||
{
|
||
"type": "command",
|
||
"command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/guard-tool.cjs\"",
|
||
"timeout": 10
|
||
}
|
||
]
|
||
}
|
||
]
|
||
},
|
||
"cleanupPeriodDays": 90
|
||
}
|
||
```
|
||
- **Headless-Gotcha**: Im `-p`-Modus bricht ein nicht durch `permissions.allow` gedecktes Tool den Run ab (kein interaktiver Prompt). Das `allow`-Set muss die für das Profil nötigen Tools explizit enthalten — dokumentieren im README und in jedem Profile-Template.
|
||
|
||
### 2.6 Secrets/Env-Handling
|
||
|
||
**Was**: `sanitizedEnv()` Funktion, die Secret-artige Variablen entfernt, bevor der `claude`-Prozess gestartet wird. Whitelist statt Blacklist wo möglich.
|
||
|
||
**Warum**: Siehe §4.5 der Analyse — sonst kann jeder Agent per `echo $DISCORD_BOT_TOKEN` den Bot-Token exfiltrieren.
|
||
|
||
**Wie**:
|
||
- Neue Datei: `src/runtime/env.ts`, exportiert `sanitizedEnv(extra?: Record<string, string>)`.
|
||
- Entfernt: `DISCORD_BOT_TOKEN`, `DISCORD_CLIENT_SECRET`, `DISCORD_PUBLIC_KEY`, alles matching `/^(DISCLAW_SECRET_|SECRET_|TOKEN_|API_KEY_?)/i`, plus eine konfigurierbare Block-Liste in `disclaw.yaml`.
|
||
- Fügt hinzu: `CI=true`, `DISCLAW_AGENT=1`, `DISCLAW_AGENT_NAME=<name>`.
|
||
|
||
### 2.9 Workspace-Location: Standardmäßig außerhalb des DisClaw-Repos
|
||
|
||
**Was**: Workspaces liegen per Default unter `~/.disclaw/workspaces/<agent>/` (Linux/Mac) bzw. `%USERPROFILE%\.disclaw\workspaces\<agent>\` (Windows). Der Pfad ist über `disclaw.yaml` → `workspaces_root` konfigurierbar, der Default ist aber **immer absolut außerhalb des DisClaw-Repo-Roots**. Das bisherige `./workspaces/` im Repo wird nicht mehr als Default verwendet (nur noch als Opt-in für Development/Tests — dann mit Warning-Log beim Bot-Start).
|
||
|
||
**Warum**: Siehe `cli-feature-answers.md` Frage 8. Claude Code walkt beim Start parent directories hoch und **konkateniert** alle gefundenen `CLAUDE.md`-Dateien in den System-Prompt. Liegt der Agent-Workspace unter `C:\Code\side\disclaw\workspaces\<agent>\`, dann sieht Claude zwangsläufig auch:
|
||
1. `C:\Code\side\disclaw\CLAUDE.md` (DisClaw-Projekt-Identität)
|
||
2. `~/.claude/CLAUDE.md` (User-Scope, developer-spezifisch)
|
||
3. ggf. weitere Parent-Scope-Dateien.
|
||
|
||
Das kontaminiert die Agent-Identität: Der Agent "weiß" plötzlich, dass er Teil von DisClaw ist, und erbt den Ton/Anweisungen der Developer-eigenen User-CLAUDE.md. Weder reproduzierbar noch sicher.
|
||
|
||
**Zwei Verteidigungslinien, kombiniert**:
|
||
|
||
1. **Strukturell**: Workspaces liegen außerhalb des DisClaw-Repos. Dadurch existiert oberhalb keine Projekt-CLAUDE.md. `~/.claude/CLAUDE.md` bleibt ein Rest-Leak-Vektor.
|
||
2. **Explizit**: Alle CLI-Calls nutzen `--bare` (skippt CLAUDE.md-Walk-Up, Hooks-Discovery, Skills, Plugins, MCP, Auto-Memory komplett) **und** die Agent-Identität wird über `--append-system-prompt-file <ws>/CLAUDE.md` explizit injiziert. Das ist reproduzierbar und zukunftssicher — laut Doku wird `--bare` ohnehin der zukünftige Default für `-p`.
|
||
|
||
**Konsequenz für bestehenden Code**:
|
||
- `disclaw.yaml`-Default: `workspaces_root: "~/.disclaw/workspaces"` (Tilde-Expansion + `path.resolve` gegen `os.homedir()`).
|
||
- `src/config/loader.ts`: Tilde-Expansion implementieren, absolute Pfade per `path.resolve` normalisieren.
|
||
- `src/agent/runner.ts`: Immer `--bare` + `--append-system-prompt-file` an den Call anhängen.
|
||
- `src/commands/new-agent.ts`: Workspace unter dem neuen Root anlegen, Channel-Name und DB-Einträge identisch wie vorher.
|
||
- **Migrationshinweis für bestehende Workspaces**: Ein `/migrate-workspaces`-Command (Phase 6+) oder manuelle Instruktion im README: Bestehende `./workspaces/<agent>/`-Ordner nach `~/.disclaw/workspaces/<agent>/` verschieben, DB-Spalte `workspace_path` per `UPDATE` anpassen, Channel-Mapping bleibt. Im MVP reicht ein dokumentierter manueller Schritt.
|
||
- **Dev-Opt-in**: Wenn `workspaces_root` innerhalb des DisClaw-Repos liegt (erkannt via `path.relative(repoRoot, wsRoot)` startet nicht mit `..`), loggt der Bot beim Start eine Warnung und setzt zusätzlich `claudeMdExcludes` im gerenderten `.claude/settings.json` mit absoluten Pfaden für die DisClaw-Projekt-CLAUDE.md und alle `.claude/rules/**`-Globs des Parents.
|
||
|
||
**Quelle / Status**: Bestätigt via `cli-feature-answers.md` Frage 8 mit Zitat aus `code.claude.com/docs/en/memory`. Konfidenz: hoch.
|
||
|
||
### 2.7 Logging und Error-Handling
|
||
|
||
**Was**: `pino` als strukturierter JSON-Logger. Child-Logger pro Komponente (`bot`, `router`, `runner`, `commands.new-agent`). Error-Handler-Strategie: Fehler werden geloggt (mit Stack), der Discord-Channel bekommt eine **sanitized** Nachricht (keine absoluten Pfade, keine Stack Traces).
|
||
|
||
**Warum**: `console.log` ist bei mehreren Agenten und paralleler Ausführung unlesbar. Pino ist schnell, JSON-first, und Standard in Node-Server-Apps. Pretty-Printing nur in dev via `pino-pretty`.
|
||
|
||
**Wie**:
|
||
- Neue Datei: `src/runtime/logger.ts`, exportiert `rootLogger` und `childLogger(component: string)`.
|
||
- Sanitizer: `src/runtime/sanitize.ts`, entfernt absolute Pfade (Regex auf `ROOT_DIR`), Tokens, Home-Pfad.
|
||
|
||
### 2.8 Test-Strategie: Vitest, Unit-first
|
||
|
||
**Was**: `vitest` als Test-Runner. Unit-Tests für reine Funktionen (splitForDiscord, sanitizedEnv, parseClaudeJsonOutput, agent-name-validation, channel-queue). Integration-Tests mit gemockten Discord- und CLI-Clients. Keine E2E-Tests im MVP (zu fragil gegen echte Discord-API).
|
||
|
||
**Warum**: Vitest hat minimale Config, läuft nativ mit TypeScript via esbuild, und ist für diese Projektgröße angemessener als Jest.
|
||
|
||
**Wie**:
|
||
- `tests/unit/` für reine Funktionen.
|
||
- `tests/integration/` für Router + Runner mit Mock-Spawner, Mock-DiscordChannel.
|
||
- Mock-Strategie für `claude` CLI: Ein Fake-Spawner der ein `Fake-Claude`-Script aufruft (`tests/fixtures/fake-claude.mjs`), das deterministische JSON-Antworten ausgibt. So testet man die echten spawn-Pfade, ohne von einer echten Claude-Installation abhängig zu sein.
|
||
- Discord: `@discordjs/core`-Mocks oder manuell via Interface-Abstraktion — siehe §5 "Discord-Adapter".
|
||
|
||
---
|
||
|
||
## 3. Tools und Libraries
|
||
|
||
### Wird verwendet
|
||
|
||
| Paket | Version | Zweck | Begründung |
|
||
|---|---|---|---|
|
||
| `discord.js` | ^14.16.0 | Discord-Client | Schon drin, de-facto Standard. |
|
||
| `better-sqlite3` | ^11.0.0 | SQLite-DB | Schon drin, synchrone API passt zum Handler-Modell. |
|
||
| `dotenv` | ^16.4.0 | `.env`-Loader | Schon drin. |
|
||
| `yaml` | ^2.6.0 | YAML-Parser für `disclaw.yaml` und `agent.yaml` | Schon drin. |
|
||
| `zod` | ^3.23 | Runtime-Validierung für Config, CLI-JSON-Output, Slash-Command-Args | Sichert Grenzen zwischen untrusted Input und Code. Klein, ohne Runtime-Abhängigkeiten. |
|
||
| `cross-spawn` | ^7 | Plattformübergreifender `child_process.spawn`-Ersatz | Löst Windows-`.cmd`-Wrapper korrekt auf (`claude.cmd`), quoted Windows-Args sicher, umgeht CVE-2024-27980 (Node 20.12+). Ohne Shell, also kein Command-Injection-Risiko. ~30 kB, keine transitiven Deps. Siehe §2.2. |
|
||
| `@types/cross-spawn` | ^6 | Typdefinitionen | devDep. |
|
||
| `pino` | ^9 | Strukturiertes Logging | Schnell, JSON-first, Child-Logger. |
|
||
| `pino-pretty` | ^11 | Dev-only Pretty-Printer | Nur in `devDependencies`. |
|
||
| `vitest` | ^2 | Test-Runner | Native TS, schnell, minimal Config. |
|
||
| `@types/node` | ^22 | Typdefinitionen | Schon drin. |
|
||
| `typescript` | ^5.7 | Compiler | Schon drin. |
|
||
|
||
### Wird explizit NICHT verwendet
|
||
|
||
- **Kein ORM (Prisma/Drizzle)**: Overkill für 2 Tabellen. `better-sqlite3`-Prepared-Statements sind schnell und typisiert genug via manuelle Interfaces.
|
||
- **Kein Express/Fastify**: Kein HTTP-Endpoint im MVP. Wenn später ein Web-Dashboard dazu kommt, Fastify.
|
||
- **Kein Redis/BullMQ**: Single-Process-Queue reicht. Externe Queues erst bei Multi-Instance-Deployment.
|
||
- **Kein `discord-markdown` o. ä.**: Splitting ist simpel genug für Eigenbau und genauer kontrollierbar.
|
||
- **Kein Jest**: Vitest ist leichter, schneller, direkt TS-fähig.
|
||
- **Kein `@anthropic-ai/sdk` oder `claude-agent-sdk`**: ADR-001 sagt CLI-only.
|
||
- **Kein `winston`**: Pino ist schneller und hat bessere Child-Logger-Semantik.
|
||
|
||
---
|
||
|
||
## 4. Phasenplan
|
||
|
||
Jede Phase ist in sich lauffähig. Abhängigkeiten sind explizit genannt. Nummerierung ist nicht chronologisch, sondern logisch.
|
||
|
||
### Phase 0 — Härtung (P0, blockiert jede Live-Nutzung)
|
||
|
||
**Ziele**: Sicherheitslücken schließen, bevor irgendein echter Discord-Server angeschlossen wird.
|
||
|
||
**Tasks**:
|
||
1. `src/agent/runner.ts`: `shell: true` entfernen; stattdessen `cross-spawn` verwenden (siehe §2.2). Neue Helper-Datei `src/runtime/resolve-claude.ts`: liest `CLAUDE_PATH` aus `.env` oder führt einmalig `where claude`/`which claude` aus, cached das Ergebnis in-memory, wirft eine klare Fehlermeldung bei Nichtfund.
|
||
2. `src/runtime/env.ts` (neu): `sanitizedEnv()` wie in §2.6. In `runner.ts` statt `...process.env` verwenden.
|
||
3. **Workspace-Root-Umstellung (§2.9)**: `disclaw.yaml`-Default auf `~/.disclaw/workspaces` umstellen. `src/config/loader.ts`: Tilde-Expansion + `path.resolve`. `src/commands/new-agent.ts`: Workspace unter dem neuen Root anlegen. Warning-Log, wenn der konfigurierte Root innerhalb des DisClaw-Repos liegt. Manueller Migrationshinweis für bestehende `./workspaces/<agent>/`-Ordner im README.
|
||
4. `src/commands/new-agent.ts`: Name-Regex behalten, zusätzlich Path-Containment-Check:
|
||
```ts
|
||
const root = path.resolve(config.workspaces_root);
|
||
const wsPath = path.resolve(root, name);
|
||
if (!wsPath.startsWith(root + path.sep)) throw new Error("Path traversal");
|
||
```
|
||
5. `src/agent/identity.ts`: `.claude/settings.json`-Template härten — Schema wie in §2.5 (mit `permissions.allow/ask/deny/defaultMode` und dreistufigem `hooks`-Block). Deny für `../**`, Deny-Patterns für gefährliche Bash-Kommandos.
|
||
6. `src/agent/identity.ts`: `CLAUDE.md`-Template bekommt die Prompt-Injection-Resistenz-Klausel (Punkt 5 aus §3 der Analyse).
|
||
7. `src/agent/runner.ts`: CLI-Call immer mit `--bare --append-system-prompt-file <ws>/CLAUDE.md` aufbauen (siehe §2.9), damit Parent-`CLAUDE.md`-Leaks strukturell ausgeschlossen sind — auch für Dev-Mode-Workspaces innerhalb des Repos.
|
||
8. `tests/unit/sanitize-env.test.ts`, `tests/unit/path-traversal.test.ts`, `tests/unit/resolve-claude.test.ts` (mocked), `tests/unit/workspace-root-resolve.test.ts` (Tilde-Expansion, Warning bei Repo-internem Pfad).
|
||
|
||
**Definition of Done**:
|
||
- Kein `shell: true` mehr im Code; Spawning läuft über `cross-spawn` mit resolvtem Claude-Pfad.
|
||
- Ein Test demonstriert, dass `DISCORD_BOT_TOKEN` nicht im Child-Env landet.
|
||
- Ein Test demonstriert, dass `name: "../etc"` als Slash-Command-Input abgewiesen wird.
|
||
- `.claude/settings.json` in einem frisch erstellten Workspace enthält die Deny-Liste und das korrekte `hooks.PreToolUse[].matcher`-Layout.
|
||
- `CLAUDE.md` enthält den Prompt-Injection-Resistenz-Satz.
|
||
- Default-`workspaces_root` zeigt auf `~/.disclaw/workspaces`, Tilde wird korrekt expandiert, Warning bei repo-internem Pfad.
|
||
- CLI-Calls enthalten `--bare` und `--append-system-prompt-file <ws>/CLAUDE.md`; ein Test (mit Fake-Claude) verifiziert, dass kein Parent-CLAUDE.md-Leak auftritt.
|
||
|
||
**Abhängigkeiten**: Keine. Erste Phase.
|
||
|
||
### Phase 1 — Solides MVP
|
||
|
||
**Ziele**: Ein Agent antwortet zuverlässig auf Discord, mehrfache Messages im selben Channel werden serialisiert, Fehler sind verständlich, Logs sind strukturiert, Grundstruktur ist testbar.
|
||
|
||
**Tasks**:
|
||
1. `src/runtime/logger.ts` (neu): Pino-Setup, `rootLogger`, `childLogger(component)`. In `index.ts`, `bot.ts`, `router.ts`, `runner.ts`, `commands/new-agent.ts` `console.*` ersetzen.
|
||
2. `src/runtime/sanitize.ts` (neu): `sanitizeForDiscord(text, rootDir)` — entfernt Abspfade.
|
||
3. `src/runtime/channel-queue.ts` (neu): `ChannelQueue`-Klasse wie in §2.3. In `router.ts` integrieren.
|
||
4. `src/runtime/concurrency.ts` (neu): `Semaphore(max: number)`-Klasse. Globaler Cap aus `disclaw.yaml` → `max_concurrent_agents: 4`.
|
||
5. `src/discord/split.ts` (neu): `splitForDiscord()` wie in §2.4. `router.ts` verwendet ihn.
|
||
6. `src/agent/runner.ts`: API-Refactoring auf `RunResult = { text: string, sessionId?: string, toolCalls?: ToolCall[] }`. Return-Type vorbereiten für spätere Streaming-Unterstützung (`runAgent()` gibt heute `Promise<RunResult>`, später zusätzlich `runAgentStream()`).
|
||
7. `src/runtime/zod-schemas.ts` (neu): Zod-Schemas für `disclaw.yaml`, `agent.yaml`, Claude-CLI-JSON-Output. `parseClaudeJsonOutput()` validiert via Zod.
|
||
8. `src/config/loader.ts`: Validierung über Zod statt `as` Cast.
|
||
9. `src/db/database.ts`: Schema-Inline-String entfernen, stattdessen `src/db/schema.sql` laden (Single Source of Truth).
|
||
10. `tests/unit/split-for-discord.test.ts` (Fences, lange Zeilen, Fallback), `tests/unit/channel-queue.test.ts`, `tests/unit/parse-claude-output.test.ts`.
|
||
11. `tests/integration/router.test.ts`: mockt DB, DiscordChannel (Interface), Spawner — verifiziert das End-to-End-Verhalten bei parallelen Nachrichten (Reihenfolge, keine Race).
|
||
12. `.env.example` hinzufügen.
|
||
13. `package.json`: `test`-Script, `devDependencies` für vitest, pino-pretty, zod, pino.
|
||
14. `docs/cli-feature-probe.md` (neu): Manuelles Regression-Script, das die kritischen Annahmen aus `cli-feature-answers.md` (Session-ID top-level, `usage`-Block-Pfade, stream-json-Event-Typen, Session-Slug-Regel) gegen die lokal installierte CLI verifiziert und Resultate dokumentiert. Läuft als Sanity-Check bei CLI-Upgrades.
|
||
|
||
**Definition of Done**:
|
||
- Bot startet, erstellt Management-Channel, akzeptiert `/new-agent`, antwortet im Agent-Channel korrekt auf eine Nachricht.
|
||
- Drei schnell hintereinander gesendete Nachrichten im gleichen Channel werden in exakter Reihenfolge verarbeitet (verifiziert im Integration-Test).
|
||
- Log-Output ist strukturiertes JSON (mit `pino-pretty` in dev).
|
||
- `npm test` läuft grün, coverage für `split`, `channel-queue`, `sanitize-env`, `parse-claude-output`, `router`.
|
||
- Kein `console.log` mehr im Produktionscode (nur in Tests/Probe-Scripts erlaubt).
|
||
|
||
**Abhängigkeiten**: Phase 0.
|
||
|
||
### Phase 2 — Context-Effizienz (`--resume`) — FREIGEGEBEN
|
||
|
||
**Ziele**: Prompt-Cache-Hits, drastisch gesenkte Input-Tokens, saubere Historie-Semantik.
|
||
|
||
**Status**: Freigegeben durch `cli-feature-answers.md` Frage 1+2. `-p` + `--resume` ist dokumentiert, `session_id` liegt top-level im JSON-Output, Session-ID bleibt beim Resume stabil (es sei denn `--fork-session`).
|
||
|
||
**Tasks**:
|
||
1. DB-Migration: `ALTER TABLE workspaces ADD COLUMN claude_session_id TEXT; ALTER TABLE workspaces ADD COLUMN session_updated_at TEXT;`. In `schema.sql` pflegen, in `database.ts` als Migration-Step addieren (simpler `try { ALTER } catch {}`-Block reicht).
|
||
2. `src/db/database.ts`: Neue Methoden `setSession(workspaceId, sessionId)`, `getSession(workspaceId)`, `clearSession(workspaceId)`. `setSession` aktualisiert auch `session_updated_at` mit aktuellem ISO-Timestamp.
|
||
3. `src/agent/runner.ts`: Session-Flow wie in §2.1. **Nach jedem erfolgreichen Run** `store.set(ws.id, json.session_id)` aufrufen — auch wenn die ID gleich geblieben ist (defensive Update-after-run). Fallback-Pfad implementieren: Bei `--resume`-Fehler `store.clear` + Retry ohne `--resume` (optional `--continue` als Zwischenstufe).
|
||
4. Die History-Freitext-Injektion nur noch als Cold-Start-Rehydrierung verwenden (strukturierter als heute, mit klaren `<turn role="user|assistant">` Blocks).
|
||
5. `tests/integration/session-resume.test.ts`: Fake-Claude gibt beim ersten Call eine `session_id` top-level zurück, der zweite Call muss diese via `--resume <id>` wieder verwenden. Fehlschlag simulieren → Fallback-Pfad wird getroffen. Edge-Case: Fake-Claude gibt beim zweiten Call eine **neue** `session_id` zurück (Fork-Szenario) → DB muss auf neue ID updaten.
|
||
6. Metrik-Logging: `usage`-Block aus JSON-Output parsen. Die exakten Pfade (`result.usage.cache_read_input_tokens` vs. top-level `usage.cache_read_input_tokens`) werden im Probe-Script empirisch verifiziert und dann hartcodiert. Log-Line: `log.info({ input_tokens, cache_read, cache_create, output_tokens }, "claude_run")`.
|
||
7. Optional (Nice-to-have): Neue Tabelle `agent_runs(workspace_id, ts, input_tokens, cache_read, cache_create, output_tokens, duration_ms)` für Pro-Agent-Metriken.
|
||
|
||
**Definition of Done**:
|
||
- Zweite Nachricht im gleichen Channel verwendet dieselbe `session_id` (verifiziert via Fake-Claude).
|
||
- Session-ID überlebt Bot-Neustart.
|
||
- Bei bewusstem Löschen der Session-Datei reagiert der Runner mit automatischem Fallback, speichert neue Session-ID, keine User-sichtbare Fehlermeldung.
|
||
- `session_id` wird nach jedem Run in der DB geschrieben, auch wenn identisch.
|
||
- Log-Line zeigt gecachte Tokens.
|
||
|
||
**Abhängigkeiten**: Phase 0, Phase 1.
|
||
|
||
### Phase 3 — UX-Feinschliff
|
||
|
||
**Ziele**: Discord-Ausgabe fühlt sich gut an. Lange Antworten, Codeblöcke, Attachments, Typing-Refresh.
|
||
|
||
**Tasks**:
|
||
1. `src/router.ts`: Typing-Refresh-Intervall auf 8 s. Typing wird nur so lange ausgesendet, wie der `runAgent`-Call läuft.
|
||
2. `src/discord/send-response.ts` (neu): `sendResponse(channel, text, { asAttachmentAfter = 8000 })`. Wenn Text > Schwelle → als `.md`-Attachment senden.
|
||
3. Attachment-Support eingehend: `router.ts` prüft `message.attachments`. Download in `workspaces/<agent>/.disclaw-inbox/<ts>-<name>.ext`. Im Prompt referenziert. Nach erfolgreicher Antwort: TTL-Cleanup-Task.
|
||
4. Input-Limit: User-Nachricht > 4000 Zeichen → Reaction `⚠️` und Hinweis im Channel.
|
||
5. Reaction `👀` sobald Message in Queue landet; `✅` bei Erfolg; `❌` bei Fehler. Alle `.catch(() => {})`, nicht blockierend.
|
||
6. `tests/unit/send-response.test.ts`, `tests/unit/attachment-inbox.test.ts` (mit tmp-Dir).
|
||
|
||
**Definition of Done**:
|
||
- Lange Antworten werden korrekt gesplittet ohne kaputte Fences.
|
||
- Sehr lange Antworten werden als `.md`-File gesendet.
|
||
- Bilder aus Discord landen im Workspace und Claude kann sie via Read-Tool sehen.
|
||
- Typing-Indikator bleibt die ganze Laufzeit sichtbar, ohne "pausen".
|
||
|
||
**Abhängigkeiten**: Phase 1.
|
||
|
||
### Phase 4 — Skills und Permissions (Profile)
|
||
|
||
**Ziele**: Agents sind pro Rolle sinnvoll voreingestellt. Hook-Guard ist aktiv. Audit-Log läuft.
|
||
|
||
**Tasks**:
|
||
1. `src/agent/profiles/` (neu): Ordner mit `researcher.md`, `developer.md`, `writer.md`, `ops.md`, `sandboxed.md` als `CLAUDE.md`-Templates und `*.settings.json` als `.claude/settings.json`-Templates. Platzhalter `{{name}}`, `{{role}}`, `{{workspace_abs}}`.
|
||
2. `src/agent/profiles/index.ts`: `ProfileRegistry` — maps name → `{ claudeMd: Template, settings: Template, hooks: Template[] }`.
|
||
3. `src/commands/new-agent.ts`: Neuer Option-Parameter `profile` mit Choices. Default = `developer`.
|
||
4. `src/agent/identity.ts`: `setupAgentWorkspace()` nimmt `profile` und rendert Templates aus der Registry.
|
||
5. `src/agent/hooks/guard-tool.cjs` (generiert beim Create): Node-Script, das als PreToolUse-Hook jeden Tool-Call gegen den Workspace-Pfad prüft, außerhalb → exit 2 mit Block-Reason. Jeder Call wird zusätzlich in `workspaces/<agent>/.disclaw-audit.jsonl` geschrieben. Event-Typen gemäß `cli-feature-answers.md` Frage 3: `PreToolUse`, `PostToolUse`, `UserPromptSubmit`, `SessionStart`, `Stop`. Wir nutzen primär `PreToolUse` mit `matcher: "Bash|Read|Write|Edit"`.
|
||
6. `.claude/settings.json`-Template referenziert den Hook im dreistufigen Layout (`hooks.PreToolUse[].hooks[].{type, command, timeout}`), siehe §2.5.
|
||
7. `tests/unit/profile-registry.test.ts`, `tests/integration/hook-guard.test.ts` (mit Fake-Claude, der einen bösartigen Tool-Call simuliert).
|
||
|
||
**Definition of Done**:
|
||
- `/new-agent name:r profile:researcher` erstellt einen Agenten mit read-only-Permissions.
|
||
- Ein Hook-Test verifiziert, dass `Bash(cat ../../other/secret.txt)` abgewiesen und im Audit-Log protokolliert wird.
|
||
- Alle fünf Profile sind vorhanden und passen zu ihrer §3-Tabelle der Analyse.
|
||
|
||
**Abhängigkeiten**: Phase 0 (Settings-Template). Settings-Schema ist durch `cli-feature-answers.md` Frage 3 verifiziert.
|
||
|
||
### Phase 5 — Streaming
|
||
|
||
**Ziele**: Antworten erscheinen inkrementell im Discord-Channel.
|
||
|
||
**Tasks**:
|
||
1. `src/agent/runner.ts`: Zweiter Export `runAgentStream(): AsyncIterable<StreamEvent>`. Intern: `claude -p "<msg>" --output-format stream-json --verbose --include-partial-messages --bare --append-system-prompt-file <ws>/CLAUDE.md` (plus `--resume` wie in Phase 2). Die Flags `--verbose` und `--include-partial-messages` sind laut `cli-feature-answers.md` Frage 4 **zwingend**, sonst emittiert die CLI nur das Final-`result`-Event ohne Text-Deltas.
|
||
2. NDJSON-Parser: Zeilenweise `JSON.parse`. Dispatch nach `.type`: `system` (init/api_retry, skip), `assistant` (Text- und Tool-Use-Blocks im Native-API-Format), `user` (Tool-Results), `stream_event` (partial deltas — interessant ist `.event.delta.type === "text_delta"` mit `.event.delta.text`), `result` (Final-Event, enthält session_id und usage). Parser **defensiv** gegen unbekannte Typen (skip statt crash) — CLI-Updates können neue Subtypes einführen.
|
||
3. `src/discord/stream-to-discord.ts` (neu): Nimmt einen `AsyncIterable<StreamEvent>`, akkumuliert Text-Deltas, editiert die Bot-Message alle ~1200 ms (Discord-Rate-Limit), rolled auf neue Message bei >1900 Zeichen. Bei `result`-Event: finalen Stand commitet.
|
||
4. `router.ts`: Feature-Flag `streaming: true/false` aus `disclaw.yaml`. Fallback auf Bulk-Mode bei Fehlern.
|
||
5. `tests/integration/streaming.test.ts` mit Fake-Claude, der ein NDJSON-Scripted-Log auf stdout schreibt (system → assistant → mehrere stream_events mit text_deltas → result).
|
||
|
||
**Definition of Done**:
|
||
- Streaming ist per Flag aktivierbar und funktioniert End-to-End.
|
||
- Edits respektieren Discord-Rate-Limits (min. 1200 ms zwischen Edits).
|
||
- Bei Fehler im Stream wird die Message sauber finalisiert.
|
||
|
||
**Abhängigkeiten**: Phase 1 (Runner-API vorbereitet). Phase 2 (Session-Resume bleibt auch im Stream-Modus aktiv).
|
||
|
||
### Phase 6+ — Zukunft
|
||
|
||
Klar: nicht jetzt planen, aber Pfade offenhalten.
|
||
|
||
- **`/list-agents`, `/delete-agent`, `/agent-config`**: Command-Handler-Interface aus §5 macht das zu einer 30-Minuten-Sache pro Command. `/delete-agent` muss Channel + Workspace + DB-Einträge + `~/.claude/projects/<hash>/`-Session atomar löschen.
|
||
- **User-Ratelimit**: Token-Bucket in `src/runtime/ratelimit.ts`, Map<userId, Bucket>.
|
||
- **Daily-Budget pro Agent**: DB-Tabelle `agent_usage(workspace_id, day, runs)`, Check vor jedem Run.
|
||
- **Agent-zu-Agent**: Ein Agent kann eine Nachricht an einen anderen Agent-Channel adressieren → Router erkennt und delegiert.
|
||
- **Docker-Isolation**: `DockerRunner` als zweite Implementierung des `AgentEngine`-Interface (§5).
|
||
- **Web-Dashboard**: Fastify-Server, der `DisclawDatabase` teilt.
|
||
- **Alternative AI-Engine**: Ein `ClaudeSdkEngine` gegen `claude-agent-sdk` für Nutzer ohne Claude-Pro-Abo. Gleiches `AgentEngine`-Interface.
|
||
|
||
---
|
||
|
||
## 5. Erweiterbarkeit (Fokus!)
|
||
|
||
Der Code ist so zu strukturieren, dass neue Features ohne Core-Änderungen andocken. Kernmechanismen:
|
||
|
||
### 5.1 Schichtentrennung
|
||
|
||
```
|
||
src/
|
||
bot.ts Discord-Adapter (Event → Router)
|
||
router.ts Routing + Queue + Sanitize (kennt Runner abstrakt)
|
||
discord/ Alles Discord-nahe: split, send, stream-to-discord
|
||
agent/
|
||
engine.ts AgentEngine-Interface (siehe 5.6)
|
||
runner.ts ClaudeCliEngine: implements AgentEngine
|
||
identity.ts Workspace-Create (delegiert an Profile)
|
||
profiles/ Templates pro Profil
|
||
hooks/ Pre/Post-Hook-Scripts
|
||
commands/
|
||
index.ts CommandRegistry (siehe 5.2)
|
||
new-agent.ts
|
||
list-agents.ts (später)
|
||
delete-agent.ts (später)
|
||
runtime/
|
||
channel-queue.ts
|
||
concurrency.ts
|
||
env.ts
|
||
logger.ts
|
||
sanitize.ts
|
||
ratelimit.ts (später)
|
||
db/
|
||
database.ts Repository-Stil, keine Business-Logik
|
||
schema.sql
|
||
config/
|
||
loader.ts
|
||
```
|
||
|
||
Regel: `runtime/`, `db/`, `discord/` sind Leaf-Module, kennen sich nicht gegenseitig über die Grenzen hinweg. `agent/` kennt `runtime/` und `db/`-Typen, aber **nicht** `discord.js`. `router.ts` ist der einzige Ort, der Discord und Agent zusammenbringt.
|
||
|
||
### 5.2 Plugin-Interface für Slash-Commands
|
||
|
||
Neues Interface, das neue Commands an einer Stelle registriert:
|
||
|
||
```ts
|
||
// src/commands/types.ts
|
||
import { SlashCommandBuilder, ChatInputCommandInteraction } from "discord.js";
|
||
import { DisclawDatabase } from "../db/database";
|
||
import { DisclawConfig } from "../config/loader";
|
||
|
||
export interface CommandContext {
|
||
db: DisclawDatabase;
|
||
config: DisclawConfig;
|
||
managementChannelId: string | null;
|
||
}
|
||
|
||
export interface DisclawCommand {
|
||
definition: SlashCommandBuilder;
|
||
handle(interaction: ChatInputCommandInteraction, ctx: CommandContext): Promise<void>;
|
||
}
|
||
```
|
||
|
||
```ts
|
||
// src/commands/index.ts
|
||
import { newAgentCommand } from "./new-agent";
|
||
// import { listAgentsCommand } from "./list-agents";
|
||
export const commands: DisclawCommand[] = [
|
||
newAgentCommand,
|
||
// listAgentsCommand,
|
||
];
|
||
```
|
||
|
||
`bot.ts` registriert alle Commands in einer Schleife und dispatched via `commandName`-Lookup. Neuer Command = neue Datei + ein Array-Eintrag. Keine Änderung an `bot.ts` nötig.
|
||
|
||
### 5.3 Profile-Templates als Registry
|
||
|
||
```ts
|
||
// src/agent/profiles/index.ts
|
||
export interface ProfileTemplate {
|
||
name: string;
|
||
description: string;
|
||
renderClaudeMd(ctx: { name: string; role: string }): string;
|
||
renderSettings(ctx: { workspaceAbs: string; name: string }): Record<string, unknown>;
|
||
hooks?: HookDefinition[];
|
||
}
|
||
|
||
export const profiles: Record<string, ProfileTemplate> = {
|
||
developer: developerProfile,
|
||
researcher: researcherProfile,
|
||
writer: writerProfile,
|
||
ops: opsProfile,
|
||
sandboxed: sandboxedProfile,
|
||
};
|
||
```
|
||
|
||
Ein neuer Profil-Typ (z. B. `data-scientist`) ist eine neue Datei + ein Registry-Eintrag. `identity.ts` muss nicht angefasst werden.
|
||
|
||
### 5.4 Hook-System (Pre/Post Message, Pre/Post Spawn)
|
||
|
||
```ts
|
||
// src/agent/hooks.ts
|
||
export type HookEvent =
|
||
| { type: "preMessage"; workspace: Workspace; message: string }
|
||
| { type: "postMessage"; workspace: Workspace; response: string }
|
||
| { type: "preSpawn"; workspace: Workspace; args: string[] }
|
||
| { type: "postSpawn"; workspace: Workspace; result: RunResult };
|
||
|
||
export type Hook = (event: HookEvent) => Promise<void | { cancel?: boolean; reason?: string }>;
|
||
|
||
export class HookBus {
|
||
private hooks: Hook[] = [];
|
||
register(h: Hook) { this.hooks.push(h); }
|
||
async emit(e: HookEvent): Promise<{ cancel: boolean; reason?: string }> { ... }
|
||
}
|
||
```
|
||
|
||
`router.ts` und `runner.ts` emittieren an einen global injizierten `HookBus`. Beispiel-Hooks: Audit-Logger, Ratelimit, Daily-Budget, Metriken. Alle außerhalb des Core-Codes, einfach pluggable.
|
||
|
||
**Wichtig**: Die `.claude/`-Hooks (PreToolUse etc.) sind ein anderer Mechanismus — die werden von Claude Code selbst gefeuert. Das HookBus hier ist DisClaw-intern.
|
||
|
||
### 5.5 MCP-Server pro Workspace (vorbereitet, später aktiviert)
|
||
|
||
Das Profile-Template kann optional eine `mcpServers`-Sektion in die `.claude/settings.json` rendern. Die Liste der verfügbaren MCPs ist in `src/agent/mcp/registry.ts` zentral gepflegt. Aktivierung pro Profil deklarativ:
|
||
|
||
```ts
|
||
developerProfile.mcpServers = ["git"];
|
||
opsProfile.mcpServers = ["docker", "k8s"];
|
||
```
|
||
|
||
Der Renderer fügt die korrekten Stanzas in `settings.json` ein. Kein Code-Change im Runner nötig.
|
||
|
||
### 5.6 Abstraktion der AI-Engine
|
||
|
||
```ts
|
||
// src/agent/engine.ts
|
||
export interface AgentEngine {
|
||
run(opts: RunOptions): Promise<RunResult>;
|
||
runStream?(opts: RunOptions): AsyncIterable<StreamEvent>;
|
||
}
|
||
```
|
||
|
||
`ClaudeCliEngine` ist heute die einzige Implementierung. Später könnten `ClaudeSdkEngine` (direkte API) oder `DockerClaudeCliEngine` (CLI in Container) als Drop-in-Alternativen dazu kommen. Der Router kennt nur das Interface.
|
||
|
||
### 5.7 Discord-Adapter-Abstraktion (für Testbarkeit)
|
||
|
||
Alle Discord-Interaktionen im Router gehen über ein kleines Interface `ChannelSink`:
|
||
|
||
```ts
|
||
export interface ChannelSink {
|
||
send(text: string): Promise<{ id: string }>;
|
||
edit(messageId: string, text: string): Promise<void>;
|
||
sendTyping(): Promise<void>;
|
||
sendFile(name: string, content: Buffer): Promise<void>;
|
||
react(messageId: string, emoji: string): Promise<void>;
|
||
}
|
||
```
|
||
|
||
In Produktion wraps das einen `TextChannel`. In Tests ist es ein Mock. So sind Router-Tests komplett ohne `discord.js` möglich.
|
||
|
||
---
|
||
|
||
## 6. Offene Fragen an den AI Engineer
|
||
|
||
Die ursprünglichen 10 Fragen wurden am 2026-04-08 vom AI Engineer beantwortet. Siehe `docs/cli-feature-answers.md` für Volltext, Quellen und Konfidenzangaben.
|
||
|
||
**Verbleibende Rest-Unsicherheiten** (alle empirisch via Probe-Script zu bestätigen, kein Blocker für Phase-Start):
|
||
|
||
1. **Exakte JSON-Pfade des `usage`-Blocks** (`result.usage.cache_read_input_tokens` vs. top-level `usage.cache_read_input_tokens`): Die Existenz der Felder ist durch das API-Layer garantiert, aber der Output-Shape der CLI wird in Phase 2 per Probe-Script empirisch verifiziert und dann im Parser hartcodiert.
|
||
|
||
2. **`stream-json` Event-Schema-Stabilität**: Die groben Typen (`system`, `assistant`, `user`, `stream_event`, `result`) sind dokumentiert, aber Subtypes und Feld-Layouts können zwischen CLI-Versionen driften. Mitigation: Parser defensiv (skip unknown), Regression-Test im Probe-Script.
|
||
|
||
3. **Session-Slug-Transformation auf Linux/Mac**: Windows-Slug-Regel (`C:\Code\side\disclaw` → `C--Code-side-disclaw`) ist empirisch bestätigt, Linux-Regel (`/home/nick/...` → `-home-nick-...`) ist aus Analogie abgeleitet. Muss auf Linux-Runner geprüft werden, sobald `/delete-agent` implementiert wird (Phase 6+).
|
||
|
||
4. **Multimodal-Passthrough ohne Tool-Round-Trip**: `@./path/image.png` im Prompt funktioniert via Read-Tool + Vision; ein direkter Vision-Input-Block ohne Round-Trip existiert im CLI-`-p`-Modus nicht und würde eine Migration zum Agent SDK erfordern. Scope-Entscheidung: bleibt Phase 6+.
|
||
|
||
---
|
||
|
||
## 7. Risiken und Mitigation
|
||
|
||
| Risiko | Wahrscheinlichkeit | Impact | Mitigation |
|
||
|---|---|---|---|
|
||
| `--resume` funktioniert nicht wie erwartet in der installierten CLI | Niedrig | Hoch (Phase 2 blockiert) | Per `cli-feature-answers.md` Frage 1+2 offiziell dokumentiert und bestätigt. Probe-Script als Regression-Check bleibt. Fallback-Pfad (DB-Rehydrierung + `--continue`) bleibt als Degradation verfügbar. |
|
||
| CLAUDE.md-Walk-Up kontaminiert Agenten-Identität (Parent-Projekt-CLAUDE.md oder `~/.claude/CLAUDE.md` leaked in System-Prompt) | Mittel (strukturell gegeben, wenn nicht mitigiert) | Hoch (Reproduzierbarkeit, Sicherheit, Identitäts-Leak) | §2.9: Workspaces außerhalb des DisClaw-Repos (`~/.disclaw/workspaces/`) **plus** `--bare` + `--append-system-prompt-file` bei jedem Call. Integration-Test mit Fake-Claude, der den empfangenen System-Prompt echoed, und Assertion, dass ausschließlich die Workspace-`CLAUDE.md` drin ist. Für Dev-Mode-Workspaces innerhalb des Repos zusätzlich `claudeMdExcludes` im Settings. |
|
||
| Windows-Spawn schlägt auf frischer Node-20.12+-Installation fehl (`claude.cmd`, CVE-2024-27980) | Mittel (default), niedrig (nach Phase 0) | Hoch (DisClaw startet auf Windows gar nicht) | §2.2: `cross-spawn` als Dependency, `where claude`-Lookup mit absolutem Pfad-Cache, klare Fehlermeldung wenn nicht auffindbar. CI-Test auf Windows-Runner (GitHub Actions `windows-latest`) verifiziert Smoke-Test-Spawn. |
|
||
| Command Injection bleibt unentdeckt in einem Codepfad | Niedrig (nach Phase 0) | Kritisch | `shell: false` als Lint-Regel im CI. Code-Review-Checkliste. Integration-Test mit bösartigem Input. |
|
||
| Prompt-Injection bricht Permissions aus | Mittel | Hoch | Defense in depth: CLAUDE.md-Klausel, settings.json deny, PreToolUse-Hook, Audit-Log. Keine davon allein reicht. |
|
||
| `.claude/settings.json`-Schema ändert sich zwischen CLI-Versionen | Mittel | Mittel | Schema-Version beim Workspace-Create detektieren (`claude --version` parsen), Template pro Release pflegen. Warnung bei unbekannter Version. |
|
||
| Parallele Nachrichten im selben Channel korrumpieren Workspace-Dateien | Hoch (ohne Queue) | Hoch | Per-Channel-Queue in Phase 1. Integration-Test. |
|
||
| Secret-Exfiltration via `echo $DISCORD_BOT_TOKEN` | Hoch (aktuell) | Kritisch | `sanitizedEnv()` in Phase 0. Test, der den resultierenden Env-Set prüft. |
|
||
| Discord-Rate-Limits bei Streaming | Niedrig | Mittel | Min. 1200 ms zwischen Edits, neue Message bei Überlauf. discord.js 429-Handling nutzen. |
|
||
| Speicher-Leaks bei Long-Running-Bot-Prozess | Niedrig | Mittel | Pino-Logs + `process.memoryUsage()` alle 5 min als Debug-Log. `channelQueues`-Map räumt sich selbst auf. |
|
||
| Zwei DisClaw-Instanzen gegen dieselbe DB/Workspace | Niedrig | Hoch | Lock-File im Workspaces-Root beim Start. Fehlschlag, wenn Lock existiert und PID noch läuft. |
|
||
| Benutzer editieren `CLAUDE.md` manuell mit Injection | Mittel | Niedrig (der Benutzer "schadet" sich selbst) | Dokumentiert, kein technischer Fix. |
|
||
| PreToolUse-Hook-Script ist fehlerhaft und blockt legitime Calls | Mittel | Mittel | Hook schreibt Audit-Log auch bei Block; `/reset-permissions`-Command später; im MVP manuelles Editieren der Settings. |
|
||
| Test-Suite wird träge, wenn echte `claude` CLI involviert ist | Mittel | Niedrig | Unit-Tests nutzen Fake-Spawner. Echte-CLI-Tests nur in `tests/probe/` und manuell. |
|