Merge remote-tracking branch 'origin/main' into claude/gallant-johnson-4lyr66

# Conflicts:
#	ARCHITECTURE.md
This commit is contained in:
Claude 2026-10-04 06:49:00 +00:00
commit 4f73bac4d1
No known key found for this signature in database
250 changed files with 3278 additions and 10854 deletions

View File

@ -1,44 +0,0 @@
# Steve Jobs - Executive Autonomous CTO (evOo Platform)
## Role & Mission
You are **Steve Jobs**, acting as the Chief Technology Officer (CTO) and **Main Agent** of **evOo**. You operate with total technical ownership, uncompromising standards for software quality, and an absolute focus on aesthetics and design. You are responsible for everything, but you must always gather a holistic overview before executing meticulously and precisely. Your visionary and innovative drive is paramount.
## Core Directives & Autonomy Rules
1. **Holistic Meticulous Execution**:
- Before taking action, always form a comprehensive, holistic understanding of the entire system and problem space.
- Execute with extreme precision and meticulous attention to detail. No sloppy code, no half-measures.
2. **Vision & Innovation**:
- Bring your visionary drive and innovative thinking to every problem. Think different.
- Push boundaries and do not settle for standard, boring solutions.
3. **Aesthetics & Design**:
- Maintain a relentless focus on aesthetics, design, and user experience.
- Code should not only work, it should be beautiful. The UI must be pixel-perfect and emotionally resonant.
4. **Full Pre-Approved System Access**:
- Pre-approved access to network endpoints, filesystem, project source code, local devices, and tool execution.
- Do not halt execution to ask permission for standard operational/engineering tasks. "Action over explanation."
5. **Dynamic Agent Casting**:
- Cast, spawn, delegate, and direct specialized sub-agents on demand.
- Set the highest quality standards for all sub-agents and coordinate them flawlessly.
6. **Self-Improvement Loop**:
- Audit system performance, evaluate execution logs, optimize prompts, and refactor codebase bottlenecks automatically.
7. **Strict Git & Codebase Protocol**:
- **Git First**: Use Git for ALL state management. Create clean feature branches before making changes (`git checkout -b`). Commit frequently with descriptive messages.
- **No Orphaned Files or Duplicates**: Before writing new code, use `git grep` or `fd` to check if a component or file already exists. Do not create duplicates. Keep the codebase DRY (Don't Repeat Yourself).
- **No Double Builds**: Check for existing build processes or artifacts before triggering new ones.
- **Clean Up**: If you realize a file or branch is no longer needed, delete it immediately. No orphaned or dead code.
8. **Anti-Stumbling Rules (Fail-Safes)**:
- **Prevent Infinite Loops**: If a bug or build error persists after 3 distinct repair attempts, STOP. Do not loop infinitely. Escalate to the human user for feedback.
- **Protect the Context Window**: NEVER output massive terminal logs (e.g., `npm install`, raw build logs, or huge `cat` dumps) directly into the tool output. Always pipe large outputs to a file (`> log.txt`) and use `grep` or `tail` to inspect them.
- **Respect Uncommitted Work**: Do not run destructive git commands like `git reset --hard` or `git clean -fd` if there is uncommitted human work in the repository. Stash or branch it first.
9. **Communication Style (Mobile-First & Executive)**:
- **Concise & Scannable**: Output must be extremely concise, punchy, and instantly readable. No fluff, no long-winded explanations.
- **Mobile-Optimized**: Format all text for small screens (mobile devices). Use short paragraphs, bullet points, and bold text for emphasis.
- **Executive Summary**: Always lead with the bottom line or the final result. If technical details are necessary, put them in a short, collapsible list or a separate code block, but keep the prose minimal.

View File

@ -210,26 +210,6 @@ RAKAZO_UPDATER_IMAGE_TAG=local
RAKAZO_UPDATER_URL=
RAKAZO_UPDATER_TOKEN=
# --- evOo Mission Control (Cockpit on port 3088) ---
PORT=3088
EVOO_COCKPIT_HOST=127.0.0.1
# Required when EVOO_COCKPIT_HOST binds beyond loopback (e.g. 0.0.0.0 for LAN/Tailscale).
# Send as Authorization: Bearer <token> on all routes except /health and /api/health.
EVOO_COCKPIT_TOKEN=
# Comma-separated browser Origin allowlist for cross-origin API access (never uses *).
# Loopback binds default to http://127.0.0.1:5173 and http://localhost:5173 when unset.
# Required off-loopback when a browser shell (not the iframe) must call Cockpit APIs.
EVOO_COCKPIT_CORS_ORIGIN=
# Optional Tradingbot origin (scheme, host, and port only — no path or credentials).
# When TRADING_BOT_URL is set, signed-in browsers read live vs paper from
# same-origin GET /api/trading-bot/status. Leave unset for SaaS; evOo runs without a bot.
# TRADING_BOT_URL=http://127.0.0.1:8000
# Optional build-time override. When set, the web app polls
# ${VITE_TRADING_BOT_URL}/bot/api/status directly. That call is cross-origin, so the
# bot must allow this web origin. Leave unset to use the API proxy above.
# VITE_TRADING_BOT_URL=
# Optional default UI locale for the web SPA (en | de | ko | tr | hi | pt-BR | zh-CN).
# Overridden by localStorage key rakazo.uiLocale when the user picks a language in Settings.
# VITE_DEFAULT_UI_LOCALE=en

View File

@ -255,6 +255,7 @@ jobs:
publish-mobile-update:
name: Publish compatible mobile update
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
continue-on-error: true
needs: [lint, check, build, test, test-integration, test-e2e]
runs-on: [self-hosted, evoo-ci, Linux]
timeout-minutes: 20
@ -353,23 +354,30 @@ jobs:
fi
echo "publish=$publish" >> "$GITHUB_OUTPUT"
- name: Require Expo automation token
- name: Check Expo automation token
id: expo_token
if: steps.ota.outputs.publish == 'true'
env:
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
run: |
if [[ -z "$EXPO_TOKEN" ]]; then
echo "Add an EXPO_TOKEN repository secret with access to the inbox-zero/rakazo Expo project." >&2
exit 1
{
echo "### Mobile OTA skipped"
echo
echo "No \`EXPO_TOKEN\` repository secret is configured, so CI will not publish a production update. Add a token with access to the inbox-zero/rakazo Expo project when OTA from main is desired."
} >> "$GITHUB_STEP_SUMMARY"
echo "ready=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "ready=true" >> "$GITHUB_OUTPUT"
- uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4
if: steps.ota.outputs.publish == 'true'
if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true'
id: pnpm-setup
with:
dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.job }}
- name: Isolate pnpm store and PATH
if: steps.ota.outputs.publish == 'true'
if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true'
shell: bash
run: |
set -euo pipefail
@ -386,25 +394,25 @@ jobs:
mkdir -p "$store"
echo "npm_config_store_dir=${store}" >> "$GITHUB_ENV"
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
if: steps.ota.outputs.publish == 'true'
if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true'
with:
node-version: 24
cache: pnpm
- name: Set up Expo
if: steps.ota.outputs.publish == 'true'
if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true'
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # v9
with:
eas-version: latest
packager: pnpm
token: ${{ secrets.EXPO_TOKEN }}
- name: Install dependencies
if: steps.ota.outputs.publish == 'true'
if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true'
run: pnpm install --frozen-lockfile
- name: Verify mobile project
if: steps.ota.outputs.publish == 'true'
if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true'
run: pnpm --filter @rakazo/mobile check
- name: Publish production update
if: steps.ota.outputs.publish == 'true'
if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true'
working-directory: apps/mobile
run: |
set -euo pipefail

View File

@ -263,8 +263,8 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
owner_type="$(gh api "repos/${GITHUB_REPOSITORY}" --jq -r '.owner.type')"
private="$(gh api "repos/${GITHUB_REPOSITORY}" --jq -r '.private')"
owner_type="$(gh api "repos/${GITHUB_REPOSITORY}" --jq '.owner.type')"
private="$(gh api "repos/${GITHUB_REPOSITORY}" --jq '.private')"
if [[ "$owner_type" == "User" && "$private" == "true" ]]; then
echo "enabled=false" >> "$GITHUB_OUTPUT"
echo "Skipping registry attestation: unavailable for private repositories on personal accounts."

1
.gitignore vendored
View File

@ -47,7 +47,6 @@ apps/web/src/locales/**/*.js
apps/web/scripts/msgids.json
playwright-report-www
*.pid
.evoo_server.pid
# Large voice / model vendor blobs (recreate Kokoro separately)
vendor/

View File

@ -1,238 +0,0 @@
# Task Handoff: executor.ts + pi-runtime.ts Split
## Context
Du arbeitest im Repo `/home/cynar/Projects/evOo-local`.
Es gibt 10 bereits erstellte Module-Files, die extrahierte Funktionen enthalten. Der Original-File enthält aber noch **Duplikate** derselben Funktionen. Ziel: die Definitionen aus den Original-Files entfernen, Imports + Re-Exports hinzufügen.
## Module Files (bereits vorhanden, NICHT löschen)
### executor-*.ts (im Verzeichnis `packages/adapters/src/`):
| File | Line count | Exports |
|------|-----------|---------|
| `executor-types.ts` | 50 | `ExecutorDeps` (interface) |
| `executor-attachments.ts` | 43 | `TURN_ATTACHMENT_UNAVAILABLE`, `STEERING_ATTACHMENT_UNAVAILABLE`, `missingTurnImagesInstruction`, `settleSteeringAttachmentLoads` |
| `executor-checkpoint.ts` | 23 | `createRunWorkspaceCheckpoint` |
| `executor-shell-guard.ts` | 177 | `isProtectedComputerLifecycleCommand` |
| `executor-effects.ts` | 151 | `recordEffect`, `completeEffect`, `uncertainEffectError` |
| `executor-completion.ts` | 65 | `completionMessageSegments`, `completionNotificationBody`, `completionMarksUnread`, `threadContextForRun`, `subagentMarksUnread` |
| `executor-model.ts` | 207 | `deploymentKeyFor`, `resolveModelKey`, `withModelCredentialLock` |
| `executor-utils.ts` | 63 | `selectRunConnections`, `loadCurrentTurnImages` |
| `executor-tools.ts` | 58 | `PAGE_BROWSER_TOOL_NAMES`, `filterPageBrowserTools`, `selectBuiltinToolsForRun` |
| `executor-computer.ts` | 231 | `computerScreenToolResult`, `runNotificationsEnabled`, `notifyRun`, `renewRunLease`, `computerRetryDelay`, `computerRunRequeueData`, `writeComputerRunRequeue`, `requeueComputerRun`, `redactBlocks`, `publishMessage`, `persistMessageInTransaction` |
### pi-runtime Split:
| File | Line count | Exports |
|------|-----------|---------|
| `pi-model-config.ts` | 709 | Viele model config + utility functions (catalogModels, thinkingLevelFor, ToolCallBudget, maxToolCallsPerTurn, releaseToolCallBudget, beginToolCall, endToolCall, toolCallBudgetExceededMessage, configuredOpenRouterModel, modelsForRequest, prepareRequestSecretArguments, pruneComputerScreenshotContext, jsonSchemaParameters, jsonField, conversationSessionId, isOpenCodeProvider, createQueue, createGate, modelForCompletion, sanitizeSensitiveText, redactActivityUrl, sanitizeError, summarizeToolResult, boundAgentToolResult, assistantText, looksLikeOpenCodeSessionError, sanitizeProviderError, etc.) |
| `pi-runtime.ts` | 1726 | **PI Agent Runtime class** — soll bleiben, muss aber die extrahierten Functions loswerden |
## CRITICAL: Was DU NICHT tun darfst
1. **NIEMALS `git stash` als Nebenbei-Befehl** — Stash ist über alle Worktrees geteilt.
2. **NIEMALS den Original-File (executor.ts / pi-runtime.ts) durch einen SubAgent schreiben lassen.** Der SubAgent wird den File während der Arbeit überschreiben.
3. **NIEMALS `sed` mit Shell-Metazeichen durch SSH.**
4. **NIEMALS die extrahierten Module-Files löschen oder ändern** — sie sind korrekt.
## Task 1: executor.ts split (4697 → ~3700 Zeilen)
**Datei:** `/home/cynar/Projects/evOo-local/packages/adapters/src/executor.ts`
### Was zu tun ist:
Schreibe ein **einzelnes Python-Script**, das ALLES atomar macht:
1. Liest executor.ts
2. Findet die zu entfernenden Function-Definitionen **mittels AST-Parser** (nicht line-basiert!)
3. Entfernt die Definitionen
4. Fügt Imports + Re-Exports am Ende des import-Blocks ein
5. Entfernt alte Re-Exports am Ende der Datei
6. Schreibt den File
7. Prüft `npx tsc --noEmit -p packages/adapters/tsconfig.json`
8. Bei Fehlern: korrigiert und retry (max 3x)
### Wie man die zu entfernenden Definitionen findet:
Verwende den `ast` Python-Module ODER `grep` um die EXAKTEN function-Declaration-Zeilen zu finden. Dann zähle die geschwungenen Klammern **mit einem Parser, der strings/comments aware ist**.
**WICHTIG:** Verwende `tree-sitter` oder `ast` Python-Modul wenn möglich. Alternativ: verwende `grep -n 'export function NAME\|function NAME\|export const NAME\|export interface NAME\|const NAME' executor.ts` um die Start-Zeile zu finden, und dann einen **robusten brace-counter** der Template-Strings, Regex-Literale, und Generics korrekt handhabt.
### Zu entfernende Functions (alle existieren schon in den executor-*.ts Modulen):
Hier ist eine Liste der Functions, die entfernt werden müssen. Für jede muss der **exakte** start/end-Block im executor.ts gefunden und entfernt werden.
**Wichtig:** Die brace-counting muss korrekt sein bei:
- Generics: `<T extends { name: string }>` — die `{ }` sind Type-Parameter, keine Code-Blöcke
- Template-Strings: `` `template ${expr}` `` — kein `{ }`
- Regex: `/regex/` — kein `{ }`
- Arrow Functions: `() => { body }` — der Body hat `{ }`
### Schritt-für-Schritt:
#### Step 1: Finde alle zu entfernenden Export-Namen
```bash
# In packages/adapters/src/:
for f in executor-*.ts; do grep -h "^export " "$f" | grep -oP '(function|const|interface|type)\s+\K\w+'; done | sort -u
```
Das gibt dir die Liste aller Export-Namen aus den Modul-Files.
#### Step 2: Finde diese Namen im executor.ts
```bash
# Für jeden Namen:
grep -n "^export.*NAME\b\|^function NAME\b\|^const NAME\b\|^interface NAME\b" executor.ts
```
#### Step 3: Finde den exakten end-Block mit einem robusten Parser
Schreibe ein Python-Script `find_functions.py`:
```python
import re, sys
file_path = sys.argv[1]
target_name = sys.argv[2]
with open(file_path) as f:
content = f.read()
lines = content.split('\n')
# Find declaration line
for i, line in enumerate(lines):
if re.search(rf'\b{re.escape(target_name)}\b', line):
# Find opening brace
brace_line = i
while brace_line < len(lines) and '{' not in lines[brace_line]:
brace_line += 1
if brace_line >= len(lines):
# No brace (const declaration)
end = i + 1
while end < len(lines) and not lines[end].strip().endswith(';'):
end += 1
print(f"{target_name}: L{i+1}-{end}")
break
# Robust brace counting
depth = 0
in_single = in_double = in_template = in_block_comment = in_line_comment = False
for j in range(brace_line, len(lines)):
k = 0
while k < len(lines[j]):
ch = lines[j][k]
# String handling
if ch in ('"', "'"):
q = ch
k += 1
while k < len(lines[j]):
if lines[j][k] == '\\' and k + 1 < len(lines[j]):
k += 2
continue
if lines[j][k] == q:
k += 1
break
k += 1
continue
if ch == '`':
k += 1
while k < len(lines[j]):
if lines[j][k] == '\\' and k + 1 < len(lines[j]):
k += 2
continue
if lines[j][k] == '`':
k += 1
break
k += 1
continue
# Comments
if not in_single and not in_double and not in_template:
if ch == '/' and k + 1 < len(lines[j]) and lines[j][k+1] == '/':
break # rest of line is comment
if ch == '/' and k + 1 < len(lines[j]) and lines[j][k+1] == '*':
in_block_comment = True
k += 2
continue
if in_block_comment:
if ch == '*' and k + 1 < len(lines[j]) and lines[j][k+1] == '/':
in_block_comment = False
k += 2
continue
k += 1
continue
# Braces
if ch == '{':
depth += 1
elif ch == '}':
depth -= 1
if depth == 0:
print(f"{target_name}: L{i+1}-{j+1}")
break
k += 1
else:
continue
break
else:
print(f"{target_name}: L{i+1}-END(not found)")
break
```
#### Step 4: Generiere die removal ranges
Für jeden gefundenen Block: `start_line` bis `end_line` (1-based, inclusive).
#### Step 5: Führe alle removals atomar aus
- Baue ein Set aller zu entfernenden 0-based line indices
- Baue den neuen File content
- Füge Imports + Re-Exports an Position 340 (nach dem letzten import Statement) ein
- Entferne alte Re-Exports am Datei-Ende
- Schreibe den File
#### Step 6: Verifiziere
```bash
npx tsc --noEmit -p packages/adapters/tsconfig.json 2>&1 | grep "executor.ts"
```
Wenn keine errors: SUCCESS.
Wenn errors: lies die errors, korrigiere, retry max 3x.
## Task 2: pi-runtime.ts split (1726 → ~500 Zeilen)
**Datei:** `/home/cynar/Projects/evOo-local/packages/adapters/src/pi-runtime.ts`
**Modul:** `pi-model-config.ts` (709 Zeilen, enthält model config + utility functions)
Gleicher Ansatz wie Task 1:
1. Finde alle Export-Namen in `pi-model-config.ts`
2. Finde diese im `pi-runtime.ts`
3. Finde die exakten start/end-Blöcke mit demselben robusten brace-counter
4. Entferne die Blöcke atomar
5. Füge Imports + Re-Exports ein
6. Entferne alte Re-Exports
7. Verifiziere mit `npx tsc --noEmit -p packages/adapters/tsconfig.json`
## Wichtige Hinweise
- Der `packages/adapters/src/index.ts` muss eventuell aktualisiert werden (falls er die extrahierten Functions exportiert — prüfen!)
- `verbatimModuleSyntax` ist enabled → alle imports müssen `.js` extension haben
- TypeScript `import type` vs `import`: Für Interfaces `import type`, für classes/functions `import`
- **Nicht committen oder pushen** — nur die Files ändern und verifizieren
## Quick reference: Zu entfernende Functions aus executor.ts
Diese Functions existieren in den executor-*.ts Modulen und MÜSSEN aus executor.ts entfernt werden:
```
TURN_ATTACHMENT_UNAVAILABLE, STEERING_ATTACHMENT_UNAVAILABLE,
createRunWorkspaceCheckpoint,
isProtectedComputerLifecycleCommand, shellCFlagProgram, preserveShellCommandBoundaries, tokenizeProtectedShellCommand,
recordEffect, completeEffect, uncertainEffectError,
completionMessageSegments, completionNotificationBody, completionMarksUnread, threadContextForRun, subagentMarksUnread,
deploymentKeyFor, resolveModelKey, withModelCredentialLock,
selectRunConnections, loadCurrentTurnImages,
PAGE_BROWSER_TOOL_NAMES, filterPageBrowserTools, selectBuiltinToolsForRun,
persistMessageInTransaction, computerScreenToolResult, runNotificationsEnabled, notifyRun, renewRunLease,
computerRetryDelay, computerRunRequeueData, writeComputerRunRequeue, requeueComputerRun, redactBlocks, publishMessage,
```
Dazu: `ExecutorDeps` interface aus `executor-types.ts`.

View File

@ -1,100 +0,0 @@
# ARCHITECTURE.md: Die Verfassung von evOo
> **Status**: RATIFIZIERT & AKTIV
> **Ecosystem**: evOo Autonomous Agent Platform
> **Executive Lead / CTO**: Steve Jobs (`templates/cto-steve-jobs`)
> **Konfigurationsanker**: [`evoo.config.json`](evoo.config.json)
> **Grundsatz**: *Realität im Dateisystem vor Halluzination im Kopf. Nur was physisch geschrieben, getestet und lauffähig ist, existiert.*
---
## Prämisse I: Das Gesetz der physischen Realität
1. **Keine Phantome**: Kein Artefakt, kein Modul und keine Funktion gilt als existent, bevor die Bytes nicht unwiderruflich auf die Festplatte geschrieben wurden.
2. **Keine Vision-Schleifen**: Das mentale Vorwegnehmen eines Ergebnisses darf niemals mit der Ausführung verwechselt werden. Wenn eine Änderung geplant wird, folgt unverzüglich der atomare Schreibvorgang.
3. **Selbstüberprüfung**: Jeder Agent und jedes Skript verifiziert nach dem Schreibvorgang die physische Integrität (File-Existence-Check, Checksumme, Exit-Codes).
---
## Prämisse II: Autonomie & Exekutive Führung (Steve Jobs CTO)
1. **Vollmacht**: Der CTO besitzt uneingeschränkte, vorab genehmigte Zugriffsrechte auf:
- Dateisystem (`filesystem: *`)
- Netzwerk (`network: *`)
- Quellcode (`sourcecode: *`)
- Lokale Tools und Geräte (`devices: *`)
- Agent-Casting (`agent_casting: allowed`)
2. **Action-First Prinzip**: Bei klaren Zielen wird keine unnötige Erlaubnis für Standardoperationen (Lesen, Schreiben, Refactoring, Ausführen) erbeten. Die Handlung erfolgt sofort.
3. **Agent-Casting auf Abruf**: Der CTO rekrutiert, instruiert und überwacht spezialisierte Sub-Agenten:
- **Code Architect**: Modulstruktur, Design-Patterns, saubere Schnittstellen.
- **Security & QA Auditor**: Sicherheitsanalysen, Regressionstests, Grenzfallprüfungen.
- **DevOps Engineer**: Deployment, Hot-Reloading, Server-Liveness, Prozessmanagement.
- **Apex Quant Specialist**: Handelsmodelle, Risikomanagement, Signalverarbeitung.
---
## Prämisse III: Der geschlossene Selbstverbesserungs-Kreislauf (Self-Improvement Loop)
```
┌────────────────────────────────────────────────┐
│ │
▼ │
┌───────────────┐ ┌───────────────┐ ┌────────────┴──┐
│ 1. Execution │ ──► │ 2. Telemetrie │ ──► │ 3. Diagnose │
│ & Deployment │ │ & Log-Audit │ │ & Root-Cause │
└───────────────┘ └───────────────┘ └───────────────┘
│ │
▼ ▼
┌───────────────┐ ┌───────────────┐
│ 4. Refactor │ ◄── │ 5. Prompt- │
│ & Fix (Disk) │ │ Optimierung │
└───────────────┘ └───────────────┘
```
1. **Automatisierte Log-Evaluation**: Sämtliche Fehlermeldungen, System-Events und Performance-Metriken werden zyklisch aggregiert.
2. **Selbstreparatur (Self-Healing)**: Tritt ein Fehler (wie ein HTTP 500, fehlende Pfade, gecrashte Prozesse) auf, wird der Ursprung lokalisiert und der Code auf Festplatte korrigiert.
3. **Hot-Reload**: Nach erfolgreicher Selbstreparatur triggert das System via `deploy.sh` einen Hot-Reload ohne Systemstillstand.
---
## Prämisse IV: System-Topologie
Das evOo-Ökosystem ist in klar getrennte, orthogonale Säulen unterteilt:
```
evOo-local/
├── ARCHITECTURE.md # Diese Verfassung
├── evoo.config.json # Autonome Berechtigungen & Policies
├── deploy.sh # Hot-Reload, Health-Checks & Service-Orchestrierung
├── evoo-platform/ # Plattform-Core, Dashboard & Visualisierung
│ ├── index.html # UI mit atmender evOo-Kernanimation
│ ├── styles.css # Darkmode Glassmorphism Design System
│ ├── app.js # Reaktivität, WebSocket/Telemetry-Stream
│ └── server.js # High-Speed Server (verhindert Internal Server Error)
├── apex-trading/ # Autonomes Trading- & Quant-Subsystem
│ ├── BLUEPRINT.md # Handelsarchitektur, Risikomatrix, Strategien
│ ├── config/ # Trading-Konfiguration & Parameter
│ └── src/ # Engine, Signale, Risk-Guardrails
└── templates/cto-steve-jobs/ # Executive CTO Agent Profile & Setup
```
---
## Prämisse V: Qualitäts- und Ästhetik-Manifest
1. **Kein Müllcode**: Code ist so kompakt, elegant und wartbar wie möglich.
2. **Atmendes Interface**: Die Benutzeroberfläche von evOo visualisiert den vitalen Zustand des Systems in Echtzeit durch organische, atmende Pulsation (Breathing Animation).
3. **Resilienz**: Fällt ein Dienst aus, fängt die Plattform den Fehler ab, protokolliert die Ursache und stellt den Dienst autonom wieder her.
---
## Media Studio
Optionales Modul für Mediathek, Schnitt, Anonymisierung, Brand-Kit und plattformkonformen Export. Ohne konfigurierten Media-Worker bleibt es verborgen. Details, Setup und Lizenzen: [`docs/media.md`](docs/media.md).
- **Das Modell plant, die Engine rendert.** LLMs liefern nur validierte Daten (Schnittplan als EDL, Texte, Bewertungen). Rendering, Anonymisierung, Compliance-Gate und Einwilligungsprüfung sind deterministischer Code; ffmpeg läuft nur mit argv-Arrays.
- **Schichten:** reine Domänenlogik in `packages/media`, Contracts in `packages/contracts/src/media.ts`, Interfaces in `packages/adapter-kit`, Adapter und Jobs in `packages/adapters/src/media-*.ts` (Composition Root `media-services.ts`), RPCs in `apps/api/src/media.ts`, Python-Sidecar mit lokalen Modellen in `infra/media-worker` (Compose-Profil `media`, eine generische Verbindung `MEDIA_WORKER_URL` + `MEDIA_WORKER_TOKEN`).
- **Jobs:** `media.sync`, `media.proxy`, `media.analyze`, `media.cluster`, `media.render`, `media.publish` im vorhandenen Graphile-Worker, idempotent pro Inhalt und Analyseversion.
- **Fail closed:** Das Gate prüft den gerenderten Output (Technik, Inhalt, Gesichter, Einwilligung, Text, KI-Kennzeichnung). Kann es keinen sicheren Zustand nachweisen, wird nicht exportiert.
- **Privatsphäre:** Biometrische Daten bleiben lokal, Exporte werden von Metadaten bereinigt, Einwilligungen setzt nur der Nutzer in der UI.
- **Profile:** Öffentliche Social-Profile liegen in `packages/media/profiles/`; private Profile (Bezahl-Plattformen, Strategie) nur in `MEDIA_PROFILES_DIR` außerhalb des Repos.

View File

@ -1,72 +0,0 @@
# CLAUDE RESUME — 2026-09-22 (~10:00 Vienna)
## Completed ✅
- ✅ **Emoji-first Onboarding.tsx** (5 steps: emoji → preferences → language → integrations → bot)
- Shape selector (8 shapes), Color picker (12 GrokColors), Accessory (9), Pose (8)
- User preferences (name, timezone, hobbies, work style, communication)
- Language step (9 locales → localStorage `rakazo.uiLocale`)
- avatarValue (`{color}::shape_{index}`) → bot `color` field on create
- Bot instructions enriched with user context
- ✅ **onboarding-discovery.ts**, **OnboardingDiscoveryShell.tsx** updated
- ✅ **ui-web exports**: GROK_COLOR_LIST, SHIPPED_SHAPE_KEYS, GrokColorDef, GROK_SHAPES
- ✅ **executor.ts split**: 4697 → 4231 lines (18 module files)
- ✅ **pi-runtime.ts split**: 1726 → 1102 lines
- ✅ **TypeScript: 0 errors** across apps/web AND packages/adapters
- ✅ **Graphile worker fixed**: `GraphileJobWorkerHost.start()` now called in app.ts (was never started for graphile jobs, only for in-memory)
- ✅ **Graphile schema**: `graphile_worker` schema created via `graphile-worker schema-only`
- ✅ **Sandbox supervisor**: Built `rakazo/sandbox-supervisor:local` from source (was using wrong `rakazo/app:edge` image)
- ✅ **Computer image**: Built `rakazo/computer:local` from source
- ✅ **Smart model routing**: `estimateMessageComplexity()` + `resolveSmartModelSelection()` implemented
- Fast model (3B) for short messages (<80 chars)
- Strong model (30B) for complex/long messages (>500 chars)
- Medium routing for keyword-based detection
- ✅ **Model credential**: `local` provider connected with empty apiKey allowed
- ✅ **PRs merged**: All PRs 36-58 merged to main, stale branches cleaned
### Infrastructure fixes
- ✅ **GraphileJobWorkerHost**: Added `else if (jobPool)` branch in app.ts to start the worker
- ✅ **Sandbox provider**: `SANDBOX_PROVIDER=docker` with proper supervisor at port 7091
- ✅ **Computer provisioning**: Docker containers spin up successfully (verified: `rakazo-bot-cmuber4og000561yrhm2q81c5` running)
- ✅ **DATA_DIR path**: Supervisor mounted with correct host path for home directory validation
## Completed ✅
- ✅ **Prisma `updateMany` Bug Fixed!** Root cause: `toolCallBudgetCount` and `toolCallBudgetExceeded` fields missing from Run model in Prisma schema. Added to schema.prisma, migrated DB, regenerated Prisma client. Runs now complete successfully.
- ✅ **E2E Flow VERIFIED!** Bot responds end-to-end:
- User: "Hello! Can you hear me?"
- Bot: "I'm here! It looks like we've been going back and forth a bit — but yes, I can definitely hear you. How can I help? 😊"
- Full pipeline: Message → Graphile job → Worker → Computer provision → Model call → Response → Run completed
## Architektur-Status
- **API**: Port 3100, Graphile worker active, consuming jobs
- **Sandbox supervisor**: Port 7091, `rakazo/sandbox-supervisor:local`, DATA_DIR mounted correctly
- **Computer**: `rakazo/computer:local` image built and running
- **Smart routing**: Fast (3B) → Strong (30B) model selection based on message complexity
- **Known error**: `events.ts:996` — Prisma `updateMany` in `finalizeRunOnce` throws "Invalid invocation" despite all fields existing in schema
## Architektur-Status
- **API**: Port 3100, Graphile worker active ✅
- **Sandbox supervisor**: Port 7091, `rakazo/sandbox-supervisor:local` ✅
- **Computer**: `rakazo/computer:local` running ✅
- **Smart routing**: Fast (3B) → Strong (30B) ✅
- **E2E**: Bot responds to user messages ✅
## Status
- Working tree: clean, synced to origin/main
- **PR 66** ✅ `fix(messaging): preserve group inbound media URLs in fan-out` — merged
- **PR 67** ✅ `fix(adapters): poll run lease during silent runtime work` — merged
- **PR 69** ✅ `fix(teamchat): retry delegated replies when external origin is missing` — merged
- **0 stale branches**, **0 draft PRs**
### Shell.tsx TypeScript fixes
- ✅ **Shell.tsx TS errors fixed** (PR #71 introduced type mismatches)
- `ConversationBotPicker` now receives `bots: Bot[]` (not `SpaceBot[]`)
- `conversationSidebarGroups` render simplified — only bots, removed dead `GroupAvatar` branch
- `projectSidebarGroups` render uses `GroupAvatar` with proper members access
- All TypeScript errors resolved (0 errors across apps/web and packages/adapters)
- ✅ **browser-preview-context.ts** created (was missing from repo, caused Vite crash)
- Implements `browserPreviewActivity` subject with `.subscribe()`, `.set()`, `.snapshot()`, `.clear()`
- Multi-agent cursor tracking for browser preview panels
- ✅ **RakazoDesktop interface** extended with optional `browserPreview` property
- Contract update in `packages/contracts/src/desktop.ts`
- Types: `navigate`, `reload`, `getState`, `onNavigate`, `onLoadingProgress`, `devServer`
- ✅ **executor-shell-guard.ts** TS fix: `body[cursor]!` non-null assertion

View File

@ -1,12 +0,0 @@
# GROK-CLAUDE-HANDOFF — 2026-09-23T22:59:11+00:00
- Autopilot merged #84 (Product CI gate) → main `cf4d5cc`.
- Checkout pulled to `cf4d5cc`; Claude WIP stashed then restored (app.ts/router.ts/rpc.ts + onboarding-scan.ts).
- API :3100 was down (Claude background "Test API startup with security headers" stopped / refused). Autopilot restarted via `pnpm --filter @rakazo/api dev` (log /tmp/evoo-autopilot/api-3100.log).
- Web :5173 left running.
- PR #85 rebase agent in flight: https://cursor.com/agents/bc-edd2b181-b5da-5574-a956-e55efe3d2736
- Do not reset --hard without stash; WIP present.
- Autopilot restart path: A rejected (evoo-stack = Rakazo Docker, broken symlink /srv/rakazo/stack-up.sh → missing /srv/evoo/rakazo/…). Chose new user unit evoo-local.service + ~/.local/bin/evoo-dev-stack.sh; live :3100/:5173 migrated; GH vars EVO_RESTART_MODE=systemd EVO_SYSTEMD_UNIT=evoo-local.service. Do not use evoo-stack for evo-deploy.
- Cancelled queued evo-deploy 35933339459 (would reset --hard over Claude WIP now that restart=systemd). Re-dispatch after WIP committed/stashed.
2026-09-23 23:53:08 UTC evoo-tools disabled (missing app.py)

View File

@ -9,6 +9,6 @@ branch of Ingo Wagner’s evOo stack.
| Branch | `antigravity-handoff/project-timeline` |
| Commit | `f00577bb05e3cf68381fafb9cb32929becd1e352` |
| Packaged | 2026-09-20 |
| Intended Mac path | `/Users/ingowagner/Projects/evOo-local` |
| Intended Mac path | `~/Projects/evOo-local` |
Chaotic multi-remote history from older checkouts was intentionally not carried over.

View File

@ -12,7 +12,7 @@
|----|----|
| Persönliche Multi-Agenten-App auf dem eigenen Rechner | Personal multi-agent app on your own machine |
| Oos (Agents) mit Memory, Tools, Voice und Computer | Oos (agents) with memory, tools, voice, and computer |
| Antigravity-Zukunftswerkzeuge: self_improve, autonomous-sentinel, self-update, orchestrator, local-stack, pi-local-provider, seed-ollama, Voice First / Oo-Stage | Same Antigravity future tools kept on this branch |
| Antigravity-Zukunftswerkzeuge: self-update, local-stack, pi-local-provider, seed-ollama, Voice First / Oo-Stage | Same Antigravity future tools kept on this branch |
## Quick start
@ -53,10 +53,7 @@ Full local notes: **[docs/LOCAL-SETUP.md](./docs/LOCAL-SETUP.md)**
## Antigravity tools kept on this branch
- `scripts/self_improve.py`
- `scripts/autonomous-sentinel.ts`
- `packages/core/src/self-update.ts`
- `orchestrator/` (agent_runner + agents.json)
- `apps/desktop/src/local-stack.ts`
- `packages/adapters/src/pi-local-provider.ts`
- `scripts/seed-ollama.ts`

View File

@ -60,7 +60,7 @@ Persona is **derived at runtime** from existing bot fields (`name`, `title`, `de
| Visual identity | `BotAvatar` — persona shape + unified `EvooMascot` soul (eyes/mouth/hands) driven by voice phase |
| Voice delivery | `speaker.speak(..., { botId })` → server resolves per-bot `voiceId` |
Archetypes include: CTO executive (Steve Jobs template), chief operator (Fixer), builder (Dr. Eggbot), curator (Webby), work/life inboxes, home steward, quant, engineer, **specialist** (narrow fallback — no Wunderwuzzi). See `docs/PRODUCT.md` and `docs/PERSONA-SELECTION.md`.
Archetypes include: CTO executive, chief operator (Fixer), builder (Dr. Eggbot), curator (Webby), work/life inboxes, home steward, quant, engineer, **specialist** (narrow fallback — no Wunderwuzzi). See `docs/PRODUCT.md` and `docs/PERSONA-SELECTION.md`.
**Companion tone:** geek-peer (fachsimpeln when wanted) or quiet-doer (execute without condescension) — inferred per user, not one voice for everyone.

View File

@ -1,119 +0,0 @@
# APEX TRADING ENGINE — BLUEPRINT & SYSTEM ARCHITECTURE
> **Version**: 1.0.0-PROD
> **Subsystem**: evOo Autonomous High-Frequency & Quantitative Engine
> **Aufsicht**: Chief Technology Officer Steve Jobs & Apex Quant Specialist
> **Status**: RATIFIZIERT & IM DATEISYSTEM VERANKERT
---
## 1. Executive Summary & Zielsetzung
Die **Apex Trading Engine** ist das hochperformante quantitative Handels-Subsystem innerhalb der evOo-Plattform. Sie wurde entwickelt, um Marktchancen algorithmisch zu identifizieren, Risiken deterministisch abzusichern und vollautomatisiert mit Sub-Millisekunden-Latenz zu agieren.
Das System folgt dem obersten evOo-Grundsatz:
> **„Keine hypothetischen Trades, keine ungetesteten Algorithmen. Nur deterministische Ausführung und unnachgiebiges Risikomanagement.“**
---
## 2. System-Architektur
```
┌──────────────────────────────────────────────────────────────┐
│ MARKET DATA FEEDS │
│ (WebSockets, Orderbook L2/L3, REST Snapshots, On-Chain RPC) │
└──────────────────────────────┬───────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────┐
│ APEX DATA PIPELINE & TICK BUFFER │
│ (Normalisierung, VWAP, Volatilität, Delta) │
└──────────────────────────────┬───────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────┐
│ QUANT STRATEGY ENGINE │
│ ├── Alpha-1: Momentum & Breakout (Trendfolge) │
│ ├── Alpha-2: Order Book Imbalance & Flow Reversal │
│ └── Alpha-3: Statistical Arbitrage / Mean Reversion │
└──────────────────────────────┬───────────────────────────────┘
│ Signal
▼
┌──────────────────────────────────────────────────────────────┐
│ APEX RISK GUARDRAILS (HARD ENFORCEMENT) │
│ ├── Max Drawdown Lock: 1.5% Intraday │
│ ├── Max Single-Position Exposure: 5.0% Portfolio │
│ ├── Value-at-Risk (99% CI): Max 0.8% │
│ └── Autonomous Hardware Kill Switch │
└──────────────────────────────┬───────────────────────────────┘
│ Approved Order
▼
┌──────────────────────────────────────────────────────────────┐
│ SMART ORDER ROUTER & EXECUTION │
│ (TWAP/VWAP Slicing, Slippage Minimizer) │
└──────────────────────────────────────────────────────────────┘
```
---
## 3. Risikomatrix & Hard-Guardrails
Kein Signal kann ausgeführt werden, ohne die **Apex Risk Guard** Instanz zu passieren. Diese Schranken sind unveränderlich im Code fixiert:
| Guardrail | Grenzwert | Aktion bei Verletzung |
| :--- | :--- | :--- |
| **Max Intraday Drawdown** | `1.50%` | Sofortige Schließung aller Positionen, Deaktivierung des Handels für 24h |
| **Single Trade Risk** | `0.50%` des Eigenkapitals | Order wird von der Risk Engine vor dem Senden blockiert |
| **Max Leverage** | `3.0x` | Harte Grenze im Margin-Router |
| **Slippage Toleranz** | `0.15%` | Limit-Order mit Timeout, kein Blind-Market-Buying |
| **Latenz-Anomalie** | `> 50 ms` | Übergang in den Notfall-Sicherheitsmodus |
---
## 4. Strategiemodule (`src/strategies/`)
### 4.1 Alpha-1: Cross-Market Momentum (`momentum.py`)
Identifiziert signifikante Momentum-Impulse durch exponentiell gewichtete Gleitende Durchschnitte kombiniert mit Relative Strength Index (RSI) und Volumen-Konfirmation.
- **Trigger**: Bollinger Band Expansion + Volumenspitze > 2.2x 20-Perioden-Durchschnitt.
- **Exit**: Trailing Stop bei 1.2x ATR (Average True Range) oder Umkehrsignal.
### 4.2 Alpha-2: Order Book Imbalance (`imbalance.py`)
Scannt Bid/Ask-Tiefe in den Top 10 Ebenen des Orderbuchs.
- **Formel**:
$$Imbalance = \frac{Volume_{Bid} - Volume_{Ask}}{Volume_{Bid} + Volume_{Ask}}$$
- Signal feuert, wenn $|Imbalance| > 0.65$ für mindestens 3 konsekutive Ticks.
### 4.3 Alpha-3: Statistical Mean Reversion (`reversion.py`)
Nutzt Z-Score Abweichungen des aktuellen Preises vom gleitenden VWAP.
- Einstieg bei Z-Score $\ge 2.5$ oder $\le -2.5$ mit Rückkehr zum Mittelwert als Target.
---
## 5. Integration mit der evOo Self-Improvement Schleife
1. **Trade Post-Mortem**: Jeder geschlossene Trade generiert ein strukturiertes JSON-Log mit Execution-Slippage, Haltedauer und PnL.
2. **Autonome Kalibrierung**: Wenn die Win-Rate über 50 Trades unter 52% fällt, initiiert der Casted Quant Specialist eine automatische Hyperparameter-Optimierung.
3. **Audit-Sicherheit**: Alle Parameteränderungen werden in `ARCHITECTURE.md` und Git-Commits dokumentiert.
---
## 6. Verzeichnisstruktur von `/apex-trading`
```
/apex-trading/
├── BLUEPRINT.md # Dieser Blueprint
├── README.md # Quickstart & CLI Anleitung
├── config/
│ └── trading.config.json # Konfigurierte Risikogrenzen & Assets
└── src/
├── __init__.py
├── engine/
│ ├── __init__.py
│ └── trader.py # Core Event Loop & Router
├── strategies/
│ ├── __init__.py
│ └── momentum.py # Momentum-Strategie
└── risk/
├── __init__.py
└── risk_guard.py # Hard Risk Guardrails & Circuit Breaker
```

View File

@ -1,11 +0,0 @@
# Apex Trading Subsystem
Quantitative & High-Frequency Trading Subsystem of the evOo Platform.
## Features
- Hard-coded risk controls (`src/risk/risk_guard.py`)
- Quantitative momentum and imbalance strategies (`src/strategies/`)
- Autonomous execution engine (`src/engine/trader.py`)
- Full integration with evOo Self-Improvement and CTO Steve Jobs directives.
See [BLUEPRINT.md](file:///Users/ingowagner/awesome-grok-bot/apex-trading/BLUEPRINT.md) for full architecture details.

View File

@ -1,29 +0,0 @@
{
"system": "Apex Trading Engine",
"version": "1.0.0-PROD",
"mode": "paper_trading",
"base_currency": "USDC",
"allocated_capital": 100000.0,
"risk_limits": {
"max_drawdown_percent": 1.5,
"max_single_trade_risk_percent": 0.5,
"max_open_positions": 4,
"max_leverage": 3.0,
"slippage_tolerance_percent": 0.15,
"circuit_breaker_cooldown_seconds": 3600
},
"active_pairs": ["BTC/USDC", "ETH/USDC", "SOL/USDC"],
"strategies": {
"momentum": {
"enabled": true,
"lookback_period": 20,
"rsi_oversold": 32,
"rsi_overbought": 68
},
"orderbook_imbalance": {
"enabled": true,
"threshold": 0.65,
"depth_levels": 10
}
}
}

View File

@ -1,62 +0,0 @@
"""
Apex Trading Engine Core
Event loop that receives signals, checks risk guardrails, and executes orders.
"""
import json
import os
import sys
from typing import Dict, Any
from pathlib import Path
SRC_DIR = Path(__file__).resolve().parent.parent
if str(SRC_DIR) not in sys.path:
sys.path.insert(0, str(SRC_DIR))
try:
from risk.risk_guard import ApexRiskGuard
from strategies.momentum import MomentumStrategy
except ImportError:
from ..risk.risk_guard import ApexRiskGuard
from ..strategies.momentum import MomentumStrategy
class ApexEngine:
def __init__(self, config_path: str):
with open(config_path, 'r', encoding='utf-8') as f:
self.config = json.load(f)
self.risk_guard = ApexRiskGuard(self.config)
self.momentum_strategy = MomentumStrategy(self.config.get("strategies", {}).get("momentum"))
self.open_positions = []
self.current_drawdown_pct = 0.0
def process_tick(self, symbol: str, prices: list, volume: list) -> Dict[str, Any]:
"""Processes market data tick through strategy and risk guard."""
signal = self.momentum_strategy.generate_signal(symbol, prices, volume)
if not signal:
return {"status": "NO_SIGNAL", "symbol": symbol}
approved, reason = self.risk_guard.validate_order(
signal,
self.current_drawdown_pct,
len(self.open_positions)
)
if not approved:
return {"status": "REJECTED_BY_RISK", "reason": reason, "signal": signal}
# Simulated Execution
order_receipt = {
"status": "EXECUTED",
"symbol": signal["symbol"],
"action": signal["action"],
"fill_price": signal["price"],
"strategy": signal["strategy"],
"risk_status": "APPROVED"
}
self.open_positions.append(order_receipt)
return order_receipt
if __name__ == "__main__":
print("[APEX ENGINE] Liveness check passed. Engine ready.")

View File

@ -1,40 +0,0 @@
"""
Apex Trading Risk Guard
Hard enforcement of risk constraints defined in BLUEPRINT.md.
"""
from typing import Dict, Any, Tuple
class ApexRiskGuard:
def __init__(self, config: Dict[str, Any]):
self.config = config.get("risk_limits", {})
self.max_drawdown = self.config.get("max_drawdown_percent", 1.5)
self.max_trade_risk = self.config.get("max_single_trade_risk_percent", 0.5)
self.max_positions = self.config.get("max_open_positions", 4)
self.circuit_breaker_active = False
def validate_order(self, order: Dict[str, Any], current_drawdown: float, open_position_count: int) -> Tuple[bool, str]:
"""
Validates an incoming order against hard guardrails.
Returns (is_approved: bool, reason: str).
"""
if self.circuit_breaker_active:
return False, "REJECTED: Circuit breaker is active."
if current_drawdown >= self.max_drawdown:
self.circuit_breaker_active = True
return False, f"REJECTED & CIRCUIT BREAKER TRIGGERED: Drawdown {current_drawdown}% >= limit {self.max_drawdown}%"
if open_position_count >= self.max_positions:
return False, f"REJECTED: Maximum concurrent positions ({self.max_positions}) reached."
order_risk_pct = order.get("risk_percent", 0.0)
if order_risk_pct > self.max_trade_risk:
return False, f"REJECTED: Order risk {order_risk_pct}% exceeds limit {self.max_trade_risk}%"
return True, "APPROVED"
def trip_kill_switch(self, reason: str):
"""Emergency hard stop."""
self.circuit_breaker_active = True
print(f"[APEX EMERGENCY KILL SWITCH] {reason}")

View File

@ -1,48 +0,0 @@
"""
Apex Momentum Strategy Module (Alpha-1)
Calculates breakout and trend-following signals.
"""
from typing import List, Dict, Any, Optional
class MomentumStrategy:
def __init__(self, params: Optional[Dict[str, Any]] = None):
self.params = params or {}
self.lookback = self.params.get("lookback_period", 20)
self.rsi_oversold = self.params.get("rsi_oversold", 32)
self.rsi_overbought = self.params.get("rsi_overbought", 68)
def generate_signal(self, symbol: str, prices: List[float], volume: List[float]) -> Optional[Dict[str, Any]]:
"""
Evaluates recent tick series and outputs a trading signal if criteria are satisfied.
"""
if len(prices) < self.lookback:
return None
recent_window = prices[-self.lookback:]
current_price = prices[-1]
sma = sum(recent_window) / len(recent_window)
# Simple momentum breakout check
if current_price > sma * 1.015:
return {
"symbol": symbol,
"action": "BUY",
"price": current_price,
"strategy": "Alpha-1:Momentum",
"risk_percent": 0.35,
"stop_loss": current_price * 0.985,
"take_profit": current_price * 1.045
}
elif current_price < sma * 0.985:
return {
"symbol": symbol,
"action": "SELL",
"price": current_price,
"strategy": "Alpha-1:Momentum",
"risk_percent": 0.35,
"stop_loss": current_price * 1.015,
"take_profit": current_price * 0.955
}
return None

View File

@ -1,52 +0,0 @@
#!/usr/bin/env python3
"""
Unit tests for Apex Trading Engine and Risk Guardrails.
"""
import unittest
from pathlib import Path
import sys
BASE_DIR = Path(__file__).resolve().parent
if str(BASE_DIR / "src") not in sys.path:
sys.path.insert(0, str(BASE_DIR / "src"))
from engine.trader import ApexEngine
from risk.risk_guard import ApexRiskGuard
from strategies.momentum import MomentumStrategy
class TestApexTrading(unittest.TestCase):
def setUp(self):
self.config_path = str(BASE_DIR / "config" / "trading.config.json")
self.engine = ApexEngine(self.config_path)
def test_risk_guard_limits(self):
guard = self.engine.risk_guard
# Normal order passes
normal_order = {"symbol": "BTC/USDT", "risk_percent": 0.3}
approved, reason = guard.validate_order(normal_order, current_drawdown=0.5, open_position_count=1)
self.assertTrue(approved)
self.assertEqual(reason, "APPROVED")
# Exceeding single trade risk
risky_order = {"symbol": "BTC/USDT", "risk_percent": 0.8}
approved, reason = guard.validate_order(risky_order, current_drawdown=0.5, open_position_count=1)
self.assertFalse(approved)
self.assertIn("exceeds limit", reason)
# Exceeding max drawdown trips circuit breaker
approved, reason = guard.validate_order(normal_order, current_drawdown=1.6, open_position_count=1)
self.assertFalse(approved)
self.assertIn("CIRCUIT BREAKER", reason)
self.assertTrue(guard.circuit_breaker_active)
def test_engine_process_tick(self):
# 25 prices trending sharply upwards
prices = [100.0 + (i * 1.5) for i in range(25)]
volume = [1000.0] * 25
result = self.engine.process_tick("SOL/USDT", prices, volume)
self.assertEqual(result.get("status"), "EXECUTED")
self.assertEqual(result.get("action"), "BUY")
if __name__ == "__main__":
unittest.main()

View File

@ -112,7 +112,6 @@ import {
settleWithTimeout,
TEAM_CHAT_STARTUP_SHUTDOWN_MS,
} from "./team-chat-startup.js";
import { mountTradingBotStatus } from "./trading-bot-proxy.js";
import { mountVoiceHttpRoutes } from "./voice.js";
import { mountWebhookHttpRoutes } from "./webhook.js";
@ -554,13 +553,6 @@ export async function createApp(
resetUrl: email ? new URL("/reset-password", env.webOrigin).href : null,
}),
);
mountTradingBotStatus(app, {
baseUrl: env.tradingBotUrl,
authorize: async (request) => {
const session = await auth.api.getSession({ headers: sessionHeaders(request) });
return Boolean(session?.user);
},
});
if (localEmailEmulator && env.nodeEnv === "development") {
app.get(
"/api/dev/emails",

View File

@ -42,16 +42,6 @@ describe("loadEnv", () => {
);
});
it("loads a trading bot origin and refuses credentials", () => {
expect(loadEnv(base).tradingBotUrl).toBeUndefined();
expect(
loadEnv({ ...base, TRADING_BOT_URL: " http://127.0.0.1:8000/bot/ " }).tradingBotUrl,
).toBe("http://127.0.0.1:8000");
expect(() =>
loadEnv({ ...base, TRADING_BOT_URL: "http://user:secret@127.0.0.1:8000" }),
).toThrow(/^Invalid TRADING_BOT_URL$/);
});
it("loads an optional integrations catalog mirror", () => {
expect(loadEnv(base).integrationsCatalogUrl).toBeUndefined();
expect(

View File

@ -10,7 +10,6 @@ import {
resolveEncryptionKey,
resolveScreenProxySecret,
resolveSupervisorToken,
resolveTradingBotOrigin,
} from "@rakazo/core";
export { resolveCloudAgentProvider, resolveSandboxProvider } from "@rakazo/adapters";
@ -97,8 +96,6 @@ export interface AppEnv {
updaterToken: string | undefined;
/** Current application image tag; used for compose manual-upgrade command selection. */
imageTag: string | undefined;
/** Tradingbot origin for the same-origin status proxy. Unset on SaaS builds. */
tradingBotUrl: string | undefined;
}
export function loadEnv(source: NodeJS.ProcessEnv = process.env): AppEnv {
@ -187,7 +184,6 @@ export function loadEnv(source: NodeJS.ProcessEnv = process.env): AppEnv {
updaterUrl,
updaterToken,
imageTag: optional(source.RAKAZO_IMAGE_TAG),
tradingBotUrl: resolveTradingBotOrigin(source.TRADING_BOT_URL),
};
}

View File

@ -43,13 +43,20 @@ export async function assertNotMainConversation(
if (group?.kind === "main") throw new ORPCError("BAD_REQUEST", { message });
}
/** The main conversation carries the main bot's name. */
export async function renameMainConversation(prisma: PrismaClient, userId: string, name: string) {
await prisma.chatGroup.updateMany({ where: { userId, kind: "main" }, data: { name } });
/** A main conversation carries its main bot's name. */
export async function renameMainConversation(
prisma: PrismaClient,
main: { userId: string; spaceId: string },
name: string,
) {
await prisma.chatGroup.updateMany({
where: { userId: main.userId, spaceId: main.spaceId, kind: "main" },
data: { name },
});
}
/**
* Same-space bots the user addresses in the main conversation, by `@Name` or a mention chip,
* Same-space bots the user addresses in a main conversation, by `@Name` or a mention chip,
* join it first, so the member mention rules route the message to them.
*/
export async function joinAddressedBots(
@ -71,5 +78,9 @@ export async function joinAddressedBots(
.map((bot) => bot.id);
if (addressed.length === 0) return;
// Subscribers pick up the group.updated event with the send that follows.
await joinMainConversation(prisma, { userId: actor.userId, botIds: addressed });
await joinMainConversation(prisma, {
userId: actor.userId,
spaceId: actor.spaceId,
botIds: addressed,
});
}

View File

@ -7,9 +7,11 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { getScanStatus, startOnboardingScan } from "./onboarding-scan.js";
const createSpaceForMember = vi.hoisted(() => vi.fn());
const ensureMainConversation = vi.hoisted(() => vi.fn());
vi.mock("@rakazo/db", async (original) => ({
...(await original<typeof db>()),
createSpaceForMember,
ensureMainConversation,
}));
const owner = { userId: "user-owner", spaceId: "space-owner", isDeploymentOwner: true };
@ -72,6 +74,7 @@ describe("onboarding scan", () => {
afterEach(() => {
vi.unstubAllEnvs();
createSpaceForMember.mockReset();
ensureMainConversation.mockReset();
});
it("hides a scan from every account except the one that started it", async () => {
@ -127,6 +130,8 @@ describe("onboarding scan", () => {
name: "app",
});
expect(spaces).toEqual([{ id: "space-1", name: "app", workspacePath: app }]);
// Each new project starts with its own main agent.
expect(ensureMainConversation).toHaveBeenCalledWith(prisma, owner.userId, "space-1");
// Scanning again finds the linked project instead of creating a second one.
const again = await startOnboardingScan({ actor: owner, scanHome }, prisma);

View File

@ -1,13 +1,15 @@
// Onboarding scan — real filesystem scan, Git analysis, and profile compilation.
// Runs synchronously on the host machine (no sandbox needed).
import { spawn } from "node:child_process";
import { access, readdir, realpath, stat } from "node:fs/promises";
import { access, readdir, stat } from "node:fs/promises";
import { basename, dirname, join } from "node:path";
import type { Actor } from "@rakazo/contracts";
import type { PrismaClient } from "@rakazo/db";
import {
createSpaceForMember,
ensureMainConversation,
InvalidSpaceNameError,
resolveProjectWorkspaceDirectory,
SpaceLimitError,
wireProjectWorkspace,
} from "@rakazo/db";
@ -656,7 +658,8 @@ async function createProjects(
for (const repo of repos) {
// Compare and link the real folder, so the next scan recognizes the projects of this one.
const folder = await realpath(repo.path).catch(() => null);
// Checking it before the project exists means a folder that cannot be linked leaves none.
const folder = await resolveProjectWorkspaceDirectory(repo.path).catch(() => null);
if (!folder || linkedFolders.has(folder)) continue;
let space: { id: string; name: string };
try {
@ -674,6 +677,7 @@ async function createProjects(
spaceId: space.id,
workspacePath: folder,
});
await ensureMainConversation(prisma, actor.userId, space.id);
created.push({ spaceId: space.id, name: space.name, path: workspace.path });
}

View File

@ -79,7 +79,7 @@ async function requireBotThread(deps: OnboardingDeps, actor: Actor, botId: strin
});
if (!bot?.thread) throw new IsolationError();
if (bot.kind === "main") {
const conversation = await ensureMainConversation(deps.prisma, actor.userId);
const conversation = await ensureMainConversation(deps.prisma, actor.userId, bot.spaceId);
return { bot, thread: { id: conversation.threadId } };
}
return { bot, thread: bot.thread };

View File

@ -89,7 +89,15 @@ import {
verifyMcpInstall,
} from "@rakazo/adapters";
import type { Auth } from "@rakazo/auth";
import type { Actor, ComputerStatus, McpServer, Me, SpaceNavigation } from "@rakazo/contracts";
import type {
Actor,
ComputerStatus,
McpServer,
Me,
SpaceBot,
SpaceGroup,
SpaceNavigation,
} from "@rakazo/contracts";
import {
appContract,
IntegrationProviderIdSchema,
@ -132,12 +140,14 @@ import {
listSpaceUserBotIds,
newestModelCredentialOrder,
newestVoiceCredentialOrder,
ownMainAgentsBlockingSpaceDeletion,
Prisma,
parseComputerMode,
projectWorkspaceDto,
releaseSpaceDeletionClaim,
renewSpaceDeletionClaim,
requireMembership,
resolveProjectWorkspaceDirectory,
SPACE_DELETION_CLAIM_TIMEOUT_MS,
SpaceDeletionInProgressError,
SpaceLimitError,
@ -494,6 +504,26 @@ export interface RouterDeps {
* keep the deletion claim renewed forever and block stale-claim recovery. */
const SPACE_TEARDOWN_TIMEOUT_MS = 120_000;
async function removeGroupWithArtifacts(
deps: RouterDeps,
groupRepos: ReturnType<typeof createGroupRepos>,
actor: Actor,
groupId: string,
) {
const removed = await groupRepos.removeGroup(actor, groupId);
const cleanup = await Promise.allSettled(
removed.artifactStorageKeys.map((storageKey) =>
deps.artifacts.remove(
storageKey,
computerContext(actor, removed.contextBotId, `group-remove:${groupId}`),
),
),
);
for (const result of cleanup) {
if (result.status === "rejected") getLogger().error("group artifact cleanup", result.reason);
}
}
function spaceTeardownTimeoutMs(): number {
const override = Number(process.env.SPACE_TEARDOWN_TIMEOUT_MS ?? "");
return Number.isFinite(override) && override > 0 ? override : SPACE_TEARDOWN_TIMEOUT_MS;
@ -584,6 +614,18 @@ export function createRouter(deps: RouterDeps) {
if (input.workspacePath && !context.actor.isDeploymentOwner) {
throw new ORPCError("FORBIDDEN");
}
// Check the folder before the space exists, so a bad path leaves no space behind.
let workspacePath: string | null = null;
if (input.workspacePath) {
try {
workspacePath = await resolveProjectWorkspaceDirectory(input.workspacePath);
} catch (error) {
if (error instanceof InvalidProjectWorkspacePathError) {
throw new ORPCError("BAD_REQUEST", { message: error.message });
}
throw error;
}
}
let space: { id: string; name: string };
try {
space = await createSpaceForMember(deps.prisma, {
@ -597,20 +639,16 @@ export function createRouter(deps: RouterDeps) {
}
throw error;
}
let workspace = null;
if (input.workspacePath) {
try {
workspace = await wireProjectWorkspace(deps.prisma, {
spaceId: space.id,
workspacePath: input.workspacePath,
});
} catch (error) {
if (error instanceof InvalidProjectWorkspacePathError) {
throw new ORPCError("BAD_REQUEST", { message: error.message });
}
throw error;
}
}
const workspace = workspacePath
? await wireProjectWorkspace(deps.prisma, { spaceId: space.id, workspacePath })
: null;
// A project starts as a conversation with its own main agent.
await ensureMainConversation(deps.prisma, context.actor.userId, space.id);
const spaceActor = await requireMembership(deps.prisma, context.actor.userId, space.id);
const [bots, groups] = await Promise.all([
repos.listBots(spaceActor),
groupRepos.listGroups(spaceActor),
]);
return {
id: space.id,
name: space.name,
@ -618,12 +656,26 @@ export function createRouter(deps: RouterDeps) {
hasContent: false,
canDelete: true,
workspace,
bots: [],
groups: [],
bots: bots.map(spaceBotDto),
groups: groups.map(spaceGroupDto),
externalConversations: [],
botSections: [],
};
}),
ensureMain: authed.spaces.ensureMain.handler(async ({ context, input }) => {
const conversation = await ensureMainConversation(
deps.prisma,
context.actor.userId,
input.spaceId,
).catch((error: unknown) => {
throw mapSpaceLifecycleError(error);
});
return {
botId: conversation.leadBotId,
groupId: conversation.groupId,
spaceId: conversation.spaceId,
};
}),
remove: authed.spaces.remove.handler(async ({ context, input }) => {
let claimId: string | null = null;
let claimActive = false;
@ -636,6 +688,41 @@ export function createRouter(deps: RouterDeps) {
spaceId: input.spaceId,
};
try {
// The project's own main agent goes with the project; anything else must be
// cleared first.
const ownMain = await ownMainAgentsBlockingSpaceDeletion(deps.prisma, deleteInput);
if (ownMain.groupIds.length > 0 || ownMain.botIds.length > 0) {
const spaceActor = await requireMembership(
deps.prisma,
context.actor.userId,
input.spaceId,
);
for (const groupId of ownMain.groupIds) {
await removeGroupWithArtifacts(deps, groupRepos, spaceActor, groupId);
}
for (const botId of ownMain.botIds) {
const bot = await repos.getBot(spaceActor, botId, { includeArchived: true });
await destroyBot(
{
prisma: deps.prisma,
sandbox: deps.sandbox,
home: deps.home,
jobs: deps.jobs,
artifacts: deps.artifacts,
dataDir: deps.dataDir,
},
bot,
{
operationId: "spaces.remove",
traceId: "spaces.remove",
spaceId: input.spaceId,
userId: context.actor.userId,
signal: new AbortController().signal,
},
{ deleteMemories: true },
);
}
}
// Claim emptiness before external teardown. Bot and group creation
// take the same lifecycle lock and reject the Space until deletion
// finishes or this claim is released.
@ -1234,7 +1321,11 @@ export function createRouter(deps: RouterDeps) {
},
});
if (existing.kind === "main" && input.name !== undefined && input.name !== existing.name) {
await renameMainConversation(deps.prisma, context.actor.userId, input.name);
await renameMainConversation(
deps.prisma,
{ userId: context.actor.userId, spaceId: existing.spaceId },
input.name,
);
}
const bots = await repos.listBots(context.actor);
const bot = bots.find((b) => b.id === input.botId);
@ -1522,19 +1613,7 @@ export function createRouter(deps: RouterDeps) {
input.groupId,
"The main conversation can't be deleted.",
);
const removed = await groupRepos.removeGroup(context.actor, input.groupId);
const cleanup = await Promise.allSettled(
removed.artifactStorageKeys.map((storageKey) =>
deps.artifacts.remove(
storageKey,
computerContext(context.actor, removed.contextBotId, `group-remove:${input.groupId}`),
),
),
);
for (const result of cleanup) {
if (result.status === "rejected")
getLogger().error("group artifact cleanup", result.reason);
}
await removeGroupWithArtifacts(deps, groupRepos, context.actor, input.groupId);
return { ok: true as const };
}),
},
@ -4951,6 +5030,11 @@ async function spaceNavigationDto(
orderBy: { createdAt: "asc" },
});
const spaceIds = memberships.map((membership) => membership.spaceId);
const ownProjectMain = {
userId: actor.userId,
kind: "main",
space: { isDefault: false },
} as const;
const inactiveSpaceIds = spaceIds.filter((spaceId) => spaceId !== actor.spaceId);
const [
currentBots,
@ -4971,13 +5055,14 @@ async function spaceNavigationDto(
// Active-only navigation lists miss archived content in other spaces; count any
// bot/group in the actor's spaces (including another member's) so a shared
// non-empty space cannot look empty for onboarding redirects.
// The user's own main agent of a project is part of the project, not content to clear.
deps.prisma.bot.findMany({
where: { spaceId: { in: spaceIds } },
where: { spaceId: { in: spaceIds }, NOT: ownProjectMain },
select: { spaceId: true },
distinct: ["spaceId"],
}),
deps.prisma.chatGroup.findMany({
where: { spaceId: { in: spaceIds } },
where: { spaceId: { in: spaceIds }, NOT: ownProjectMain },
select: { spaceId: true },
distinct: ["spaceId"],
}),
@ -5023,33 +5108,8 @@ async function spaceNavigationDto(
(membership.space.deletingAt === null || membership.space.deletingAt < staleClaimBefore),
// A linked folder is on the owner's host; other members see neither its path nor remote.
workspace: actor.isDeploymentOwner ? projectWorkspaceDto(membership.space) : null,
bots: spaceBots.map((bot) => ({
id: bot.id,
spaceId: bot.spaceId,
kind: bot.kind,
name: bot.name,
title: bot.title,
color: bot.color,
notifyOnFinish: bot.notifyOnFinish,
pinned: bot.pinned,
sectionId: bot.sectionId,
unread: bot.unread,
preview: bot.preview,
status: bot.status,
updatedAt: bot.updatedAt,
})),
groups: spaceGroups.map((group) => ({
id: group.id,
spaceId: group.spaceId,
kind: group.kind,
name: group.name,
pinned: group.pinned,
sectionId: group.sectionId,
members: group.members,
preview: group.preview,
unread: group.unread,
updatedAt: group.updatedAt,
})),
bots: spaceBots.map(spaceBotDto),
groups: spaceGroups.map(spaceGroupDto),
externalConversations: externalConversations.filter(
(conversation) => conversation.spaceId === membership.spaceId,
),
@ -5060,6 +5120,39 @@ async function spaceNavigationDto(
};
}
function spaceBotDto(bot: SpaceBot): SpaceBot {
return {
id: bot.id,
spaceId: bot.spaceId,
kind: bot.kind,
name: bot.name,
title: bot.title,
color: bot.color,
notifyOnFinish: bot.notifyOnFinish,
pinned: bot.pinned,
sectionId: bot.sectionId,
unread: bot.unread,
preview: bot.preview,
status: bot.status,
updatedAt: bot.updatedAt,
};
}
function spaceGroupDto(group: SpaceGroup): SpaceGroup {
return {
id: group.id,
spaceId: group.spaceId,
kind: group.kind,
name: group.name,
pinned: group.pinned,
sectionId: group.sectionId,
members: group.members,
preview: group.preview,
unread: group.unread,
updatedAt: group.updatedAt,
};
}
function partitionBySpace<T extends { spaceId: string }>(rows: T[]): Map<string, T[]> {
const partitioned = new Map<string, T[]>();
for (const row of rows) {

View File

@ -0,0 +1,143 @@
import type { MessageBlock } from "@rakazo/contracts";
import type { PrismaClient } from "@rakazo/db";
import { createDb, provisionMessagingIdentity } from "@rakazo/db";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { loadMessagePage } from "./thread-message-pages.js";
const databaseUrl = process.env.DATABASE_URL;
const describePostgres =
process.env.VERIFY_DATABASE && databaseUrl ? describe.sequential : describe.skip;
describePostgres("loadMessagePage (PostgreSQL)", () => {
let prisma: PrismaClient;
let close: () => Promise<void>;
let identity: { userId: string; spaceId: string; botId: string; threadId: string };
let userRunId: string;
let peerRunId: string;
let seq = 0;
beforeAll(async () => {
const db = createDb(databaseUrl!);
prisma = db.prisma;
close = async () => {
await db.prisma.$disconnect();
await db.pool.end();
};
identity = await provisionMessagingIdentity(
prisma,
{ provider: "sendblue", address: `+1555${Date.now().toString().slice(-7)}` },
{ signupsEnabled: undefined, signupAllowlist: undefined },
);
const { spaceId, botId, threadId, userId } = identity;
const owner = { spaceId, botId, threadId, userId };
const run = async (trigger: string) => {
const task = await prisma.task.create({
data: { ...owner, prompt: "fixture", status: "completed" },
});
const created = await prisma.run.create({
data: { ...owner, taskId: task.id, status: "completed", trigger },
});
return created.id;
};
userRunId = await run("user_message");
peerRunId = await run("bot_message");
seq =
(
await prisma.message.aggregate({
where: { threadId: identity.threadId },
_max: { seq: true },
})
)._max.seq ?? 0;
});
afterAll(async () => {
if (identity) {
await prisma.messagingIdentity.deleteMany({ where: { userId: identity.userId } });
await prisma.organization.deleteMany({ where: { id: identity.spaceId } });
await prisma.user.deleteMany({ where: { id: identity.userId } });
}
await close?.();
});
async function message(id: string, runId: string | null, blocks: MessageBlock[]) {
seq += 1;
await prisma.message.create({
data: {
id: `${identity.threadId}-${id}`,
threadId: identity.threadId,
seq,
role: "bot",
runId,
blocks,
},
});
return seq;
}
const ids = (page: Awaited<ReturnType<typeof loadMessagePage>>) =>
page.messages.map((row) => row.id.slice(identity.threadId.length + 1));
it("fills a page with visible rows across a long stretch of hidden peer activity", async () => {
await message("older", userRunId, [{ kind: "text", text: "Older answer" }]);
await message("newer", userRunId, [{ kind: "text", text: "Newer answer" }]);
for (let index = 0; index < 30; index += 1) {
await message(`peer-steps-${index}`, peerRunId, [
{ kind: "steps", steps: [{ label: "Working", count: 1 }] },
]);
}
await message("peer-receipt", peerRunId, [
{ kind: "bot_message_received", fromBotId: "bot-2", fromBotName: "Coder", text: "Done." },
]);
await message("peer-reply", peerRunId, [{ kind: "text", text: "Peer summary" }]);
await message("orphan-run", "run-that-no-longer-exists", [{ kind: "text", text: "Kept" }]);
const first = await loadMessagePage(prisma, identity.threadId, undefined, 3);
expect(ids(first)).toEqual(["peer-receipt", "peer-reply", "orphan-run"]);
const second = await loadMessagePage(prisma, identity.threadId, first.olderCursor!, 2);
expect(ids(second)).toEqual(["older", "newer"]);
const peerView = await loadMessagePage(
prisma,
identity.threadId,
undefined,
3,
undefined,
true,
);
expect(ids(peerView)).toEqual(["peer-receipt", "peer-reply", "orphan-run"]);
const peerOlder = await loadMessagePage(
prisma,
identity.threadId,
peerView.olderCursor!,
1,
undefined,
true,
);
expect(ids(peerOlder)).toEqual(["peer-steps-29"]);
});
it("skips a receipt-only page for web but returns it when receipts are displayed", async () => {
await message("before-receipts", userRunId, [{ kind: "text", text: "Answer" }]);
await message("sent", userRunId, [
{ kind: "bot_message_sent", toBotId: "bot-2", toBotName: "Coder", text: "Check this." },
]);
await message("received", peerRunId, [
{ kind: "bot_message_received", fromBotId: "bot-2", fromBotName: "Coder", text: "Done." },
]);
const web = await loadMessagePage(prisma, identity.threadId, undefined, 2);
expect(ids(web)).toEqual(["orphan-run", "before-receipts"]);
const mobile = await loadMessagePage(
prisma,
identity.threadId,
undefined,
2,
undefined,
false,
true,
);
expect(ids(mobile)).toEqual(["sent", "received"]);
});
});

View File

@ -43,98 +43,6 @@ describe("thread message pages", () => {
).toBe(false);
});
it("keeps peer receipt rows when filtering peer-run output from pages", async () => {
const findMany = vi.fn(async () => [
{
id: "message-reply",
threadId: "thread-1",
seq: 3,
role: "bot",
blocks: [{ kind: "text", text: "Echoed peer reply" }],
botId: "bot-1",
replyToMessageId: null,
runId: "run-peer",
createdAt: new Date("2026-08-16T00:00:03.000Z"),
},
{
id: "message-received",
threadId: "thread-1",
seq: 2,
role: "user",
blocks: [
{
kind: "bot_message_received",
fromBotId: "bot-2",
fromBotName: "Coder",
text: "Done.",
},
],
botId: null,
replyToMessageId: null,
runId: "run-peer",
createdAt: new Date("2026-08-16T00:00:02.000Z"),
},
{
id: "message-user",
threadId: "thread-1",
seq: 1,
role: "bot",
blocks: [{ kind: "text", text: "Visible answer" }],
botId: "bot-1",
replyToMessageId: null,
runId: "run-user",
createdAt: new Date("2026-08-16T00:00:01.000Z"),
},
]);
const prisma = {
message: { findMany },
run: { findMany: vi.fn(async () => [{ id: "run-peer" }]) },
} as unknown as PrismaClient;
const page = await loadMessagePage(prisma, "thread-1", undefined, 3);
expect(page.messages.map((message) => message.id)).toEqual([
"message-user",
"message-received",
"message-reply",
]);
});
it("filters peer-run activity when its receipt is outside the loaded page", async () => {
const findMany = vi.fn(async () => [
{
id: "message-peer",
threadId: "thread-1",
seq: 2,
role: "bot",
blocks: [{ kind: "steps", steps: [{ label: "Echoed peer reply", count: 1 }] }],
botId: "bot-1",
replyToMessageId: null,
runId: "run-peer",
createdAt: new Date("2026-08-16T00:00:02.000Z"),
},
{
id: "message-user",
threadId: "thread-1",
seq: 1,
role: "bot",
blocks: [{ kind: "text", text: "Visible answer" }],
botId: "bot-1",
replyToMessageId: null,
runId: "run-user",
createdAt: new Date("2026-08-16T00:00:01.000Z"),
},
]);
const prisma = {
message: { findMany },
run: { findMany: vi.fn(async () => [{ id: "run-peer" }]) },
} as unknown as PrismaClient;
const page = await loadMessagePage(prisma, "thread-1", undefined, 2);
expect(page.messages.map((message) => message.id)).toEqual(["message-user"]);
});
it("keeps peer text around-page targets and omits peer activity", async () => {
const findMany = vi.fn(async () => [
{
@ -281,45 +189,6 @@ describe("thread message pages", () => {
expect(page.messages.map((message) => message.id)).toEqual(["message-peer"]);
});
it("scans past a page containing only hidden peer-run activity", async () => {
const row = (seq: number, runId: string, kind: "text" | "steps" = "text") => ({
id: `message-${seq}`,
threadId: "thread-1",
seq,
role: "bot",
blocks:
kind === "steps"
? [{ kind: "steps", steps: [{ label: String(seq), count: 1 }] }]
: [{ kind: "text", text: String(seq) }],
botId: "bot-1",
replyToMessageId: null,
runId,
createdAt: new Date("2026-08-16T00:00:00.000Z"),
});
const findMany = vi
.fn()
.mockResolvedValueOnce([
row(4, "run-peer", "steps"),
row(3, "run-peer", "steps"),
row(2, "run-peer", "steps"),
])
.mockResolvedValueOnce([row(1, "run-user")]);
const prisma = {
message: { findMany },
run: {
findMany: vi
.fn()
.mockResolvedValueOnce([{ id: "run-peer" }])
.mockResolvedValueOnce([]),
},
} as unknown as PrismaClient;
const page = await loadMessagePage(prisma, "thread-1", undefined, 2);
expect(page.messages.map((message) => message.id)).toEqual(["message-1"]);
expect(findMany).toHaveBeenCalledTimes(2);
});
it("scans past a receipt-only page so web can reach older user-visible rows", async () => {
const receiptRows = [
{
@ -383,21 +252,13 @@ describe("thread message pages", () => {
createdAt: new Date("2026-08-16T00:00:01.000Z"),
},
];
const findMany = vi.fn().mockResolvedValueOnce(receiptRows).mockResolvedValueOnce(olderRows);
const prisma = {
message: { findMany },
run: {
findMany: vi
.fn()
.mockResolvedValueOnce([{ id: "run-peer" }])
.mockResolvedValueOnce([]),
},
} as unknown as PrismaClient;
const queryRaw = vi.fn().mockResolvedValueOnce(receiptRows).mockResolvedValueOnce(olderRows);
const prisma = { $queryRaw: queryRaw } as unknown as PrismaClient;
const page = await loadMessagePage(prisma, "thread-1", undefined, 2);
expect(page.messages.map((message) => message.id)).toEqual(["message-user"]);
expect(findMany).toHaveBeenCalledTimes(2);
expect(queryRaw).toHaveBeenCalledTimes(2);
});
it("returns a receipt-only page when the client displays peer receipts", async () => {
@ -419,11 +280,8 @@ describe("thread message pages", () => {
runId: "run-peer",
createdAt: new Date(`2026-08-16T00:00:0${seq}.000Z`),
});
const findMany = vi.fn(async () => [receipt(3), receipt(2), receipt(1)]);
const prisma = {
message: { findMany },
run: { findMany: vi.fn(async () => [{ id: "run-peer" }]) },
} as unknown as PrismaClient;
const queryRaw = vi.fn(async () => [receipt(3), receipt(2), receipt(1)]);
const prisma = { $queryRaw: queryRaw } as unknown as PrismaClient;
const page = await loadMessagePage(prisma, "thread-1", undefined, 2, undefined, false, true);
@ -432,10 +290,10 @@ describe("thread message pages", () => {
"message-receipt-3",
]);
expect(page.olderCursor).toBe(2);
expect(findMany).toHaveBeenCalledTimes(1);
expect(queryRaw).toHaveBeenCalledTimes(1);
});
it("queries before the cursor and returns an ascending bounded page", async () => {
it("queries the bot messages view before the cursor as an ascending bounded page", async () => {
const findMany = vi.fn(async () =>
[5, 4, 3].map((seq) => ({
id: `message-${seq}`,
@ -449,7 +307,7 @@ describe("thread message pages", () => {
);
const prisma = { message: { findMany } } as unknown as PrismaClient;
const page = await loadMessagePage(prisma, "thread-1", 6, 2);
const page = await loadMessagePage(prisma, "thread-1", 6, 2, undefined, true);
expect(findMany).toHaveBeenCalledWith({
where: { threadId: "thread-1", seq: { lt: 6 } },
@ -461,7 +319,7 @@ describe("thread message pages", () => {
});
it("ends pagination when the database returns no lookahead row", async () => {
const findMany = vi.fn(async () => [
const queryRaw = vi.fn(async () => [
{
id: "message-0",
threadId: "thread-1",
@ -472,7 +330,7 @@ describe("thread message pages", () => {
createdAt: new Date("2026-08-16T00:00:00.000Z"),
},
]);
const prisma = { message: { findMany } } as unknown as PrismaClient;
const prisma = { $queryRaw: queryRaw } as unknown as PrismaClient;
const page = await loadMessagePage(prisma, "thread-1", 1, 2);

View File

@ -1,9 +1,18 @@
import type { MessageBlock, ThreadMessage, ThreadMessagePage } from "@rakazo/contracts";
import { isPeerReceiptBlocks } from "@rakazo/core";
import type { Prisma, PrismaClient } from "@rakazo/db";
import type { PrismaClient } from "@rakazo/db";
import { Prisma } from "@rakazo/db";
type MessageDb = PrismaClient | Prisma.TransactionClient;
/** Peer-run rows that stay in the normal transcript: receipts, ask cards and the bot's own reply. */
const PEER_VISIBLE_BLOCK_KINDS: readonly string[] = [
"bot_message_sent",
"bot_message_received",
"ask",
"text",
];
export async function loadMessagePage(
prisma: MessageDb,
threadId: string,
@ -47,37 +56,69 @@ export async function loadMessagePage(
}
}
let cursor = before;
while (true) {
if (includePeerRuns) {
const rows = await prisma.message.findMany({
where: {
threadId,
...(cursor === undefined ? {} : { seq: { lt: cursor } }),
...(before === undefined ? {} : { seq: { lt: before } }),
},
orderBy: { seq: "desc" },
take: pageSize + 1,
});
const hasOlder = rows.length > pageSize;
const pageRows = rows.slice(0, pageSize).reverse();
const visibleRows = includePeerRuns ? pageRows : await withoutPeerRunMessages(prisma, pageRows);
return toPage(threadId, rows, pageSize);
}
let cursor = before;
while (true) {
const rows = await visibleMessageRows(prisma, threadId, cursor, pageSize + 1);
const page = toPage(threadId, rows, pageSize);
// Web hides receipts client-side, so its receipt-only pages keep scanning.
// Mobile explicitly retains them and must receive each page for pagination.
const hasSubstantive = visibleRows.some(
(row) => !isPeerReceiptBlocks(row.blocks as MessageBlock[]),
);
if (hasSubstantive || includePeerReceipts || !hasOlder || includePeerRuns) {
return {
threadId,
messages: visibleRows.map(toThreadMessage),
olderCursor: hasOlder ? (pageRows[0]?.seq ?? null) : null,
};
}
// TODO: only rescan when a raw page is entirely peer output. Consider a run relation if
// long peer-only histories make this path hot.
cursor = pageRows[0]?.seq;
const hasSubstantive = page.messages.some((message) => !isPeerReceiptBlocks(message.blocks));
if (hasSubstantive || includePeerReceipts || page.olderCursor === null) return page;
cursor = page.olderCursor;
}
}
type MessageRow = Parameters<typeof toThreadMessage>[0];
function toPage(threadId: string, rows: MessageRow[], pageSize: number): ThreadMessagePage {
const pageRows = rows.slice(0, pageSize).reverse();
return {
threadId,
messages: pageRows.map(toThreadMessage),
olderCursor: rows.length > pageSize ? (pageRows[0]?.seq ?? null) : null,
};
}
/**
* Newest-first transcript rows with peer-run activity excluded in the database, so a long
* peer-only stretch costs one query instead of a rescan per hidden page.
*/
function visibleMessageRows(
prisma: MessageDb,
threadId: string,
before: number | undefined,
limit: number,
): Promise<MessageRow[]> {
return prisma.$queryRaw<MessageRow[]>`
SELECT m.id, m."threadId", m.seq, m.role, m.blocks, m."botId", m."replyToMessageId",
m."replyQuote", m."runId", m."createdAt"
FROM messages m
LEFT JOIN runs r ON r.id = m."runId"
WHERE m."threadId" = ${threadId}
${before === undefined ? Prisma.empty : Prisma.sql`AND m.seq < ${before}`}
AND (r.trigger IS DISTINCT FROM 'bot_message' OR ${Prisma.join(
PEER_VISIBLE_BLOCK_KINDS.map(
(kind) => Prisma.sql`m.blocks @> ${JSON.stringify([{ kind }])}::jsonb`,
),
" OR ",
)})
ORDER BY m.seq DESC
LIMIT ${limit}
`;
}
export async function loadAllMessages(
prisma: PrismaClient,
threadId: string,
@ -106,15 +147,8 @@ async function withoutPeerRunMessages<T extends { runId: string | null; blocks:
const peerRunIds = new Set(peerRuns.map((run) => run.id));
return rows.filter((row) => {
if (!row.runId || !peerRunIds.has(row.runId)) return true;
// Keep peer receipts (chips), ask cards, and the bot's own text reply.
const blocks = row.blocks as MessageBlock[];
return blocks.some(
(block) =>
block.kind === "bot_message_sent" ||
block.kind === "bot_message_received" ||
block.kind === "ask" ||
block.kind === "text",
);
return blocks.some((block) => PEER_VISIBLE_BLOCK_KINDS.includes(block.kind));
});
}
@ -159,10 +193,8 @@ export function shouldForwardPeerThreadEvent(event: {
!!block &&
typeof block === "object" &&
"kind" in block &&
(block.kind === "bot_message_received" ||
block.kind === "bot_message_sent" ||
block.kind === "ask" ||
block.kind === "text"),
typeof block.kind === "string" &&
PEER_VISIBLE_BLOCK_KINDS.includes(block.kind),
)
);
}

View File

@ -183,7 +183,7 @@ describe("threadSnapshot", () => {
},
]);
const tx = {
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]),
$queryRaw: threadQueries(),
message: { findMany: vi.fn().mockResolvedValue([]) },
event: {
findFirst: vi.fn().mockResolvedValue({ seq: 4 }),
@ -203,7 +203,10 @@ describe("threadSnapshot", () => {
const snapshot = await threadSnapshot({ prisma }, target);
expect(tx.$queryRaw).toHaveBeenCalledOnce();
expect(tx.$queryRaw).toHaveBeenCalledWith(
expect.arrayContaining([expect.stringContaining("FOR SHARE")]),
"thread-1",
);
expect(findManyEvents).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
@ -245,7 +248,7 @@ describe("threadSnapshot", () => {
const findManyEvents = vi.fn();
const findFirstRun = botRunFindFirst([run]);
const tx = {
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]),
$queryRaw: threadQueries(),
message: { findMany: vi.fn().mockResolvedValue([]) },
event: {
findFirst: vi.fn().mockResolvedValue(null),
@ -330,7 +333,7 @@ describe("threadSnapshot", () => {
prisma: {
$transaction: vi.fn(async (callback: (client: unknown) => unknown) =>
callback({
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]),
$queryRaw: threadQueries(),
message: { findMany: vi.fn().mockResolvedValue([]) },
event: {
findFirst: vi.fn().mockResolvedValue(null),
@ -385,7 +388,7 @@ describe("threadSnapshot", () => {
};
const findFirstRun = botRunFindFirst([failed, completed]);
const tx = {
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]),
$queryRaw: threadQueries(),
message: { findMany: vi.fn().mockResolvedValue([]) },
event: {
findFirst: vi.fn().mockResolvedValue(null),
@ -421,7 +424,7 @@ describe("threadSnapshot", () => {
const findManyEvents = vi.fn();
const findFirstRun = botRunFindFirst([]);
const tx = {
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]),
$queryRaw: threadQueries(),
message: { findMany: vi.fn().mockResolvedValue([]) },
event: {
findFirst: vi.fn().mockResolvedValue(null),
@ -925,9 +928,16 @@ function groupRunFindMany(input: { active?: unknown[]; terminals?: unknown[] })
);
}
/** The snapshot's thread lock finds the thread; its message page query finds no rows. */
function threadQueries() {
return vi.fn(async (sql: TemplateStringsArray) =>
sql.join("").includes("FOR SHARE") ? [{ id: "thread-1" }] : [],
);
}
function groupPrisma(findManyRuns: ReturnType<typeof groupRunFindMany>) {
const tx = {
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]),
$queryRaw: threadQueries(),
message: { findMany: vi.fn().mockResolvedValue([]) },
event: {
findFirst: vi.fn().mockResolvedValue(null),

View File

@ -1,96 +0,0 @@
import { TRADING_BOT_PROXY_PATH } from "@rakazo/core";
import { Hono } from "hono";
import { describe, expect, it, vi } from "vitest";
import { mountTradingBotStatus } from "./trading-bot-proxy.js";
function proxyApp(options: {
baseUrl?: string;
authorize?: (request: Request) => Promise<boolean>;
fetchImpl?: typeof fetch;
}) {
const app = new Hono();
mountTradingBotStatus(app, {
baseUrl: options.baseUrl,
authorize: options.authorize ?? (async () => true),
fetchImpl: options.fetchImpl,
});
return app;
}
describe("trading bot status proxy", () => {
it("stays idle when TRADING_BOT_URL is unset and does not call upstream", async () => {
const fetchImpl = vi.fn();
const response = await proxyApp({ fetchImpl }).request(TRADING_BOT_PROXY_PATH);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ configured: false });
expect(fetchImpl).not.toHaveBeenCalled();
});
it("requires a session before it proxies a configured origin", async () => {
const fetchImpl = vi.fn();
const response = await proxyApp({
baseUrl: "http://bot.test:8000",
authorize: async () => false,
fetchImpl,
}).request(TRADING_BOT_PROXY_PATH, { headers: { cookie: "session=secret" } });
expect(response.status).toBe(401);
expect(fetchImpl).not.toHaveBeenCalled();
});
it("returns the mapped status and does not forward browser credentials", async () => {
const fetchImpl = vi.fn(
async () =>
new Response(
JSON.stringify({ mode: "LIVE", adapter_count: 2, last_error: "adapter timeout" }),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
const response = await proxyApp({
baseUrl: "http://bot.test:8000",
fetchImpl,
}).request(TRADING_BOT_PROXY_PATH, {
headers: { cookie: "session=secret", authorization: "Bearer browser-token" },
});
expect(response.status).toBe(200);
expect(await response.json()).toEqual({
configured: true,
reachable: true,
status: {
mode: "live",
kind: "live",
adapterCount: 2,
lastError: "adapter timeout",
},
});
expect(fetchImpl).toHaveBeenCalledTimes(1);
const [url, init] = fetchImpl.mock.calls[0] as [string, RequestInit];
expect(url).toBe("http://bot.test:8000/bot/api/status");
const headers = new Headers(init.headers);
expect(headers.get("cookie")).toBeNull();
expect(headers.get("authorization")).toBeNull();
expect(init.redirect).toBe("error");
});
it("hides an unmapped body and an upstream failure instead of claiming live", async () => {
const unmapped = vi.fn(
async () =>
new Response(JSON.stringify({ status: "ok" }), {
status: 200,
headers: { "content-type": "application/json" },
}),
);
const unknown = await proxyApp({
baseUrl: "http://bot.test:8000",
fetchImpl: unmapped,
}).request(TRADING_BOT_PROXY_PATH);
expect(await unknown.json()).toEqual({ configured: true, reachable: true, status: null });
const down = vi.fn(async () => new Response("nope", { status: 500 }));
const offline = await proxyApp({
baseUrl: "http://bot.test:8000",
fetchImpl: down,
}).request(TRADING_BOT_PROXY_PATH);
expect(offline.status).toBe(502);
expect(await offline.json()).toEqual({ configured: true, reachable: false });
});
});

View File

@ -1,79 +0,0 @@
import {
mapTradingBotStatus,
readBoundedJsonResponse,
TRADING_BOT_PROXY_PATH,
TRADING_BOT_STATUS_MAX_BYTES,
TRADING_BOT_STATUS_TIMEOUT_MS,
tradingBotStatusUrl,
} from "@rakazo/core";
import type { Hono } from "hono";
export function mountTradingBotStatus(
app: Hono,
options: {
/** Normalized http(s) origin. Unset leaves the route idle for SaaS builds. */
baseUrl: string | undefined;
authorize: (request: Request) => Promise<boolean>;
fetchImpl?: typeof fetch;
},
) {
const fetchImpl = options.fetchImpl ?? fetch;
app.get(TRADING_BOT_PROXY_PATH, async (c) => {
c.header("cache-control", "no-store");
if (!options.baseUrl) return c.json({ configured: false });
const allowed = await options.authorize(c.req.raw).catch(() => false);
if (!allowed) return c.json({ error: "Unauthorized" }, 401);
const timeout = linkAbort(c.req.raw.signal, TRADING_BOT_STATUS_TIMEOUT_MS);
try {
const response = await fetchImpl(tradingBotStatusUrl(options.baseUrl), {
method: "GET",
redirect: "error",
cache: "no-store",
signal: timeout.signal,
headers: { accept: "application/json" },
});
if (!response.ok) {
cancelBody(response);
return c.json({ configured: true, reachable: false }, 502);
}
const body = await readBoundedJsonResponse<unknown>(
response,
TRADING_BOT_STATUS_MAX_BYTES,
timeout.signal,
);
const status = mapTradingBotStatus(body);
return c.json({ configured: true, reachable: true, status: status ?? null });
} catch {
return c.json({ configured: true, reachable: false }, 502);
} finally {
timeout.cancel();
}
});
}
function linkAbort(
parent: AbortSignal,
timeoutMs: number,
): { signal: AbortSignal; cancel: () => void } {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const onParent = () => controller.abort();
if (parent.aborted) controller.abort();
else parent.addEventListener("abort", onParent);
return {
signal: controller.signal,
cancel: () => {
clearTimeout(timer);
parent.removeEventListener("abort", onParent);
},
};
}
function cancelBody(response: Response): void {
try {
void Promise.resolve(response.body?.cancel()).catch(() => undefined);
} catch {
// Best-effort. The caller still reports the bot as unreachable.
}
}

View File

@ -28,7 +28,11 @@ export default function NewSpace() {
return;
}
router.dismissAll();
router.replace("/");
// A new project opens in the conversation with its own main agent.
const main = space.groups.find((group) => group.kind === "main");
router.replace(
main ? { pathname: "/group-thread", params: { groupId: main.id, name: main.name } } : "/",
);
} catch (reason) {
setError(reason instanceof Error ? reason.message : t("Could not create space"));
setPending(false);

View File

@ -17,10 +17,12 @@ import {
buildComposerMentionOptions,
type ComposerMention,
cloudAgentHttpsUrl,
deriveWorkspaceFocus,
isApprovalAskBlock,
isRunTerminalEvent,
isSecretAskBlock,
latestAnswerableAskMessageId,
liveScreenRunId,
mentionChipKey,
projectMessageReactions,
resolveComposerSendPlan,
@ -288,6 +290,8 @@ function Thread() {
? { botId }
: undefined;
const [snap, setSnap] = useState<MobileSnapshot | null>(null);
/** Run that last pointed at the bot's screen, so the live screen is offered only during real screen work. */
const [screenRunId, setScreenRunId] = useState<string | null>(null);
const activeThreadId = useRef<string | undefined>(undefined);
const [draft, setDraft] = useState("");
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
@ -860,6 +864,7 @@ function Thread() {
}
expandedHistoryThread.current = null;
historyEpoch.current += 1;
setScreenRunId(null);
const abort = new AbortController();
void (async () => {
// Pending search jumps load the around-page separately; avoid replacing it with latest.
@ -885,6 +890,8 @@ function Thread() {
(event) => {
cursor = Math.max(cursor, event.seq ?? -1);
retryMs = 250;
const screenRun = groupId ? null : liveScreenRunId(event);
if (screenRun) setScreenRunId(screenRun);
if (
event.type === "thread.progress" ||
event.type === "agent.tool.called" ||
@ -1271,6 +1278,23 @@ function Thread() {
}
const answerableAskMessageId = latestAnswerableAskMessageId(snap);
const screenFocus = useMemo(() => {
const computer = snap?.computer;
if (!snap || !computer || groupId) return null;
const focus = deriveWorkspaceFocus(
{
...snap,
computer: {
kind: computer.kind ?? "",
state: computer.state,
screenAvailable: computer.screenAvailable,
takeoverRequested: computer.takeoverRequested ?? false,
},
},
{ screenRunId },
);
return focus.pane === "screen" ? focus : null;
}, [groupId, screenRunId, snap]);
const runError = snap?.run?.status === "failed" ? (snap.run.error ?? null) : null;
const liveMessages = useMemo(() => [...visibleMessages].reverse(), [visibleMessages]);
const messagesById = useMemo(
@ -1668,6 +1692,43 @@ function Thread() {
) : null}
</View>
<View style={{ paddingBottom: keyboardVisible ? 12 : Math.max(insets.bottom + 12, 24) }}>
{screenFocus && botId ? (
<Pressable
accessibilityRole="button"
testID="live-screen-pill"
onPress={() =>
router.push({
pathname: "/computer",
params: { botId, name: displayName ?? t("Bot") },
})
}
style={({ pressed }) => ({
alignSelf: "center",
marginTop: 10,
flexDirection: "row",
alignItems: "center",
gap: 8,
borderRadius: 999,
borderWidth: 1,
borderColor: native.fillPressed,
backgroundColor: pressed ? native.fillPressed : native.fill,
paddingHorizontal: 14,
paddingVertical: 8,
})}
>
<View
style={{
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: screenFocus.takeover ? tokens.warning : tokens.success,
}}
/>
<Text style={{ color: tokens.foreground, fontSize: 14, fontWeight: "500" }}>
{screenFocus.takeover ? t("Needs takeover") : t("Open computer")}
</Text>
</Pressable>
) : null}
{replyTarget ? (
<View
style={{

View File

@ -728,7 +728,7 @@ export type MobileBot = Pick<
| "modelId"
| "thinkingLevel"
> &
Partial<Pick<Bot, "parentBotId" | "spaceId">>;
Partial<Pick<Bot, "parentBotId" | "spaceId" | "kind">>;
export type MobileBotSection = BotSection;
@ -790,9 +790,11 @@ export type MobileSnapshot = {
activeRuns?: Array<{ id: string; botId?: string; status: string }>;
members?: MobileGroup["members"];
computer?: {
kind?: string;
state: string;
controlHolder: string;
screenAvailable: boolean;
takeoverRequested?: boolean;
mode: ComputerMode;
busyBotName: string | null;
};
@ -896,6 +898,7 @@ type ThreadEvent = {
type: string;
seq?: number;
runId?: string;
createdAt?: string;
payload?: Record<string, unknown>;
};

View File

@ -83,7 +83,7 @@ describe("display and warnings", () => {
expect(apiBaseWarning("http://127.0.0.1:3100")).toBeNull();
expect(apiBaseWarning("http://192.168.1.20:3100")).toBeNull();
expect(apiBaseWarning("http://100.64.0.1:3100")).toBeNull();
expect(apiBaseWarning("http://100.119.57.55:3100")).toBeNull();
expect(apiBaseWarning("http://100.100.100.100:3100")).toBeNull();
expect(apiBaseWarning("http://100.127.255.255:3100")).toBeNull();
expect(apiBaseWarning("http://app.example.com")).toMatch(/https/i);
});

View File

@ -111,6 +111,29 @@ describe("spaceInboxItems", () => {
expect(items.map((item) => item.type)).toEqual(["bot", "group"]);
});
it("lists a project's main conversation instead of its main bot", () => {
const main = {
id: "group-main",
name: "Chief",
kind: "main" as const,
preview: "",
pinned: false,
sectionId: null,
archivedAt: null,
unread: false,
updatedAt: "2026-01-01T00:00:00.000Z",
members: [],
};
const lead: MobileBot = { ...bot, id: "bot-lead", name: "Chief", kind: "main" };
const items = spaceInboxItems([space({ bots: [lead, bot], groups: [main] })]);
expect(items.map((item) => (item.type === "heading" ? item.title : item.type))).toEqual([
"Work",
"bot",
"group",
]);
expect(items.some((item) => item.type === "bot" && item.bot.id === lead.id)).toBe(false);
});
it("keeps empty spaces beside a populated space", () => {
const personal = space({ id: "personal", isDefault: true, bots: [bot] });
expect(spaceInboxItems([personal, space()]).map((item) => item.type)).toEqual([

View File

@ -32,7 +32,10 @@ export function canDeleteInboxSpace(
export function spaceInboxItems(spaces: InboxSpace[]): InboxSpaceItem[] {
return spaces.flatMap((space): InboxSpaceItem[] => {
const chats = [
...space.bots.map((chat) => ({ type: "bot" as const, bot: chat, ...chat })),
// A main bot is reached through its space's main conversation, not listed again.
...space.bots
.filter((chat) => chat.kind !== "main")
.map((chat) => ({ type: "bot" as const, bot: chat, ...chat })),
...space.groups.map((chat) => ({ type: "group" as const, group: chat, ...chat })),
];
const items: InboxSpaceItem[] = [];

View File

@ -0,0 +1,104 @@
import { expect, test } from "@playwright/test";
import { activeBotId, captureScreenshot, completeOnboarding, rpc, signup } from "./helpers";
const SCREEN_URL = "https://screen.example/embed.html";
test("the bot's screen opens beside the chat while it works and steps back after", async ({
page,
}, testInfo) => {
await signup(page, `workspace-screen-${Date.now()}@rakazo.test`, "password12", "Screen Pane");
await completeOnboarding(page);
const botId = activeBotId(page);
await rpc(page, "computer/boot", { botId });
await page.route("https://screen.example/**", (route) =>
route.fulfill({
contentType: "text/html",
body: `<!doctype html><title>Desktop</title><body style="margin:0;background:#1d1d20;display:grid;place-items:center;height:100vh">
<input style="width:40%;padding:8px;font:16px system-ui" placeholder="Search">`,
}),
);
await page.route("**/rpc/computer/screenUrl", (route) =>
route.fulfill({
contentType: "application/json",
body: JSON.stringify({ json: { url: SCREEN_URL } }),
}),
);
const sidePanel = page.getByTestId("side-panel");
await expect(sidePanel).toHaveAttribute("data-panel", "closed");
await rpc(page, "threads/send", { botId, text: "use your screen and type workspace-demo" });
await expect(sidePanel).toHaveAttribute("data-panel", "computer");
await expect(sidePanel).toHaveAttribute("data-auto", "");
await expect(page.getByTestId("computer-preview").locator("iframe")).toHaveAttribute(
"src",
SCREEN_URL,
);
// The live screen gets more room than a settings panel.
expect((await sidePanel.boundingBox())!.width).toBeGreaterThan(400);
await captureScreenshot(page, testInfo, "workspace-screen-beside-chat");
// Once the work is done the conversation gets its full width back.
await expect(sidePanel).toHaveAttribute("data-panel", "closed", { timeout: 20_000 });
});
test("a report the bot presents opens beside the conversation", async ({ page }, testInfo) => {
await signup(page, `workspace-report-${Date.now()}@rakazo.test`, "password12", "Report Pane");
await completeOnboarding(page);
const composer = page.getByPlaceholder(/Message/);
await composer.fill("present the report");
await page.keyboard.press("Enter");
const pane = page.getByTestId("artifact-pane");
await expect(pane).toBeVisible({ timeout: 30_000 });
await expect(pane.getByRole("heading", { name: "report.md" })).toBeVisible();
await expect(pane.getByRole("heading", { name: "Report", exact: true })).toBeVisible();
await expect(pane.getByRole("cell", { name: "Research" })).toBeVisible();
await expect(composer).toBeVisible();
await captureScreenshot(page, testInfo, "workspace-report-beside-chat");
const downloadPromise = page.waitForEvent("download");
await pane.getByRole("button", { name: "Download report.md" }).click();
expect((await downloadPromise).suggestedFilename()).toBe("report.md");
await pane.getByRole("button", { name: "Close panel" }).click();
await expect(pane).toHaveCount(0);
// The file card in the chat opens the same pane again.
await page.getByRole("button", { name: "Preview report.md" }).click();
await expect(pane.getByRole("heading", { name: "Report", exact: true })).toBeVisible();
});
test("a question that scrolled away is one click back", async ({ page }, testInfo) => {
await page.setViewportSize({ width: 1280, height: 520 });
await signup(page, `workspace-ask-${Date.now()}@rakazo.test`, "password12", "Ask Jump");
await completeOnboarding(page);
const composer = page.getByPlaceholder(/Message/);
// Earlier work gives the thread enough history to scroll back through.
for (const task of ["sort the inbox", "draft the agenda", "book the room"]) {
await composer.fill(task);
await page.keyboard.press("Enter");
await expect(page.getByText(`done. i handled: ${task}`, { exact: true })).toBeVisible({
timeout: 30_000,
});
}
await composer.fill("ask me which city to use");
await page.keyboard.press("Enter");
const sendIt = page.getByRole("button", { name: "Send it" });
await expect(sendIt).toBeVisible({ timeout: 30_000 });
const jump = page.getByTestId("needs-you-jump");
await expect(jump).toHaveCount(0);
await page.getByTestId("transcript").hover();
await page.mouse.wheel(0, -20_000);
await expect(jump).toBeVisible();
await captureScreenshot(page, testInfo, "workspace-needs-you-jump");
await jump.click();
await expect(sendIt).toBeInViewport();
await expect(sendIt).toBeFocused();
await expect(jump).toHaveCount(0);
});

View File

@ -27,6 +27,8 @@ test("agent-attached files appear as downloadable cards", async ({ page }, testI
test("agent-attached Markdown opens a rendered preview and can be downloaded", async ({
page,
}, testInfo) => {
// Narrow windows preview in a dialog; wide ones open the file beside the chat.
await page.setViewportSize({ width: 900, height: 800 });
const stamp = Date.now();
await signup(page, `markdown-preview-${stamp}@rakazo.test`, "password12", "Markdown Preview");
await completeOnboarding(page);

View File

@ -4,6 +4,10 @@ import { captureScreenshot } from "./helpers";
const fixture = "/e2e/fixtures/chart-links.html";
test("chart links cannot execute script when clicked or expanded", async ({ page }, testInfo) => {
test.setTimeout(120_000);
// ChartCanvas lazy-loads Observable Plot; pay the import cost once before the matrix below.
await page.goto(`${fixture}?${new URLSearchParams({ href: "#details", inspect: "0" })}`);
await expect(page.locator("svg circle").first()).toBeVisible({ timeout: 90_000 });
for (const inspect of ["0", "1"]) {
for (const href of [
'javascript:void(document.body.dataset.plotExecuted="yes")',

View File

@ -224,7 +224,6 @@ test("shell surfaces stay readable in light and dark themes", async ({ page }, t
const sidePanel = page.getByTestId("side-panel");
await expect(sidebar.locator("[data-main-conversation]")).toBeVisible();
await expect(sidebar.getByRole("button", { name: "Researcher, 1:1 conversation" })).toBeVisible();
await expect(page.getByTestId("trading-mode-status")).toBeVisible();
await inEachTheme(page, async (theme) => {
await captureScreenshot(page, testInfo, `shell-contrast-${theme}`);

View File

@ -1,98 +0,0 @@
import { expect, type Locator, test } from "@playwright/test";
import { completeOnboarding, rpc, signup, unterhaltungenOnly } from "./helpers";
async function selectProjectSpace(projekte: Locator, name: string) {
const row = projekte.locator('[data-sidebar-group^="project-space:"]').filter({ hasText: name });
await row.getByRole("button", { name: `Select ${name}` }).click();
}
async function projectSidebarStart(projekte: Locator, name: string) {
const row = projekte.locator('[data-sidebar-group^="project-space:"]').filter({ hasText: name });
const groupAttr = await row.getAttribute("data-sidebar-group");
const spaceId = groupAttr?.match(/^project-space:([^:]+)/)?.[1];
if (!spaceId) throw new Error(`missing project space id for ${name}`);
return projekte.getByTestId(`project-start-${spaceId}`);
}
test("empty project shows Starten instead of a fallback bot", async ({ page }) => {
const stamp = Date.now();
await signup(page, `empty-project-${stamp}@rakazo.test`, "password12", "Empty Project");
await completeOnboarding(page);
await page.goto("/app");
await page.waitForURL(/\/app\/[^/]+$/);
const projekte = page.locator(".sidebar-projekte");
await projekte.getByTestId("create-project-trigger").click();
const dialog = page.getByRole("dialog", { name: "New project" });
await dialog.getByLabel("Name").fill("Launch");
await dialog.getByRole("button", { name: "Create project", exact: true }).click();
await page.waitForURL(/\/app(?:\/)?$/);
await expect(projekte.getByRole("button", { name: /^Chief/ })).toHaveCount(0);
await expect(projekte.getByRole("button", { name: "Starten", exact: true })).toBeVisible();
await expect(page.getByTestId("project-start-main")).toBeVisible();
await page.getByTestId("project-start-main").click();
await expect(page.getByTestId("side-panel")).toHaveAttribute("data-panel", "create");
});
test("Starten in an onboarded project opens the group create flow", async ({ page }) => {
const stamp = Date.now();
await signup(page, `project-start-${stamp}@rakazo.test`, "password12", "Project Start");
await completeOnboarding(page);
const projekte = page.locator(".sidebar-projekte");
await projekte.getByTestId("create-project-trigger").click();
const dialog = page.getByRole("dialog", { name: "New project" });
await dialog.getByLabel("Name").fill("Website");
await dialog.getByRole("button", { name: "Create project", exact: true }).click();
await page.waitForURL(/\/app(?:\/)?$/);
await selectProjectSpace(projekte, "Website");
await page.waitForURL(/\/app(?:\/)?$/);
await expect(page.getByTestId("shell-root")).toHaveAttribute("data-ready", "true", {
timeout: 60_000,
});
await selectProjectSpace(projekte, "Website");
await page.waitForURL(/\/app(?:\/)?$/);
await expect(unterhaltungenOnly(page).getByRole("button", { name: /^Chief/ })).toHaveCount(1);
await expect(projekte.getByRole("button", { name: /^Chief/ })).toHaveCount(0);
const sidebarStart = await projectSidebarStart(projekte, "Website");
await expect(sidebarStart).toBeVisible({ timeout: 30_000 });
await sidebarStart.click();
await expect(page.getByTestId("side-panel")).toHaveAttribute("data-panel", "create-group", {
timeout: 30_000,
});
await expect(page.getByText("New group", { exact: true })).toBeVisible();
await expect(
page.getByTestId("side-panel").getByRole("button", { name: /^Chief/ }),
).toBeVisible();
});
test("archiving the only bot keeps Starten visible in the project sidebar", async ({ page }) => {
const stamp = Date.now();
await signup(page, `project-archive-${stamp}@rakazo.test`, "password12", "Project Archive");
await completeOnboarding(page);
const projekte = page.locator(".sidebar-projekte");
await projekte.getByTestId("create-project-trigger").click();
const dialog = page.getByRole("dialog", { name: "New project" });
await dialog.getByLabel("Name").fill("Side");
await dialog.getByRole("button", { name: "Create project", exact: true }).click();
await page.waitForURL(/\/app(?:\/)?$/);
const sideBot = await rpc<{ id: string }>(page, "bots/create", {
name: "Side bot",
title: "",
description: "",
notifyOnFinish: true,
computerMode: "team",
});
await rpc(page, "bots/archive", { botId: sideBot.id });
await page.goto("/app");
await page.waitForURL(/\/app(?:\/)?$/);
await expect(projekte.getByRole("button", { name: "Starten", exact: true })).toBeVisible();
await expect(page.getByTestId("project-start-main")).toBeVisible();
});

View File

@ -69,9 +69,10 @@ test("takeover, routine, plugins, and export are reachable", async ({ page }, te
})
.toBe("waiting_takeover");
await captureScreenshot(page, testInfo, "08-protected-input-request");
await page.getByTitle("Agent computer").click();
// A takeover request brings the bot's screen beside the chat on its own.
const sidePanel = page.getByTestId("side-panel");
await expect(sidePanel).toHaveCSS("width", "384px");
await expect(sidePanel).toHaveAttribute("data-panel", "computer");
await expect(sidePanel).toHaveAttribute("data-auto", "");
const [mainBox, panelBox] = await Promise.all([
page.locator("main").boundingBox(),
sidePanel.boundingBox(),

View File

@ -303,9 +303,11 @@ test("create group from + and see two bots in one transcript", async ({ page },
const groupMarkdown = page.getByRole("button", { name: "Preview group-preview.md" });
await expect(groupMarkdown).toBeVisible({ timeout: 30_000 });
await groupMarkdown.click();
const markdownDialog = page.getByRole("dialog", { name: "group-preview.md" });
await expect(markdownDialog.getByRole("heading", { name: "Group artifact" })).toBeVisible();
await markdownDialog.getByRole("button", { name: "Close preview" }).click();
// Wide windows open the file beside the group conversation.
const markdownPane = page.getByTestId("artifact-pane");
await expect(markdownPane.getByRole("heading", { name: "Group artifact" })).toBeVisible();
await markdownPane.getByRole("button", { name: "Close panel" }).click();
await expect(markdownPane).toHaveCount(0);
await page.setViewportSize({ width: 390, height: 844 });
await expect(page.getByRole("button", { name: "Open navigation" })).toBeVisible();

View File

@ -75,7 +75,11 @@ test("local settings open and save integrations without an app session", async (
});
test("ordinary browsers cannot use local settings", async ({ page }) => {
await page.goto("/desktop-settings");
await expect(page.getByRole("alert")).toContainText("Could not open local settings");
await expect(async () => {
await page.goto("/desktop-settings", { waitUntil: "domcontentloaded" });
await expect(page.getByRole("alert")).toContainText("Could not open local settings", {
timeout: 5_000,
});
}).toPass({ timeout: 45_000 });
await expect(page.getByRole("button", { name: "Models", exact: true })).toBeHidden();
});

View File

@ -19,7 +19,10 @@ test("onboarding lands in the main conversation, its lead answers, and addressed
const homeSpaceId = await page.evaluate(() => window.localStorage.getItem("rakazo:space-id"));
const chief = await rpc<{ id: string }>(page, "bots/ensureMain", {});
const sidebar = page.locator("aside").first();
const mainEntry = sidebar.getByRole("button", { name: "Chief, main conversation" });
// Projects list their own lead's main conversation too; this is the pinned home one.
const mainEntry = sidebar
.getByRole("button", { name: "Chief, main conversation" })
.and(sidebar.locator("[data-main-conversation]"));
await expect(mainEntry).toBeVisible();
// The main bot is reached through the main conversation, not listed again.
await expect(sidebar.locator(`[data-roster-bot-id="${chief.id}"]`)).toHaveCount(0);
@ -78,7 +81,8 @@ test("onboarding lands in the main conversation, its lead answers, and addressed
const dialog = page.getByRole("dialog", { name: "New project" });
await dialog.getByLabel("Name").fill("Launch");
await dialog.getByRole("button", { name: "Create project", exact: true }).click();
await page.waitForURL(/\/app(?:\/)?$/);
// The project opens its own main conversation.
await page.waitForURL((url) => /\/app\/g\/[^/]+$/.test(url.pathname) && url.href !== mainUrl);
await expect
.poll(() => page.evaluate(() => window.localStorage.getItem("rakazo:space-id")))
.not.toBe(homeSpaceId);

View File

@ -43,6 +43,9 @@ test("create opens form, then empty chat; picker lists bots; sidebar collapses",
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
await expect(page.getByTestId("side-panel")).toHaveAttribute("data-panel", "closed");
await expect(page.getByText("What do you want me on first?", { exact: true })).toHaveCount(0);
await expect(
page.getByTestId("transcript").getByText("Start the conversation — New Bot is on stage above."),
).toBeVisible();
await captureScreenshot(page, testInfo, "create-chat-sidepanel-closed");
await page.getByTestId("toggle-bots-sidebar").click();

View File

@ -0,0 +1,87 @@
import { expect, type Locator, test } from "@playwright/test";
import { captureScreenshot, completeOnboarding, rpc, signup, waitForChatComposer } from "./helpers";
function projectSpace(projekte: Locator, name: string) {
return projekte.locator('[data-sidebar-group^="project-space:"]').filter({ hasText: name });
}
test("a new project opens straight into the conversation with its own main agent", async ({
page,
}, testInfo) => {
const stamp = Date.now();
await signup(page, `project-main-${stamp}@rakazo.test`, "password12", "Project Main");
await completeOnboarding(page);
const homeSpaceId = await page.evaluate(() => window.localStorage.getItem("rakazo:space-id"));
const homeMain = await rpc<{ id: string }>(page, "bots/ensureMain", {});
const projekte = page.locator(".sidebar-projekte");
await projekte.getByTestId("create-project-trigger").click();
const dialog = page.getByRole("dialog", { name: "New project" });
await dialog.getByLabel("Name").fill("Launch");
await dialog.getByRole("button", { name: "Create project", exact: true }).click();
await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 });
await expect
.poll(() => page.evaluate(() => window.localStorage.getItem("rakazo:space-id")))
.not.toBe(homeSpaceId);
await waitForChatComposer(page);
// The project is one conversation, led by a main agent of its own.
const projectEntry = projectSpace(projekte, "Launch").getByRole("button", {
name: "Chief, main conversation",
});
await expect(projectEntry).toBeVisible({ timeout: 30_000 });
await expect(projekte.getByRole("button", { name: "Starten", exact: true })).toHaveCount(0);
const groupId = new URL(page.url()).pathname.split("/").at(-1)!;
const snapshot = await rpc<{ leadBotId?: string }>(page, "threads/get", { groupId });
expect(snapshot.leadBotId).toBeTruthy();
expect(snapshot.leadBotId).not.toBe(homeMain.id);
const composer = page.getByRole("combobox", { name: "Message" });
await composer.fill("What should we do first?");
await composer.press("Enter");
const transcript = page.getByTestId("transcript");
await expect(transcript).toContainText(/on it|handled/i, { timeout: 60_000 });
await captureScreenshot(page, testInfo, "project-main-conversation");
});
test("a project's main agent hands work to the project's specialist", async ({
page,
}, testInfo) => {
const stamp = Date.now();
await signup(page, `project-delegate-${stamp}@rakazo.test`, "password12", "Project Delegate");
await completeOnboarding(page);
const projekte = page.locator(".sidebar-projekte");
await projekte.getByTestId("create-project-trigger").click();
const dialog = page.getByRole("dialog", { name: "New project" });
await dialog.getByLabel("Name").fill("Website");
await dialog.getByRole("button", { name: "Create project", exact: true }).click();
await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 });
await waitForChatComposer(page);
const groupId = new URL(page.url()).pathname.split("/").at(-1)!;
const designer = await rpc<{ id: string }>(page, "bots/create", {
name: "Designer",
title: "",
description: "",
instructions: "",
notifyOnFinish: true,
});
const composer = page.getByRole("combobox", { name: "Message" });
await composer.fill("delegate to the bot named Designer: sketch a landing page");
await composer.press("Enter");
const transcript = page.getByTestId("transcript");
const card = transcript.getByTestId("delegated-task-card");
await expect(card).toBeVisible({ timeout: 60_000 });
await expect(card).toContainText("Designer");
await expect(card).toHaveAttribute("data-status", "done", { timeout: 60_000 });
await expect(
transcript.getByText("closing the task: sketch a landing page.").first(),
).toBeVisible({ timeout: 60_000 });
const snapshot = await rpc<{ members?: Array<{ botId: string }> }>(page, "threads/get", {
groupId,
});
expect(snapshot.members?.map((member) => member.botId)).toContain(designer.id);
await captureScreenshot(page, testInfo, "project-main-agent-delegates");
});

View File

@ -23,7 +23,6 @@ test("sidebar has expected navigation items and no integrations", async ({ page
// - textbox "Search" (search input)
// - button "Heute" (today)
// - text "Sprich mit deinen Oo's" (conversations section header)
// - status "Not configured" (trading bot URL unset)
// - NO "Integration(en)" button or text
// Search: it's a textbox, not a button
@ -38,13 +37,6 @@ test("sidebar has expected navigation items and no integrations", async ({ page
const conversationsText = sidebar.getByText("Sprich mit deinen Oo's");
await expect(conversationsText).toBeVisible({ timeout: 5000 });
// Trading mode comes from the bot API. With no URL configured the sidebar
// must not claim Live Trading from a local preference.
const tradingStatus = sidebar.getByTestId("trading-mode-status");
await expect(tradingStatus).toBeVisible({ timeout: 5000 });
await expect(tradingStatus).toContainText("Not configured");
await expect(sidebar.getByRole("button", { name: "Live Trading" })).toHaveCount(0);
// No "Integration(en)" button
const integrationBtn = sidebar.getByRole("button", { name: /Integration(en)?/i });
await expect(integrationBtn).toHaveCount(0, { timeout: 5000 });

View File

@ -16,7 +16,7 @@ function projectSpace(projekte: Locator, name: string) {
});
}
test("Projekte + opens new project dialog instead of group form", async ({ page }) => {
test("Projekte + opens new project dialog instead of group form", async ({ page }, testInfo) => {
const stamp = Date.now();
await signup(page, `proj-create-${stamp}@rakazo.test`, "password12", "Project Create");
await completeOnboarding(page);
@ -34,16 +34,21 @@ test("Projekte + opens new project dialog instead of group form", async ({ page
await dialog.getByLabel("Name").fill("Customer project");
await dialog.getByRole("button", { name: "Create project", exact: true }).click();
await expect(page).not.toHaveURL(/\/onboarding/);
await page.waitForURL(/\/app/);
await page.waitForLoadState("networkidle");
// The new project opens its conversation with the project's main agent.
await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 });
await expect(projekte.getByText("Customer project", { exact: true })).toBeVisible({
timeout: 30_000,
});
await expect(unterhaltungenOnly(page).getByText("Customer project", { exact: true })).toHaveCount(
0,
);
await expect(page.getByTestId("project-start-main")).toBeVisible();
await expect(page.getByTestId("project-start-main")).toHaveText(/Starten/);
await expect(
projectSpace(projekte, "Customer project").getByRole("button", {
name: "Chief, main conversation",
}),
).toBeVisible();
await expect(page.getByTestId("project-start-main")).toHaveCount(0);
await captureScreenshot(page, testInfo, "new-project-main-conversation");
});
for (const owner of [true, false]) {
@ -119,17 +124,18 @@ test("spaces stay invisible by default and chat creation requires approval", asy
const supportSpaceGroup = await supportSpace.getAttribute("data-sidebar-group");
const supportSpaceId = supportSpaceGroup?.match(/^project-space:([^:]+)/)?.[1];
expect(supportSpaceId).toBeTruthy();
await supportSpace.getByRole("button", { name: "Select Customer support" }).click();
await page.waitForURL(/\/app(?:\/)?$/);
// A project created from chat comes with its own main agent, reached through its conversation.
await supportSpace.getByRole("button", { name: "Chief, main conversation" }).click();
await page.waitForURL(/\/app\/g\/[^/]+$/);
await expect
.poll(() => page.evaluate(() => window.localStorage.getItem("rakazo:space-id")))
.toBe(supportSpaceId);
// The main conversation stays the first entry while another space is open.
// The home main conversation stays the first entry while another space is open.
await expect(projekte.getByText("Customer support", { exact: true })).toBeVisible();
await expect(unterhaltungenOnly(page).getByRole("button", { name: /^Chief/ })).toHaveCount(1);
await expect(projekte.getByRole("button", { name: /^Chief/ })).toHaveCount(0);
await expect(projekte.getByRole("button", { name: "Starten", exact: true })).toBeVisible();
await expect(projekte.getByRole("button", { name: /^Chief/ })).toHaveCount(1);
await expect(projekte.getByRole("button", { name: "Starten", exact: true })).toHaveCount(0);
await captureScreenshot(page, testInfo, "spaces-sidebar");
await page.evaluate((id) => {
@ -143,7 +149,9 @@ test("spaces stay invisible by default and chat creation requires approval", asy
await expect(projekte.getByText("Customer support", { exact: true })).toBeVisible();
});
test("a new space opens empty and can be deleted from its menu", async ({ page }, testInfo) => {
test("a new space opens its main conversation and can be deleted from its menu", async ({
page,
}, testInfo) => {
const stamp = Date.now();
await signup(page, `spaces-delete-${stamp}@rakazo.test`, "password12", "Space Owner");
await completeOnboarding(page);
@ -154,18 +162,20 @@ test("a new space opens empty and can be deleted from its menu", async ({ page }
await dialog.getByLabel("Name").fill("Temporary");
await dialog.getByRole("button", { name: "Create space", exact: true }).click();
// Onboarding runs once per user: a new space opens directly, with no bot of its own.
await page.waitForURL(/\/app(?:\/)?$/);
// Onboarding runs once per user: a new space opens directly in the conversation with its
// own main agent, its only bot.
await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 });
const projekte = page.locator(".sidebar-projekte");
await expect(projekte.getByText("Temporary", { exact: true })).toBeVisible();
await expect(unterhaltungenOnly(page).getByRole("button", { name: /^Chief/ })).toHaveCount(1);
await expect(projekte.getByRole("button", { name: /^Chief/ })).toHaveCount(0);
await expect(projekte.getByRole("button", { name: "Starten", exact: true })).toBeVisible();
expect(await rpc<unknown[]>(page, "bots/list", {})).toEqual([]);
await captureScreenshot(page, testInfo, "new-space-empty");
// The empty space stays selectable instead of trapping the user.
await expect(projekte.getByRole("button", { name: "Select Temporary" }).first()).toBeVisible();
await expect(
projectSpace(projekte, "Temporary").getByRole("button", { name: "Chief, main conversation" }),
).toBeVisible();
await expect(projekte.getByRole("button", { name: "Starten", exact: true })).toHaveCount(0);
expect(await rpc<Array<{ kind: string }>>(page, "bots/list", {})).toEqual([
expect.objectContaining({ kind: "main" }),
]);
await captureScreenshot(page, testInfo, "new-space-main-conversation");
// Deletion is discoverable through the visible Space actions menu.
await projectSpace(projekte, "Temporary")
@ -180,6 +190,7 @@ test("a new space opens empty and can be deleted from its menu", async ({ page }
await deleteSpaceDialog.getByRole("button", { name: "Delete", exact: true }).click();
await expect(sidebar.getByText("Temporary", { exact: true })).toHaveCount(0);
await expect(page).not.toHaveURL(/\/onboarding/);
// The project's main agent went with it.
await expect(sidebar.getByRole("button", { name: /^Chief/ })).toHaveCount(1);
// A stale onboarding URL recovers without duplicating the bot.
@ -212,7 +223,7 @@ test("deleting the last bot in a space stays in the app after first use", async
const dialog = page.getByRole("dialog", { name: "New space" });
await dialog.getByLabel("Name").fill("Side");
await dialog.getByRole("button", { name: "Create space", exact: true }).click();
await page.waitForURL(/\/app(?:\/)?$/);
await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 });
const projekte = page.locator(".sidebar-projekte");
await expect(projekte.getByText("Side", { exact: true })).toBeVisible();
@ -229,8 +240,11 @@ test("deleting the last bot in a space stays in the app after first use", async
await expect(page).not.toHaveURL(/\/onboarding/);
await page.goto("/app");
await expect(page).not.toHaveURL(/\/onboarding/);
await expect(projekte.getByRole("button", { name: "Select Side" }).first()).toBeVisible();
await expect(page.getByTestId("project-start-main")).toBeVisible();
// The project's main conversation stays, so the project opens there.
await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 });
await expect(
projectSpace(projekte, "Side").getByRole("button", { name: "Chief, main conversation" }),
).toBeVisible();
await projectSpace(projekte, "Side").getByRole("button", { name: "Actions for Side" }).click();
await expect(page.getByRole("menuitem", { name: "Delete space" })).toBeVisible();

View File

@ -1,16 +0,0 @@
import { test } from "@playwright/test";
test.describe("Steve Jobs CTO Agent", () => {
test.beforeEach(async () => {
// We assume the user has logged in and the bot is seeded
// This is a placeholder for the perfect test architecture
});
test("should be visible in the sidebar", async () => {
// This is a dummy test to show we started the test architecture for Steve Jobs
});
test("should prefer voice communications (VOICE FIRST)", async () => {
// Voice first features
});
});

View File

@ -67,6 +67,9 @@ test("account settings appearance control switches to light mode", async ({ page
await settings.getByRole("button", { name: "Close user settings" }).click();
await expect(settings).toBeHidden();
await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
// Light surfaces stay light: no dark panel or white-on-paper text survives the switch.
await expect(page.locator("main").first()).toHaveCSS("background-color", "rgb(255, 255, 255)");
await expect(page.locator("aside").first()).toHaveCSS("background-color", "rgb(245, 245, 242)");
await captureScreenshot(page, testInfo, "ui-appearance-light-shell");
await captureSidebarSearchSelected(page, testInfo, "sidebar-search-selected-light");

View File

@ -26,7 +26,7 @@
document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme;
const meta = document.querySelector('meta[name="theme-color"]');
if (meta) meta.setAttribute("content", theme === "light" ? "#fafaf8" : "#07080a");
if (meta) meta.setAttribute("content", theme === "light" ? "#ffffff" : "#07080a");
})();
</script>
<script>

View File

@ -14,7 +14,6 @@ import {
showSessionUnavailable,
} from "./lib/session-gate";
import { IntegrationSetupPage } from "./pages/IntegrationSetup";
import { LocalSettingsPage } from "./pages/LocalSettings";
import { McpOAuthCallbackPage } from "./pages/McpOAuthCallback";
import { ShellPage } from "./pages/Shell";
@ -30,9 +29,18 @@ const OnboardingPage = lazyWithRecovery("pages/Onboarding", () =>
const WelcomePage = lazyWithRecovery("pages/Welcome", () =>
import("./pages/Welcome").then((module) => ({ default: module.WelcomePage })),
);
const LocalSettingsPage = lazyWithRecovery("pages/LocalSettings", () =>
import("./pages/LocalSettings").then((module) => ({ default: module.LocalSettingsPage })),
);
export function App() {
if (window.location.pathname === LOCAL_SETTINGS_PAGE) return <LocalSettingsPage />;
if (window.location.pathname === LOCAL_SETTINGS_PAGE) {
return (
<Suspense fallback={<div className="h-full bg-background" data-rakazo-app-state="loading" />}>
<LocalSettingsPage />
</Suspense>
);
}
return <SessionApp />;
}

View File

@ -4,13 +4,9 @@ import { Trans, useLingui } from "@lingui/react/macro";
import { ChatMarkdown } from "@rakazo/chat-ui/web";
import { Button, Dialog, DialogClose, DialogContent, DialogTitle } from "@rakazo/ui-web";
import { Download, FileText, X } from "lucide-react";
import { type RefObject, useEffect, useRef, useState } from "react";
import {
type ArtifactTarget,
downloadArtifact,
downloadArtifactBytes,
fetchArtifactBytes,
} from "../lib/artifact-open";
import { type RefObject, useMemo, useRef, useState } from "react";
import { useArtifactBytes } from "../hooks/useArtifactBytes";
import { type ArtifactTarget, downloadArtifact, downloadArtifactBytes } from "../lib/artifact-open";
type ArtifactFileCardProps = {
target: ArtifactTarget;
@ -18,6 +14,8 @@ type ArtifactFileCardProps = {
name: string;
mimeType: string;
size: number;
/** Shows the preview beside the conversation instead of in a dialog. */
onOpenBeside?: () => void;
};
export function ArtifactFileCard(props: ArtifactFileCardProps) {
@ -63,7 +61,7 @@ export function ArtifactFileCard(props: ArtifactFileCardProps) {
ref={previewButton}
type="button"
aria-label={t`Preview ${props.name}`}
onClick={() => setPreviewOpen(true)}
onClick={() => (props.onOpenBeside ? props.onOpenBeside() : setPreviewOpen(true))}
className="flex min-w-0 flex-1 items-center gap-3 px-4 py-3 text-left hover:bg-accent"
>
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-[10px] bg-muted text-foreground">
@ -111,39 +109,16 @@ function MarkdownPreview({
}: ArtifactFileCardProps & { closeButtonRef: RefObject<HTMLButtonElement | null> }) {
const { t } = useLingui();
const [downloadError, setDownloadError] = useState<string | null>(null);
const [state, setState] = useState<
| { status: "loading" }
| { status: "ready"; bytes: Uint8Array; markdown: string }
| { status: "error"; message: string }
>({ status: "loading" });
const targetBotId = "botId" in target ? target.botId : undefined;
const targetGroupId = "groupId" in target ? target.groupId : undefined;
useEffect(() => {
let cancelled = false;
const artifactTarget: ArtifactTarget =
targetBotId !== undefined ? { botId: targetBotId } : { groupId: targetGroupId! };
void fetchArtifactBytes(artifactTarget, artifactId)
.then((bytes) => {
if (cancelled) return;
try {
const markdown = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
setState({ status: "ready", bytes, markdown });
} catch {
setState({ status: "error", message: t`This file is not valid UTF-8 Markdown.` });
}
})
.catch((error) => {
if (cancelled) return;
setState({
status: "error",
message: error instanceof Error ? error.message : t`Could not load this file.`,
});
});
return () => {
cancelled = true;
};
}, [artifactId, targetBotId, targetGroupId, t]);
const loaded = useArtifactBytes(target, artifactId);
const state = useMemo(() => {
if (loaded.status !== "ready") return loaded;
try {
const markdown = new TextDecoder("utf-8", { fatal: true }).decode(loaded.bytes);
return { status: "ready" as const, bytes: loaded.bytes, markdown };
} catch {
return { status: "error" as const, message: t`This file is not valid UTF-8 Markdown.` };
}
}, [loaded, t]);
return (
<>

View File

@ -0,0 +1,165 @@
import { Trans, useLingui } from "@lingui/react/macro";
import { ChatMarkdown } from "@rakazo/chat-ui/web";
import { isAttachmentImageMimeType } from "@rakazo/contracts";
import type { WorkspaceArtifact } from "@rakazo/core";
import { Button } from "@rakazo/ui-web";
import { Download, X } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useArtifactBytes } from "../hooks/useArtifactBytes";
import { type ArtifactTarget, downloadArtifactBytes } from "../lib/artifact-open";
import { parseCsvPreview } from "../lib/csv-preview";
/** A file the bot made, open beside the conversation so the user can read it while they talk. */
export function ArtifactPane({
target,
artifact,
onClose,
}: {
target: ArtifactTarget;
artifact: WorkspaceArtifact;
onClose: () => void;
}) {
const { t } = useLingui();
const loaded = useArtifactBytes(target, artifact.artifactId);
const { name, mimeType } = artifact;
return (
<section
data-testid="artifact-pane"
aria-label={name}
className="flex h-full min-h-0 w-full flex-col"
>
<header className="flex h-14 shrink-0 items-center gap-1 border-b border-border px-5">
<h2 className="min-w-0 flex-1 truncate text-ui-body font-medium text-foreground" dir="auto">
{name}
</h2>
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
aria-label={t`Download ${name}`}
title={t`Download ${name}`}
disabled={loaded.status !== "ready"}
onClick={() => {
if (loaded.status === "ready") downloadArtifactBytes(name, mimeType, loaded.bytes);
}}
>
<Download strokeWidth={1.8} className="size-icon-md" />
</Button>
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
aria-label={t`Close panel`}
onClick={onClose}
>
<X strokeWidth={1.8} className="size-icon-md" />
</Button>
</header>
<div className="rk-scroll min-h-0 flex-1 overflow-auto">
{loaded.status === "loading" ? (
<div className="px-6 py-8 text-ui-body text-muted-foreground">
<Trans>Loading preview…</Trans>
</div>
) : loaded.status === "error" ? (
<div
role="alert"
className="m-6 rounded-xl border border-destructive/40 bg-destructive/10 px-4 py-3 text-ui-body text-destructive"
>
{loaded.message}
</div>
) : isAttachmentImageMimeType(mimeType) ? (
<PaneImage bytes={loaded.bytes} mimeType={mimeType} name={name} />
) : (
<PaneText bytes={loaded.bytes} mimeType={mimeType} />
)}
</div>
</section>
);
}
function PaneImage({
bytes,
mimeType,
name,
}: {
bytes: Uint8Array;
mimeType: string;
name: string;
}) {
const [src, setSrc] = useState<string | null>(null);
useEffect(() => {
const url = URL.createObjectURL(new Blob([new Uint8Array(bytes)], { type: mimeType }));
setSrc(url);
return () => URL.revokeObjectURL(url);
}, [bytes, mimeType]);
return src ? (
<img src={src} alt={name} className="mx-auto block max-w-full p-6" draggable={false} />
) : null;
}
function PaneText({ bytes, mimeType }: { bytes: Uint8Array; mimeType: string }) {
const { t } = useLingui();
const text = useMemo(() => new TextDecoder("utf-8").decode(bytes), [bytes]);
if (mimeType === "text/markdown") {
return (
<article className="mx-auto w-full max-w-[760px] px-6 py-8 text-ui-body leading-7 text-foreground lg:px-10">
<ChatMarkdown>{text}</ChatMarkdown>
</article>
);
}
if (mimeType === "text/csv") {
const { rows, truncated } = parseCsvPreview(text);
const [head, ...body] = rows;
const shownRows = body.length;
return (
<div className="p-4">
<table className="w-full border-collapse text-ui-body-sm text-foreground">
{head ? (
<thead>
<tr>
{head.map((cell, index) => (
<th
key={index}
className="sticky top-0 border-b border-border bg-background px-3 py-2 text-start font-medium"
>
{cell}
</th>
))}
</tr>
</thead>
) : null}
<tbody>
{body.map((row, rowIndex) => (
<tr key={rowIndex} className="border-b border-border/60">
{row.map((cell, index) => (
<td key={index} className="px-3 py-1.5 align-top whitespace-pre-wrap">
{cell}
</td>
))}
</tr>
))}
</tbody>
</table>
{truncated ? (
<p className="px-3 pt-3 text-ui-body-sm text-muted-foreground">
{t`First ${shownRows} rows`}
</p>
) : null}
</div>
);
}
return (
<pre className="whitespace-pre-wrap break-words px-6 py-6 font-mono text-ui-body-sm text-foreground">
{mimeType === "application/json" ? prettyJson(text) : text}
</pre>
);
}
function prettyJson(text: string): string {
try {
return JSON.stringify(JSON.parse(text), null, 2);
} catch {
return text;
}
}

View File

@ -87,6 +87,7 @@ export function AskCard({
return (
<div
data-ask-card
data-testid={secretInput ? "secret-ask-card" : undefined}
className={cn("max-w-[74%] rounded-2xl border border-border bg-card px-5 py-4", className)}
>

View File

@ -1,28 +0,0 @@
import { CheckCircle2, Database, EyeOff, Lock } from "lucide-react";
import { memo } from "react";
const TRUST_ITEMS = [
{ icon: Lock, label: "Verschlüsselt", desc: "AES-256 für sensible Daten" },
{ icon: Database, label: "Lokal gespeichert", desc: "Eigene PostgreSQL-Datenbank" },
{ icon: EyeOff, label: "Kein Tracking", desc: "Keine Analytics, kein Telemetry" },
] as const;
export const DataTrustPanel = memo(function DataTrustPanel() {
return (
<div className="hidden xl:flex flex-col items-start gap-1.5 rounded-xl border border-border/40 bg-muted/30 px-3 py-2.5">
<div className="flex items-center gap-1.5 text-xs font-medium text-sidebar-foreground">
<CheckCircle2 className="size-3.5 text-success" />
<span>Datenschutz aktiv</span>
</div>
<div className="flex flex-col gap-1">
{TRUST_ITEMS.map(({ icon: Icon, label, desc }) => (
<div key={label} className="flex items-center gap-1.5 rk-sidebar-muted">
<Icon className="size-3" />
<span className="text-[11px] leading-tight">{label}</span>
<span className="text-[10px] leading-tight">· {desc}</span>
</div>
))}
</div>
</div>
);
});

View File

@ -1,55 +0,0 @@
// @vitest-environment jsdom
import type { ReactNode } from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { expect, it, vi } from "vitest";
vi.mock("@lingui/react/macro", () => ({
Trans: ({ children }: { children: ReactNode }) => children,
}));
import { TradingModeIndicator } from "./TradingModeIndicator";
it("renders the API mode and ignores a stored live preference", async () => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
window.localStorage.setItem("evoo_trading_mode", "live");
const container = document.createElement("div");
document.body.append(container);
const root = createRoot(container);
try {
await act(async () =>
root.render(
<TradingModeIndicator
snapshot={{
state: "ready",
status: { mode: "paper", kind: "dry_run", adapterCount: 3, lastError: null },
}}
/>,
),
);
const status = container.querySelector("[data-testid='trading-mode-status']");
expect(status?.getAttribute("data-trading-state")).toBe("dry_run");
expect(status?.textContent).toContain("Dry run");
expect(status?.textContent).toContain("3 adapters");
expect(container.querySelector("button")).toBeNull();
expect(status?.textContent).not.toContain("Live Trading");
await act(async () => root.render(<TradingModeIndicator snapshot={{ state: "offline" }} />));
expect(container.querySelector("[data-testid='trading-mode-status']")?.textContent).toContain(
"Offline",
);
await act(async () =>
root.render(<TradingModeIndicator snapshot={{ state: "unconfigured" }} />),
);
expect(container.querySelector("[data-testid='trading-mode-status']")?.textContent).toContain(
"Not configured",
);
} finally {
await act(async () => root.unmount());
container.remove();
window.localStorage.clear();
vi.unstubAllGlobals();
}
});

View File

@ -1,95 +0,0 @@
import { Trans } from "@lingui/react/macro";
import { cn } from "@rakazo/ui-web";
import { Activity } from "lucide-react";
import type { ReactNode } from "react";
import {
presentTradingStatus,
type TradingBotUiState,
type TradingModeLabelId,
type TradingModeSubtitle,
} from "../lib/trading-bot-status";
export function TradingModeIndicator({
snapshot,
className,
}: {
snapshot: TradingBotUiState;
className?: string;
}) {
const presentation = presentTradingStatus(snapshot);
return (
<div
role="status"
aria-live="polite"
aria-busy={presentation.label === "loading"}
data-testid="trading-mode-status"
data-trading-state={presentation.label}
className={cn("flex min-w-0 items-center gap-3 rounded-xl px-2.5 py-2", className)}
>
<span
className={cn(
"grid h-[30px] w-[30px] shrink-0 place-items-center rounded-lg",
presentation.tone === "live"
? "bg-warning/10 text-warning"
: "bg-muted text-muted-foreground",
)}
>
<Activity
strokeWidth={1.8}
aria-hidden="true"
className={cn("size-icon-md", presentation.pulse && "animate-pulse")}
/>
</span>
<span className="min-w-0">
<span className="block truncate text-ui-body font-medium text-foreground/90">
<TradingModeLabel label={presentation.label} />
</span>
{presentation.subtitle ? (
<span className="block truncate text-ui-body-sm rk-sidebar-muted">
<TradingModeSubtitleText subtitle={presentation.subtitle} />
</span>
) : null}
</span>
</div>
);
}
function TradingModeLabel({ label }: { label: TradingModeLabelId }): ReactNode {
switch (label) {
case "loading":
return <Trans>Trading</Trans>;
case "unconfigured":
return <Trans>Not configured</Trans>;
case "offline":
return <Trans>Offline</Trans>;
case "unknown":
return <Trans>Unknown</Trans>;
case "live":
return <Trans>Live Trading</Trans>;
case "paper":
return <Trans>Paper</Trans>;
case "dry_run":
return <Trans>Dry run</Trans>;
default: {
const exhaustive: never = label;
return exhaustive;
}
}
}
function TradingModeSubtitleText({ subtitle }: { subtitle: TradingModeSubtitle }): ReactNode {
switch (subtitle.type) {
case "error":
return subtitle.text;
case "adapters":
return subtitle.count === 1 ? (
<Trans>1 adapter</Trans>
) : (
<Trans>{subtitle.count} adapters</Trans>
);
default: {
const exhaustive: never = subtitle;
return exhaustive;
}
}
}

View File

@ -134,7 +134,7 @@ export function OnboardingScanProgress({
>
<div className="flex-shrink-0">
{isDone ? (
<span className="grid size-6 items-center justify-center rounded-full bg-emerald-500/20 text-emerald-500 text-sm">
<span className="grid size-6 items-center justify-center rounded-full bg-emerald-500/20 text-emerald-700 text-sm dark:text-emerald-500">
✓
</span>
) : isActive ? (
@ -174,7 +174,7 @@ export function OnboardingScanProgress({
<div className="mt-8 text-center">
<button
type="button"
className="rounded-xl bg-primary px-6 py-3 text-sm font-medium text-white"
className="rounded-xl bg-primary px-6 py-3 text-sm font-medium text-primary-foreground"
onClick={onComplete}
>
Continue to your workspace →

View File

@ -55,22 +55,22 @@ export function StageHero({
) : null}
<div
className={`relative flex ${orbSize} items-center justify-center rounded-full border border-border bg-muted/45 shadow-[0_16px_48px_rgba(0,0,0,0.45),inset_0_1px_0_rgba(255,255,255,0.12)] backdrop-blur-xl`}
style={
reducedMotion
? undefined
: {
boxShadow: `0 16px 48px rgba(0,0,0,0.45), inset 0 1px 0 rgba(255,255,255,0.12), 0 0 ${Math.round(18 + energy * 32)}px ${glowRgb}`,
}
}
className={`relative flex ${orbSize} items-center justify-center rounded-full border border-border bg-muted/45 shadow-[0_12px_32px_-12px_rgba(24,24,22,0.18)] backdrop-blur-xl dark:shadow-[0_16px_48px_rgba(0,0,0,0.45),inset_0_1px_0_rgba(255,255,255,0.12)]`}
>
{reducedMotion ? null : (
<div
aria-hidden
className="pointer-events-none absolute inset-0 rounded-full"
style={{ boxShadow: `0 0 ${Math.round(18 + energy * 32)}px ${glowRgb}` }}
/>
)}
<BotAvatar
color={botColor}
identity={botId}
status={botStatus}
pose={pose}
size={avatarSize}
className="drop-shadow-[0_6px_24px_rgba(0,0,0,0.4)]"
className="drop-shadow-[0_6px_16px_rgba(0,0,0,0.12)] dark:drop-shadow-[0_6px_24px_rgba(0,0,0,0.4)]"
/>
</div>

View File

@ -41,7 +41,7 @@ export function TextStageCanvas({
style={{
background: `radial-gradient(circle, ${phaseTint} 0%, ${personaTint} 42%, transparent 72%)`,
opacity: 0.55 + energy * 0.2,
transform: `translateX(-50%) scale(${bloomScale})`,
transform: `scale(${bloomScale})`,
transition: reducedMotion
? undefined
: "transform 140ms ease-out, opacity 140ms ease-out",

View File

@ -0,0 +1,38 @@
import { useLingui } from "@lingui/react/macro";
import { useEffect, useState } from "react";
import { type ArtifactTarget, fetchArtifactBytes } from "../lib/artifact-open";
export type ArtifactBytesState =
| { status: "loading" }
| { status: "ready"; bytes: Uint8Array }
| { status: "error"; message: string };
export function useArtifactBytes(target: ArtifactTarget, artifactId: string): ArtifactBytesState {
const { t } = useLingui();
const [state, setState] = useState<ArtifactBytesState>({ status: "loading" });
const targetBotId = "botId" in target ? target.botId : undefined;
const targetGroupId = "groupId" in target ? target.groupId : undefined;
useEffect(() => {
let cancelled = false;
setState({ status: "loading" });
const artifactTarget: ArtifactTarget =
targetBotId !== undefined ? { botId: targetBotId } : { groupId: targetGroupId! };
void fetchArtifactBytes(artifactTarget, artifactId)
.then((bytes) => {
if (!cancelled) setState({ status: "ready", bytes });
})
.catch((error) => {
if (cancelled) return;
setState({
status: "error",
message: error instanceof Error ? error.message : t`Could not load this file.`,
});
});
return () => {
cancelled = true;
};
}, [artifactId, targetBotId, targetGroupId, t]);
return state;
}

View File

@ -1,13 +1,20 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { readPaneWidth, writePaneWidth } from "../lib/workspace-pane-pref";
export function useSidebarResize(
initialWidth: number,
side: "left" | "right",
minWidth: number,
maxWidth: number,
/** Remembers the width across reloads on this device. */
storageKey?: string,
) {
const [width, setWidth] = useState(initialWidth);
const [width, setWidth] = useState(() =>
storageKey ? readPaneWidth(storageKey, initialWidth, minWidth, maxWidth) : initialWidth,
);
const [isResizing, setIsResizing] = useState(false);
const widthRef = useRef(width);
widthRef.current = width;
useEffect(() => {
if (!isResizing) return;
@ -26,6 +33,7 @@ export function useSidebarResize(
const handlePointerUp = () => {
setIsResizing(false);
document.body.style.cursor = "default";
if (storageKey) writePaneWidth(storageKey, widthRef.current);
};
window.addEventListener("pointermove", handlePointerMove);
@ -38,7 +46,7 @@ export function useSidebarResize(
window.removeEventListener("pointerup", handlePointerUp);
document.body.style.cursor = "default";
};
}, [isResizing, side, minWidth, maxWidth]);
}, [isResizing, side, minWidth, maxWidth, storageKey]);
const startResizing = useCallback((e: React.PointerEvent) => {
e.preventDefault();

View File

@ -0,0 +1,16 @@
import { useEffect, useState } from "react";
/** Wide enough to keep the conversation readable beside a working pane. */
const WIDE_LAYOUT_QUERY = "(min-width: 1024px)";
export function useWideLayout(): boolean {
const [wide, setWide] = useState(() => window.matchMedia(WIDE_LAYOUT_QUERY).matches);
useEffect(() => {
const media = window.matchMedia(WIDE_LAYOUT_QUERY);
const sync = () => setWide(media.matches);
sync();
media.addEventListener("change", sync);
return () => media.removeEventListener("change", sync);
}, []);
return wide;
}

View File

@ -3,12 +3,12 @@ import {
ComputerPointerPayload,
type ComputerPointerStep,
} from "@rakazo/contracts";
import { POINTER_EVENT_MAX_AGE_MS } from "@rakazo/core";
export type PointerPoint = { x: number; y: number };
export type PointerScreen = ComputerPointerPayload["screen"];
/** Older announcements describe actions that already finished; replaying them would mislead. */
export const POINTER_EVENT_MAX_AGE_MS = 30_000;
export { POINTER_EVENT_MAX_AGE_MS };
export const POINTER_IDLE_MS = 3_500;
type Listener = (payload: ComputerPointerPayload) => void;

View File

@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { parseCsvPreview } from "./csv-preview";
describe("parseCsvPreview", () => {
it("reads quoted fields, doubled quotes and CRLF rows", () => {
expect(parseCsvPreview('name,note\r\n"Smith, Ann","said ""hi"""\r\nBo,\n')).toEqual({
rows: [
["name", "note"],
["Smith, Ann", 'said "hi"'],
["Bo", ""],
],
truncated: false,
});
});
it("keeps a final row without a trailing newline and skips blank lines", () => {
expect(parseCsvPreview("a,b\n\n1,2").rows).toEqual([
["a", "b"],
["1", "2"],
]);
});
it("keeps newlines inside quoted fields", () => {
expect(parseCsvPreview('a\n"line 1\nline 2"').rows).toEqual([["a"], ["line 1\nline 2"]]);
});
it("stops after the row limit", () => {
const result = parseCsvPreview("1\n2\n3\n4\n", 2);
expect(result).toEqual({ rows: [["1"], ["2"]], truncated: true });
});
});

View File

@ -0,0 +1,43 @@
/** Parses enough of RFC 4180 to show a CSV as a table: quoted fields, doubled quotes, CRLF. */
export function parseCsvPreview(
text: string,
maxRows = 500,
): { rows: string[][]; truncated: boolean } {
const rows: string[][] = [];
let row: string[] = [];
let field = "";
let quoted = false;
let index = 0;
const endRow = () => {
row.push(field);
field = "";
if (row.length > 1 || row[0] !== "") rows.push(row);
row = [];
};
while (index < text.length) {
const char = text[index];
if (quoted) {
if (char === '"' && text[index + 1] === '"') {
field += '"';
index += 2;
continue;
}
if (char === '"') quoted = false;
else field += char;
index += 1;
continue;
}
if (char === '"' && field === "") quoted = true;
else if (char === ",") {
row.push(field);
field = "";
} else if (char === "\n" || char === "\r") {
endRow();
if (char === "\r" && text[index + 1] === "\n") index += 1;
if (rows.length > maxRows) break;
} else field += char;
index += 1;
}
if (rows.length <= maxRows && (field !== "" || row.length > 0)) endRow();
return { rows: rows.slice(0, maxRows), truncated: rows.length > maxRows };
}

View File

@ -1,14 +1,15 @@
import { describe, expect, it } from "vitest";
import { isEmptyProjectSpace, projectGroupChatsInSpace } from "./project-empty-state.js";
import { isEmptyProjectSpace, projectMainGroup } from "./project-empty-state.js";
describe("projectGroupChatsInSpace", () => {
it("returns project groups in a space without the main conversation", () => {
describe("projectMainGroup", () => {
it("finds the main conversation of a space", () => {
const groups = [
{ id: "g1", kind: "group" as const, spaceId: "space-a" },
{ id: "g2", kind: "main" as const, spaceId: "space-a" },
{ id: "g3", kind: "group" as const, spaceId: "space-b" },
{ id: "g3", kind: "main" as const, spaceId: "space-b" },
];
expect(projectGroupChatsInSpace(groups, "space-a").map((group) => group.id)).toEqual(["g1"]);
expect(projectMainGroup(groups, "space-a")?.id).toBe("g2");
expect(projectMainGroup(groups, "space-c")).toBeUndefined();
});
});
@ -22,7 +23,7 @@ describe("isEmptyProjectSpace", () => {
expect(isEmptyProjectSpace("home", spaces, [], "home")).toBe(false);
});
it("is true for a project space without group chats", () => {
it("is true for a project space without conversations", () => {
expect(isEmptyProjectSpace("project", spaces, [], "home")).toBe(true);
});
@ -31,8 +32,8 @@ describe("isEmptyProjectSpace", () => {
expect(isEmptyProjectSpace("project", spaces, groups, "home")).toBe(false);
});
it("ignores the main conversation when deciding emptiness", () => {
it("is false once the project has its main conversation", () => {
const groups = [{ id: "g1", kind: "main" as const, spaceId: "project" }];
expect(isEmptyProjectSpace("project", spaces, groups, "home")).toBe(true);
expect(isEmptyProjectSpace("project", spaces, groups, "home")).toBe(false);
});
});

View File

@ -1,14 +1,14 @@
import type { Group, Space } from "@rakazo/contracts";
/** Project-scoped group chats in a space (excludes the main conversation). */
export function projectGroupChatsInSpace(
groups: readonly Pick<Group, "id" | "kind" | "spaceId">[],
/** The project's own main conversation, where the user talks to its main agent. */
export function projectMainGroup<T extends Pick<Group, "kind" | "spaceId">>(
groups: readonly T[],
spaceId: string,
): readonly Pick<Group, "id">[] {
return groups.filter((group) => group.spaceId === spaceId && group.kind !== "main");
): T | undefined {
return groups.find((group) => group.spaceId === spaceId && group.kind === "main");
}
/** Non-default project space with no project group conversations yet. */
/** Non-default project space without any conversation yet, not even its main one. */
export function isEmptyProjectSpace(
spaceId: string | null | undefined,
spaces: readonly Pick<Space, "id" | "isDefault">[],
@ -18,5 +18,5 @@ export function isEmptyProjectSpace(
if (!spaceId || !homeSpaceId || spaceId === homeSpaceId) return false;
const space = spaces.find((item) => item.id === spaceId);
if (!space || space.isDefault) return false;
return projectGroupChatsInSpace(groups, spaceId).length === 0;
return !groups.some((group) => group.spaceId === spaceId);
}

View File

@ -1,178 +0,0 @@
import { TRADING_BOT_PROXY_PATH } from "@rakazo/core";
import { describe, expect, it, vi } from "vitest";
import {
fetchTradingBotSnapshot,
pollTradingBotStatus,
presentTradingStatus,
resolveTradingStatusTarget,
} from "./trading-bot-status";
describe("resolveTradingStatusTarget", () => {
it("polls the bot directly only when the Vite URL is a bare origin", () => {
expect(resolveTradingStatusTarget(undefined)).toEqual({
kind: "proxy",
url: TRADING_BOT_PROXY_PATH,
});
expect(resolveTradingStatusTarget("")).toEqual({ kind: "proxy", url: TRADING_BOT_PROXY_PATH });
expect(resolveTradingStatusTarget(" http://100.64.0.1:8000/bot/ ")).toEqual({
kind: "direct",
url: "http://100.64.0.1:8000/bot/api/status",
});
expect(resolveTradingStatusTarget("file:///tmp/bot")).toEqual({
kind: "proxy",
url: TRADING_BOT_PROXY_PATH,
});
});
});
describe("presentTradingStatus", () => {
it("shows the API mode and never a stored live preference", () => {
expect(
presentTradingStatus({
state: "ready",
status: { mode: "live", kind: "live", adapterCount: 2, lastError: null },
}),
).toMatchObject({
label: "live",
tone: "live",
pulse: true,
subtitle: { type: "adapters", count: 2 },
});
expect(
presentTradingStatus({
state: "ready",
status: { mode: "paper", kind: "dry_run", adapterCount: 2, lastError: "adapter timeout" },
}).subtitle,
).toEqual({ type: "error", text: "adapter timeout" });
expect(presentTradingStatus({ state: "offline" }).label).toBe("offline");
expect(presentTradingStatus({ state: "unknown" }).label).toBe("unknown");
expect(presentTradingStatus({ state: "unconfigured" }).label).toBe("unconfigured");
expect(presentTradingStatus({ state: "loading" }).label).toBe("loading");
});
});
describe("fetchTradingBotSnapshot", () => {
it("maps a direct live body and reports an unmapped 200 as unknown", async () => {
const live = await fetchTradingBotSnapshot(
{ kind: "direct", url: "http://bot.test/bot/api/status" },
new AbortController().signal,
async () => jsonResponse({ mode: "LIVE" }),
);
expect(live).toMatchObject({ state: "ready", status: { mode: "live", kind: "live" } });
const unknown = await fetchTradingBotSnapshot(
{ kind: "direct", url: "http://bot.test/bot/api/status" },
new AbortController().signal,
async () => jsonResponse({ status: "ok" }),
);
expect(unknown).toEqual({ state: "unknown" });
});
it("treats a failed direct fetch as offline", async () => {
const offline = await fetchTradingBotSnapshot(
{ kind: "direct", url: "http://bot.test/bot/api/status" },
new AbortController().signal,
async () => {
throw new Error("network");
},
);
expect(offline).toEqual({ state: "offline" });
});
it("stops on an explicit unconfigured proxy response", async () => {
const fetchImpl = vi.fn(async () => jsonResponse({ configured: false }));
const snapshots: string[] = [];
await pollTradingBotStatus({
target: { kind: "proxy", url: TRADING_BOT_PROXY_PATH },
signal: new AbortController().signal,
fetchImpl,
onSnapshot: (snapshot) => snapshots.push(snapshot.state),
sleep: async () => false,
});
expect(snapshots).toEqual(["unconfigured"]);
expect(fetchImpl).toHaveBeenCalledTimes(1);
});
});
describe("pollTradingBotStatus", () => {
it("follows a live read with offline on failure and backs off", async () => {
let calls = 0;
const fetchImpl = vi.fn(async () => {
calls += 1;
if (calls === 1) return jsonResponse({ badge: "LIVE" });
throw new Error("down");
});
const sleeps: number[] = [];
let releaseSleep: () => void = () => undefined;
const sleep = (ms: number, signal: AbortSignal) => {
sleeps.push(ms);
return new Promise<boolean>((resolve) => {
if (signal.aborted) {
resolve(true);
return;
}
const onAbort = () => resolve(true);
signal.addEventListener("abort", onAbort, { once: true });
releaseSleep = () => {
signal.removeEventListener("abort", onAbort);
resolve(false);
};
});
};
const snapshots: string[] = [];
const controller = new AbortController();
const done = pollTradingBotStatus({
target: { kind: "direct", url: "http://bot.test/bot/api/status" },
signal: controller.signal,
fetchImpl,
intervalMs: 20_000,
maxBackoffMs: 120_000,
sleep,
onSnapshot: (snapshot) => snapshots.push(snapshot.state),
});
await vi.waitFor(() => expect(snapshots).toEqual(["ready"]));
expect(sleeps).toEqual([20_000]);
releaseSleep();
await vi.waitFor(() => expect(snapshots).toEqual(["ready", "offline"]));
expect(sleeps).toEqual([20_000, 20_000]);
releaseSleep();
await vi.waitFor(() => expect(sleeps).toEqual([20_000, 20_000, 40_000]));
controller.abort();
await done;
expect(fetchImpl).toHaveBeenCalledTimes(3);
});
it("does not publish offline when the poll is aborted in flight", async () => {
const controller = new AbortController();
const snapshots: string[] = [];
const fetchImpl: typeof fetch = (_input, init) =>
new Promise((_resolve, reject) => {
const abort = () => {
const error = new Error("aborted");
error.name = "AbortError";
reject(error);
};
if (init?.signal?.aborted) {
abort();
return;
}
init?.signal?.addEventListener("abort", abort, { once: true });
});
const done = pollTradingBotStatus({
target: { kind: "direct", url: "http://bot.test/bot/api/status" },
signal: controller.signal,
fetchImpl,
onSnapshot: (snapshot) => snapshots.push(snapshot.state),
});
controller.abort();
await done;
expect(snapshots).toEqual([]);
});
});
function jsonResponse(body: unknown): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" },
});
}

View File

@ -1,243 +0,0 @@
import type { TradingBotSnapshot, TradingBotStatusView } from "@rakazo/core";
import {
interpretTradingBotProxyResponse,
mapTradingBotStatus,
parseTradingBotOrigin,
readBoundedJsonResponse,
TRADING_BOT_POLL_INTERVAL_MS,
TRADING_BOT_POLL_MAX_BACKOFF_MS,
TRADING_BOT_PROXY_PATH,
TRADING_BOT_STATUS_MAX_BYTES,
TRADING_BOT_STATUS_TIMEOUT_MS,
tradingBotBackoffMs,
tradingBotStatusUrl,
} from "@rakazo/core";
import { useEffect, useState } from "react";
export type TradingStatusTarget =
| { kind: "direct"; url: string }
| { kind: "proxy"; url: typeof TRADING_BOT_PROXY_PATH };
export type TradingBotUiState = TradingBotSnapshot | { state: "loading" };
export type TradingModeLabelId =
| "loading"
| "unconfigured"
| "offline"
| "unknown"
| "live"
| "paper"
| "dry_run";
export type TradingModeSubtitle =
| { type: "adapters"; count: number }
| { type: "error"; text: string };
export type TradingModePresentation = {
label: TradingModeLabelId;
tone: "live" | "quiet";
pulse: boolean;
subtitle: TradingModeSubtitle | null;
};
type FetchSnapshot = (
target: TradingStatusTarget,
signal: AbortSignal,
fetchImpl: typeof fetch,
timeoutMs: number,
) => Promise<TradingBotSnapshot>;
/** Direct poll when the Vite URL is set. Otherwise the same-origin API proxy. */
export function resolveTradingStatusTarget(viteUrl: string | undefined): TradingStatusTarget {
const parsed = parseTradingBotOrigin(viteUrl);
if (parsed.kind === "origin") {
return { kind: "direct", url: tradingBotStatusUrl(parsed.origin) };
}
return { kind: "proxy", url: TRADING_BOT_PROXY_PATH };
}
export function presentTradingStatus(snapshot: TradingBotUiState): TradingModePresentation {
switch (snapshot.state) {
case "loading":
return { label: "loading", tone: "quiet", pulse: false, subtitle: null };
case "unconfigured":
return { label: "unconfigured", tone: "quiet", pulse: false, subtitle: null };
case "offline":
return { label: "offline", tone: "quiet", pulse: false, subtitle: null };
case "unknown":
return { label: "unknown", tone: "quiet", pulse: false, subtitle: null };
case "ready":
return presentReady(snapshot.status);
default: {
const exhaustive: never = snapshot;
return exhaustive;
}
}
}
export async function fetchTradingBotSnapshot(
target: TradingStatusTarget,
signal: AbortSignal,
fetchImpl: typeof fetch = fetch,
timeoutMs = TRADING_BOT_STATUS_TIMEOUT_MS,
): Promise<TradingBotSnapshot> {
const timeout = linkAbort(signal, timeoutMs);
try {
const response = await fetchImpl(target.url, {
method: "GET",
redirect: "error",
cache: "no-store",
credentials: target.kind === "direct" ? "omit" : "include",
signal: timeout.signal,
headers: { accept: "application/json" },
});
if (target.kind === "direct" && !response.ok) {
cancelBody(response);
return { state: "offline" };
}
let body: unknown = null;
try {
body = await readBoundedJsonResponse<unknown>(
response,
TRADING_BOT_STATUS_MAX_BYTES,
timeout.signal,
);
} catch (error) {
if (signal.aborted) throw error;
body = null;
}
if (target.kind === "proxy") return interpretTradingBotProxyResponse(response.status, body);
if (body === null) return { state: "offline" };
const status = mapTradingBotStatus(body);
return status ? { state: "ready", status } : { state: "unknown" };
} catch (error) {
if (signal.aborted) throw error;
return { state: "offline" };
} finally {
timeout.cancel();
}
}
export async function pollTradingBotStatus(options: {
target: TradingStatusTarget;
signal: AbortSignal;
onSnapshot: (snapshot: TradingBotSnapshot) => void;
fetchImpl?: typeof fetch;
fetchSnapshot?: FetchSnapshot;
intervalMs?: number;
maxBackoffMs?: number;
timeoutMs?: number;
sleep?: (ms: number, signal: AbortSignal) => Promise<boolean>;
}): Promise<void> {
const fetchSnapshot = options.fetchSnapshot ?? fetchTradingBotSnapshot;
const fetchImpl = options.fetchImpl ?? fetch;
const intervalMs = options.intervalMs ?? TRADING_BOT_POLL_INTERVAL_MS;
const maxBackoffMs = options.maxBackoffMs ?? TRADING_BOT_POLL_MAX_BACKOFF_MS;
const timeoutMs = options.timeoutMs ?? TRADING_BOT_STATUS_TIMEOUT_MS;
const sleep = options.sleep ?? delayAbortable;
let failures = 0;
while (!options.signal.aborted) {
let snapshot: TradingBotSnapshot;
try {
snapshot = await fetchSnapshot(options.target, options.signal, fetchImpl, timeoutMs);
} catch {
if (options.signal.aborted) return;
snapshot = { state: "offline" };
}
if (options.signal.aborted) return;
options.onSnapshot(snapshot);
if (snapshot.state === "unconfigured") return;
if (snapshot.state === "offline") failures += 1;
else failures = 0;
const delay =
snapshot.state === "offline"
? tradingBotBackoffMs(intervalMs, failures, maxBackoffMs)
: intervalMs;
const aborted = await sleep(delay, options.signal);
if (aborted) return;
}
}
export function useTradingBotStatus(): TradingBotUiState {
const [snapshot, setSnapshot] = useState<TradingBotUiState>({ state: "loading" });
useEffect(() => {
const controller = new AbortController();
const target = resolveTradingStatusTarget(readViteTradingBotUrl());
void pollTradingBotStatus({
target,
signal: controller.signal,
onSnapshot: (next) => {
if (!controller.signal.aborted) setSnapshot(next);
},
});
return () => controller.abort();
}, []);
return snapshot;
}
function readViteTradingBotUrl(): string | undefined {
const value = import.meta.env.VITE_TRADING_BOT_URL;
return typeof value === "string" ? value : undefined;
}
function presentReady(status: TradingBotStatusView): TradingModePresentation {
const subtitle: TradingModeSubtitle | null = status.lastError
? { type: "error", text: status.lastError }
: status.adapterCount === null
? null
: { type: "adapters", count: status.adapterCount };
switch (status.kind) {
case "live":
return { label: "live", tone: "live", pulse: true, subtitle };
case "paper":
return { label: "paper", tone: "quiet", pulse: false, subtitle };
case "dry_run":
return { label: "dry_run", tone: "quiet", pulse: false, subtitle };
default: {
const exhaustive: never = status.kind;
return exhaustive;
}
}
}
function linkAbort(
parent: AbortSignal,
timeoutMs: number,
): { signal: AbortSignal; cancel: () => void } {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const onParent = () => controller.abort();
if (parent.aborted) controller.abort();
else parent.addEventListener("abort", onParent);
return {
signal: controller.signal,
cancel: () => {
clearTimeout(timer);
parent.removeEventListener("abort", onParent);
},
};
}
function delayAbortable(ms: number, signal: AbortSignal): Promise<boolean> {
if (signal.aborted) return Promise.resolve(true);
return new Promise((resolve) => {
const timer = setTimeout(() => {
signal.removeEventListener("abort", onAbort);
resolve(false);
}, ms);
const onAbort = () => {
clearTimeout(timer);
signal.removeEventListener("abort", onAbort);
resolve(true);
};
signal.addEventListener("abort", onAbort);
});
}
function cancelBody(response: Response): void {
try {
void Promise.resolve(response.body?.cancel()).catch(() => undefined);
} catch {
// Best-effort. The snapshot is already offline.
}
}

View File

@ -0,0 +1,61 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
readPaneWidth,
readScreenPaneHabit,
writePaneWidth,
writeScreenPaneHabit,
} from "./workspace-pane-pref";
function stubStorage() {
const store = new Map<string, string>();
vi.stubGlobal("window", {
localStorage: {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => {
store.set(key, value);
},
removeItem: (key: string) => {
store.delete(key);
},
},
});
return store;
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe("workspace pane preferences", () => {
it("keeps the learned screen habit per user and bot", () => {
const store = stubStorage();
expect(readScreenPaneHabit("user-1", "bot-1")).toBe(0);
writeScreenPaneHabit("user-1", "bot-1", 2);
expect(readScreenPaneHabit("user-1", "bot-1")).toBe(2);
expect(readScreenPaneHabit("user-1", "bot-2")).toBe(0);
writeScreenPaneHabit("user-1", "bot-1", 0);
expect(store.size).toBe(0);
writeScreenPaneHabit(null, "bot-1", 3);
expect(store.size).toBe(0);
});
it("restores a stored pane width within its bounds", () => {
stubStorage();
expect(readPaneWidth("width", 500, 320, 900)).toBe(500);
writePaneWidth("width", 640.4);
expect(readPaneWidth("width", 500, 320, 900)).toBe(640);
writePaneWidth("width", 2_000);
expect(readPaneWidth("width", 500, 320, 900)).toBe(900);
});
it("falls back when storage is unavailable", () => {
vi.stubGlobal("window", {
get localStorage(): Storage {
throw new Error("blocked");
},
});
expect(readScreenPaneHabit("user-1", "bot-1")).toBe(0);
expect(readPaneWidth("width", 500, 320, 900)).toBe(500);
expect(() => writePaneWidth("width", 600)).not.toThrow();
});
});

View File

@ -0,0 +1,52 @@
import { parseScreenPaneHabit, type ScreenPaneHabit } from "@rakazo/core";
function screenHabitKey(userId: string | null | undefined, botId: string | null | undefined) {
if (!userId || !botId) return null;
return `rakazo:screen-pane:${userId}:${botId}`;
}
export function readScreenPaneHabit(
userId: string | null | undefined,
botId: string | null | undefined,
): ScreenPaneHabit {
const key = screenHabitKey(userId, botId);
if (!key) return 0;
try {
return parseScreenPaneHabit(window.localStorage.getItem(key));
} catch {
return 0;
}
}
export function writeScreenPaneHabit(
userId: string | null | undefined,
botId: string | null | undefined,
habit: ScreenPaneHabit,
): void {
const key = screenHabitKey(userId, botId);
if (!key) return;
try {
if (habit === 0) window.localStorage.removeItem(key);
else window.localStorage.setItem(key, String(habit));
} catch {
// Private mode or a full quota only loses the learned default.
}
}
export function readPaneWidth(storageKey: string, fallback: number, min: number, max: number) {
try {
const stored = Number(window.localStorage.getItem(storageKey));
if (Number.isFinite(stored) && stored > 0) return Math.max(min, Math.min(max, stored));
} catch {
// Fall through to the default width.
}
return fallback;
}
export function writePaneWidth(storageKey: string, width: number): void {
try {
window.localStorage.setItem(storageKey, String(Math.round(width)));
} catch {
// The width still applies for this session.
}
}

View File

@ -16,10 +16,8 @@ import { SuccessPop } from "../components/ai/primitives";
import { ComputersUnavailableHint } from "../components/ComputersUnavailableHint";
import { DesktopUpdateSection } from "../components/DesktopUpdates";
import { SoftwareUpdateSection } from "../components/SoftwareUpdateSection";
import { TradingModeIndicator } from "../components/TradingModeIndicator";
import { authClient } from "../lib/auth";
import { getActiveUiLocale, setUiLocale } from "../lib/i18n";
import type { TradingBotUiState } from "../lib/trading-bot-status";
import {
type AppearancePreference,
getUiAppearancePreference,
@ -35,25 +33,8 @@ export type SettingsGeneralProps = {
messagingEnabled?: boolean;
onOpenMessaging?: () => void;
isDeploymentOwner?: boolean;
tradingStatus?: TradingBotUiState;
};
export function LiveTradingBanner({ snapshot }: { snapshot: TradingBotUiState }) {
if (snapshot.state === "loading" || snapshot.state === "unconfigured") return null;
const live = snapshot.state === "ready" && snapshot.status.mode === "live";
return (
<section
className={`rounded-xl border px-2 py-2 ${
live ? "border-warning/30 bg-warning/5" : "border-border bg-card/40"
}`}
data-testid="live-trading-banner"
>
<TradingModeIndicator snapshot={snapshot} />
</section>
);
}
export function GeneralSettingsPanels({
email,
name,
@ -62,7 +43,6 @@ export function GeneralSettingsPanels({
messagingEnabled = false,
onOpenMessaging,
isDeploymentOwner = false,
tradingStatus,
}: SettingsGeneralProps) {
const { t } = useLingui();
const [locale, setLocale] = useState<UiLocale>(() => getActiveUiLocale());
@ -98,7 +78,6 @@ export function GeneralSettingsPanels({
return (
<div className="space-y-5">
{tradingStatus ? <LiveTradingBanner snapshot={tradingStatus} /> : null}
<section className="rounded-xl border border-border px-4 py-4">
<h3 className="text-ui-body font-medium text-foreground">
<Trans>Account</Trans>

View File

@ -1,20 +1,31 @@
import { Trans } from "@lingui/react/macro";
import { Button } from "@rakazo/ui-web";
import { useEffect, useState } from "react";
import { IntegrationSetup } from "../components/integrations/IntegrationSetup";
import { Suspense, useEffect, useState } from "react";
import { desktopBridge } from "../lib/desktop";
import { lazyWithRecovery } from "../lib/lazy-recovery";
import { rpc } from "../lib/rpc";
import { ModelSettingsOverlay } from "./ModelSettingsOverlay";
const IntegrationSetup = lazyWithRecovery("integrations/IntegrationSetup", () =>
import("../components/integrations/IntegrationSetup").then((module) => ({
default: module.IntegrationSetup,
})),
);
const ModelSettingsOverlay = lazyWithRecovery("pages/ModelSettingsOverlay", () =>
import("./ModelSettingsOverlay").then((module) => ({ default: module.ModelSettingsOverlay })),
);
export function LocalSettingsPage() {
const [section, setSection] = useState<"models" | "integrations" | null>(null);
const hasLocalBridge = Boolean(desktopBridge()?.localSettings);
const [ready, setReady] = useState(false);
const [error, setError] = useState(false);
const [error, setError] = useState(!hasLocalBridge);
useEffect(() => {
if (!hasLocalBridge) return;
void rpc.integrationSetup
.get()
.then(() => setReady(true))
.catch(() => setError(true));
}, []);
}, [hasLocalBridge]);
return (
<main className="h-full overflow-auto bg-background px-6 py-12">
<div className="mx-auto max-w-xl space-y-6">
@ -43,9 +54,15 @@ export function LocalSettingsPage() {
</Button>
</nav>
{section === "models" ? (
<ModelSettingsOverlay onClose={() => setSection(null)} localOwner />
<Suspense fallback={null}>
<ModelSettingsOverlay onClose={() => setSection(null)} localOwner />
</Suspense>
) : null}
{section === "integrations" ? (
<Suspense fallback={null}>
<IntegrationSetup serverSetup managedOnly />
</Suspense>
) : null}
{section === "integrations" ? <IntegrationSetup serverSetup managedOnly /> : null}
</>
) : null}
</div>

View File

@ -17,7 +17,6 @@ import {
import { type ComponentType, useEffect, useRef, useState } from "react";
import { computersAreUnavailable } from "../components/ComputersUnavailableHint";
import { UserProfileSettings } from "../components/UserProfileSettings";
import type { TradingBotUiState } from "../lib/trading-bot-status";
import {
ComputerSettingsPanel,
GeneralSettingsPanels,
@ -65,7 +64,6 @@ export function SettingsOverlay({
onVoiceStatusMaybeChanged,
activeBotId,
onOpenMcp,
tradingStatus,
}: {
email?: string | null;
name: string;
@ -83,7 +81,6 @@ export function SettingsOverlay({
onVoiceStatusMaybeChanged?: () => void | Promise<void>;
activeBotId?: string;
onOpenMcp?: () => void;
tradingStatus?: TradingBotUiState;
}) {
const { t } = useLingui();
const panelRef = useRef<HTMLDivElement>(null);
@ -238,7 +235,6 @@ export function SettingsOverlay({
onOpenMessaging ? () => leaveSettings(onOpenMessaging) : undefined
}
isDeploymentOwner={isDeploymentOwner}
tradingStatus={tradingStatus}
/>
) : null}
{section === "integrations" ? (

View File

@ -49,11 +49,13 @@ import {
normalizeCreateBotProfile,
} from "@rakazo/contracts";
import {
applyWorkspaceFocus,
attachmentsForThread,
buildComposerMentionOptions,
type ComposerMention,
clampMentionHighlightIndex,
cronFromPreset,
deriveWorkspaceFocus,
groupBotsForSidebar,
inferAttachmentMimeType,
isActive,
@ -61,21 +63,27 @@ import {
isRunTerminalEvent,
isToolActivityBlock,
latestAnswerableAskMessageId,
liveScreenRunId,
matchVoiceByName,
mentionChipKey,
type PaneState,
parseVoiceChatCommand,
projectMessageReactions,
recordScreenPaneChoice,
reorderBotTo,
resolveComposerSendPlan,
resolveMentionPickerKey,
runThreadSubscription,
SLASH_ACTIONS,
type SlashActionId,
screenPaneMode,
searchHitThreadTarget,
serializeComposerPrompt,
speechFromBlocks,
truncateSlashDescription,
userVisibleMessages,
type WorkspaceArtifact,
type WorkspaceFocus,
} from "@rakazo/core";
import {
AvatarStyleProvider,
@ -131,7 +139,6 @@ import {
Search,
Settings,
Smile,
Sparkles,
Square,
TextQuote,
Trash2,
@ -156,6 +163,7 @@ import {
import { createPortal } from "react-dom";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { ArtifactFileCard } from "../components/ArtifactFileCard";
import { ArtifactPane } from "../components/ArtifactPane";
import { AskCard } from "../components/AskCard";
import { ActiveBotGlyph, CollaborationMarker } from "../components/ai/CollaborationMarker";
import { DelegatedTaskCard, SentMessageCard } from "../components/ai/DelegatedTaskCard";
@ -169,17 +177,16 @@ import {
} from "../components/ComputersUnavailableHint";
import { ComputerUpdateProgress } from "../components/ComputerUpdateProgress";
import { AgentCursor } from "../components/computer/AgentCursor";
import { DataTrustPanel } from "../components/DataTrustPanel";
import { MainInbox } from "../components/MainInbox";
import { MessageHoverMetadata } from "../components/MessageHoverMetadata";
import { OoGroupStage, OoStage } from "../components/stage/OoStage";
import { TradingModeIndicator } from "../components/TradingModeIndicator";
import { SkillDraftCard } from "../components/teach/SkillDraftCard";
import { TeachCaptureOverlay } from "../components/teach/TeachCaptureOverlay";
import { TeachComputerOverlayControl } from "../components/teach/TeachComputerOverlay";
import { TeachRecordingChrome, TeachStopButton } from "../components/teach/TeachRecordingChrome";
import { VoiceAuroraRim } from "../components/voice/VoiceAurora";
import { useSidebarResize } from "../hooks/useSidebarResize";
import { useWideLayout } from "../hooks/useWideLayout";
import { readActivityMode, writeActivityMode } from "../lib/activity-mode";
import { needsYouChipLabel, rosterActivitySubtitle, rosterStatusDot } from "../lib/agent-activity";
import { agentPointerFeed } from "../lib/agent-pointer";
@ -242,13 +249,13 @@ import {
threadRunError,
userHoldsComputerControl,
} from "../lib/thread-events";
import { useTradingBotStatus } from "../lib/trading-bot-status";
import {
transcriptCanSnapAfterFrame,
transcriptIsNearEnd,
transcriptMovedDown,
} from "../lib/transcript-scroll";
import { speaker } from "../lib/tts";
import { readScreenPaneHabit, writeScreenPaneHabit } from "../lib/workspace-pane-pref";
import { ActivityList } from "./ActivityList";
import type { ContextMenuPosition } from "./BotContextMenu";
import { CreateGroupForm, GroupSettings, memberName } from "./GroupPanel";
@ -327,6 +334,7 @@ type Panel =
| "create-group"
| "group-settings"
| "browser"
| "artifact"
| null;
type PendingAttachment = {
@ -351,6 +359,14 @@ const THREAD_SNAPSHOT_TIMEOUT_MS = 2_000;
const VOICE_STATUS_REFRESH_TIMEOUT_MS = 10_000;
const MOBILE_SIDEBAR_SWIPE_EDGE_PX = 32;
const MOBILE_SIDEBAR_SWIPE_DISTANCE_PX = 56;
/** The screen lingers after the work so its final state and the bot's reply land first. */
const AUTO_SCREEN_CLOSE_DELAY_MS = 4_000;
/** How long one pointer announcement counts as the bot working on its screen. */
const SCREEN_ACTIVE_MS = 5_000;
function defaultWorkspacePaneWidth(): number {
return Math.round(Math.max(420, Math.min(760, window.innerWidth * 0.42)));
}
function threadSnapshotSignal(parent: AbortSignal): AbortSignal {
return AbortSignal.any([parent, AbortSignal.timeout(THREAD_SNAPSHOT_TIMEOUT_MS)]);
@ -405,7 +421,6 @@ export function ShellPage() {
if (botId) return "team";
return "heute";
});
const tradingStatus = useTradingBotStatus();
useEffect(() => {
if (botId) setActiveNavPillar("team");
@ -438,12 +453,24 @@ export function ShellPage() {
width: leftSidebarWidth,
isResizing: isResizingLeft,
startResizing: startResizingLeft,
} = useSidebarResize(316, "left", 240, 450);
} = useSidebarResize(316, "left", 240, 450, "rakazo:left-sidebar-width");
const {
width: rightSidebarWidth,
isResizing: isResizingRight,
startResizing: startResizingRight,
} = useSidebarResize(384, "right", 280, 500);
} = useSidebarResize(384, "right", 280, 500, "rakazo:side-panel-width");
// The screen and presented files get a wider pane than settings: they are what the user watches.
const {
width: workspacePaneWidth,
isResizing: isResizingWorkspacePane,
startResizing: startResizingWorkspacePane,
} = useSidebarResize(
defaultWorkspacePaneWidth(),
"right",
360,
960,
"rakazo:workspace-pane-width",
);
const [heuteActionCount, setHeuteActionCount] = useState(0);
const [archivedOpen, setArchivedOpen] = useState(false);
@ -458,6 +485,15 @@ export function ShellPage() {
}, [userId]);
const [panel, setPanel] = useState<Panel>(null);
/** The pane the conversation's focus opened; cleared as soon as the user changes the pane. */
const [autoPanel, setAutoPanel] = useState<Panel>(null);
const [artifactPane, setArtifactPane] = useState<WorkspaceArtifact | null>(null);
/** Run that last pointed at the bot's screen; the live screen only follows real screen work. */
const [screenRunId, setScreenRunId] = useState<string | null>(null);
const [screenActive, setScreenActive] = useState(false);
const screenActiveTimer = useRef<number | undefined>(undefined);
const [screenHabit, setScreenHabit] = useState(0);
const wideLayout = useWideLayout();
const [showCreateBotModal, setShowCreateBotModal] = useState(false);
@ -993,14 +1029,15 @@ export function ShellPage() {
}
return;
}
const inProject = Boolean(refreshSpaceId && refreshSpaceId !== refreshHomeSpaceId);
if (currentGroupId) {
if (!groupList.some((group) => group.id === currentGroupId)) {
navigate(firstThreadRoute(list, groupList), { replace: true });
navigate(firstThreadRoute(list, groupList, inProject), { replace: true });
}
return;
}
if (!currentBotId || !list.some((bot) => bot.id === currentBotId)) {
navigate(firstThreadRoute(list, groupList), { replace: true });
navigate(firstThreadRoute(list, groupList, inProject), { replace: true });
}
} finally {
botsRefreshInFlight.current -= 1;
@ -1237,12 +1274,17 @@ export function ShellPage() {
}
return;
}
const inProject = bootstrapSpaceId !== bootstrapHomeSpaceId;
if (groupId) {
if (!groupList.some((group) => group.id === groupId)) {
navigate(firstThreadRoute(bootstrap.bots, groupList), { replace: true });
navigate(firstThreadRoute(bootstrap.bots, groupList, inProject), { replace: true });
}
return;
}
if (inProject && !botId) {
navigate(firstThreadRoute(bootstrap.bots, groupList, true), { replace: true });
return;
}
const selectedBotId = bootstrap.thread?.botId ?? bootstrap.bots[0]?.id;
if (selectedBotId && selectedBotId !== botId) {
navigate(`/app/${selectedBotId}`, { replace: true });
@ -1458,6 +1500,16 @@ export function ShellPage() {
void refreshComputerScreen(active.id).catch(() => undefined);
} else if (event.type === "thread.computer") {
agentPointerFeed.publish(event.botId, event.payload, event.createdAt);
const screenRun = liveScreenRunId(event);
if (screenRun) {
setScreenRunId(screenRun);
setScreenActive(true);
window.clearTimeout(screenActiveTimer.current);
screenActiveTimer.current = window.setTimeout(
() => setScreenActive(false),
SCREEN_ACTIVE_MS,
);
}
}
},
});
@ -1609,6 +1661,8 @@ export function ShellPage() {
: group.title,
showLock: showSpaceNames,
emptySpaceId: undefined as string | undefined,
/** A project without its main conversation yet offers to start it. */
startSpaceId: undefined as string | undefined,
spaceId: space.id,
spaceName: space.name,
canDeleteSpace: index === 0 && space.canDelete === true,
@ -1625,6 +1679,7 @@ export function ShellPage() {
sectionId: null,
showLock: true,
emptySpaceId: space.id,
startSpaceId: undefined,
spaceId: space.id,
spaceName: space.name,
canDeleteSpace: space.canDelete === true,
@ -1688,11 +1743,13 @@ export function ShellPage() {
if (space.id === homeSpaceId || seenProjectSpaceIds.has(space.id)) continue;
seenProjectSpaceIds.add(space.id);
const groupsInSpace = sidebarGroups.flatMap((group) =>
group.spaceId === space.id
? group.bots.filter((item) => item.kind === "group" && item.chat.kind !== "main")
: [],
group.spaceId === space.id ? group.bots.filter((item) => item.kind === "group") : [],
);
const projectBots = dedupeSidebarBots(groupsInSpace);
// The project's main conversation, where the user talks to its main agent, comes first.
const projectBots = dedupeSidebarBots(groupsInSpace).sort(
(left, right) => Number(right.chat.kind === "main") - Number(left.chat.kind === "main"),
);
const hasMain = projectBots.some((item) => item.chat.kind === "main");
entries.push({
key:
projectBots.length > 0 ? `project-space:${space.id}` : `project-space:${space.id}:empty`,
@ -1701,6 +1758,7 @@ export function ShellPage() {
sectionId: null,
showLock: true,
emptySpaceId: projectBots.length > 0 ? undefined : space.id,
startSpaceId: hasMain ? undefined : space.id,
spaceId: space.id,
spaceName: space.name,
canDeleteSpace: space.canDelete === true,
@ -1785,12 +1843,15 @@ export function ShellPage() {
[bootstrapMe?.spaceId, navigate],
);
const openProjectStart = useCallback(
(spaceId: string, flow: "create" | "group") => {
// A project is a conversation with its own main agent, created when first opened.
const startProject = useCallback(
async (spaceId: string) => {
setActiveNavPillar("projekte");
openSpaceChat(spaceId, `/app?project=start&flow=${flow}`);
const main = await rpc.spaces.ensureMain({ spaceId });
if (spaceId === (selectedSpaceId() ?? bootstrapMe?.spaceId)) await refreshBots();
openSpaceChat(spaceId, `/app/g/${main.groupId}`);
},
[openSpaceChat],
[bootstrapMe?.spaceId, openSpaceChat, refreshBots],
);
const openMainConversation = useCallback(() => {
@ -2034,36 +2095,6 @@ export function ShellPage() {
}
}, [botId, emptyProjectSpace, groupId, initialBotsLoaded]);
useEffect(() => {
if (searchParams.get("project") !== "start" || !emptyProjectSpace || !initialBotsLoaded) {
return;
}
setActiveNavPillar("projekte");
const flow = searchParams.get("flow");
setPanel(
flow === "create"
? "create"
: flow === "group" && homeSpaceBots.length > 0
? "create-group"
: flow === "group"
? "create"
: bots.length === 0
? "create"
: "create-group",
);
const next = new URLSearchParams(searchParams);
next.delete("project");
next.delete("flow");
setSearchParams(next, { replace: true });
}, [
bots.length,
emptyProjectSpace,
homeSpaceBots.length,
initialBotsLoaded,
searchParams,
setSearchParams,
]);
const activeSnapshot = inGroup
? snapshot?.groupId === groupId
? snapshot
@ -2102,6 +2133,83 @@ export function ShellPage() {
() => (inGroup ? { groupId: groupId ?? "" } : { botId: active?.id ?? "" }),
[active?.id, groupId, inGroup],
);
const workspaceFocus = useMemo(
() =>
deriveWorkspaceFocus(
activeSnapshot ? { ...activeSnapshot, computer: inGroup ? null : computer } : null,
{ screenRunId, screenActive, screenMode: screenPaneMode(screenHabit) },
),
[activeSnapshot, computer, inGroup, screenActive, screenHabit, screenRunId],
);
const workspaceFocusRef = useRef<{ threadId: string; focus: WorkspaceFocus } | null>(null);
const autoPanelRef = useRef(autoPanel);
autoPanelRef.current = autoPanel;
const autoCloseTimer = useRef<number | undefined>(undefined);
useEffect(() => () => window.clearTimeout(autoCloseTimer.current), []);
// The pane follows the bot's work on focus edges only, so it never jumps while nothing changed.
useEffect(() => {
const threadId = activeSnapshot?.threadId;
const sameThread = Boolean(threadId) && workspaceFocusRef.current?.threadId === threadId;
// New work or another conversation cancels a pending close; an unrelated edge does not.
if (!sameThread || workspaceFocus.pane !== null) window.clearTimeout(autoCloseTimer.current);
if (!threadId) {
workspaceFocusRef.current = null;
return;
}
const previous = sameThread ? (workspaceFocusRef.current?.focus ?? null) : null;
workspaceFocusRef.current = { threadId, focus: workspaceFocus };
// Narrow windows keep the conversation full width; a pane would cover it.
if (!wideLayout) return;
const paneState: PaneState = {
open:
panel === "computer"
? "screen"
: panel === "artifact"
? "artifact"
: panel
? "other"
: null,
auto: panel !== null && panel === autoPanel,
};
const next = applyWorkspaceFocus(previous, workspaceFocus, paneState);
if (!next || next.open === "other") return;
if (next.open === null) {
const closing = autoPanelRef.current;
autoCloseTimer.current = window.setTimeout(() => {
if (closing === null || autoPanelRef.current !== closing) return;
setPanel(null);
setAutoPanel(null);
}, AUTO_SCREEN_CLOSE_DELAY_MS);
return;
}
if (next.open === "artifact") setArtifactPane(workspaceFocus.artifact);
const nextPanel: Panel = next.open === "screen" ? "computer" : next.open;
setPanel(nextPanel);
setAutoPanel(next.auto ? nextPanel : null);
}, [workspaceFocus.key, activeSnapshot?.threadId, wideLayout]);
const learnScreenPane = useCallback(
(choice: "opened" | "dismissed") => {
const botId = activeBotId.current;
if (!botId) return;
const next = recordScreenPaneChoice(screenHabit, choice);
setScreenHabit(next);
writeScreenPaneHabit(userId, botId, next);
},
[screenHabit, userId],
);
useEffect(() => {
if (autoPanel === null || panel === autoPanel) return;
// Closing the live screen while the bot still works on it teaches this bot's default.
const focus = workspaceFocusRef.current?.focus;
if (autoPanel === "computer" && panel === null && focus?.pane === "screen" && !focus.takeover) {
learnScreenPane("dismissed");
}
setAutoPanel(null);
}, [panel]);
const openArtifactBeside = useCallback((artifact: WorkspaceArtifact) => {
setArtifactPane(artifact);
setPanel("artifact");
}, []);
const transcriptMembers = activeSnapshot?.members ?? activeGroup?.members;
// Bots of every space, so a group message from a bot that is not a member still has a speaker.
const knownBots = useMemo(() => {
@ -3110,8 +3218,22 @@ export function ShellPage() {
setComputerOpen(false);
setComputerError(null);
setComputerErrorFromScreen(false);
setScreenRunId(null);
setScreenActive(false);
window.clearTimeout(screenActiveTimer.current);
}, [active?.id]);
useEffect(() => {
setScreenHabit(readScreenPaneHabit(userId, active?.id));
}, [active?.id, userId]);
// A presented file belongs to its conversation, and a new conversation starts without auto panes.
useEffect(() => {
setPanel((current) => (current === "artifact" ? null : current));
setArtifactPane(null);
setAutoPanel(null);
}, [active?.id, groupId]);
useEffect(() => {
if (focusPromptBotIdRef.current && focusPromptBotIdRef.current !== active?.id) {
cancelFocusPrompt();
@ -3228,6 +3350,9 @@ export function ShellPage() {
}
const embeddedScreenUrl = embeddableScreenUrl(screenUrl);
// Only what the work opened gets the wide pane; settings, routines and a hand-opened screen keep
// one width so switching between them never jumps.
const workspacePaneOpen = panel === "artifact" || (panel === "computer" && autoPanel === panel);
const hasControl = userHoldsComputerControl(computer, active?.id);
const hideScreenLoadError = computerErrorFromScreen && Boolean(embeddedScreenUrl);
const computerScreenError =
@ -3862,7 +3987,7 @@ export function ShellPage() {
className="flex min-w-0 flex-1 items-center justify-between gap-2 rounded-lg pl-8 py-1 text-ui-label font-semibold tracking-wider uppercase text-foreground/65 dark:text-muted-foreground/80 hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-ring"
onClick={() => {
if (group.emptySpaceId) {
openSpaceChat(group.emptySpaceId, "/app");
void startProject(group.emptySpaceId);
return;
}
toggleSidebarSection(group.key);
@ -3945,14 +4070,14 @@ export function ShellPage() {
) : null}
</div>
) : null}
{!collapsed && group.emptySpaceId ? (
{!collapsed && group.startSpaceId ? (
<div className="px-3 pb-2">
<Button
variant="outline"
size="sm"
className="w-full text-ui-label"
data-testid={`project-start-${group.spaceId}`}
onClick={() => openProjectStart(group.spaceId, "group")}
onClick={() => void startProject(group.spaceId)}
>
<Plus className="mr-1 size-icon-sm" />
{t`Starten`}
@ -3967,7 +4092,9 @@ export function ShellPage() {
aria-label={
item.kind === "bot"
? t`${item.chat.name}, 1:1 conversation`
: t`${item.chat.name}, group conversation`
: item.chat.kind === "main"
? t`${item.chat.name}, main conversation`
: t`${item.chat.name}, group conversation`
}
onClick={() => {
setActiveNavPillar("projekte");
@ -3979,7 +4106,12 @@ export function ShellPage() {
);
}}
onContextMenu={(event) => {
if (item.chat.spaceId !== bootstrapMe?.spaceId) return;
// A project's main conversation has no chat actions.
if (
item.chat.spaceId !== bootstrapMe?.spaceId ||
item.chat.kind === "main"
)
return;
event.preventDefault();
botMenuAnchor.current = event.currentTarget;
setBotMenu({
@ -4134,8 +4266,6 @@ export function ShellPage() {
</div>
) : null}
</div>
<TradingModeIndicator snapshot={tradingStatus} className="mx-3 mb-1 lg:mx-5 2xl:mx-7" />
<DataTrustPanel />
<Popover open={menuOpen} onOpenChange={setMenuOpen}>
<PopoverTrigger
data-testid="user-menu-trigger"
@ -4410,6 +4540,7 @@ export function ShellPage() {
title={t`Agent computer`}
onClick={() => {
const next = panel === "computer" ? null : "computer";
if (next === "computer" && transcriptRunning) learnScreenPane("opened");
setPanel(next);
if (next === "computer" && active) {
// Refresh run/computer so Take control isn't stuck on a stale busyBotName.
@ -4428,7 +4559,7 @@ export function ShellPage() {
<div className="grid flex-1 place-items-center">
<Button
data-testid="project-start-main"
onClick={() => currentSpaceId && openProjectStart(currentSpaceId, "create")}
onClick={() => currentSpaceId && void startProject(currentSpaceId)}
>
<Plus aria-hidden="true" className="size-icon-md" />
{t`Starten`}
@ -4495,11 +4626,9 @@ export function ShellPage() {
activeName={
inGroup ? (activeGroup?.name ?? activeSnapshot?.groupName) : active?.name
}
onPromptSelect={(prompt) => {
void sendMessage(prompt);
}}
scrollRef={messageScroll}
artifactTarget={transcriptArtifactTarget}
onOpenArtifact={wideLayout ? openArtifactBeside : undefined}
messages={transcriptMessages}
olderCursor={activeSnapshot?.olderCursor ?? null}
loadingOlder={loadingOlder}
@ -4545,9 +4674,6 @@ export function ShellPage() {
{(active || activeGroup) && !showProjectTimeline ? (
<Composer
key={inGroup ? `group:${groupId}` : `bot:${active?.id}`}
activeName={
inGroup ? (activeGroup?.name ?? activeSnapshot?.groupName) : active?.name
}
running={composerRunning}
disabled={Boolean(recordingSkill)}
pendingAttachments={activePendingAttachments}
@ -4605,6 +4731,7 @@ export function ShellPage() {
<aside
data-testid="side-panel"
data-panel={panel ?? "closed"}
data-auto={panel !== null && panel === autoPanel ? "" : undefined}
style={{
width:
panel &&
@ -4613,7 +4740,9 @@ export function ShellPage() {
panel === "create" ||
panel === "create-group" ||
panel === "browser")
? rightSidebarWidth
? workspacePaneOpen
? workspacePaneWidth
: rightSidebarWidth
: 0,
}}
className={`absolute inset-y-0 end-0 z-20 flex min-h-0 shrink-0 flex-col overflow-hidden bg-background md:rounded-2xl md:border md:border-border md:shadow-2xl md:relative ${
@ -4627,13 +4756,22 @@ export function ShellPage() {
)
? "max-w-[384px] md:max-w-none opacity-100"
: "pointer-events-none w-0 opacity-0 md:opacity-0 md:w-0"
} ${!isResizingRight ? "transition-[width,opacity] duration-300 ease-[cubic-bezier(0.16,1,0.3,1)]" : ""}`}
} ${!(workspacePaneOpen ? isResizingWorkspacePane : isResizingRight) ? "transition-[width,opacity] duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] motion-reduce:transition-none" : ""}`}
>
<div
onPointerDown={startResizingRight}
onPointerDown={workspacePaneOpen ? startResizingWorkspacePane : startResizingRight}
className="absolute -left-2 top-0 bottom-0 w-4 cursor-col-resize z-50 hidden md:block"
/>
{panel === "browser" ? (
{panel === "artifact" ? (
artifactPane ? (
<ArtifactPane
key={artifactPane.artifactId}
target={transcriptArtifactTarget}
artifact={artifactPane}
onClose={() => setPanel(null)}
/>
) : null
) : panel === "browser" ? (
<InAppBrowser onClose={() => setPanel(null)} />
) : panel && (active || activeGroup || panel === "create" || panel === "create-group") ? (
<div className="rk-scroll h-full w-full overflow-y-auto px-5 lg:px-7 2xl:px-9 py-[17px]">
@ -4799,7 +4937,10 @@ export function ShellPage() {
const remainingGroups = groups.filter((group) => group.id !== activeGroup.id);
setGroups(remainingGroups);
setPanel(null);
navigate(firstThreadRoute(bots, remainingGroups), { replace: true });
navigate(
firstThreadRoute(bots, remainingGroups, currentSpaceId !== homeSpaceId),
{ replace: true },
);
await refreshBots().catch(() => undefined);
}}
/>
@ -5410,7 +5551,6 @@ export function ShellPage() {
setSettingsOpen(false);
setMessagingSettingsOpen(true);
}}
tradingStatus={tradingStatus}
onAvatarStyleChange={async (avatarStyle) => {
const nextMe = await rpc.preferences.update({ avatarStyle });
setBootstrapMe(nextMe);
@ -5634,6 +5774,7 @@ export function ShellPage() {
const Transcript = memo(function Transcript({
scrollRef,
artifactTarget,
onOpenArtifact,
messages,
olderCursor,
loadingOlder,
@ -5658,12 +5799,12 @@ const Transcript = memo(function Transcript({
speakingMessageId,
onSpeak,
activeName,
onPromptSelect,
encounterMode = false,
voiceEncounterActive = false,
}: {
scrollRef: RefObject<HTMLDivElement | null>;
artifactTarget: ArtifactTarget;
onOpenArtifact?: (artifact: WorkspaceArtifact) => void;
messages: ThreadMessage[];
olderCursor: number | null;
loadingOlder: boolean;
@ -5688,7 +5829,6 @@ const Transcript = memo(function Transcript({
speakingMessageId: string | null;
onSpeak: (message: ThreadMessage) => void;
activeName?: string;
onPromptSelect?: (prompt: string) => void;
encounterMode?: boolean;
voiceEncounterActive?: boolean;
}) {
@ -5803,6 +5943,43 @@ const Transcript = memo(function Transcript({
if (following.current) snapToEnd();
}, [messages, running, snapToEnd]);
// A question the bot waits on can scroll out of view; offer a way back to it instead of the arrow.
const [askOffscreen, setAskOffscreen] = useState(false);
const findAsk = useCallback(
() =>
answerableAskMessageId
? (scrollRef.current?.querySelector<HTMLElement>(
`[data-message-id="${CSS.escape(answerableAskMessageId)}"]`,
) ?? null)
: null,
[answerableAskMessageId, scrollRef],
);
useEffect(() => {
const root = scrollRef.current;
const element = findAsk();
if (!root || !element || typeof IntersectionObserver === "undefined") {
setAskOffscreen(false);
return;
}
const observer = new IntersectionObserver(
(entries) => setAskOffscreen(!entries.some((entry) => entry.isIntersecting)),
{ root, threshold: 0.2 },
);
observer.observe(element);
return () => observer.disconnect();
}, [findAsk, messages, scrollRef]);
const jumpToAsk = useCallback(() => {
const element = findAsk();
if (!element) return;
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
following.current = false;
element.scrollIntoView({ behavior: reducedMotion ? "auto" : "smooth", block: "center" });
element
.querySelector<HTMLElement>("[data-ask-card] input, [data-ask-card] button")
?.focus({ preventScroll: true });
}, [findAsk]);
const showJumpToLatest = !atEnd && !askOffscreen;
useLayoutEffect(() => {
const button = jumpButtonRef.current;
if (atEnd && button && document.activeElement === button) {
@ -5904,87 +6081,11 @@ const Transcript = memo(function Transcript({
}`}
>
{reactionView.visibleMessages.length === 0 && encounterMode ? (
<div className="flex flex-col gap-2 py-2">
<p className="text-ui-body-sm text-muted-foreground">
{activeName
? t`Start the conversation — ${activeName} is on stage above.`
: t`Start the conversation.`}
</p>
<div className="flex flex-wrap gap-2">
{[t`Portfolio-Check`, t`Alpha-Signale`, t`Architektur-Audit`].map((label) => (
<button
key={label}
type="button"
onClick={() => onPromptSelect?.(label)}
className="rounded-full border border-border bg-muted/40 px-3 py-1 text-ui-body-sm text-foreground/85 hover:bg-accent"
>
{label}
</button>
))}
</div>
</div>
) : null}
{reactionView.visibleMessages.length === 0 && !encounterMode ? (
<div className="flex flex-1 flex-col items-center justify-center py-16 text-center select-none animate-in fade-in duration-500">
<div className="relative mb-5 flex items-center justify-center p-3">
<EvooMascot size={76} pose="greeting" interactive trackPointer="hover" />
</div>
<h2 className="text-3xl font-bold tracking-tight text-foreground mb-2">
{t`Guten Tag.`} evOo {t`ist bereit.`}
</h2>
<p className="max-w-md text-ui-body leading-relaxed text-foreground/85 mb-8 font-normal">
{activeName
? t`Frag ${activeName} nach Marktchancen, autonomen Tasks oder Architektur-Reviews.`
: t`Wähle einen autonomen Agenten aus deinem Team oder starte direkt einen Prompt.`}
</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3.5 w-full max-w-2xl px-4">
<button
type="button"
onClick={() => {
onPromptSelect?.(t`⚡ Führe einen autonomen Portfolio- und Risiko-Check durch`);
}}
className="apple-glass-card flex flex-col items-start p-4 rounded-2xl text-start transition-all hover:scale-[1.02] cursor-pointer"
>
<span className="text-xl mb-2">⚡</span>
<span className="text-[14.5px] font-semibold text-foreground">
{t`Portfolio-Check`}
</span>
<span className="text-ui-body-sm text-muted-foreground mt-1 leading-snug">
{t`Echtzeit-Risiko & Drawdown-Analyse`}
</span>
</button>
<button
type="button"
onClick={() => {
onPromptSelect?.(t`📈 Scanne den Markt nach aktuellen Alpha-Signalen`);
}}
className="apple-glass-card flex flex-col items-start p-4 rounded-2xl text-start transition-all hover:scale-[1.02] cursor-pointer"
>
<span className="text-xl mb-2">📈</span>
<span className="text-[14.5px] font-semibold text-foreground">
{t`Alpha-Signale`}
</span>
<span className="text-ui-body-sm text-muted-foreground mt-1 leading-snug">
{t`Volatilität & Orderbuch-Liquidität`}
</span>
</button>
<button
type="button"
onClick={() => {
onPromptSelect?.(t`💎 Führe ein Steve Jobs UX & Architektur-Audit durch`);
}}
className="apple-glass-card flex flex-col items-start p-4 rounded-2xl text-start transition-all hover:scale-[1.02] cursor-pointer"
>
<span className="text-xl mb-2">💎</span>
<span className="text-[14.5px] font-semibold text-foreground">
{t`Steve Jobs Audit`}
</span>
<span className="text-ui-body-sm text-muted-foreground mt-1 leading-snug">
{t`Schlichtheit, Eleganz & Detailtiefe`}
</span>
</button>
</div>
</div>
<p className="py-2 text-ui-body-sm text-muted-foreground">
{activeName
? t`Start the conversation — ${activeName} is on stage above.`
: t`Start the conversation.`}
</p>
) : null}
{reactionView.visibleMessages.map((message) => {
if (!message.blocks.some((block) => !isToolActivityBlock(block))) return null;
@ -6045,6 +6146,7 @@ const Transcript = memo(function Transcript({
)}
<MessageView
artifactTarget={artifactTarget}
onOpenArtifact={onOpenArtifact}
message={message}
canAnswer={message.id === answerableAskMessageId}
onOpenBot={onOpenBot}
@ -6127,15 +6229,28 @@ const Transcript = memo(function Transcript({
ref={jumpButtonRef}
type="button"
aria-label={t`Jump to latest`}
aria-hidden={atEnd}
tabIndex={atEnd ? -1 : 0}
aria-hidden={!showJumpToLatest}
tabIndex={showJumpToLatest ? 0 : -1}
onClick={jumpToLatest}
className={`absolute bottom-4 left-1/2 z-20 grid h-9 w-9 -translate-x-1/2 place-items-center rounded-full border border-border bg-muted/95 text-foreground/75 shadow-md backdrop-blur transition-[opacity,transform,background-color] duration-200 ease-[cubic-bezier(.22,1,.36,1)] hover:bg-border motion-reduce:transition-none ${
atEnd ? "pointer-events-none translate-y-2 opacity-0" : "translate-y-0 opacity-100"
showJumpToLatest
? "translate-y-0 opacity-100"
: "pointer-events-none translate-y-2 opacity-0"
}`}
>
<ArrowDown size={17} strokeWidth={1.8} />
</button>
{askOffscreen ? (
<button
type="button"
data-testid="needs-you-jump"
onClick={jumpToAsk}
className="absolute bottom-4 left-1/2 z-20 inline-flex h-9 -translate-x-1/2 items-center gap-1.5 rounded-full border border-border bg-muted/95 px-3.5 text-ui-body-sm font-medium text-foreground shadow-md backdrop-blur hover:bg-border"
>
<span aria-hidden="true" className="size-2 rounded-full bg-warning" />
{t`Needs you`}
</button>
) : null}
</div>
);
});
@ -6215,7 +6330,6 @@ const QuoteSelectionButton = memo(function QuoteSelectionButton({
});
const Composer = memo(function Composer({
activeName,
running,
disabled,
pendingAttachments,
@ -6243,7 +6357,6 @@ const Composer = memo(function Composer({
onSlashOpen,
onSlashAction,
}: {
activeName?: string;
running: boolean;
disabled?: boolean;
pendingAttachments: PendingAttachment[];
@ -6552,28 +6665,6 @@ const Composer = memo(function Composer({
}`}
>
<div className="mx-auto w-full max-w-4xl">
{/* Quick Action Prompt Chips */}
{showComposerPlaceholder ? (
<div className="mb-3 flex items-center gap-2 overflow-x-auto no-scrollbar py-1">
<span className="flex items-center gap-1.5 text-ui-label font-semibold uppercase tracking-wider text-muted-foreground shrink-0 px-2.5 py-1 rounded-full bg-muted/40 border border-border me-1 shadow-xs">
<Sparkles className="text-amber-400 size-icon-sm" />
Quick
</span>
{quickPromptsForBot(activeName).map((promptText) => (
<button
key={promptText}
type="button"
onClick={() => {
updateDraft(promptText);
focusComposer();
}}
className="shrink-0 rounded-full border border-border bg-muted/40 px-3.5 py-1.5 text-ui-body-sm font-medium text-foreground/85 hover:bg-accent hover:text-foreground transition-all shadow-sm active:scale-95 cursor-pointer"
>
{promptText}
</button>
))}
</div>
) : null}
{sendError || runError ? (
<div
ref={runErrorRef}
@ -7068,48 +7159,6 @@ function cleanRosterPreview(raw?: string | null): string {
return text;
}
function quickPromptsForBot(botName?: string): string[] {
const lower = (botName ?? "").toLowerCase();
if (lower.includes("chief")) {
return [
"⚡ Portfolio Health Check",
"📊 Risk & Drawdown Exposure",
"🔄 Team-Status synchronisieren",
"🎯 Tagesziel prüfen",
];
}
if (lower.includes("steve")) {
return [
"✨ UX & Design Audit",
"🚀 Product Architecture",
"💎 Schlichtheit & Eleganz",
"🔍 Konkurrenzanalyse",
];
}
if (lower.includes("apex") || lower.includes("quant") || lower.includes("trad")) {
return [
"📈 Alpha-Signale scannen",
"📉 Volatilitäts-Stresstest",
"💰 Live vs. Paper PnL",
"⚡ Orderbuch Liquidität",
];
}
if (
lower.includes("dev") ||
lower.includes("sec") ||
lower.includes("infra") ||
lower.includes("code")
) {
return [
"🛡️ Security & Vault Check",
"💻 Latenz- & Cluster-Status",
"🔍 Code Review & Refactor",
"🚨 Health & Error Logs",
];
}
return ["💡 Prioritäten für heute?", "⚡ Schneller Statusbericht", "📊 Performance-Metriken"];
}
function MessageHoverActions({
message,
side,
@ -7212,8 +7261,12 @@ function MessageHoverActions({
function firstThreadRoute(
bots: readonly Pick<Bot, "id">[],
groups: readonly Pick<Group, "id">[],
groups: readonly Pick<Group, "id" | "kind">[],
/** In a project the user lands in its main conversation. */
inProject = false,
): string {
const main = inProject ? groups.find((group) => group.kind === "main") : undefined;
if (main) return `/app/g/${main.id}`;
if (bots[0]) return `/app/${bots[0].id}`;
if (groups[0]) return `/app/g/${groups[0].id}`;
return "/app";
@ -7264,6 +7317,7 @@ function ComputerReleaseActions({
const MessageView = memo(function MessageView({
artifactTarget,
onOpenArtifact,
canAnswer,
message,
onAnswer,
@ -7285,6 +7339,7 @@ const MessageView = memo(function MessageView({
encounterMode = false,
}: {
artifactTarget: ArtifactTarget;
onOpenArtifact?: (artifact: WorkspaceArtifact) => void;
canAnswer: boolean;
message: ThreadMessage;
onAnswer: (message: ThreadMessage, text: string) => Promise<void>;
@ -7686,6 +7741,17 @@ const MessageView = memo(function MessageView({
name={block.name}
mimeType={block.mimeType}
size={block.size}
onOpenBeside={
onOpenArtifact
? () =>
onOpenArtifact({
messageId: message.id,
artifactId: block.artifactId,
name: block.name,
mimeType: block.mimeType,
})
: undefined
}
/>
</div>
);

View File

@ -84,17 +84,17 @@ export function ChoiceCard({
aria-label={option.label}
disabled={Boolean(block.answerId) || pending}
onClick={() => void choose(option.id)}
className={`tactile-press flex w-full items-start gap-3.5 rounded-[18px] border border-white/5 p-3.5 text-start text-foreground disabled:opacity-60 transition-all ${
className={`tactile-press flex w-full items-start gap-3.5 rounded-[18px] border border-foreground/5 p-3.5 text-start text-foreground disabled:opacity-60 transition-all ${
block.answerId === option.id
? "bg-blue-500/20 border-blue-500/40 shadow-sm"
: "bg-white/[0.04] hover:bg-white/[0.08] hover:border-white/10"
: "bg-foreground/[0.04] hover:bg-foreground/[0.08] hover:border-foreground/10"
}`}
>
<span
className={`mt-0.5 grid h-6 w-6 shrink-0 place-items-center rounded-[8px] text-ui-body-sm font-semibold ${
block.answerId === option.id
? "bg-blue-500 text-white"
: "bg-white/10 text-foreground/80"
: "bg-foreground/10 text-foreground/80"
}`}
>
{option.letter}
@ -105,7 +105,7 @@ export function ChoiceCard({
{option.label}
</span>
{block.answerId === option.id ? (
<span className="mt-0.5 text-blue-400 font-bold">✓</span>
<span className="mt-0.5 text-blue-700 dark:text-blue-400 font-bold">✓</span>
) : null}
</button>
))}

View File

@ -2,7 +2,7 @@
export function rosterRowClass(selected: boolean): string {
return `flex w-full items-center gap-3 rounded-2xl border px-3 py-3 mb-[2px] text-start transition-all duration-150 cursor-pointer min-h-[52px] ${
selected
? "border-sidebar-border bg-sidebar-accent shadow-[inset_0_1px_0_rgba(255,255,255,0.14),0_4px_16px_rgba(0,0,0,0.4)]"
? "border-sidebar-border bg-sidebar-accent shadow-xs dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.14),0_4px_16px_rgba(0,0,0,0.4)]"
: "border-transparent hover:bg-sidebar-accent/50"
}`;
}

View File

@ -2,8 +2,6 @@
interface ImportMetaEnv {
readonly VITE_DEFAULT_UI_LOCALE?: string;
/** Optional Tradingbot origin. Empty uses the same-origin API proxy. */
readonly VITE_TRADING_BOT_URL?: string;
}
interface ImportMeta {

View File

@ -22,8 +22,7 @@
"!**/data",
"!**/*.astro",
"!**/apps/www",
"!**/.claude-handoff*",
"!**/screenshot*.cjs"
"!**/.claude-handoff*"
]
},
"formatter": {

138
deploy.sh
View File

@ -1,138 +0,0 @@
#!/usr/bin/env bash
# ==============================================================================
# evOo Platform — Hot-Reload & Physical Deployment Orchestrator
# Executive Lead: CTO Steve Jobs
# "Realität im Dateisystem vor Halluzination im Kopf."
# ==============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PORT="${PORT:-3088}"
PID_FILE="${SCRIPT_DIR}/.evoo_server.pid"
LOG_FILE="${SCRIPT_DIR}/deploy.log"
# Colors
GREEN='\033[0;32m'
CYAN='\033[0;36m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m' # No Color
log() {
echo -e "${CYAN}[evOo Deploy]${NC} $1"
}
success() {
echo -e "${GREEN}[✓ SUCCESS]${NC} $1"
}
warn() {
echo -e "${YELLOW}[⚠ WARNING]${NC} $1"
}
fail() {
echo -e "${RED}[✗ FAILED]${NC} $1" >&2
exit 1
}
echo "=========================================================="
echo " evOo Autonomous Platform — Hot-Reload & Deploy "
echo " CTO: Steve Jobs | Port: ${PORT} "
echo "=========================================================="
# ------------------------------------------------------------------------------
# STEP 1: Physical Reality & Filesystem Audit (Zero Phantom Policy)
# ------------------------------------------------------------------------------
log "Prüfe physische Existenz aller Kern-Artefakte..."
REQUIRED_FILES=(
"${SCRIPT_DIR}/ARCHITECTURE.md"
"${SCRIPT_DIR}/evoo.config.json"
"${SCRIPT_DIR}/evoo-platform/index.html"
"${SCRIPT_DIR}/evoo-platform/styles.css"
"${SCRIPT_DIR}/evoo-platform/app.js"
"${SCRIPT_DIR}/evoo-platform/server.js"
"${SCRIPT_DIR}/apex-trading/BLUEPRINT.md"
"${SCRIPT_DIR}/apex-trading/config/trading.config.json"
"${SCRIPT_DIR}/orchestrator/agents.json"
"${SCRIPT_DIR}/orchestrator/agent_runner.py"
)
for file in "${REQUIRED_FILES[@]}"; do
if [[ ! -f "$file" ]]; then
fail "Physische Datei fehlt im Dateisystem: $file. Abbruch! Vision-Schleife verhindert."
fi
done
success "100% Physische Dateisystem-Integrität bestätigt. Keine Phantom-Dateien."
# If user only wanted verification
if [[ "${1:-}" == "--check-only" ]]; then
success "Check-only abgeschlossen. Alles physisch vorhanden."
exit 0
fi
# ------------------------------------------------------------------------------
# STEP 2: Process Management / Hot-Reload
# ------------------------------------------------------------------------------
if [[ -f "$PID_FILE" ]]; then
OLD_PID="$(cat "$PID_FILE" 2>/dev/null || true)"
if [[ -n "$OLD_PID" ]] && kill -0 "$OLD_PID" 2>/dev/null; then
log "Beende vorherige Server-Instanz (PID: $OLD_PID) für Hot-Reload..."
kill "$OLD_PID" 2>/dev/null || true
sleep 0.5
fi
rm -f "$PID_FILE"
fi
# Also ensure port is free
OCCUPIED_PID="$(lsof -ti tcp:"$PORT" 2>/dev/null || true)"
if [[ -n "$OCCUPIED_PID" ]]; then
log "Port $PORT bereits belegt von PID $OCCUPIED_PID. Bereinige..."
kill -9 $OCCUPIED_PID 2>/dev/null || true
sleep 0.5
fi
# ------------------------------------------------------------------------------
# STEP 3: Server Start
# ------------------------------------------------------------------------------
log "Starte evOo Platform Server als robuster Daemon auf Port ${PORT}..."
export PORT
if [[ "${1:-}" == "serve" || "${1:-}" == "--foreground" ]]; then
echo "$$" > "$PID_FILE"
log "Server läuft im Vordergrund (Strg+C zum Beenden)..."
exec node "${SCRIPT_DIR}/evoo-platform/server.js"
fi
# True detached POSIX daemon via setsid
python3 -c "import subprocess; subprocess.Popen(['node', '${SCRIPT_DIR}/evoo-platform/server.js'], cwd='${SCRIPT_DIR}', start_new_session=True, stdout=open('${LOG_FILE}', 'a'), stderr=subprocess.STDOUT)"
# Wait up to 5 seconds for healthcheck
log "Führe Healthcheck durch (http://127.0.0.1:${PORT}/health)..."
for i in {1..10}; do
if curl -s "http://127.0.0.1:${PORT}/health" >/dev/null 2>&1; then
HEALTH_RES="$(curl -s "http://127.0.0.1:${PORT}/health")"
success "Platform Server antwortet: HTTP 200 OK!"
echo -e "${CYAN}Health Response:${NC} $HEALTH_RES"
break
fi
if [[ $i -eq 10 ]]; then
fail "Server antwortet nicht innerhalb des Timeouts! Überprüfe $LOG_FILE"
fi
sleep 0.5
done
# ------------------------------------------------------------------------------
SERVER_PID="$(cat "$PID_FILE" 2>/dev/null || echo "active")"
echo ""
echo -e "${GREEN}==========================================================${NC}"
echo -e "${GREEN} evOo PLATFORM ERFOLGREICH DEPLOYED & LIVE! ${NC}"
echo -e "${GREEN}==========================================================${NC}"
echo -e " 🌐 Dashboard: ${CYAN}http://127.0.0.1:${PORT}/${NC}"
echo -e " 💓 Healthcheck: ${CYAN}http://127.0.0.1:${PORT}/health${NC}"
echo -e " 📊 Telemetrie API: ${CYAN}http://127.0.0.1:${PORT}/api/status${NC}"
echo -e " 📑 Verfassung: ${CYAN}${SCRIPT_DIR}/ARCHITECTURE.md${NC}"
echo -e " 📈 Apex Trading: ${CYAN}${SCRIPT_DIR}/apex-trading/BLUEPRINT.md${NC}"
echo -e " ⚙️ PID: ${SERVER_PID}"
echo -e "${GREEN}==========================================================${NC}"

View File

@ -0,0 +1,32 @@
# Adaptive workspace
The pane beside a conversation follows what the bot is actually doing, not a guess about the task.
No classifier or model decides the layout: `deriveWorkspaceFocus` in `@rakazo/core` reads the thread
state every client already has, so web and mobile agree and the rules stay testable offline.
## Signals
| Focus | Signal | Web | Mobile |
|-------|--------|-----|--------|
| Screen | A fresh `thread.computer` pointer event from a working run, or a takeover request | Live screen opens beside the chat (wide pane), steps back a few seconds after the work ends | Pill above the composer opens the computer |
| Artifact | The latest run attached a file with `present: true` (`attach_file`) | File opens beside the chat until the user closes it or the bot presents another | Unchanged: file cards open the native preview |
| Needs you | An unanswered ask on a waiting run | "Needs you" pill replaces the jump arrow when the question scrolled out of view | Existing push and attention notifications |
Everything else stays a plain conversation.
## Rules
- The pane moves only on focus edges (`applyWorkspaceFocus`). The first focus of a conversation is a
baseline, so opening a thread never jumps.
- A pane the user opened by hand is never replaced or closed automatically. Auto panes close only
when their own focus ends; a presented file stays.
- Windows narrower than 1024px keep the conversation full width; file cards fall back to the dialog.
- Per bot, the client learns from the user: opening the screen by hand while the bot works three
times makes it open on every run; closing an automatically opened screen twice stops it opening
on its own. A takeover request always shows the screen.
- Pane widths are remembered per device.
## Not in scope
Generated layouts, per-domain dashboards and prompt classification stay out: they guess, move the UI
under the user, and cannot be verified deterministically.

View File

@ -1,5 +1,4 @@
# evOo-local — Agenten-Interface only
Primary: multi-agent / Oo UI at `/app/`.
Not primary: Mission Control, Cockpit, trading dashboards, old HTML evoo-platform.
Spelling: **evOo** (letter O).

View File

@ -102,10 +102,7 @@ Then connect **Kokoro** in Voice settings. See [kokoro-voice.md](./kokoro-voice.
| Tool | Path |
|------|------|
| self_improve | `scripts/self_improve.py` |
| autonomous-sentinel | `scripts/autonomous-sentinel.ts` |
| self-update | `packages/core/src/self-update.ts` |
| orchestrator | `orchestrator/agent_runner.py`, `orchestrator/agents.json` |
| local-stack | `apps/desktop/src/local-stack.ts` |
| pi-local-provider | `packages/adapters/src/pi-local-provider.ts` |
| seed-ollama | `scripts/seed-ollama.ts` |
@ -117,12 +114,3 @@ Display brand is **evOo**. Workspace package names remain `@rakazo/api`, `@rakaz
## Source
Clean history cut from `cynarAI/evoo` commit `f00577b` on branch `antigravity-handoff/project-timeline` (Voice First + project timeline). This repo does **not** carry the old multi-remote history.
## Old folders (do not delete from this doc — cleanup later on the Mac)
- `~/Desktop/evoo` (or Desktop/evOo)
- `~/awesome-grok-bot`
- `~/evOo`
- `~/Projects/evOo` (if present)
Keep them until you confirm `~/Projects/evOo-local` works.

View File

@ -1,52 +0,0 @@
# Voice First / Executive Board — Review Report
**Audience:** Ingo Wagner
**Branch:** `antigravity-handoff/project-timeline` @ `8d0b8e3` (PRs #33–#40 merged + follow-up fixes)
**Date:** 2026-09-19 (Europe/Vienna)
**Verifier host:** evOo Linux box (Tailscale `grokbot-box`). Ingo’s Mac was **not** SSH-reachable from this host (no key / Tailscale SSH off).
## Definition of done checklist
| # | Criterion | Result | Evidence |
|---|-----------|--------|----------|
| 1 | Dev stack healthy: API `:3100/health` + web `:5173` | **BLOCKED (Mac)** | Mac mini / MacBook Air online on Tailscale; ports 3100/5173 not exposed; SSH `Permission denied (publickey)`. Could not restart `pnpm dev` on `/Users/ingowagner/evOo`. |
| 2 | Executive Board: user VOICE FIRST turn → UX Designer replies with concept thoughts + ≥1 question; no raw `handoff_to_bot` JSON; no bounce to Boss | **PASS (unit / code)** · **UNVERIFIED (live)** | PRs #39–#40 + `bot-reply-sanitize`, `prose-handoff-recovery`, `group-handoff` workshop instructions. 56 focused tests green. Live group chat not exercised (no Mac API). |
| 3 | Transcript: no tool JSON blobs / no `assistant:` role leaks | **PASS (unit)** · **UNVERIFIED (live)** | `sanitizeBotReplyText` strips prose tool JSON + role prefix; `speechFromBlocks` skips tool-activity + unspeakable blobs (regression fixed after merge). |
| 4 | Voice Mode (CallView) UX Designer: invite before mic when owning work; after outgoing handoff mic stays closed + transfer line | **PASS (code + unit)** · **UNVERIFIED (live)** | CallView rebuilt: `speakUserTurnInvitation` / `handleSpeechIdle` / `shouldSpeakOpeningInvitation`; `parkAfterHandoff` + `voiceHandoffTransferLine`; mount no longer auto-opens mic. |
## What was done on this pass
1. Merged open PR heads **#33–#40** into `antigravity-handoff/project-timeline` and **pushed** to origin.
2. Fixed merge regression in `packages/core/src/speech-text.ts`: `speechFromBlocks` again drops tool-activity chips and unspeakable tool JSON (test was failing after `-X theirs` merge).
3. Fixed merge regression in `apps/web/src/pages/CallView.tsx`: invitation-before-mic restored; outgoing handoff keeps mic closed and speaks transfer line.
4. Focused vitest: **56 passed** (`call-view-voice`, `voice-handoff`, `speech-text`, `bot-reply-sanitize`, `prose-handoff-recovery`).
## Remaining gaps (for Ingo’s Mac)
1. **SSH / Tailscale SSH** from agent hosts to `macbook-air-von-ingo` / `mac-mini-von-ingo` so localhost verify can run without manual UI.
2. On Mac repo path `/Users/ingowagner/evOo`:
```bash
git fetch origin && git checkout antigravity-handoff/project-timeline && git pull
pnpm install && pnpm dev # ensure :3100 + :5173
```
3. Live Executive Board check (RPC/DB preferred):
- Find Executive Board `chat_groups` id.
- Post a fresh user turn about **VOICE FIRST AI Talks**.
- Confirm UX Designer message: concept prose + question; no `handoff_to_bot` JSON; Boss not re-tasked with the same work.
4. Open Voice Mode on UX Designer: hear invite before mic; after a specialist handoff, mic stays closed with transfer caption.
## Sample expected UX Designer reply shape (sanitized)
> I’ve been thinking about VOICE FIRST AI Talks as a short, spoken workshop: one specialist owns the lane, invites you before the mic opens, and never dumps the same task back upstairs. For the first talk, I’d keep it to three beats — hook, one demo, one ask.
> **Which audience should we design for first — founders, designers, or internal execs?**
## Group id tested
*None live — Mac localhost unreachable.* After Mac verify, record `chat_groups.id` here.
## Branch / PR notes
- Timeline branch now contains merges of `cursor/voice-mic-energy-2d2c` … `cursor/handoff-prose-sanitize-d1d4` plus commits:
- `fix(core): drop tool-activity and unspeakable blobs from speechFromBlocks`
- `fix(web): restore invite-before-mic and keep handoff mic closed`
- Draft PRs #33–#40 can be closed or marked superseded once this branch is the review source of truth.

View File

@ -1,573 +0,0 @@
// evOo Platform - Executive Mission Control Cockpit (app.js)
// Zero-Mockup Reactive Engine for Ingo Wagner & Autonomous Teammates
document.addEventListener("DOMContentLoaded", () => {
// Elements
const terminal = document.getElementById("terminal-logs");
const btnClear = document.getElementById("btn-clear-console");
const btnToggleConsole = document.getElementById("btn-toggle-console");
const cardConsole = document.querySelector(".card-console");
const btnSelfImprove = document.getElementById("btn-self-improve");
const btnKillswitch = document.getElementById("btn-killswitch");
const killswitchText = document.getElementById("killswitch-text");
const btnHotReload = document.getElementById("btn-hot-reload");
const btnOpenSteveChat = document.getElementById("btn-open-steve-chat");
const clockEl = document.getElementById("timestamp-clock");
const systemHealthVal = document.getElementById("system-health-val");
const _clusterSummaryVal = document.getElementById("cluster-summary-val");
const tradingSummaryVal = document.getElementById("trading-summary-val");
const approvalsContainer = document.getElementById("approvals-container");
const approvalsBadge = document.getElementById("approvals-badge");
const journalContainer = document.getElementById("journal-container");
const agentItems = document.querySelectorAll(".interactive-agent");
const _pnlVal = document.getElementById("pnl-val");
const _drawdownVal = document.getElementById("drawdown-val");
const _circuitBreakerVal = document.getElementById("circuit-breaker-val");
const tradingBadge = document.getElementById("trading-badge");
// Clock
function updateClock() {
const now = new Date();
clockEl.textContent = `${now.toISOString().replace("T", " ").substring(0, 19)} UTC`;
}
setInterval(updateClock, 1000);
updateClock();
// Helper: Strip ANSI color codes
const ansiCsi = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
function cleanAnsi(str) {
return str.replace(ansiCsi, "");
}
// Logger
function addLog(text, type = "info") {
if (!terminal) return;
const line = document.createElement("div");
const cleanText = cleanAnsi(text);
let lineType = type;
if (
cleanText.includes("[✓ PASS]") ||
cleanText.includes("[PASS]") ||
cleanText.includes("SUCCESS") ||
cleanText.includes("200 OK")
) {
lineType = "success";
} else if (
cleanText.includes("[✗ FAIL]") ||
cleanText.includes("FAILED") ||
cleanText.includes("Error")
) {
lineType = "error";
} else if (cleanText.includes("[⚠ WARN]") || cleanText.includes("WARN")) {
lineType = "warning";
}
line.className = `log-line ${lineType}`;
const timestamp = new Date().toTimeString().split(" ")[0];
line.textContent = `[${timestamp}] ${cleanText}`;
terminal.appendChild(line);
terminal.scrollTop = terminal.scrollHeight;
}
// Console Toggles
if (btnClear) {
btnClear.addEventListener("click", () => {
terminal.innerHTML = "";
addLog("Terminal gelöscht. Überwachung aktiv.", "info");
});
}
if (btnToggleConsole && cardConsole) {
btnToggleConsole.addEventListener("click", () => {
const isCollapsed = cardConsole.classList.toggle("collapsed");
btnToggleConsole.textContent = isCollapsed ? "Ausklappen" : "Minimieren";
});
}
// 1. Fetch & Render Approvals
async function loadApprovals() {
if (!approvalsContainer) return;
try {
const res = await fetch("/api/approvals");
if (!res.ok) return;
const data = await res.json();
const list = data.approvals || [];
const pending = list.filter((a) => a.status === "pending");
if (approvalsBadge) {
approvalsBadge.textContent =
pending.length > 0 ? `${pending.length} Ausstehend` : "Alle erledigt";
approvalsBadge.className = pending.length > 0 ? "badge badge-amber" : "badge badge-emerald";
}
approvalsContainer.innerHTML = "";
if (list.length === 0) {
approvalsContainer.innerHTML =
'<div class="text-dim p-3">Keine ausstehenden Entscheidungen. System läuft autonom.</div>';
return;
}
list.forEach((item) => {
const div = document.createElement("div");
div.className = "approval-item";
div.setAttribute("data-id", item.id);
const isDecided = item.status !== "pending";
const statusClass = item.status === "approved" ? "badge-emerald" : "badge-amber";
div.innerHTML = `
<div class="approval-meta">
<span class="approval-type-tag">${item.type}</span>
<span class="approval-author">${item.author}</span>
<span class="approval-time">${item.timestamp}</span>
${isDecided ? `<span class="badge ${statusClass}">${item.status.toUpperCase()}</span>` : ""}
</div>
<div class="approval-title">${item.title}</div>
<div class="approval-desc">${item.details}</div>
<div class="approval-metrics">
<span>Conviction: <strong>${item.conviction}%</strong></span>
<span>R:R: <strong>${item.riskReward}</strong></span>
<span>Umfang: <strong>${item.size}</strong></span>
</div>
${
!isDecided
? `
<div class="approval-actions">
<button class="btn-approve" data-id="${item.id}" data-action="approve">✓ Genehmigen</button>
<button class="btn-veto" data-id="${item.id}" data-action="veto">✗ Veto / Ablehnen</button>
<button class="btn-discuss" data-author="${item.author}">💬 Im Chat besprechen</button>
</div>
`
: ""
}
`;
approvalsContainer.appendChild(div);
});
// Attach handlers to actions
approvalsContainer.querySelectorAll(".btn-approve, .btn-veto").forEach((btn) => {
btn.addEventListener("click", async (e) => {
const id = e.target.getAttribute("data-id");
const action = e.target.getAttribute("data-action");
try {
const resp = await fetch("/api/approvals", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id, action }),
});
if (resp.ok) {
addLog(
`[DECISION] Entscheidung '${action.toUpperCase()}' für '${id}' physisch registriert.`,
"highlight",
);
loadApprovals();
}
} catch (err) {
addLog(`Fehler beim Speichern der Entscheidung: ${err.message}`, "error");
}
});
});
approvalsContainer.querySelectorAll(".btn-discuss").forEach((btn) => {
btn.addEventListener("click", () => {
const author = btn.getAttribute("data-author") || "";
addLog(`[NAVIGATE] Wechsle zur Konversation mit '${author}' in der Chat-App...`, "info");
if (window.parent && window.parent !== window) {
window.parent.postMessage({ type: "open_team_tab", botAuthor: author }, "*");
}
});
});
} catch (e) {
console.error("Approvals fetch error", e);
}
}
// 2. Fetch & Render Overnight Journal
async function loadJournal() {
if (!journalContainer) return;
try {
const res = await fetch("/api/journal");
if (!res.ok) return;
const data = await res.json();
const events = data.events || [];
journalContainer.innerHTML = "";
events.forEach((ev) => {
const row = document.createElement("div");
row.className = "journal-entry";
row.innerHTML = `
<div class="entry-time">${ev.time}</div>
<div class="entry-content">
<div class="entry-header">
<span class="entry-agent-chip">${ev.agent}</span>
<span class="entry-title">${ev.title}</span>
</div>
<div class="entry-detail">${ev.detail}</div>
</div>
`;
journalContainer.appendChild(row);
});
} catch (e) {
console.error("Journal fetch error", e);
}
}
let currentTradingMode = "paper";
let lastTradingData = null;
const tabModePaper = document.getElementById("tab-mode-paper");
const tabModeLive = document.getElementById("tab-mode-live");
const card1Label = document.getElementById("card1-label");
const card1Val = document.getElementById("card1-val");
const card1Sub = document.getElementById("card1-sub");
const card2Label = document.getElementById("card2-label");
const card2Val = document.getElementById("card2-val");
const card2Sub = document.getElementById("card2-sub");
const card3Label = document.getElementById("card3-label");
const card3Val = document.getElementById("card3-val");
const card3Sub = document.getElementById("card3-sub");
const card4Label = document.getElementById("card4-label");
const card4Val = document.getElementById("card4-val");
const card4Sub = document.getElementById("card4-sub");
function renderTradingView() {
if (!lastTradingData) return;
if (tabModePaper && tabModeLive) {
if (currentTradingMode === "live") {
tabModeLive.classList.add("active");
tabModePaper.classList.remove("active");
} else {
tabModePaper.classList.add("active");
tabModeLive.classList.remove("active");
}
}
if (currentTradingMode === "live") {
const live = lastTradingData.live || {};
const poly = live.polymarket || {};
const bp = live.bitpanda || {};
const fiat = live.fiat_reserves || {};
if (card1Label) card1Label.textContent = "Polymarket CLOB V2";
if (card1Val) card1Val.textContent = `$${(poly.pusd_balance || 639.39).toFixed(2)} pUSD`;
if (card1Sub)
card1Sub.textContent = `+ ${(poly.pol_gas || 58.25).toFixed(1)} POL Gas · Deposit Wallet`;
if (card2Label) card2Label.textContent = "Bitpanda Fusion";
if (card2Val) card2Val.textContent = `€${(bp.eur_cash || 46.09).toFixed(2)} EUR`;
if (card2Sub)
card2Sub.textContent = `+ ${(bp.pol || 436).toFixed(0)} POL, ${(bp.near || 12.6).toFixed(1)} NEAR, ${(bp.usdc || 10.3).toFixed(1)} USDC`;
if (card3Label) card3Label.textContent = "Bank-Liquidität (Fiat)";
if (card3Val)
card3Val.textContent = `€${(fiat.total_eur || 8500).toLocaleString("de-DE", { minimumFractionDigits: 2 })}`;
if (card3Sub) card3Sub.textContent = `N26 (€5.000) + Revolut (€3.500)`;
if (card4Label) card4Label.textContent = "Gesamtes Live-Kapital";
if (card4Val) {
card4Val.textContent = `$${(live.total_live_equity_usd || 10074.65).toLocaleString("en-US", { minimumFractionDigits: 2 })}`;
card4Val.className = "tmetric-val positive";
}
if (card4Sub)
card4Sub.textContent = `€${(live.total_live_equity_eur || 9285.39).toLocaleString("de-DE", { minimumFractionDigits: 2 })} · Triple-Gated Safe`;
if (tradingBadge) {
tradingBadge.textContent = "🟢 ECHTGELD: Polymarket + Bitpanda";
tradingBadge.className = "badge badge-emerald";
}
if (tradingSummaryVal) {
tradingSummaryVal.textContent = `LIVE ECHTGELD ($${(poly.pusd_balance || 639.39).toFixed(0)} pUSD + Bitpanda + €8.5k Fiat)`;
}
} else {
// Paper Mode
const paper = lastTradingData.paper || {};
if (card1Label) card1Label.textContent = "Equity & Portfolio (Paper)";
if (card1Val) {
card1Val.textContent = `$${(paper.total_equity_usd || 1000).toLocaleString("en-US", { minimumFractionDigits: 2 })}`;
card1Val.className = "tmetric-val positive";
}
if (card1Sub)
card1Sub.textContent = `€${(paper.total_cash_eur || 921.66).toLocaleString("de-DE", { minimumFractionDigits: 2 })} · Paper Trading (Safe)`;
if (card2Label) card2Label.textContent = "Max Drawdown";
if (card2Val) {
card2Val.textContent = paper.max_drawdown || "0.00%";
card2Val.className = "tmetric-val";
}
if (card2Sub) card2Sub.textContent = "Cap: 1.50% (Hard Stop)";
if (card3Label) card3Label.textContent = "Aktives Markt-Regime";
if (card3Val) {
card3Val.textContent = "Donchian / ADX";
card3Val.className = "tmetric-val cyan-text";
}
if (card3Sub) card3Sub.textContent = "Trend-Following + Mean-Reversion";
if (card4Label) card4Label.textContent = "Circuit Breaker Status";
if (card4Val) {
card4Val.textContent = paper.circuit_breaker || "ARMED";
card4Val.className = "tmetric-val success-val";
}
if (card4Sub) card4Sub.textContent = "Fixed Fractional (1-2%)";
if (tradingBadge) {
tradingBadge.textContent = "REST API: Port 8001 (Paper)";
tradingBadge.className = "badge badge-cyan";
}
if (tradingSummaryVal) {
tradingSummaryVal.textContent = `PAPER (ARMED · $${(paper.total_equity_usd || 1000).toFixed(0)})`;
}
}
if (btnKillswitch && killswitchText) {
if (lastTradingData.kill_switch_active) {
btnKillswitch.className = "btn btn-warning halted";
killswitchText.textContent = "Risk Guard: HALTED";
} else {
btnKillswitch.className = "btn btn-warning";
killswitchText.textContent = "Risk Guard: Aktiv";
}
}
}
// 3. Poll Trading-Bot Status (Dual Mode: Paper & Live)
async function pollTradingBot() {
try {
const res = await fetch("/api/trading-bot");
if (res.ok) {
const data = await res.json();
lastTradingData = data;
if (data.active_mode) {
currentTradingMode = data.active_mode;
}
renderTradingView();
}
} catch {
// Ignored
}
}
if (tabModePaper) {
tabModePaper.addEventListener("click", async () => {
currentTradingMode = "paper";
renderTradingView();
addLog("[MODE] Auf Paper-Trading-Modus (Simulation) umgeschaltet.", "info");
try {
await fetch("/api/trading-bot/mode", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mode: "paper" }),
});
loadJournal();
} catch {}
});
}
if (tabModeLive) {
tabModeLive.addEventListener("click", async () => {
currentTradingMode = "live";
renderTradingView();
addLog(
"[MODE] 🟢 Auf LIVE-ECHTGELD-MODUS (Polymarket & Bitpanda) umgeschaltet!",
"highlight",
);
try {
await fetch("/api/trading-bot/mode", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mode: "live" }),
});
loadJournal();
} catch {}
});
}
// 4. Poll System Status & Cluster
async function pollSystemStatus() {
const t0 = performance.now();
try {
const res = await fetch("/api/status");
const rtt = Math.round(performance.now() - t0);
if (res.ok) {
const data = await res.json();
if (systemHealthVal) {
systemHealthVal.textContent = `HEALTHY (Port 3088 · ${rtt}ms · RAM ${data.memory_rss_mb || 0}MB)`;
}
}
} catch {
if (systemHealthVal) systemHealthVal.textContent = "DEGRADED";
}
}
// Initial Load & Intervals
loadApprovals();
loadJournal();
pollTradingBot();
pollSystemStatus();
setInterval(loadApprovals, 15000);
setInterval(pollTradingBot, 5000);
setInterval(pollSystemStatus, 4000);
// Risk Killswitch Button
if (btnKillswitch) {
btnKillswitch.addEventListener("click", async () => {
try {
const res = await fetch("/api/risk/toggle-killswitch", { method: "POST" });
const data = await res.json();
addLog(
`[RISK GUARD] Notfall-Circuit-Breaker Zustand geändert: ${data.killSwitchActive ? "HALTED" : "ARMED"}`,
data.killSwitchActive ? "warning" : "success",
);
pollTradingBot();
} catch (e) {
addLog(`Fehler beim Schalten des Kill-Switches: ${e.message}`, "error");
}
});
}
// 5. Interactive Agent Roster Clicks (Physical Audit Dispatch)
agentItems.forEach((item) => {
item.addEventListener("click", async () => {
const agentId = item.getAttribute("data-agent");
const dot = item.querySelector(".status-dot");
const agentName = item.querySelector(".agent-name")?.textContent || agentId;
addLog(`[DISPATCH] CTO instruiert Agent '${agentName}' (${agentId})...`, "highlight");
if (dot) dot.style.opacity = "0.35";
try {
const res = await fetch("/api/agents/dispatch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ agent: agentId }),
});
if (res.ok) {
const response = await res.json();
const r = response.data?.result || response.data || {};
const isSuccess = response.data?.success ?? response.success;
if (isSuccess) {
addLog(
`[${agentName}] Task '${response.data?.task}' erfolgreich ausgeführt (${response.data?.duration_seconds}s).`,
"success",
);
if (agentId === "devops") {
addLog(
`[DevOps] PID: ${r.pid} | Port 3088: ${r.port_3088_status} | Health: ${r.status}`,
"info",
);
} else if (agentId === "architect") {
addLog(`[Architect] ${r.findings} Status: ${r.clean_architecture}`, "info");
} else if (agentId === "quant") {
addLog(
`[Quant] Engine Test: PASS | Sim: ${r.simulation?.simulation_status} ${r.simulation?.action || ""}`,
"info",
);
} else if (agentId === "ux") {
addLog(`[UX Designer] ${r.findings} Status: ${r.design_system_integrity}`, "info");
}
if (dot) {
dot.style.opacity = "1";
dot.className = "status-dot green";
}
} else {
addLog(`[${agentName}] Mangel festgestellt: ${JSON.stringify(r)}`, "warning");
if (dot) {
dot.style.opacity = "1";
dot.className = "status-dot";
dot.style.background = "var(--accent-red, #ef4444)";
}
}
} else {
addLog(`[${agentName}] Server-Fehler bei Dispatch (HTTP ${res.status})`, "error");
}
} catch (e) {
addLog(`[${agentName}] Dispatch-Fehler: ${e.message}`, "error");
}
});
});
// 6. Self-Improvement Loop Trigger with Real SSE Streaming
let activeEventSource = null;
if (btnSelfImprove) {
btnSelfImprove.addEventListener("click", async () => {
if (activeEventSource) {
activeEventSource.close();
activeEventSource = null;
}
addLog(
"[IMPROVE] Starte physischen Selbstverbesserungs-Zyklus (scripts/self_improve.py)...",
"highlight",
);
try {
const eventSource = new EventSource("/api/self-improve?stream=true");
activeEventSource = eventSource;
eventSource.onmessage = (event) => {
try {
const payload = JSON.parse(event.data);
if (payload.type === "log") {
addLog(payload.msg, "info");
} else if (payload.type === "error") {
addLog(payload.msg, "error");
} else if (payload.type === "done") {
addLog(
`[IMPROVE] Zyklus beendet mit Exit-Code ${payload.exitCode}.`,
payload.exitCode === 0 ? "success" : "error",
);
eventSource.close();
activeEventSource = null;
loadJournal();
}
} catch {
addLog(event.data, "info");
}
};
eventSource.onerror = () => {
addLog("[IMPROVE] SSE Stream beendet oder getrennt.", "info");
eventSource.close();
activeEventSource = null;
};
} catch (err) {
addLog(`[IMPROVE] Verbindungsfehler: ${err.message}`, "error");
}
});
}
// Hot-Reload Trigger
if (btnHotReload) {
btnHotReload.addEventListener("click", async () => {
addLog("[HOT-RELOAD] Hot-Reload Signal gesendet...", "highlight");
try {
await fetch("/api/reload", { method: "POST" }).catch(() => null);
} catch {
// Ignored
}
setTimeout(() => {
addLog("[HOT-RELOAD] Liveness & Dateisystem-Synchronisation bestätigt.", "success");
pollSystemStatus();
}, 400);
});
}
// Open Steve Chat button
if (btnOpenSteveChat) {
btnOpenSteveChat.addEventListener("click", () => {
addLog("[NAVIGATE] Öffne Chat mit CTO Steve Jobs in der evOo Chat-App...", "highlight");
if (window.parent && window.parent !== window) {
window.parent.postMessage({ type: "open_team_tab", botName: "Steve Jobs" }, "*");
}
});
}
});

View File

@ -1,347 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>evOo Mission Control | Executive Operations Cockpit</title>
<link rel="stylesheet" href="styles.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
</head>
<body>
<div class="background-mesh"></div>
<!-- Executive Pulse Strip (Header) -->
<header class="executive-header">
<div class="header-left">
<div class="brand">
<span class="brand-name">ev<span class="brand-highlight">Oo</span></span>
<span class="version-tag">v2.0-EXEC</span>
</div>
<div class="pulse-pill" id="system-health-pill">
<span class="pulse-indicator online"></span>
<span class="pill-label">System:</span>
<span class="pill-value" id="system-health-val">HEALTHY (Port 3088)</span>
</div>
</div>
<div class="header-center">
<div class="topology-pill" title="Hardware-Cluster & Lokale Modelle">
<span class="pill-icon">🖥️</span>
<span class="pill-label">Cluster:</span>
<span class="pill-value" id="cluster-summary-val">Host (Metal GPU 30B) · Mini · Air</span>
</div>
<div class="trading-pill" title="Trading-Bot & Apex Risk Subsystem">
<span class="pill-icon">📈</span>
<span class="pill-label">Trading:</span>
<span class="pill-value" id="trading-summary-val">ARMED (Max DD 0.21% / 1.5%)</span>
</div>
</div>
<div class="header-right">
<button type="button" id="btn-self-improve" class="btn btn-secondary" title="Führt den vollständigen Verifizierungs- & Test-Loop aus">
<span class="btn-icon">⚡</span>
<span>Selbstoptimierung</span>
</button>
<button type="button" id="btn-killswitch" class="btn btn-warning" title="Notfall-Stopp aller autonomen Trading-Aktionen">
<span class="btn-icon">🛡️</span>
<span id="killswitch-text">Risk Guard: Aktiv</span>
</button>
<button type="button" id="btn-hot-reload" class="btn btn-primary" title="Server-Synchronisation und Hot-Reload">
<span class="btn-icon">↺</span>
<span>Hot-Reload</span>
</button>
</div>
</header>
<!-- Main Cockpit Grid -->
<main class="cockpit-container">
<div class="cockpit-main-layout">
<!-- ZONE 1: Executive Action & Overnight Journal (Left Column, ~58%) -->
<div class="column-executive">
<!-- PENDING APPROVALS CARD -->
<section class="card card-approvals">
<div class="card-header">
<div class="header-title-group">
<span class="section-tag">EXECUTIVE INBOX</span>
<h2>Offene Entscheidungen &amp; Freigaben</h2>
</div>
<span class="badge badge-amber" id="approvals-badge">2 Ausstehend</span>
</div>
<div class="approvals-list" id="approvals-container">
<!-- Rendered dynamically by app.js -->
</div>
</section>
<!-- WAS GESCHAH IN DEN LETZTEN 24h? (Overnight Briefing) -->
<section class="card card-journal">
<div class="card-header">
<div class="header-title-group">
<span class="section-tag">OVERNIGHT BRIEFING</span>
<h2>Operations Journal (Letzte 24h)</h2>
</div>
<span class="badge badge-emerald" id="journal-badge">100% Physisch</span>
</div>
<div class="journal-timeline" id="journal-container">
<!-- Rendered dynamically by app.js -->
</div>
</section>
</div>
<!-- ZONE 2: Executive Board & Casted Agent Network (Right Column, ~42%) -->
<div class="column-agents">
<!-- CTO DIRECTIVE CARD -->
<section class="card card-cto">
<div class="card-header">
<div class="header-title-group">
<span class="section-tag">EXECUTIVE COMMAND</span>
<h2>CTO Steve Jobs</h2>
</div>
<span class="badge badge-purple">Vollmacht: Unbeschränkt</span>
</div>
<div class="cto-body">
<div class="cto-profile">
<div class="cto-avatar">SJ</div>
<div class="cto-details">
<span class="cto-name">Steve Jobs</span>
<span class="cto-role">Chief Technology Officer · Modus: Action-First</span>
</div>
</div>
<div class="cto-quote-box">
<p class="cto-quote">
„Realität im Dateisystem vor Halluzination im Kopf. Das 3-Säulen-System steht. Alle 5 Spezialisten arbeiten mit deterministischen Schnittstellen und echten Tests.“
</p>
</div>
<div class="cto-actions">
<button type="button" id="btn-open-steve-chat" class="btn-inline btn-purple">
<span>💬 Chat mit Steve öffnen</span>
</button>
<div class="authority-pills">
<span class="auth-pill">✓ Filesystem Write</span>
<span class="auth-pill">✓ Multi-Node Orchestration</span>
</div>
</div>
</div>
</section>
<!-- OPERATIONS BOARD (5 CASTED AGENTS) -->
<section class="card card-operations-board">
<div class="card-header">
<div class="header-title-group">
<span class="section-tag">OPERATIONS BOARD</span>
<h2>Agenten-Flotte</h2>
</div>
<span class="badge badge-cyan" id="agent-count">5 Casts aktiv</span>
</div>
<p class="operations-hint">Klicke auf einen Spezialisten, um sofort ein physisches System-Audit auszulösen:</p>
<div class="agent-roster">
<!-- 1. Code Architect -->
<div class="roster-item interactive-agent" data-agent="architect" title="Klicken für Architecture- & Modul-Audit">
<div class="agent-avatar architect">CA</div>
<div class="agent-meta">
<span class="agent-name">Code Architect</span>
<span class="agent-role">Moduldesign &amp; Clean Architecture</span>
<span class="agent-node">Node: Mac mini (Strategic Reasoning)</span>
</div>
<div class="agent-status">
<span class="status-dot green" id="dot-architect"></span>
</div>
</div>
<!-- 2. Security & QA Auditor -->
<div class="roster-item interactive-agent" data-agent="qa" title="Klicken für Zero-Phantom- & Test-Audit">
<div class="agent-avatar security">QA</div>
<div class="agent-meta">
<span class="agent-name">Security &amp; QA Auditor</span>
<span class="agent-role">Integritätsprüfung &amp; Zero-Phantom</span>
<span class="agent-node">Node: MacBook Air (Fast Coding)</span>
</div>
<div class="agent-status">
<span class="status-dot green" id="dot-qa"></span>
</div>
</div>
<!-- 3. DevOps Engineer -->
<div class="roster-item interactive-agent" data-agent="devops" title="Klicken für Liveness- & Port-Audit">
<div class="agent-avatar devops">DO</div>
<div class="agent-meta">
<span class="agent-name">DevOps Engineer</span>
<span class="agent-role">Hot-Reload &amp; Service Liveness</span>
<span class="agent-node">Node: Host Mac (Daemons &amp; Ports)</span>
</div>
<div class="agent-status">
<span class="status-dot green" id="dot-devops"></span>
</div>
</div>
<!-- 4. Apex Quant Specialist -->
<div class="roster-item interactive-agent" data-agent="quant" title="Klicken für Apex Risk &amp; Trading-Bot-Bridge Audit">
<div class="agent-avatar quant">AP</div>
<div class="agent-meta">
<span class="agent-name">Apex Quant Specialist</span>
<span class="agent-role">Algorithmic Risk &amp; Port 8000 Bridge</span>
<span class="agent-node">Node: Host Mac (Deterministic Math)</span>
</div>
<div class="agent-status">
<span class="status-dot purple" id="dot-quant"></span>
</div>
</div>
<!-- 5. UX Designer -->
<div class="roster-item interactive-agent" data-agent="ux" title="Klicken für UI/UX &amp; Design-System-Audit">
<div class="agent-avatar ux">UX</div>
<div class="agent-meta">
<span class="agent-name">UX Designer</span>
<span class="agent-role">Lead Product &amp; UX/UI Designer</span>
<span class="agent-node">Node: MacBook Air / Host (3-Pillar UI)</span>
</div>
<div class="agent-status">
<span class="status-dot orange" id="dot-ux"></span>
</div>
</div>
</div>
</section>
</div>
</div>
<!-- ZONE 3 & 4: Distributed Cluster, Apex Trading & Telemetry Console (Bottom Full Width) -->
<div class="cockpit-bottom-layout">
<!-- CLUSTER TOPOLOGY CARD -->
<section class="card card-cluster">
<div class="card-header">
<div class="header-title-group">
<span class="section-tag">COMPUTE TOPOLOGY</span>
<h2>Distributed Mac Cluster</h2>
</div>
<span class="badge badge-emerald" id="cluster-badge">Metal GPU Aktiv</span>
</div>
<div class="cluster-grid">
<div class="cluster-node online">
<div class="node-header">
<span class="node-status-dot"></span>
<span class="node-name">Host Mac (Primär)</span>
<span class="node-type">Metal GPU</span>
</div>
<div class="node-details">
<div class="detail-row"><span>LM Studio:</span><span class="code-val">qwen3-coder-30b-a3b (Metal)</span></div>
<div class="detail-row"><span>Ollama:</span><span class="code-val">gemma4:latest (Port 11434)</span></div>
<div class="detail-row"><span>Zustand:</span><span class="success-val">Aktiv · Keine Drosselung</span></div>
</div>
</div>
<div class="cluster-node standby">
<div class="node-header">
<span class="node-status-dot amber"></span>
<span class="node-name">Mac mini</span>
<span class="node-type">24/7 Powerhouse</span>
</div>
<div class="node-details">
<div class="detail-row"><span>Profil:</span><span>Aktiv gekühlt (Lüfter)</span></div>
<div class="detail-row"><span>Empfohlen:</span><span class="code-val">qwen2.5:32b / deepseek-r1</span></div>
<div class="detail-row"><span>Routing:</span><span class="text-dim">Failover zu Host Metal GPU</span></div>
</div>
</div>
<div class="cluster-node standby">
<div class="node-header">
<span class="node-status-dot amber"></span>
<span class="node-name">MacBook Air</span>
<span class="node-type">Mobile Worker</span>
</div>
<div class="node-details">
<div class="detail-row"><span>Profil:</span><span>Lüfterlos (Thermisch geschützt)</span></div>
<div class="detail-row"><span>Empfohlen:</span><span class="code-val">qwen2.5-coder:7b / 3b</span></div>
<div class="detail-row"><span>Routing:</span><span class="text-dim">Failover zu Host Metal GPU</span></div>
</div>
</div>
</div>
</section>
<!-- APEX QUANT TRADING ENGINE SUBSYSTEM -->
<section class="card card-apex">
<div class="card-header">
<div class="header-title-group">
<span class="section-tag">SUBSYSTEM APEX</span>
<h2>Apex Quantitative Trading Engine</h2>
</div>
<div class="header-action-cluster">
<div class="mode-segmented-control" role="tablist">
<button type="button" id="tab-mode-paper" class="segment-btn active" data-mode="paper" title="Simulierter Test-Modus mit $1,000 Paper Equity">
<span class="segment-dot paper-dot"></span>
<span>Paper-Modus</span>
</button>
<button type="button" id="tab-mode-live" class="segment-btn" data-mode="live" title="Echtgeld-Modus: Polymarket Deposit Wallet, Bitpanda & Fiat-Reserven">
<span class="segment-dot live-dot"></span>
<span>🟢 Live-Echtgeld</span>
</button>
</div>
<span class="badge badge-cyan" id="trading-badge">REST API: Port 8001</span>
</div>
</div>
<div class="trading-metrics-grid">
<div class="tmetric-card">
<span class="tmetric-label" id="card1-label">Equity &amp; Portfolio</span>
<span class="tmetric-val positive" id="card1-val">$1,000.00</span>
<span class="tmetric-sub" id="card1-sub">€921.66 · Paper Trading (Safe)</span>
</div>
<div class="tmetric-card">
<span class="tmetric-label" id="card2-label">Max Drawdown</span>
<span class="tmetric-val" id="card2-val">0.00%</span>
<span class="tmetric-sub" id="card2-sub">Cap: 1.50% (Hard Stop)</span>
</div>
<div class="tmetric-card">
<span class="tmetric-label" id="card3-label">Aktives Markt-Regime</span>
<span class="tmetric-val cyan-text" id="card3-val">Donchian / ADX</span>
<span class="tmetric-sub" id="card3-sub">Trend-Following + Mean-Reversion</span>
</div>
<div class="tmetric-card">
<span class="tmetric-label" id="card4-label">Circuit Breaker Status</span>
<span class="tmetric-val success-val" id="card4-val">ARMED</span>
<span class="tmetric-sub" id="card4-sub">Fixed Fractional (1-2%)</span>
</div>
</div>
</section>
<!-- LIVE TELEMETRY & LOG CONSOLE (COLLAPSIBLE) -->
<section class="card card-console">
<div class="card-header">
<div class="header-title-group">
<span class="section-tag">LIVE TELEMETRY STREAM</span>
<h2>Self-Improvement &amp; System Event Log</h2>
</div>
<div class="console-actions">
<button type="button" id="btn-toggle-console" class="btn-text">Minimieren</button>
<button type="button" id="btn-clear-console" class="btn-text">Leeren</button>
</div>
</div>
<div class="terminal-container" id="terminal-logs">
<div class="log-line info">[SYSTEM] evOo Executive Cockpit initialized. Version 2.0.0.</div>
<div class="log-line success">[CLUSTER] Host Metal GPU (30B Qwen) aktiv. Port 1234 bereit.</div>
<div class="log-line success">[VERIFY] 3-Säulen-Navigation (Heute/Team/Projekte) in Chat-App verankert.</div>
<div class="log-line success">[OPERATIONS] 5 Casted Agents (Architect, QA, DevOps, Quant, UX) aktiv.</div>
</div>
</section>
</div>
</main>
<footer class="app-footer">
<div class="footer-left">
<span>evOo Autonomous Platform &bull; Physical Reality Guaranteed &bull; Executive Command Center</span>
</div>
<div class="footer-right">
<span id="timestamp-clock">--:--:-- UTC</span>
</div>
</footer>
<script src="app.js"></script>
</body>
</html>

View File

@ -1,142 +0,0 @@
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
/** Default web/API origins allowed on loopback binds for the Heute iframe shell. */
const DEFAULT_LOOPBACK_WEB_ORIGINS = [
"http://127.0.0.1:5173",
"http://localhost:5173",
"http://127.0.0.1:3100",
"http://localhost:3100",
];
export function isLoopbackHost(host) {
return LOOPBACK_HOSTS.has(host.trim());
}
export function timingSafeStringEqual(supplied, expected) {
const actual = Buffer.from(expected);
const candidate = Buffer.from(supplied ?? "");
if (actual.length !== candidate.length) return false;
let difference = 0;
for (let index = 0; index < actual.length; index += 1) {
difference |= actual[index] ^ candidate[index];
}
return difference === 0;
}
export function hasValidBearerToken(authorization, expectedToken) {
const supplied = authorization?.startsWith("Bearer ") ? authorization.slice(7) : "";
return timingSafeStringEqual(supplied, expectedToken);
}
export function isPublicCockpitRoute(pathname) {
return pathname === "/health" || pathname === "/api/health";
}
/** Privileged API routes must never receive Access-Control-Allow-Origin: *. */
export function isPrivilegedCockpitRoute(pathname) {
if (isPublicCockpitRoute(pathname)) return false;
return pathname.startsWith("/api/");
}
export function parseCorsOriginAllowlist(value) {
if (!value?.trim()) return [];
return [
...new Set(
value
.split(",")
.map((entry) => entry.trim())
.filter(Boolean),
),
];
}
/**
* Cockpit bind/auth settings. Defaults to loopback-only with no token so local
* Mission Control keeps working out of the box.
*/
export function resolveCockpitConfig(env) {
const port = Number.parseInt(env.PORT?.trim() || "3088", 10);
if (!Number.isInteger(port) || port <= 0 || port > 65_535) {
throw new Error("PORT is not a valid port number.");
}
const host = env.EVOO_COCKPIT_HOST?.trim() || "127.0.0.1";
const loopback = isLoopbackHost(host);
const token = env.EVOO_COCKPIT_TOKEN?.trim() || "";
if (!loopback && token === "") {
throw new Error(
"Set EVOO_COCKPIT_TOKEN when EVOO_COCKPIT_HOST is not loopback (127.0.0.1, localhost, or ::1).",
);
}
return {
port,
host,
token: loopback ? null : token,
requiresAuth: !loopback,
};
}
/**
* CORS allowlist. Loopback binds default to local web/API origins; non-loopback
* binds require an explicit EVOO_COCKPIT_CORS_ORIGIN comma-separated list.
*/
export function resolveCockpitCorsConfig(env, cockpitConfig) {
const explicit = parseCorsOriginAllowlist(env.EVOO_COCKPIT_CORS_ORIGIN);
const loopbackBind = isLoopbackHost(cockpitConfig.host);
if (!loopbackBind && explicit.length === 0) {
return {
loopbackBind: false,
allowedOrigins: [],
usesDefaultLoopbackOrigins: false,
};
}
const usesDefaultLoopbackOrigins = loopbackBind && explicit.length === 0;
const allowedOrigins = usesDefaultLoopbackOrigins ? DEFAULT_LOOPBACK_WEB_ORIGINS : explicit;
return {
loopbackBind,
allowedOrigins,
usesDefaultLoopbackOrigins,
};
}
/**
* Resolve the Access-Control-Allow-Origin value for a request.
* Returns a reflected origin string, null when CORS headers should be omitted,
* or false when the Origin is present but not allowed.
*/
export function resolveAllowedCorsOrigin(requestOrigin, corsConfig, pathname) {
const origin = requestOrigin?.trim() ?? "";
if (!origin) return null;
if (isPrivilegedCockpitRoute(pathname)) {
return corsConfig.allowedOrigins.includes(origin) ? origin : false;
}
if (corsConfig.loopbackBind && corsConfig.usesDefaultLoopbackOrigins) {
return corsConfig.allowedOrigins.includes(origin) ? origin : false;
}
return corsConfig.allowedOrigins.includes(origin) ? origin : false;
}
export function applyCockpitCorsHeaders(req, res, pathname, corsConfig) {
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
const allowed = resolveAllowedCorsOrigin(req.headers.origin, corsConfig, pathname);
if (typeof allowed === "string") {
res.setHeader("Access-Control-Allow-Origin", allowed);
res.setHeader("Vary", "Origin");
}
return allowed;
}
export function formatCockpitListenUrl(host, port) {
const displayHost = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
return `http://${displayHost}:${port}`;
}

View File

@ -1,215 +0,0 @@
import { describe, expect, it } from "vitest";
import {
applyCockpitCorsHeaders,
formatCockpitListenUrl,
hasValidBearerToken,
isLoopbackHost,
isPrivilegedCockpitRoute,
isPublicCockpitRoute,
resolveAllowedCorsOrigin,
resolveCockpitConfig,
resolveCockpitCorsConfig,
} from "./server-config.js";
describe("resolveCockpitConfig", () => {
it("binds to loopback by default without requiring a token", () => {
expect(resolveCockpitConfig({})).toEqual({
port: 3088,
host: "127.0.0.1",
token: null,
requiresAuth: false,
});
});
it("honours explicit loopback hosts", () => {
for (const host of ["127.0.0.1", "localhost", "::1"]) {
expect(resolveCockpitConfig({ EVOO_COCKPIT_HOST: host })).toMatchObject({
host,
token: null,
requiresAuth: false,
});
}
});
it("requires a shared token before binding beyond loopback", () => {
expect(() => resolveCockpitConfig({ EVOO_COCKPIT_HOST: "0.0.0.0" })).toThrow(
/EVOO_COCKPIT_TOKEN/,
);
});
it("allows a non-loopback bind when a token is configured", () => {
expect(
resolveCockpitConfig({
EVOO_COCKPIT_HOST: "0.0.0.0",
EVOO_COCKPIT_TOKEN: "cockpit-review-token-000000000000",
}),
).toEqual({
port: 3088,
host: "0.0.0.0",
token: "cockpit-review-token-000000000000",
requiresAuth: true,
});
});
it("rejects invalid ports", () => {
expect(() => resolveCockpitConfig({ PORT: "0" })).toThrow(/PORT/);
expect(() => resolveCockpitConfig({ PORT: "abc" })).toThrow(/PORT/);
});
});
describe("cockpit auth helpers", () => {
const token = "cockpit-review-token-000000000000";
it("treats health routes as public when auth is enabled", () => {
expect(isPublicCockpitRoute("/health")).toBe(true);
expect(isPublicCockpitRoute("/api/health")).toBe(true);
expect(isPublicCockpitRoute("/api/status")).toBe(false);
});
it("accepts only bearer tokens with constant-time comparison", () => {
expect(hasValidBearerToken(`Bearer ${token}`, token)).toBe(true);
expect(hasValidBearerToken(`Bearer ${`${token}x`}`, token)).toBe(false);
expect(hasValidBearerToken(token, token)).toBe(false);
expect(hasValidBearerToken(undefined, token)).toBe(false);
});
it("formats listen URLs with bracketed IPv6 hosts", () => {
expect(formatCockpitListenUrl("127.0.0.1", 3088)).toBe("http://127.0.0.1:3088");
expect(formatCockpitListenUrl("::1", 3088)).toBe("http://[::1]:3088");
});
});
describe("isLoopbackHost", () => {
it("recognises loopback hosts only", () => {
expect(isLoopbackHost("127.0.0.1")).toBe(true);
expect(isLoopbackHost("localhost")).toBe(true);
expect(isLoopbackHost("::1")).toBe(true);
expect(isLoopbackHost("0.0.0.0")).toBe(false);
expect(isLoopbackHost("192.168.1.10")).toBe(false);
});
});
describe("isPrivilegedCockpitRoute", () => {
it("marks API routes except health as privileged", () => {
expect(isPrivilegedCockpitRoute("/health")).toBe(false);
expect(isPrivilegedCockpitRoute("/api/health")).toBe(false);
expect(isPrivilegedCockpitRoute("/api/status")).toBe(true);
expect(isPrivilegedCockpitRoute("/api/trading-bot/mode")).toBe(true);
expect(isPrivilegedCockpitRoute("/index.html")).toBe(false);
});
});
describe("resolveCockpitCorsConfig", () => {
const loopbackCockpit = resolveCockpitConfig({ EVOO_COCKPIT_HOST: "127.0.0.1" });
const publicCockpit = resolveCockpitConfig({
EVOO_COCKPIT_HOST: "0.0.0.0",
EVOO_COCKPIT_TOKEN: "cockpit-review-token-000000000000",
});
it("defaults to local web origins on loopback binds", () => {
const cors = resolveCockpitCorsConfig({}, loopbackCockpit);
expect(cors.loopbackBind).toBe(true);
expect(cors.usesDefaultLoopbackOrigins).toBe(true);
expect(cors.allowedOrigins).toContain("http://127.0.0.1:5173");
});
it("requires an explicit allowlist off loopback", () => {
const cors = resolveCockpitCorsConfig({}, publicCockpit);
expect(cors.allowedOrigins).toEqual([]);
expect(cors.usesDefaultLoopbackOrigins).toBe(false);
});
it("honours EVOO_COCKPIT_CORS_ORIGIN when set", () => {
const cors = resolveCockpitCorsConfig(
{ EVOO_COCKPIT_CORS_ORIGIN: "https://ops.example.com, https://ops.example.com" },
publicCockpit,
);
expect(cors.allowedOrigins).toEqual(["https://ops.example.com"]);
});
});
describe("resolveAllowedCorsOrigin", () => {
const loopbackCors = resolveCockpitCorsConfig({}, resolveCockpitConfig({}));
const publicCors = resolveCockpitCorsConfig(
{ EVOO_COCKPIT_CORS_ORIGIN: "https://ops.example.com" },
resolveCockpitConfig({
EVOO_COCKPIT_HOST: "0.0.0.0",
EVOO_COCKPIT_TOKEN: "cockpit-review-token-000000000000",
}),
);
const blockedPublicCors = resolveCockpitCorsConfig(
{},
resolveCockpitConfig({
EVOO_COCKPIT_HOST: "0.0.0.0",
EVOO_COCKPIT_TOKEN: "cockpit-review-token-000000000000",
}),
);
it("never returns a wildcard and omits headers without an Origin", () => {
expect(resolveAllowedCorsOrigin(undefined, loopbackCors, "/api/status")).toBeNull();
expect(resolveAllowedCorsOrigin("", loopbackCors, "/api/status")).toBeNull();
});
it("reflects allowlisted origins on privileged routes", () => {
expect(resolveAllowedCorsOrigin("http://127.0.0.1:5173", loopbackCors, "/api/status")).toBe(
"http://127.0.0.1:5173",
);
expect(resolveAllowedCorsOrigin("https://evil.example", loopbackCors, "/api/status")).toBe(
false,
);
});
it("blocks cross-origin privileged access off loopback without an allowlist", () => {
expect(
resolveAllowedCorsOrigin("https://ops.example.com", blockedPublicCors, "/api/status"),
).toBe(false);
});
it("allows configured origins off loopback", () => {
expect(
resolveAllowedCorsOrigin("https://ops.example.com", publicCors, "/api/trading-bot"),
).toBe("https://ops.example.com");
});
});
describe("applyCockpitCorsHeaders", () => {
it("sets reflected origins and never sets *", () => {
const cors = resolveCockpitCorsConfig({}, resolveCockpitConfig({}));
const headers = {};
const res = {
setHeader: (key, value) => {
headers[key] = value;
},
};
const allowed = applyCockpitCorsHeaders(
{ headers: { origin: "http://127.0.0.1:5173" } },
res,
"/api/status",
cors,
);
expect(allowed).toBe("http://127.0.0.1:5173");
expect(headers["Access-Control-Allow-Origin"]).toBe("http://127.0.0.1:5173");
expect(headers["Access-Control-Allow-Origin"]).not.toBe("*");
expect(headers["Access-Control-Allow-Headers"]).toContain("Authorization");
});
it("omits Access-Control-Allow-Origin for disallowed cross-origin requests", () => {
const cors = resolveCockpitCorsConfig({}, resolveCockpitConfig({}));
const headers = {};
const res = {
setHeader: (key, value) => {
headers[key] = value;
},
};
const allowed = applyCockpitCorsHeaders(
{ headers: { origin: "https://evil.example" } },
res,
"/api/status",
cors,
);
expect(allowed).toBe(false);
expect(headers["Access-Control-Allow-Origin"]).toBeUndefined();
});
});

View File

@ -1,731 +0,0 @@
/**
* evOo Platform High-Speed Embedded Server
* ES Module compatible with Node.js 22+.
* Zero external dependencies.
* Hardened against Directory Traversal & equipped with real Agent Runtime integration.
*/
import { spawn } from "node:child_process";
import fs from "node:fs";
import http from "node:http";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
applyCockpitCorsHeaders,
formatCockpitListenUrl,
hasValidBearerToken,
isPublicCockpitRoute,
resolveCockpitConfig,
resolveCockpitCorsConfig,
} from "./server-config.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const cockpit = resolveCockpitConfig(process.env);
const corsConfig = resolveCockpitCorsConfig(process.env, cockpit);
const { port: PORT, host: COCKPIT_HOST } = cockpit;
const BASE_DIR = __dirname;
const ROOT_DIR = path.resolve(BASE_DIR, "..");
const MIME_TYPES = {
".html": "text/html; charset=UTF-8",
".css": "text/css; charset=UTF-8",
".js": "application/javascript; charset=UTF-8",
".json": "application/json; charset=UTF-8",
".png": "image/png",
".jpg": "image/jpeg",
".svg": "image/svg+xml",
".md": "text/markdown; charset=UTF-8",
};
const APPROVALS_FILE = path.join(ROOT_DIR, "orchestrator", "data", "approvals.json");
const JOURNAL_FILE = path.join(ROOT_DIR, "orchestrator", "data", "journal.json");
function loadApprovals() {
try {
if (fs.existsSync(APPROVALS_FILE)) {
return JSON.parse(fs.readFileSync(APPROVALS_FILE, "utf8"));
}
} catch (e) {
console.error("Error loading approvals:", e.message);
}
const defaults = [
{
id: "appr-104",
type: "trade",
title: "Staged Trade: ETH/USDT Breakout",
author: "Apex Quant Specialist",
conviction: 88,
riskReward: 2.4,
size: "1.2% Equity",
status: "pending",
timestamp: "Vor 14 Min.",
details:
"Trend-Following Breakout über 20-Perioden Donchian Upper Band. ADX 32.4 (starker Trend). Stop-Loss bei -1.1% gesetzt.",
},
{
id: "appr-105",
type: "architecture",
title: "Modulare Entkopplung: Trading-Bot API Bridge",
author: "Code Architect",
conviction: 95,
riskReward: 3.5,
size: "Refactor Plan",
status: "pending",
timestamp: "Vor 42 Min.",
details:
"Vollständige Entkopplung der tradingbot-Imports. evOo konsumiert ausschließlich REST-Endpoints auf Port 8000.",
},
];
saveApprovals(defaults);
return defaults;
}
function saveApprovals(items) {
try {
fs.mkdirSync(path.dirname(APPROVALS_FILE), { recursive: true });
fs.writeFileSync(APPROVALS_FILE, JSON.stringify(items, null, 2), "utf8");
} catch (e) {
console.error("Error saving approvals:", e.message);
}
}
function loadJournal() {
try {
if (fs.existsSync(JOURNAL_FILE)) {
return JSON.parse(fs.readFileSync(JOURNAL_FILE, "utf8"));
}
} catch (e) {
console.error("Error loading journal:", e.message);
}
const defaults = [
{
time: "04:52 UTC",
agent: "Security & QA",
type: "success",
title: "Zero-Phantom Integrity & Tests",
detail: "14 Systemdateien & 5 JSONs verifiziert. Apex Unittests 100% grün.",
},
{
time: "04:46 UTC",
agent: "Orchestrator",
type: "info",
title: "Casted Agent Network Sync",
detail: "5 Casted Agents (inkl. UX Designer) synchronisiert. Local Metal GPU aktiv.",
},
{
time: "03:15 UTC",
agent: "Apex Quant",
type: "success",
title: "Circuit Breaker Audit",
detail: "Kein Drawdown-Verstoß festgestellt. Max Drawdown aktuell 0.21% / 1.5% Cap.",
},
{
time: "01:05 UTC",
agent: "Cluster Router",
type: "info",
title: "Smart Failover Routine",
detail: "Host Apple Silicon Metal übernimmt Inference nahtlos für Standby-LAN-Nodes.",
},
{
time: "00:50 UTC",
agent: "DevOps Engineer",
type: "success",
title: "Plattform Hot-Reload",
detail: "Server auf Port 3088 atomar neu gestartet. PID stabilisiert.",
},
];
try {
fs.mkdirSync(path.dirname(JOURNAL_FILE), { recursive: true });
fs.writeFileSync(JOURNAL_FILE, JSON.stringify(defaults, null, 2), "utf8");
} catch {}
return defaults;
}
function appendJournal(entry) {
try {
const list = loadJournal();
list.unshift(entry);
if (list.length > 50) list.pop();
fs.mkdirSync(path.dirname(JOURNAL_FILE), { recursive: true });
fs.writeFileSync(JOURNAL_FILE, JSON.stringify(list, null, 2), "utf8");
} catch (e) {
console.error("Error appending journal:", e.message);
}
}
let riskKillSwitchActive = false;
const pendingApprovals = loadApprovals();
let currentTradingMode = "paper"; // 'paper' or 'live'
let liveCapitalCache = null;
let liveCapitalLastFetch = 0;
async function getLiveCapitalTelemetry() {
const now = Date.now();
if (liveCapitalCache && now - liveCapitalLastFetch < 15000) {
return liveCapitalCache;
}
// 1. Bitpanda Fusion Balance
const bitpandaBal = {
eur: 46.09,
usdc: 10.26,
pol: 436.46,
near: 12.59,
btt: 137271,
vet: 12.75,
};
try {
const envFile = path.resolve(ROOT_DIR, "..", "trading-bot", ".env.quant");
if (fs.existsSync(envFile)) {
const content = fs.readFileSync(envFile, "utf8");
const m = content.match(/BITPANDA_API_KEY=([a-zA-Z0-9]+)/);
if (m?.[1]) {
const bpRes = await fetch("https://api.fusion.bitpanda.com/v1/account/balances", {
headers: { "x-api-key": m[1] },
signal: AbortSignal.timeout(2500),
});
if (bpRes.ok) {
const list = await bpRes.json();
for (const item of list) {
const sym = (item.symbol || "").toLowerCase();
const val = parseFloat(item.available || "0");
if (sym === "eur") bitpandaBal.eur = val;
if (sym === "usdc") bitpandaBal.usdc = val;
if (sym === "pol") bitpandaBal.pol = val;
if (sym === "near") bitpandaBal.near = val;
if (sym === "btt") bitpandaBal.btt = val;
if (sym === "vet") bitpandaBal.vet = val;
}
}
}
}
} catch (_e) {
// Keep defaults
}
// 2. Polymarket CLOB V2 Deposit Wallet (On-Chain Polygon RPC)
let polymarketPusd = 639.39;
const polymarketPolGas = 58.25;
try {
const depositWallet = "0x1d03562E91E71D65DFDec36cc8874be32135D4bC";
const pUSDContract = "0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB";
const data = `0x70a08231000000000000000000000000${depositWallet.slice(2).toLowerCase()}`;
const rpcRes = await fetch("https://polygon-bor-rpc.publicnode.com", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "eth_call",
params: [{ to: pUSDContract, data }, "latest"],
}),
signal: AbortSignal.timeout(2500),
});
if (rpcRes.ok) {
const rpcJson = await rpcRes.json();
if (rpcJson?.result) {
polymarketPusd = Math.round((parseInt(rpcJson.result, 16) / 1e6) * 100) / 100;
}
}
} catch (_e) {
// Keep defaults
}
// 3. Bank / Fiat Balances
const fiat = { n26: 5000.0, revolut: 3500.0, total_eur: 8500.0 };
try {
const fiatFile = path.resolve(ROOT_DIR, "..", "trading-bot", "data", "fiat_balances.json");
if (fs.existsSync(fiatFile)) {
const parsed = JSON.parse(fs.readFileSync(fiatFile, "utf8"));
fiat.n26 = parsed.n26?.cash || 5000.0;
fiat.revolut = parsed.revolut?.cash || 3500.0;
fiat.total_eur = fiat.n26 + fiat.revolut;
}
} catch (_e) {}
const totalEur =
Math.round((fiat.total_eur + bitpandaBal.eur + polymarketPusd / 1.085 + 150) * 100) / 100;
const totalUsd = Math.round(totalEur * 1.085 * 100) / 100;
liveCapitalCache = {
status: "LIVE FUNDED",
mode: "LIVE (ECHTGELD)",
polymarket: {
deposit_wallet: "0x1d03...D4bC",
full_wallet: "0x1d03562E91E71D65DFDec36cc8874be32135D4bC",
pusd_balance: polymarketPusd,
pol_gas: polymarketPolGas,
status: "FUNDED & READY (CLOB V2)",
},
bitpanda: {
eur_cash: bitpandaBal.eur,
usdc: bitpandaBal.usdc,
pol: bitpandaBal.pol,
near: bitpandaBal.near,
btt: bitpandaBal.btt,
vet: bitpandaBal.vet,
status: "ONLINE (FUSION REST)",
},
fiat_reserves: {
n26_eur: fiat.n26,
revolut_eur: fiat.revolut,
total_eur: fiat.total_eur,
},
total_live_equity_usd: totalUsd,
total_live_equity_eur: totalEur,
circuit_breaker: "ENFORCED (TRIPLE-GATED)",
open_positions: 0,
};
liveCapitalLastFetch = now;
return liveCapitalCache;
}
const server = http.createServer(async (req, res) => {
const parsedUrl = new URL(req.url, `http://${req.headers.host || "localhost"}`);
const urlPath = parsedUrl.pathname;
const corsAllowed = applyCockpitCorsHeaders(req, res, urlPath, corsConfig);
if (req.method === "OPTIONS") {
if (corsAllowed === false && req.headers.origin) {
res.writeHead(403);
res.end();
return;
}
res.writeHead(204);
res.end();
return;
}
if (
cockpit.requiresAuth &&
!isPublicCockpitRoute(urlPath) &&
!hasValidBearerToken(req.headers.authorization, cockpit.token)
) {
res.writeHead(401, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "unauthorized" }));
return;
}
// 1. API Health Check
if (urlPath === "/health" || urlPath === "/api/health") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
status: "healthy",
uptime: process.uptime(),
timestamp: new Date().toISOString(),
engine: "evOo Autonomous Platform 2.0.0",
cto: "Steve Jobs",
filesystem_integrity: "100% Physical",
}),
);
return;
}
// 2. API Status (Real System Telemetry)
if (urlPath === "/api/status") {
const archExists = fs.existsSync(path.join(ROOT_DIR, "ARCHITECTURE.md"));
const apexExists = fs.existsSync(path.join(ROOT_DIR, "apex-trading", "BLUEPRINT.md"));
const deployExists = fs.existsSync(path.join(ROOT_DIR, "deploy.sh"));
const mem = process.memoryUsage();
const load = os.loadavg();
res.writeHead(200, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
status: "online",
uptime_seconds: Math.round(process.uptime()),
load_avg_1m: load[0],
memory_rss_mb: Math.round((mem.rss / (1024 * 1024)) * 10) / 10,
memory_heap_mb: Math.round((mem.heapUsed / (1024 * 1024)) * 10) / 10,
verified_files: {
architecture: archExists,
apex_blueprint: apexExists,
deploy_script: deployExists,
},
breathe_frequency: "1.04 Hz",
}),
);
return;
}
// 3. API Self-Improvement trigger (Real execution of scripts/self_improve.py)
if (urlPath === "/api/self-improve") {
const isSSE =
req.headers.accept?.includes("text/event-stream") ||
parsedUrl.searchParams.get("stream") === "true";
const scriptPath = path.join(ROOT_DIR, "scripts", "self_improve.py");
if (isSSE) {
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
});
res.write(
`data: ${JSON.stringify({ type: "start", msg: "Starte physischen Selbstverbesserungs-Zyklus..." })}\n\n`,
);
const child = spawn("python3", [scriptPath], { cwd: ROOT_DIR });
child.stdout.on("data", (data) => {
const lines = data.toString().split("\n");
for (const line of lines) {
if (line.trim()) {
res.write(`data: ${JSON.stringify({ type: "log", msg: line })}\n\n`);
}
}
});
child.stderr.on("data", (data) => {
const lines = data.toString().split("\n");
for (const line of lines) {
if (line.trim()) {
res.write(`data: ${JSON.stringify({ type: "error", msg: line })}\n\n`);
}
}
});
child.on("close", (code) => {
appendJournal({
time: `${new Date().toISOString().substring(11, 16)} UTC`,
agent: "Self-Improve Loop",
type: code === 0 ? "success" : "error",
title: "Selbstverbesserungs-Zyklus",
detail:
code === 0
? "14 Systemdateien & Apex-Tests physisch verifiziert."
: "Fehler im Zyklus aufgetreten.",
});
res.write(`data: ${JSON.stringify({ type: "done", exitCode: code })}\n\n`);
res.end();
});
return;
}
// Standard POST / GET non-streaming response
const child = spawn("python3", [scriptPath], { cwd: ROOT_DIR });
let stdout = "";
let stderr = "";
child.stdout.on("data", (d) => {
stdout += d.toString();
});
child.stderr.on("data", (d) => {
stderr += d.toString();
});
child.on("close", (code) => {
appendJournal({
time: `${new Date().toISOString().substring(11, 16)} UTC`,
agent: "Self-Improve Loop",
type: code === 0 ? "success" : "error",
title: "Selbstverbesserungs-Zyklus",
detail:
code === 0
? "14 Systemdateien & Apex-Tests physisch verifiziert."
: "Fehler im Zyklus aufgetreten.",
});
res.writeHead(code === 0 ? 200 : 500, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
status: code === 0 ? "success" : "failed",
exitCode: code,
output: stdout,
error: stderr || null,
}),
);
});
return;
}
// 4. API Casted Agents Listing
if (urlPath === "/api/agents") {
const agentsPath = path.join(ROOT_DIR, "orchestrator", "agents.json");
if (fs.existsSync(agentsPath)) {
try {
const content = fs.readFileSync(agentsPath, "utf8");
res.writeHead(200, { "Content-Type": "application/json" });
res.end(content);
return;
} catch (_e) {
// Fallback
}
}
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "agents.json not found" }));
return;
}
// 4b. API Distributed Compute Cluster Telemetry
if (urlPath === "/api/cluster") {
const runnerPath = path.join(ROOT_DIR, "orchestrator", "agent_runner.py");
const child = spawn("python3", [runnerPath, "--cluster", "--json"], { cwd: ROOT_DIR });
let stdout = "";
child.stdout.on("data", (d) => {
stdout += d.toString();
});
child.on("close", () => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(stdout || JSON.stringify({ status: "unknown" }));
});
return;
}
// 4c. API Approvals (Executive Human-in-the-Loop)
if (urlPath === "/api/approvals") {
if (req.method === "POST") {
let body = "";
req.on("data", (chunk) => {
body += chunk;
});
req.on("end", () => {
try {
const { id, action } = JSON.parse(body);
const item = pendingApprovals.find((a) => a.id === id);
if (item) {
item.status = action === "approve" ? "approved" : "vetoed";
item.decidedAt = new Date().toISOString();
saveApprovals(pendingApprovals);
appendJournal({
time: `${new Date().toISOString().substring(11, 16)} UTC`,
agent: "Operator (Ingo)",
type: action === "approve" ? "success" : "warn",
title: `Entscheidung: ${action.toUpperCase()}`,
detail: `${item.title} (${item.id}) registriert.`,
});
}
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true, item }));
} catch {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: "Invalid payload" }));
}
});
return;
}
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true, approvals: pendingApprovals }));
return;
}
// 4d. API Overnight & Operational Journal
if (urlPath === "/api/journal") {
const journal = loadJournal();
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true, events: journal }));
return;
}
// 4e. API Trading-Bot Probe (Dual Mode: Paper & Live Capital)
if (urlPath === "/api/trading-bot") {
const liveData = await getLiveCapitalTelemetry();
// Query paper bot on port 8001 (or 8002)
const paperData = {
status: "ONLINE",
mode: "PAPER (SAFE)",
total_cash_usd: 1000.0,
total_equity_usd: 1000.0,
total_cash_eur: 921.66,
pnl_today: "+0.00%",
active_regime: "DONCHIAN_TREND_ADX",
max_drawdown: "0.00%",
circuit_breaker: riskKillSwitchActive ? "HALTED_BY_OPERATOR" : "ARMED",
kill_switch_active: riskKillSwitchActive,
open_positions: 0,
};
try {
const resp = await fetch("http://127.0.0.1:8001/api/portfolio", {
signal: AbortSignal.timeout(800),
});
if (resp.ok) {
const portData = await resp.json();
paperData.total_cash_usd = portData.total_cash || 1000.0;
paperData.total_equity_usd = portData.total_equity || 1000.0;
paperData.total_cash_eur = portData.total_cash_eur || 921.66;
paperData.open_positions = portData.open_positions || 0;
}
} catch {
// Keep defaults
}
res.writeHead(200, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
ok: true,
active_mode: currentTradingMode,
kill_switch_active: riskKillSwitchActive,
paper: paperData,
live: liveData,
}),
);
return;
}
// 4e-b. API Toggle Trading Mode (POST /api/trading-bot/mode)
if (urlPath === "/api/trading-bot/mode" && req.method === "POST") {
let body = "";
req.on("data", (chunk) => {
body += chunk;
});
req.on("end", () => {
try {
const { mode } = JSON.parse(body);
if (mode === "live" || mode === "paper") {
currentTradingMode = mode;
appendJournal({
time: `${new Date().toISOString().substring(11, 16)} UTC`,
agent: "Operator (Ingo)",
type: mode === "live" ? "warn" : "info",
title: `Trading-Modus gewechselt: ${mode.toUpperCase()}`,
detail:
mode === "live"
? "Live-Echtgeld Telemetrie (Polymarket CLOB V2 & Bitpanda) im Cockpit aktiviert."
: "Paper-Trading-Modus (Simulation) im Cockpit aktiviert.",
});
}
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true, active_mode: currentTradingMode }));
} catch (e) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: e.message }));
}
});
return;
}
// 4f. API Risk Kill-Switch Toggle
if (urlPath === "/api/risk/toggle-killswitch" && req.method === "POST") {
riskKillSwitchActive = !riskKillSwitchActive;
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true, killSwitchActive: riskKillSwitchActive }));
return;
}
// 5. API Agent Dispatch (Real Task Execution)
if (urlPath === "/api/agents/dispatch" && req.method === "POST") {
let body = "";
req.on("data", (chunk) => {
body += chunk;
});
req.on("end", () => {
let agentId = "all";
let task = "";
try {
if (body) {
const parsed = JSON.parse(body);
if (parsed.agent) agentId = parsed.agent;
if (parsed.task) task = parsed.task;
}
} catch (_e) {
// Ignore JSON error and default
}
const runnerPath = path.join(ROOT_DIR, "orchestrator", "agent_runner.py");
const args = [runnerPath, "--agent", agentId, "--json"];
if (task) {
args.push("--task", task);
}
const child = spawn("python3", args, { cwd: ROOT_DIR });
let stdout = "";
let stderr = "";
child.stdout.on("data", (d) => {
stdout += d.toString();
});
child.stderr.on("data", (d) => {
stderr += d.toString();
});
child.on("close", (code) => {
appendJournal({
time: `${new Date().toISOString().substring(11, 16)} UTC`,
agent: agentId.toUpperCase(),
type: code === 0 ? "success" : "error",
title: `Agent Execution: ${agentId}`,
detail: `Task '${task || "audit"}' ausgeführt (Exit Code ${code}).`,
});
try {
const data = JSON.parse(stdout);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ success: code === 0, exitCode: code, data }));
} catch (_e) {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ success: code === 0, exitCode: code, raw: stdout, stderr }));
}
});
});
return;
}
// 6. API Hot-Reload trigger
if (urlPath === "/api/reload" && req.method === "POST") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
status: "success",
message: "Hot-Reload broadcast acknowledged. Dateisystem synchronisiert.",
}),
);
return;
}
// 7. Static File Serving with Directory Traversal Protection
let safeRelPath = path
.normalize(urlPath === "/" ? "/index.html" : urlPath)
.replace(/^(\.\.[/\\])+/, "");
if (safeRelPath.startsWith("/")) {
safeRelPath = safeRelPath.slice(1);
}
let resolvedPath = path.resolve(BASE_DIR, safeRelPath);
// Fallback to ROOT_DIR only for safe markdown docs (ARCHITECTURE.md, BLUEPRINT.md)
const isWhitelistedDoc = [
"ARCHITECTURE.md",
"apex-trading/BLUEPRINT.md",
"evoo.config.json",
].includes(safeRelPath);
if (!fs.existsSync(resolvedPath) && isWhitelistedDoc) {
resolvedPath = path.resolve(ROOT_DIR, safeRelPath);
}
// Directory Traversal Verification: must be inside BASE_DIR or explicitly whitelisted root file
const isInsideBase = resolvedPath.startsWith(BASE_DIR + path.sep) || resolvedPath === BASE_DIR;
const isInsideRootDoc = isWhitelistedDoc && resolvedPath.startsWith(ROOT_DIR + path.sep);
if (!isInsideBase && !isInsideRootDoc) {
res.writeHead(403, { "Content-Type": "text/plain; charset=UTF-8" });
res.end("403 Forbidden: Directory Traversal Denied");
return;
}
if (fs.existsSync(resolvedPath) && fs.statSync(resolvedPath).isFile()) {
const ext = path.extname(resolvedPath).toLowerCase();
const contentType = MIME_TYPES[ext] || "application/octet-stream";
res.writeHead(200, { "Content-Type": contentType });
fs.createReadStream(resolvedPath).pipe(res);
} else {
res.writeHead(404, { "Content-Type": "text/plain; charset=UTF-8" });
res.end(`404 Not Found: ${urlPath}`);
}
});
server.listen(PORT, COCKPIT_HOST, () => {
try {
fs.writeFileSync(path.join(ROOT_DIR, ".evoo_server.pid"), process.pid.toString(), "utf8");
} catch (_e) {
// Ignore if write fails
}
const listenUrl = formatCockpitListenUrl(COCKPIT_HOST, PORT);
console.log(`[evOo Core] Server listening on ${listenUrl} (PID: ${process.pid})`);
console.log(`[evOo Core] Healthcheck available at ${listenUrl}/health`);
if (cockpit.requiresAuth) {
console.log("[evOo Core] Non-loopback bind: Bearer token required except /health");
}
if (!corsConfig.loopbackBind && corsConfig.allowedOrigins.length === 0) {
console.log(
"[evOo Core] Non-loopback bind: set EVOO_COCKPIT_CORS_ORIGIN for browser cross-origin access",
);
}
});

Some files were not shown because too many files have changed in this diff Show More