Compare commits

..

3 Commits

Author SHA1 Message Date
99a6a9fd73 Merge branch 'main' into fix/1445
Some checks failed
Check PR for Changes / check-changes (pull_request) Successful in 15s
Review Approval Gate / verify-review (pull_request) Failing after 10s
CI / test (pull_request) Failing after 1m11s
CI / validate (pull_request) Failing after 1m14s
2026-04-22 01:15:24 +00:00
653f76077a Merge branch 'main' into fix/1445
Some checks failed
Check PR for Changes / check-changes (pull_request) Successful in 14s
CI / test (pull_request) Failing after 1m21s
CI / validate (pull_request) Failing after 1m24s
Review Approval Gate / verify-review (pull_request) Failing after 10s
2026-04-22 01:08:11 +00:00
Alexander Whitestone
59784e620c fix: #1445
Some checks failed
Check PR for Changes / check-changes (pull_request) Successful in 12s
CI / test (pull_request) Failing after 59s
CI / validate (pull_request) Failing after 53s
Review Approval Gate / verify-review (pull_request) Failing after 6s
- Add CI workflow to check for empty PRs
- Update PR template with reviewer guidelines
- Add zombie PR detection script
- Add documentation for rubber-stamping prevention

Prevents rubber-stamping of PRs with no changes by:
1. Automated CI checks that block zombie PRs
2. Clear reviewer guidelines in PR template
3. Detection script for existing zombie PRs
4. Comprehensive documentation

Addresses issue #1445: process: Prevent rubber-stamping of PRs with no changes
2026-04-14 23:36:34 -04:00
14 changed files with 572 additions and 2142 deletions

View File

@@ -0,0 +1,116 @@
# .gitea/workflows/check-pr-changes.yml
# CI workflow to prevent rubber-stamping of PRs with no changes
# Issue #1445: process: Prevent rubber-stamping of PRs with no changes
name: Check PR for Changes
on:
pull_request:
types: [opened, synchronize, reopened]
jobs:
check-changes:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
fetch-depth: 0 # Fetch full history for diff comparison
- name: Check for empty PR
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Get PR number from context
PR_NUMBER="${{ github.event.pull_request.number }}"
echo "Checking PR #$PR_NUMBER for changes..."
# Get the base and head commits
BASE_SHA="${{ github.event.pull_request.base.sha }}"
HEAD_SHA="${{ github.event.pull_request.head.sha }}"
echo "Base SHA: $BASE_SHA"
echo "Head SHA: $HEAD_SHA"
# Get diff stats
DIFF_STATS=$(git diff --stat "$BASE_SHA" "$HEAD_SHA")
if [ -z "$DIFF_STATS" ]; then
echo "❌ ERROR: PR has no changes!"
echo ""
echo "This PR has 0 additions, 0 deletions, and 0 files changed."
echo "This is a 'zombie PR' that should not be merged."
echo ""
echo "Rubber-stamping (approving PRs with no changes) is prohibited."
echo "Reviewers must verify that PRs contain actual changes."
echo ""
echo "If this is a mistake, please add actual changes to the PR."
echo "If this PR is not needed, please close it."
exit 1
else
echo "✅ PR has changes:"
echo "$DIFF_STATS"
# Get detailed stats
ADDITIONS=$(git diff --numstat "$BASE_SHA" "$HEAD_SHA" | awk '{sum+=$1} END {print sum}')
DELETIONS=$(git diff --numstat "$BASE_SHA" "$HEAD_SHA" | awk '{sum+=$2} END {print sum}')
FILES_CHANGED=$(git diff --numstat "$BASE_SHA" "$HEAD_SHA" | wc -l)
echo ""
echo "Summary:"
echo " Files changed: $FILES_CHANGED"
echo " Additions: $ADDITIONS"
echo " Deletions: $DELETIONS"
# Check if this is a "zombie PR" (no actual changes)
if [ "$ADDITIONS" -eq 0 ] && [ "$DELETIONS" -eq 0 ]; then
echo ""
echo "⚠️ WARNING: PR has files changed but no additions or deletions!"
echo "This might be a binary file change or permission change."
echo "Reviewers should verify this is intentional."
fi
fi
- name: Check for empty commits
run: |
# Check if there are any commits with no changes
BASE_SHA="${{ github.event.pull_request.base.sha }}"
HEAD_SHA="${{ github.event.pull_request.head.sha }}"
# Get list of commits
COMMITS=$(git log --oneline "$BASE_SHA".."$HEAD_SHA")
if [ -z "$COMMITS" ]; then
echo "❌ ERROR: PR has no commits!"
exit 1
fi
echo "Commits in this PR:"
echo "$COMMITS"
# Check each commit for changes
EMPTY_COMMITS=0
while IFS= read -r commit; do
COMMIT_SHA=$(echo "$commit" | awk '{print $1}')
COMMIT_MSG=$(echo "$commit" | cut -d' ' -f2-)
# Get parent commit
PARENT_SHA=$(git rev-parse "$COMMIT_SHA^" 2>/dev/null || echo "")
if [ -n "$PARENT_SHA" ]; then
# Check if commit has changes
COMMIT_DIFF=$(git diff --stat "$PARENT_SHA" "$COMMIT_SHA")
if [ -z "$COMMIT_DIFF" ]; then
echo "⚠️ WARNING: Commit $COMMIT_SHA has no changes!"
echo " Message: $COMMIT_MSG"
EMPTY_COMMITS=$((EMPTY_COMMITS + 1))
fi
fi
done <<< "$COMMITS"
if [ "$EMPTY_COMMITS" -gt 0 ]; then
echo ""
echo "⚠️ Found $EMPTY_COMMITS commit(s) with no changes."
echo "Consider squashing or amending these commits."
fi

View File

@@ -1,65 +1,73 @@
## Description
<!-- Provide a clear description of what this PR does -->
## Changes Made
<!-- List the specific changes made in this PR -->
### Files Changed
<!-- List the files that were modified -->
### Type of Change
<!-- Check the relevant option -->
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] Documentation update
- [ ] Refactoring (no functional changes)
- [ ] Test updates
- [ ] CI/CD changes
## Testing
<!-- Describe the tests you ran to verify your changes -->
### Test Instructions
<!-- Provide step-by-step instructions to test your changes -->
## Checklist
<!-- Check all that apply -->
- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my own code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream modules
## Reviewer Guidelines
<!-- IMPORTANT: Reviewers must follow these guidelines to prevent rubber-stamping -->
### ⚠️ Reviewers MUST verify:
1. **PR has actual changes** - Check that the PR contains additions, deletions, or modifications
2. **Changes match description** - Verify the changes match what's described in the PR
3. **Code quality** - Review code for bugs, security issues, performance problems
4. **Tests are adequate** - Ensure new code is properly tested
5. **Documentation is updated** - Check if documentation needs updates
### ❌ DO NOT approve if:
- PR has 0 additions, 0 deletions, and 0 files changed (zombie PR)
- Changes don't match the PR description
- Code has obvious bugs or security issues
- No tests for new functionality
- Documentation is missing or incorrect
### ✅ DO approve if:
- PR has meaningful changes that match the description
- Code is clean, well-tested, and documented
- Changes follow project conventions
- No obvious issues or risks
## Related Issues
<!-- Link any related issues -->
- Fixes #<!-- issue number -->
- Related to #<!-- issue number -->
## Additional Notes
<!-- Add any other context about the PR here -->
---
**⚠️ Before submitting your pull request:**
1. [x] I've read [BRANCH_PROTECTION.md](BRANCH_PROTECTION.md)
2. [x] I've followed [CONTRIBUTING.md](CONTRIBUTING.md) guidelines
3. [x] My changes have appropriate test coverage
4. [x] I've updated documentation where needed
5. [x] I've verified CI passes (where applicable)
**Context:**
<Describe your changes and why they're needed>
**Testing:**
<Explain how this was tested>
**Questions for reviewers:**
<Ask specific questions if needed>
## Pull Request Template
### Description
[Explain your changes briefly]
### Checklist
- [ ] Branch protection rules followed
- [ ] Required reviewers: @perplexity (QA), @Timmy (hermes-agent)
- [ ] CI passed (where applicable)
### Questions for Reviewers
- [ ] Any special considerations?
- [ ] Does this require additional documentation?
# Pull Request Template
## Summary
Briefly describe the changes in this PR.
## Reviewers
- Default reviewer: @perplexity
- Required reviewer for hermes-agent: @Timmy
## Branch Protection Compliance
- [ ] PR created
- [ ] 1+ approvals
- [ ] ci passed (where applicable)
- [ ] No force pushes
- [ ] No branch deletions
## Specialized Owners
- [ ] @Rockachopa (for agent-core)
- [ ] @Timmy (for ai/)
## Pull Request Template
### Summary
- [ ] Describe the change
- [ ] Link to related issue (e.g. `Closes #123`)
### Checklist
- [ ] Branch protection rules respected
- [ ] CI/CD passing (where applicable)
- [ ] Code reviewed by @perplexity
- [ ] No force pushes to main
### Review Requirements
- [ ] @perplexity for all repos
- [ ] @Timmy for hermes-agent changes
**By submitting this PR, I confirm that:**
1. I have actually reviewed the code changes
2. The changes are meaningful and not a zombie PR
3. I have tested the changes locally (if applicable)
4. I understand that rubber-stamping (approving PRs with no changes) is prohibited

3
app.js
View File

@@ -734,9 +734,6 @@ async function init() {
const response = await fetch('./portals.json');
const portalData = await response.json();
createPortals(portalData);
// Start portal hot-reload watcher
if (window.PortalHotReload) PortalHotReload.start(5000);
} catch (e) {
console.error('Failed to load portals.json:', e);
addChatMessage('error', 'Portal registry offline. Check logs.');

193
bin/check_zombie_prs.py Executable file
View File

@@ -0,0 +1,193 @@
#!/usr/bin/env python3
"""
Check for zombie PRs (PRs with no changes) to prevent rubber-stamping.
Issue #1445: process: Prevent rubber-stamping of PRs with no changes
"""
import json
import os
import sys
import urllib.request
import subprocess
from typing import Dict, List, Any, Optional
# Configuration
GITEA_BASE = "https://forge.alexanderwhitestone.com/api/v1"
TOKEN_PATH = os.path.expanduser("~/.config/gitea/token")
ORG = "Timmy_Foundation"
class ZombiePRChecker:
def __init__(self):
self.token = self._load_token()
def _load_token(self) -> str:
"""Load Gitea API token."""
try:
with open(TOKEN_PATH, "r") as f:
return f.read().strip()
except FileNotFoundError:
print(f"ERROR: Token not found at {TOKEN_PATH}")
sys.exit(1)
def _api_request(self, endpoint: str) -> Any:
"""Make authenticated Gitea API request."""
url = f"{GITEA_BASE}{endpoint}"
headers = {"Authorization": f"token {self.token}"}
req = urllib.request.Request(url, headers=headers)
try:
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as e:
if e.code == 404:
return None
error_body = e.read().decode() if e.fp else "No error body"
print(f"API Error {e.code}: {error_body}")
return None
def get_open_prs(self, repo: str) -> List[Dict]:
"""Get open PRs for a repository."""
endpoint = f"/repos/{ORG}/{repo}/pulls?state=open"
prs = self._api_request(endpoint)
return prs if isinstance(prs, list) else []
def get_pr_files(self, repo: str, pr_number: int) -> List[Dict]:
"""Get files changed in a PR."""
endpoint = f"/repos/{ORG}/{repo}/pulls/{pr_number}/files"
files = self._api_request(endpoint)
return files if isinstance(files, list) else []
def is_zombie_pr(self, repo: str, pr_number: int) -> Dict[str, Any]:
"""Check if a PR is a zombie (no actual changes)."""
pr_files = self.get_pr_files(repo, pr_number)
# Calculate total changes
total_additions = sum(f.get("additions", 0) for f in pr_files)
total_deletions = sum(f.get("deletions", 0) for f in pr_files)
total_changes = sum(f.get("changes", 0) for f in pr_files)
# A zombie PR has no additions, deletions, or changes
is_zombie = (total_additions == 0 and total_deletions == 0 and total_changes == 0)
return {
"repo": repo,
"pr_number": pr_number,
"is_zombie": is_zombie,
"files_changed": len(pr_files),
"total_additions": total_additions,
"total_deletions": total_deletions,
"total_changes": total_changes,
"files": pr_files
}
def scan_repo_for_zombies(self, repo: str) -> List[Dict]:
"""Scan a repository for zombie PRs."""
open_prs = self.get_open_prs(repo)
zombies = []
print(f"Scanning {repo} for zombie PRs...")
print(f"Found {len(open_prs)} open PRs")
for pr in open_prs:
pr_number = pr["number"]
pr_title = pr["title"]
# Check if it's a zombie
zombie_info = self.is_zombie_pr(repo, pr_number)
if zombie_info["is_zombie"]:
zombie_info["title"] = pr_title
zombie_info["author"] = pr["user"]["login"]
zombie_info["created"] = pr["created_at"]
zombies.append(zombie_info)
print(f" 🧟 ZOMBIE: #{pr_number} - {pr_title}")
else:
print(f" ✅ OK: #{pr_number} - {pr_title} ({zombie_info['total_changes']} changes)")
return zombies
def generate_report(self, zombies_by_repo: Dict[str, List[Dict]]) -> str:
"""Generate a report of zombie PRs found."""
total_zombies = sum(len(zombies) for zombies in zombies_by_repo.values())
report = "# Zombie PR Detection Report\n\n"
report += f"## Summary\n"
report += f"- **Total zombie PRs found:** {total_zombies}\n"
report += f"- **Repositories scanned:** {len(zombies_by_repo)}\n\n"
if total_zombies == 0:
report += "✅ **No zombie PRs found.**\n"
else:
report += "⚠️ **Zombie PRs found:**\n\n"
for repo, zombies in zombies_by_repo.items():
if zombies:
report += f"### {repo}\n"
for zombie in zombies:
report += f"- **#{zombie['pr_number']}**: {zombie['title']}\n"
report += f" - Author: {zombie['author']}\n"
report += f" - Created: {zombie['created']}\n"
report += f" - Files changed: {zombie['files_changed']}\n"
report += f" - Total changes: {zombie['total_changes']}\n"
report += "\n"
# Add recommendations
report += "## Recommendations\n"
report += "1. **Close zombie PRs** - PRs with no actual changes should be closed\n"
report += "2. **Validate before merge** - CI should reject PRs with no changes\n"
report += "3. **Prevent future zombies** - Agents should validate changes before creating PRs\n"
report += "4. **Review process** - Reviewers must verify PRs have actual changes\n"
return report
def main():
"""Main entry point for zombie PR checker."""
import argparse
parser = argparse.ArgumentParser(description="Check for zombie PRs (PRs with no actual changes)")
parser.add_argument("--repos", nargs="+",
default=["the-nexus", "timmy-home", "timmy-config", "hermes-agent", "the-beacon"],
help="Repositories to scan")
parser.add_argument("--report", action="store_true", help="Generate report")
parser.add_argument("--json", action="store_true", help="Output JSON instead of report")
args = parser.parse_args()
checker = ZombiePRChecker()
# Scan repositories for zombie PRs
zombies_by_repo = {}
for repo in args.repos:
zombies = checker.scan_repo_for_zombies(repo)
zombies_by_repo[repo] = zombies
# Generate output
if args.json:
print(json.dumps(zombies_by_repo, indent=2))
elif args.report:
report = checker.generate_report(zombies_by_repo)
print(report)
else:
# Default: show summary
total_zombies = sum(len(zombies) for zombies in zombies_by_repo.values())
print(f"\nZombie PR Detection Complete")
print("=" * 60)
print(f"Total zombie PRs found: {total_zombies}")
if total_zombies > 0:
print("\nZombie PRs:")
for repo, zombies in zombies_by_repo.items():
for zombie in zombies:
print(f" {repo} #{zombie['pr_number']}: {zombie['title']}")
sys.exit(1)
else:
print("\n✅ No zombie PRs found")
sys.exit(0)
if __name__ == "__main__":
main()

View File

@@ -1,835 +0,0 @@
/**
* cockpit.js — Nexus Operator Cockpit
*
* Provides the operator-facing control surface for the Nexus:
* - Inspector/right rail (SESSION | FILES | MEMORY | AGENT tabs)
* - Session taxonomy (group / tag / pin / archive)
* - Git/dirty/unsaved-state indicator in workspace header
* - Shell terminal panel via cockpit_pty.py + xterm.js
*
* Connects to: ws://127.0.0.1:8766 (cockpit_pty.py)
* Patterns sourced from: dodo-reach/hermes-desktop, outsourc-e/hermes-workspace,
* nesquena/hermes-webui. See docs/ATLAS_COCKPIT_PATTERNS.md.
*
* Refs: #1695
*/
'use strict';
// ─── Constants ──────────────────────────────────────────────────────────────
const COCKPIT_WS_URL = 'ws://127.0.0.1:8766';
const GIT_POLL_INTERVAL_MS = 15000; // 15s
const SESSION_STORE_KEY = 'nexus_cockpit_sessions';
// ─── SessionStore ────────────────────────────────────────────────────────────
// Local-first session taxonomy: group / tag / pin / archive.
// Persists to localStorage (fast) and syncs to cockpit_pty.py (durable).
// Pattern: adapted from nesquena/hermes-webui session list model.
class SessionStore {
constructor() {
this._sessions = this._load();
this._listeners = [];
}
// ── Persistence ─────────────────────────────────────────────────────────
_load() {
try {
const raw = localStorage.getItem(SESSION_STORE_KEY);
return raw ? JSON.parse(raw) : [];
} catch {
return [];
}
}
_save() {
try {
localStorage.setItem(SESSION_STORE_KEY, JSON.stringify(this._sessions));
} catch (e) {
console.warn('[cockpit] localStorage save failed:', e);
}
this._emit();
}
replaceAll(sessions) {
this._sessions = sessions;
this._save();
}
// ── CRUD ─────────────────────────────────────────────────────────────────
create(name, { group = '', tags = [], pinned = false } = {}) {
const session = {
id: `sess_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
name,
group,
tags: Array.isArray(tags) ? tags : [],
pinned: Boolean(pinned),
archived: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
this._sessions.unshift(session);
this._save();
return session;
}
get(id) {
return this._sessions.find(s => s.id === id) || null;
}
update(id, patch) {
const idx = this._sessions.findIndex(s => s.id === id);
if (idx === -1) return null;
this._sessions[idx] = { ...this._sessions[idx], ...patch, updatedAt: new Date().toISOString() };
this._save();
return this._sessions[idx];
}
delete(id) {
const before = this._sessions.length;
this._sessions = this._sessions.filter(s => s.id !== id);
if (this._sessions.length !== before) this._save();
}
// ── Taxonomy actions ─────────────────────────────────────────────────────
pin(id) { return this.update(id, { pinned: true }); }
unpin(id) { return this.update(id, { pinned: false }); }
archive(id) { return this.update(id, { archived: true, pinned: false }); }
unarchive(id) { return this.update(id, { archived: false }); }
setGroup(id, group) { return this.update(id, { group }); }
addTag(id, tag) {
const s = this.get(id);
if (!s) return null;
const tags = Array.from(new Set([...s.tags, tag]));
return this.update(id, { tags });
}
removeTag(id, tag) {
const s = this.get(id);
if (!s) return null;
return this.update(id, { tags: s.tags.filter(t => t !== tag) });
}
// ── Queries ──────────────────────────────────────────────────────────────
listActive() {
return this._sessions
.filter(s => !s.archived)
.sort((a, b) => (b.pinned ? 1 : 0) - (a.pinned ? 1 : 0) || b.updatedAt.localeCompare(a.updatedAt));
}
listArchived() {
return this._sessions.filter(s => s.archived);
}
listByGroup(group) {
return this.listActive().filter(s => s.group === group);
}
listByTag(tag) {
return this.listActive().filter(s => s.tags.includes(tag));
}
listPinned() {
return this.listActive().filter(s => s.pinned);
}
allGroups() {
return Array.from(new Set(this._sessions.filter(s => s.group).map(s => s.group)));
}
allTags() {
return Array.from(new Set(this._sessions.flatMap(s => s.tags)));
}
getAll() {
return [...this._sessions];
}
// ── Observer ─────────────────────────────────────────────────────────────
onChange(fn) { this._listeners.push(fn); }
_emit() { this._listeners.forEach(fn => fn(this._sessions)); }
}
// ─── GitStatusWidget ─────────────────────────────────────────────────────────
// Shows branch name + dirty badge in the workspace header.
// Pattern: adapted from outsourc-e/hermes-workspace git badge.
class GitStatusWidget {
constructor(containerEl, cockpitWs) {
this.el = containerEl;
this.ws = cockpitWs;
this._status = null;
this._render({ branch: '…', dirty: false, error: null });
}
update(status) {
this._status = status;
this._render(status);
}
_render({ branch, dirty, ahead, behind, staged, unstaged, untracked, error }) {
if (!this.el) return;
if (error) {
this.el.innerHTML = `<span class="git-badge git-badge--error" title="${error}">git: err</span>`;
return;
}
const aheadBehind = (ahead || behind)
? ` <span class="git-ahead-behind">↑${ahead || 0}${behind || 0}</span>`
: '';
const dirtyCount = (staged || 0) + (unstaged || 0) + (untracked || 0);
const dirtyBadge = dirty
? `<span class="git-dirty-badge" title="${staged}S ${unstaged}M ${untracked}?">●${dirtyCount}</span>`
: `<span class="git-clean-badge">✓</span>`;
this.el.innerHTML = `
<span class="git-badge ${dirty ? 'git-badge--dirty' : 'git-badge--clean'}">
<span class="git-branch-icon">⎇</span>
<span class="git-branch-name">${branch}</span>
${dirtyBadge}${aheadBehind}
</span>`;
}
}
// ─── CockpitTerminal ─────────────────────────────────────────────────────────
// xterm.js terminal wired to cockpit_pty.py over WebSocket.
// Pattern: adopted from dodo-reach/hermes-desktop terminal panel.
class CockpitTerminal {
constructor(containerEl, cockpitWs) {
this.containerEl = containerEl;
this.ws = cockpitWs;
this.term = null;
this.fitAddon = null;
this._started = false;
}
async init() {
if (!window.Terminal || !window.FitAddon) {
console.warn('[cockpit] xterm.js not loaded — terminal panel unavailable');
if (this.containerEl) {
this.containerEl.innerHTML = `
<div class="terminal-unavailable">
<span class="terminal-unavailable-icon">⊘</span>
<span>Terminal unavailable — xterm.js not loaded</span>
</div>`;
}
return;
}
this.term = new window.Terminal({
cursorBlink: true,
fontFamily: '"JetBrains Mono", "Fira Code", monospace',
fontSize: 13,
lineHeight: 1.4,
theme: {
background: '#0a0c10',
foreground: '#d0f0ff',
cursor: '#4af0c0',
selectionBackground: 'rgba(74,240,192,0.25)',
black: '#0d1117',
red: '#ff5f87',
green: '#4af0c0',
yellow: '#f0e04a',
blue: '#7b5cff',
magenta: '#d07bff',
cyan: '#4af0c0',
white: '#d0f0ff',
brightBlack: '#4a5568',
brightRed: '#ff7b9c',
brightGreen: '#7bffd4',
brightYellow: '#ffe87b',
brightBlue: '#9d80ff',
brightMagenta: '#e5a0ff',
brightCyan: '#7bffd4',
brightWhite: '#ffffff',
},
});
const { FitAddon } = window.FitAddon || {};
if (FitAddon) {
this.fitAddon = new FitAddon();
this.term.loadAddon(this.fitAddon);
}
this.term.open(this.containerEl);
if (this.fitAddon) this.fitAddon.fit();
// Forward terminal input to PTY
this.term.onData(data => {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({
type: 'pty_input',
data: btoa(unescape(encodeURIComponent(data))),
}));
}
});
// Resize observer
if (window.ResizeObserver) {
const ro = new ResizeObserver(() => {
if (this.fitAddon) {
this.fitAddon.fit();
const { cols, rows } = this.term;
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type: 'pty_resize', cols, rows }));
}
}
});
ro.observe(this.containerEl);
}
this._started = false;
}
startSession(cwd) {
if (!this.term || this._started) return;
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
const { cols, rows } = this.term;
this.ws.send(JSON.stringify({ type: 'pty_start', cols, rows, cwd }));
this._started = true;
}
handleOutput(b64data) {
if (!this.term) return;
try {
const data = decodeURIComponent(escape(atob(b64data)));
this.term.write(data);
} catch {
// Raw fallback for binary
const bytes = Uint8Array.from(atob(b64data), c => c.charCodeAt(0));
this.term.write(bytes);
}
}
handleExit(code) {
if (!this.term) return;
this.term.writeln(`\r\n\x1b[33m[shell exited: ${code}]\x1b[0m`);
this._started = false;
}
focus() {
if (this.term) this.term.focus();
}
}
// ─── InspectorRail ───────────────────────────────────────────────────────────
// Right-side panel with tabs: SESSION | FILES | MEMORY | AGENT
// Pattern: adapted from outsourc-e/hermes-workspace inspector rail (simplified 6→4 tabs).
class InspectorRail {
constructor(railEl, sessionStore, gitWidget, terminal) {
this.el = railEl;
this.store = sessionStore;
this.gitWidget = gitWidget;
this.terminal = terminal;
this._activeTab = 'session';
this._agentHealth = null;
this._init();
}
_init() {
if (!this.el) return;
this.el.innerHTML = `
<div class="rail-header">
<div class="rail-tabs" role="tablist">
<button class="rail-tab active" data-tab="session" role="tab" aria-selected="true">SESSION</button>
<button class="rail-tab" data-tab="files" role="tab">FILES</button>
<button class="rail-tab" data-tab="memory" role="tab">MEMORY</button>
<button class="rail-tab" data-tab="agent" role="tab">AGENT</button>
</div>
<button class="rail-close-btn" id="rail-close-btn" title="Close inspector" aria-label="Close inspector">✕</button>
</div>
<div class="rail-body">
<div class="rail-pane" id="rail-pane-session" role="tabpanel"></div>
<div class="rail-pane" id="rail-pane-files" role="tabpanel" style="display:none;"></div>
<div class="rail-pane" id="rail-pane-memory" role="tabpanel" style="display:none;"></div>
<div class="rail-pane" id="rail-pane-agent" role="tabpanel" style="display:none;"></div>
</div>`;
// Tab switching
this.el.querySelectorAll('.rail-tab').forEach(btn => {
btn.addEventListener('click', () => this._switchTab(btn.dataset.tab));
});
// Close button
const closeBtn = this.el.querySelector('#rail-close-btn');
if (closeBtn) closeBtn.addEventListener('click', () => Cockpit.hideInspector());
// Session store observer
this.store.onChange(() => {
if (this._activeTab === 'session') this._renderSession();
});
this._renderSession();
this._renderFiles();
this._renderMemory();
this._renderAgent();
}
_switchTab(tab) {
this._activeTab = tab;
this.el.querySelectorAll('.rail-tab').forEach(btn => {
const active = btn.dataset.tab === tab;
btn.classList.toggle('active', active);
btn.setAttribute('aria-selected', active);
});
this.el.querySelectorAll('.rail-pane').forEach(pane => {
pane.style.display = pane.id === `rail-pane-${tab}` ? '' : 'none';
});
if (tab === 'session') this._renderSession();
if (tab === 'agent') this._renderAgent();
}
// ── Session pane ─────────────────────────────────────────────────────────
_renderSession() {
const pane = this.el.querySelector('#rail-pane-session');
if (!pane) return;
const pinned = this.store.listPinned();
const active = this.store.listActive().filter(s => !s.pinned);
const archived = this.store.listArchived();
const groups = this.store.allGroups();
const tags = this.store.allTags();
pane.innerHTML = `
<div class="rail-section">
<div class="rail-section-header">
<span>Sessions</span>
<button class="rail-action-btn" id="sess-new-btn">+ New</button>
</div>
${pinned.length ? `
<div class="sess-group-label">📌 Pinned</div>
${pinned.map(s => this._sessionCard(s)).join('')}` : ''}
${active.length ? `
<div class="sess-group-label">Active</div>
${active.map(s => this._sessionCard(s)).join('')}` : ''}
${!pinned.length && !active.length ? `<div class="rail-empty">No active sessions. Create one to get started.</div>` : ''}
</div>
${archived.length ? `
<div class="rail-section">
<details class="sess-archive-details">
<summary class="sess-archive-summary">Archived (${archived.length})</summary>
${archived.map(s => this._sessionCard(s)).join('')}
</details>
</div>` : ''}
${groups.length ? `
<div class="rail-section">
<div class="rail-section-header">Groups</div>
${groups.map(g => `<span class="sess-group-tag" data-group="${g}">${g}</span>`).join('')}
</div>` : ''}
${tags.length ? `
<div class="rail-section">
<div class="rail-section-header">Tags</div>
${tags.map(t => `<span class="sess-tag-badge" data-tag="${t}">#${t}</span>`).join('')}
</div>` : ''}`;
// New session button
pane.querySelector('#sess-new-btn')?.addEventListener('click', () => this._createSession());
// Card actions
pane.querySelectorAll('[data-sess-action]').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
const { sessAction, sessId } = btn.dataset;
this._sessAction(sessAction, sessId);
});
});
// Group / tag filter clicks
pane.querySelectorAll('[data-group]').forEach(el => {
el.addEventListener('click', () => this._switchTab('session'));
});
}
_sessionCard(s) {
const tagBadges = s.tags.map(t => `<span class="sess-tag-mini">#${t}</span>`).join('');
const groupBadge = s.group ? `<span class="sess-group-mini">${s.group}</span>` : '';
const pinnedIcon = s.pinned ? '📌 ' : '';
const archivedIcon = s.archived ? '🗄 ' : '';
return `
<div class="sess-card ${s.pinned ? 'sess-card--pinned' : ''} ${s.archived ? 'sess-card--archived' : ''}" data-id="${s.id}">
<div class="sess-card-name">${pinnedIcon}${archivedIcon}${this._esc(s.name)}</div>
<div class="sess-card-meta">${groupBadge}${tagBadges}</div>
<div class="sess-card-actions">
${s.pinned
? `<button class="sess-act-btn" data-sess-action="unpin" data-sess-id="${s.id}" title="Unpin">📌</button>`
: `<button class="sess-act-btn" data-sess-action="pin" data-sess-id="${s.id}" title="Pin">📍</button>`}
${s.archived
? `<button class="sess-act-btn" data-sess-action="unarchive" data-sess-id="${s.id}" title="Restore">↩</button>`
: `<button class="sess-act-btn" data-sess-action="archive" data-sess-id="${s.id}" title="Archive">🗄</button>`}
<button class="sess-act-btn" data-sess-action="delete" data-sess-id="${s.id}" title="Delete">✕</button>
</div>
</div>`;
}
_sessAction(action, id) {
switch (action) {
case 'pin': this.store.pin(id); break;
case 'unpin': this.store.unpin(id); break;
case 'archive': this.store.archive(id); break;
case 'unarchive': this.store.unarchive(id); break;
case 'delete':
if (confirm('Delete this session?')) this.store.delete(id);
break;
}
this._renderSession();
}
_createSession() {
const name = prompt('Session name:');
if (!name || !name.trim()) return;
const group = prompt('Group (optional — leave blank for none):') || '';
const tagsRaw = prompt('Tags (comma-separated, optional):') || '';
const tags = tagsRaw.split(',').map(t => t.trim()).filter(Boolean);
this.store.create(name.trim(), { group, tags });
this._renderSession();
}
// ── Files pane ───────────────────────────────────────────────────────────
_renderFiles() {
const pane = this.el.querySelector('#rail-pane-files');
if (!pane) return;
pane.innerHTML = `
<div class="rail-section">
<div class="rail-section-header">Workspace Files</div>
<div class="rail-empty rail-empty--hint">
File browser is populated by the active session context.<br>
Open a session and use the terminal to navigate.
</div>
</div>
<div class="rail-section">
<div class="rail-section-header">Recent Artifacts</div>
<div id="rail-artifacts-list" class="rail-artifact-list">
<div class="rail-empty">No artifacts yet.</div>
</div>
</div>`;
}
addArtifact(name, type, ref) {
const list = this.el.querySelector('#rail-artifacts-list');
if (!list) return;
const empty = list.querySelector('.rail-empty');
if (empty) empty.remove();
const item = document.createElement('div');
item.className = 'rail-artifact-item';
item.innerHTML = `
<span class="artifact-type-badge artifact-type--${type}">${type}</span>
<span class="artifact-name">${this._esc(name)}</span>
${ref ? `<a class="artifact-ref" href="${this._esc(ref)}" target="_blank" rel="noopener">↗</a>` : ''}`;
list.prepend(item);
}
// ── Memory pane ──────────────────────────────────────────────────────────
_renderMemory() {
const pane = this.el.querySelector('#rail-pane-memory');
if (!pane) return;
pane.innerHTML = `
<div class="rail-section">
<div class="rail-section-header">Memory References</div>
<div class="rail-empty rail-empty--hint">
Memory entries appear here when Timmy surfaces them.<br>
Interact with the 3D memory palace to populate.
</div>
</div>
<div class="rail-section">
<div class="rail-section-header">Skills</div>
<div id="rail-skills-list" class="rail-skills-list">
<div class="rail-empty">No skills registered.</div>
</div>
</div>`;
}
addMemoryRef(key, summary) {
const section = this.el.querySelector('#rail-pane-memory .rail-section');
if (!section) return;
const item = document.createElement('div');
item.className = 'rail-mem-item';
item.innerHTML = `<span class="mem-key">${this._esc(key)}</span><span class="mem-summary">${this._esc(summary)}</span>`;
section.appendChild(item);
}
// ── Agent pane ───────────────────────────────────────────────────────────
_renderAgent() {
const pane = this.el.querySelector('#rail-pane-agent');
if (!pane) return;
const h = this._agentHealth;
pane.innerHTML = `
<div class="rail-section">
<div class="rail-section-header">Agent Health</div>
<div class="agent-health-card">
<div class="agent-health-row">
<span class="agent-health-label">Timmy</span>
<span class="agent-health-dot ${h?.timmy === 'ok' ? 'dot--ok' : h?.timmy === 'warn' ? 'dot--warn' : 'dot--unknown'}"></span>
<span class="agent-health-status">${h?.timmy || 'unknown'}</span>
</div>
<div class="agent-health-row">
<span class="agent-health-label">Hermes WS</span>
<span class="agent-health-dot ${h?.ws === 'ok' ? 'dot--ok' : 'dot--unknown'}"></span>
<span class="agent-health-status">${h?.ws || 'unknown'}</span>
</div>
<div class="agent-health-row">
<span class="agent-health-label">Cockpit PTY</span>
<span class="agent-health-dot ${h?.pty === 'ok' ? 'dot--ok' : 'dot--unknown'}"></span>
<span class="agent-health-status">${h?.pty || 'unknown'}</span>
</div>
<div class="agent-health-row">
<span class="agent-health-label">Bannerlord</span>
<span class="agent-health-dot ${h?.bannerlord === 'ok' ? 'dot--ok' : h?.bannerlord === 'warn' ? 'dot--warn' : 'dot--unknown'}"></span>
<span class="agent-health-status">${h?.bannerlord || 'unknown'}</span>
</div>
</div>
</div>
<div class="rail-section">
<div class="rail-section-header">Session Info</div>
<div class="agent-session-info">
<div class="agent-info-row"><span>Active sessions</span><span>${this.store.listActive().length}</span></div>
<div class="agent-info-row"><span>Pinned</span><span>${this.store.listPinned().length}</span></div>
<div class="agent-info-row"><span>Archived</span><span>${this.store.listArchived().length}</span></div>
<div class="agent-info-row"><span>Groups</span><span>${this.store.allGroups().length}</span></div>
<div class="agent-info-row"><span>Tags</span><span>${this.store.allTags().length}</span></div>
</div>
</div>`;
}
updateAgentHealth(health) {
this._agentHealth = health;
if (this._activeTab === 'agent') this._renderAgent();
}
_esc(str) {
return String(str || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
}
// ─── Cockpit (main controller) ────────────────────────────────────────────────
const Cockpit = (() => {
let _ws = null;
let _wsReady = false;
let _gitPollTimer = null;
let _store = null;
let _gitWidget = null;
let _terminal = null;
let _rail = null;
let _inspectorVisible = false;
// ── WebSocket lifecycle ─────────────────────────────────────────────────
function _connect() {
try {
_ws = new WebSocket(COCKPIT_WS_URL);
} catch (e) {
console.warn('[cockpit] WebSocket connection failed:', e);
return;
}
_ws.addEventListener('open', _onOpen);
_ws.addEventListener('message', _onMessage);
_ws.addEventListener('close', _onClose);
_ws.addEventListener('error', e => console.warn('[cockpit] ws error:', e));
}
function _onOpen() {
_wsReady = true;
console.info('[cockpit] Connected to cockpit_pty.py');
// Update agent health
_rail?.updateAgentHealth({ ..._rail._agentHealth, pty: 'ok' });
// Load sessions from server (merges with localStorage)
_ws.send(JSON.stringify({ type: 'session_load' }));
// Start git polling
_pollGit();
_gitPollTimer = setInterval(_pollGit, GIT_POLL_INTERVAL_MS);
// Start terminal session if terminal panel is visible
if (_terminal && document.getElementById('cockpit-terminal-panel')?.classList.contains('panel--visible')) {
_terminal.startSession(window.__NEXUS_ROOT__ || '.');
}
}
function _onMessage(event) {
let msg;
try { msg = JSON.parse(event.data); }
catch { return; }
switch (msg.type) {
case 'pty_output':
_terminal?.handleOutput(msg.data);
break;
case 'pty_exit':
_terminal?.handleExit(msg.code);
break;
case 'git_status':
_gitWidget?.update(msg);
break;
case 'session_data':
if (msg.sessions && msg.sessions.length > 0) {
// Server sessions win over local if they have more entries
const local = _store.getAll();
if (msg.sessions.length >= local.length) {
_store.replaceAll(msg.sessions);
}
}
break;
case 'error':
console.warn('[cockpit] server error:', msg.message);
break;
}
}
function _onClose() {
_wsReady = false;
clearInterval(_gitPollTimer);
_rail?.updateAgentHealth({ ..._rail._agentHealth, pty: 'unknown' });
console.info('[cockpit] Disconnected from cockpit_pty.py. Will retry in 10s.');
setTimeout(_connect, 10000);
}
function _pollGit() {
if (_ws && _ws.readyState === WebSocket.OPEN) {
_ws.send(JSON.stringify({ type: 'git_status' }));
}
}
function _syncSessions() {
if (_ws && _ws.readyState === WebSocket.OPEN) {
_ws.send(JSON.stringify({ type: 'session_save', sessions: _store.getAll() }));
}
}
// ── Public API ──────────────────────────────────────────────────────────
function init() {
_store = new SessionStore();
// Git status widget
const gitEl = document.getElementById('cockpit-git-status');
_gitWidget = new GitStatusWidget(gitEl, _ws);
// Inspector rail
const railEl = document.getElementById('cockpit-inspector-rail');
_rail = new InspectorRail(railEl, _store, _gitWidget, _terminal);
// Terminal
const termEl = document.getElementById('cockpit-terminal-body');
_terminal = new CockpitTerminal(termEl, _ws);
_terminal.init().catch(console.error);
// Wire toggle buttons
const inspectorToggle = document.getElementById('cockpit-inspector-toggle');
inspectorToggle?.addEventListener('click', toggleInspector);
const terminalToggle = document.getElementById('cockpit-terminal-toggle');
terminalToggle?.addEventListener('click', toggleTerminal);
const terminalStart = document.getElementById('cockpit-terminal-start');
terminalStart?.addEventListener('click', () => {
if (_terminal) {
_terminal.ws = _ws;
_terminal.startSession('.');
_terminal.focus();
}
});
// Sync sessions to server on change
_store.onChange(_syncSessions);
// Connect to cockpit_pty.py
_connect();
// Reflect WS health immediately
_rail?.updateAgentHealth({
timmy: 'unknown',
ws: document.getElementById('ws-status-dot')?.classList.contains('connected') ? 'ok' : 'unknown',
pty: 'unknown',
bannerlord: 'unknown',
});
console.info('[cockpit] Initialized.');
}
function showInspector() {
const rail = document.getElementById('cockpit-inspector-rail');
if (rail) {
rail.classList.add('rail--visible');
_inspectorVisible = true;
document.getElementById('cockpit-inspector-toggle')?.classList.add('active');
}
}
function hideInspector() {
const rail = document.getElementById('cockpit-inspector-rail');
if (rail) {
rail.classList.remove('rail--visible');
_inspectorVisible = false;
document.getElementById('cockpit-inspector-toggle')?.classList.remove('active');
}
}
function toggleInspector() {
_inspectorVisible ? hideInspector() : showInspector();
}
function showTerminal() {
const panel = document.getElementById('cockpit-terminal-panel');
if (panel) {
panel.classList.add('panel--visible');
if (_terminal && _ws?.readyState === WebSocket.OPEN && !_terminal._started) {
_terminal.ws = _ws;
_terminal.startSession('.');
}
setTimeout(() => _terminal?.focus(), 100);
}
}
function hideTerminal() {
const panel = document.getElementById('cockpit-terminal-panel');
if (panel) panel.classList.remove('panel--visible');
}
function toggleTerminal() {
const panel = document.getElementById('cockpit-terminal-panel');
if (panel?.classList.contains('panel--visible')) hideTerminal();
else showTerminal();
}
function updateAgentHealth(health) {
_rail?.updateAgentHealth(health);
}
function addArtifact(name, type, ref) {
_rail?.addArtifact(name, type, ref);
}
function addMemoryRef(key, summary) {
_rail?.addMemoryRef(key, summary);
}
return { init, showInspector, hideInspector, toggleInspector, showTerminal, hideTerminal, toggleTerminal, updateAgentHealth, addArtifact, addMemoryRef };
})();
// Auto-init when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', Cockpit.init);
} else {
Cockpit.init();
}
// Expose globally for integration with app.js
window.Cockpit = Cockpit;

View File

@@ -1,356 +0,0 @@
#!/usr/bin/env python3
"""
Nexus Cockpit PTY Server — Operator shell relay and git status.
Exposes:
ws://127.0.0.1:8766 WebSocket for PTY sessions and git status polling
Protocol (WebSocket messages, JSON):
Client -> Server:
{"type": "pty_resize", "cols": 80, "rows": 24}
{"type": "pty_input", "data": "<base64-encoded stdin>"}
{"type": "git_status"}
{"type": "session_save", "session": {...}}
{"type": "session_load"}
Server -> Client:
{"type": "pty_output", "data": "<base64-encoded stdout>"}
{"type": "pty_exit", "code": 0}
{"type": "git_status", "branch": "main", "dirty": false, "ahead": 0, "behind": 0, "staged": 0, "unstaged": 0, "untracked": 0}
{"type": "session_data", "sessions": [...]}
{"type": "error", "message": "..."}
Security: binds to 127.0.0.1 only. Never expose externally.
"""
import asyncio
import base64
import json
import logging
import os
import pty
import select
import signal
import subprocess
import sys
import fcntl
import termios
import struct
from pathlib import Path
import websockets
# Configuration
HOST = "127.0.0.1"
PORT = 8766
NEXUS_ROOT = Path(__file__).parent
SESSION_STORE_PATH = NEXUS_ROOT / ".cockpit_sessions.json"
SHELL = os.environ.get("SHELL", "/bin/bash")
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [cockpit-pty] %(levelname)s %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger("cockpit-pty")
def get_git_status(cwd: Path) -> dict:
"""Return git status summary for the given directory."""
result = {
"branch": "unknown",
"dirty": False,
"ahead": 0,
"behind": 0,
"staged": 0,
"unstaged": 0,
"untracked": 0,
"error": None,
}
try:
# Branch name
branch_out = subprocess.check_output(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=cwd, stderr=subprocess.DEVNULL, timeout=5
).decode().strip()
result["branch"] = branch_out
# Porcelain status
status_out = subprocess.check_output(
["git", "status", "--porcelain=v1", "-u"],
cwd=cwd, stderr=subprocess.DEVNULL, timeout=5
).decode()
staged = 0
unstaged = 0
untracked = 0
for line in status_out.splitlines():
if len(line) < 2:
continue
x, y = line[0], line[1]
if x == "?" and y == "?":
untracked += 1
else:
if x != " " and x != "?":
staged += 1
if y != " " and y != "?":
unstaged += 1
result["staged"] = staged
result["unstaged"] = unstaged
result["untracked"] = untracked
result["dirty"] = (staged + unstaged + untracked) > 0
# Ahead/behind upstream
try:
rev_out = subprocess.check_output(
["git", "rev-list", "--left-right", "--count", "@{upstream}...HEAD"],
cwd=cwd, stderr=subprocess.DEVNULL, timeout=5
).decode().strip()
behind_str, ahead_str = rev_out.split()
result["ahead"] = int(ahead_str)
result["behind"] = int(behind_str)
except Exception:
pass # No upstream — that's fine
except subprocess.CalledProcessError as e:
result["error"] = f"git error: {e}"
except FileNotFoundError:
result["error"] = "git not found"
except Exception as e:
result["error"] = str(e)
return result
def load_sessions() -> list:
"""Load sessions from the session store."""
if SESSION_STORE_PATH.exists():
try:
return json.loads(SESSION_STORE_PATH.read_text())
except Exception:
return []
return []
def save_sessions(sessions: list) -> None:
"""Persist sessions to disk."""
SESSION_STORE_PATH.write_text(json.dumps(sessions, indent=2))
class PTYSession:
"""Manages a single PTY subprocess."""
def __init__(self, cols: int = 80, rows: int = 24, cwd: str = None):
self.cols = cols
self.rows = rows
self.cwd = cwd or str(NEXUS_ROOT)
self.master_fd: int = None
self.pid: int = None
self._reader: asyncio.StreamReader = None
self._closed = False
def spawn(self):
"""Fork a shell into a PTY."""
pid, master_fd = pty.fork()
if pid == 0:
# Child — exec the shell
os.chdir(self.cwd)
env = os.environ.copy()
env["TERM"] = "xterm-256color"
env["COLUMNS"] = str(self.cols)
env["LINES"] = str(self.rows)
os.execvpe(SHELL, [SHELL], env)
else:
self.pid = pid
self.master_fd = master_fd
self._resize(self.cols, self.rows)
def _resize(self, cols: int, rows: int):
self.cols = cols
self.rows = rows
if self.master_fd is not None:
try:
winsize = struct.pack("HHHH", rows, cols, 0, 0)
fcntl.ioctl(self.master_fd, termios.TIOCSWINSZ, winsize)
except OSError:
pass
def write_input(self, data: bytes):
"""Write raw bytes to the PTY master (stdin of the shell)."""
if self.master_fd is not None:
try:
os.write(self.master_fd, data)
except OSError:
pass
def read_output(self, timeout: float = 0.02) -> bytes:
"""Non-blocking read from PTY master (stdout of the shell)."""
if self.master_fd is None:
return b""
try:
r, _, _ = select.select([self.master_fd], [], [], timeout)
if r:
return os.read(self.master_fd, 4096)
except OSError:
pass
return b""
def close(self):
if self._closed:
return
self._closed = True
if self.pid:
try:
os.kill(self.pid, signal.SIGTERM)
except ProcessLookupError:
pass
if self.master_fd is not None:
try:
os.close(self.master_fd)
except OSError:
pass
async def handle_client(websocket: websockets.WebSocketServerProtocol):
"""Handle one browser connection: PTY relay + git status + session management."""
addr = websocket.remote_address
logger.info(f"Cockpit client connected from {addr}")
pty_session: PTYSession = None
pty_task: asyncio.Task = None
async def pty_output_loop():
"""Read PTY output in a loop and forward to browser."""
loop = asyncio.get_event_loop()
while True:
if pty_session is None or pty_session._closed:
break
# Run blocking read in executor
data = await loop.run_in_executor(None, pty_session.read_output)
if data:
try:
await websocket.send(json.dumps({
"type": "pty_output",
"data": base64.b64encode(data).decode(),
}))
except websockets.exceptions.ConnectionClosed:
break
else:
# Check if child has exited
if pty_session.pid:
try:
result = os.waitpid(pty_session.pid, os.WNOHANG)
if result[0] != 0:
code = result[1] >> 8
try:
await websocket.send(json.dumps({
"type": "pty_exit",
"code": code,
}))
except Exception:
pass
break
except ChildProcessError:
break
await asyncio.sleep(0.01)
try:
async for raw_msg in websocket:
try:
msg = json.loads(raw_msg)
except json.JSONDecodeError:
await websocket.send(json.dumps({"type": "error", "message": "Invalid JSON"}))
continue
msg_type = msg.get("type")
if msg_type == "pty_start":
cols = int(msg.get("cols", 80))
rows = int(msg.get("rows", 24))
cwd = msg.get("cwd", str(NEXUS_ROOT))
if pty_session:
pty_session.close()
if pty_task:
pty_task.cancel()
pty_session = PTYSession(cols=cols, rows=rows, cwd=cwd)
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, pty_session.spawn)
pty_task = asyncio.create_task(pty_output_loop())
logger.info(f"PTY session started: pid={pty_session.pid} cols={cols} rows={rows}")
elif msg_type == "pty_input":
if pty_session:
raw = base64.b64decode(msg.get("data", ""))
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, pty_session.write_input, raw)
elif msg_type == "pty_resize":
if pty_session:
cols = int(msg.get("cols", 80))
rows = int(msg.get("rows", 24))
pty_session._resize(cols, rows)
elif msg_type == "git_status":
status = await asyncio.get_event_loop().run_in_executor(
None, get_git_status, NEXUS_ROOT
)
status["type"] = "git_status"
await websocket.send(json.dumps(status))
elif msg_type == "session_save":
sessions = msg.get("sessions", [])
await asyncio.get_event_loop().run_in_executor(
None, save_sessions, sessions
)
await websocket.send(json.dumps({"type": "session_saved", "count": len(sessions)}))
elif msg_type == "session_load":
sessions = await asyncio.get_event_loop().run_in_executor(
None, load_sessions
)
await websocket.send(json.dumps({"type": "session_data", "sessions": sessions}))
else:
await websocket.send(json.dumps({"type": "error", "message": f"Unknown type: {msg_type}"}))
except websockets.exceptions.ConnectionClosed:
logger.info(f"Cockpit client disconnected {addr}")
except Exception as e:
logger.error(f"Cockpit handler error for {addr}: {e}")
finally:
if pty_task:
pty_task.cancel()
if pty_session:
pty_session.close()
logger.info(f"Cockpit session cleaned up for {addr}")
async def main():
logger.info(f"Starting Nexus Cockpit PTY server on ws://{HOST}:{PORT}")
logger.info(f"Shell: {SHELL}")
logger.info(f"Nexus root: {NEXUS_ROOT}")
logger.info(f"Session store: {SESSION_STORE_PATH}")
stop = asyncio.get_event_loop().create_future()
def shutdown():
if not stop.done():
stop.set_result(None)
loop = asyncio.get_event_loop()
for sig in (signal.SIGINT, signal.SIGTERM):
try:
loop.add_signal_handler(sig, shutdown)
except NotImplementedError:
pass
async with websockets.serve(handle_client, HOST, PORT):
logger.info("Cockpit PTY server ready.")
await stop
logger.info("Cockpit PTY server shutdown complete.")
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -1,88 +0,0 @@
# ADR-001: Cockpit Shell Boundary and Transport Model
**Date:** 2026-04-22
**Status:** Accepted
---
## Context
The Nexus operator cockpit requires a real shell/terminal accessible from the browser UI. The cockpit is a local-first operator tool for managing and observing Timmy, an AI agent running on the operator's machine. The existing Nexus infrastructure consists of:
- `server.py` — Python WebSocket bridge on `ws://127.0.0.1:8765` serving as the main nexus broadcast bus (agent telemetry, portal state, heartbeat)
- `app.js` + `index.html` — Three.js frontend rendering the 3D world and operator HUD
A shell terminal in the cockpit would let the operator issue commands, tail logs, and interact with Timmy's runtime without leaving the browser UI.
### Options Considered
**Option 1: Native/local PTY via Python `pty` module**
Spawn a local shell process (e.g. `bash` or `zsh`) using Python's stdlib `pty` module. Stream PTY I/O over a dedicated WebSocket. Render in the browser with xterm.js.
**Option 2: Remote SSH PTY**
SSH from a backend process into localhost (or a remote host) and relay the SSH PTY stream over WebSocket.
**Option 3: Browser-only pseudo-terminal**
Implement a fake shell in JavaScript with no real process backing it — command parsing and output simulation only.
---
## Decision
**Adopt Option 1: Native/local PTY via Python `pty` module, rendered with xterm.js over a dedicated WebSocket at `ws://127.0.0.1:8766`.**
The PTY server (`cockpit_pty.py`) runs as a sidecar alongside `server.py`. It is intentionally kept on a separate port (8766) to avoid coupling shell I/O to the main nexus broadcast bus (8765). The browser-side terminal is rendered with xterm.js, loaded from CDN.
### Rationale
- **Local-first architecture match.** Nexus is explicitly a local-first tool. The operator is always on the same machine as the agent. There is no need for remote shell infrastructure.
- **Zero external dependencies for the backend.** Python's `pty` module is part of the standard library. No third-party process manager, SSH daemon, or shell relay binary is required.
- **Separation of concerns.** Keeping shell I/O on a dedicated WebSocket (8766) prevents noisy PTY data from polluting the telemetry/broadcast bus (8765). Each WebSocket has a single clear responsibility.
- **xterm.js is the canonical browser terminal renderer.** It handles ANSI escape sequences, resize events, and scrollback correctly. Loading from CDN is appropriate for a local operator tool with no offline-first requirement for the terminal component specifically.
- **Precedent from Atlas UI sources.** The terminal panel split-view pattern was mined from `dodo-reach/hermes-desktop`, which demonstrated that xterm.js + WebSocket relay is a proven, low-friction approach for agent management UIs.
---
## Consequences
### Positive
- Real shell access from the browser cockpit with minimal infrastructure.
- The PTY is a genuine OS-level shell; any CLI tool available on the operator's machine works as expected.
- The main nexus bus remains clean and single-purpose.
- xterm.js handles terminal emulation correctly without bespoke ANSI parsing code.
### Negative / Constraints
- A second server process (`cockpit_pty.py`) must be started alongside `server.py`. Operators running Nexus locally must start both, or a launcher script must manage both processes.
- The shell terminal is **only accessible when running locally.** This is intentional — the cockpit is a local operator tool, not a remote management surface. No remote access is provided by design.
- xterm.js is loaded from CDN. If the operator has no internet access at startup, the terminal panel will not render. (This is acceptable given the local-first context; the 3D world and agent telemetry do not depend on xterm.js.)
- `cockpit_pty.py` is a trusted-localhost-only server. It must not be exposed beyond `127.0.0.1`. No auth is implemented; the local-only bind address is the security boundary.
---
## Rejected Alternatives
### Option 2: SSH PTY
Rejected. SSH introduces key management overhead (keygen, authorized_keys, known_hosts) that is entirely unnecessary for a local-only tool. Connecting to localhost via SSH to relay a PTY that is already local adds latency and complexity with no benefit. Python `pty` does the same job from a single stdlib import.
### Option 3: Browser-only pseudo-terminal
Rejected. A fake shell with no real process backing it does not provide genuine shell access. The operator cockpit requires the ability to run real commands — tail logs, inspect files, restart processes — not a simulated command interface.
### ttyd / wetty
Rejected. Both are capable tools, but they are heavy external dependencies (Go binary, Node.js process) for a problem that Python's stdlib `pty` module solves directly. Introducing an external binary creates installation friction and diverges from the "minimal sidecar" model appropriate for a local operator tool.
---
## References
- `server.py` — main nexus WebSocket bridge (port 8765)
- `cockpit_pty.py` — PTY sidecar (port 8766, to be implemented)
- Atlas UI sources mined during cockpit design:
- `dodo-reach/hermes-desktop` — terminal panel UX and xterm.js integration pattern
- `outsourc-e/hermes-workspace` — inspector rail layout
- `nesquena/hermes-webui` — session taxonomy primitives
- `ATLAS_COCKPIT_PATTERNS.md` — detailed record of adopted, adapted, and rejected patterns from the above sources

View File

@@ -1,129 +0,0 @@
# ATLAS_COCKPIT_PATTERNS.md
## Nexus Operator Cockpit — Atlas UI Pattern Audit
This document catalogs which UI/UX patterns were adopted, adapted, or rejected from three Atlas source repositories when designing the Nexus operator cockpit. The Nexus cockpit is a browser-based operator terminal for managing and observing Timmy, a local-first AI agent. Its architecture is documented in `ADR-cockpit-shell-boundary.md`.
---
## Source Repositories
| Repo | Description |
|---|---|
| `dodo-reach/hermes-desktop` | Desktop Electron app for Hermes agent management |
| `outsourc-e/hermes-workspace` | Workspace management UI with inspector rails |
| `nesquena/hermes-webui` | Web UI for Hermes with session management |
---
## dodo-reach/hermes-desktop
### What Was Audited
- Terminal panel implementation (xterm.js integration, shell spawning via Electron IPC)
- Agent status indicator components in the sidebar
- Split-view panel layout (terminal pane + agent detail pane)
- Native OS notification hooks
- Electron main/renderer IPC patterns for shell relay
### Adopted
**Terminal panel split-view layout**
The resizable split-panel layout pairing a terminal pane with a detail/inspector pane was adopted directly. Nexus uses the same conceptual arrangement: xterm.js fills one panel, agent telemetry fills the adjacent panel. The resize handle behavior and panel persistence are modeled on hermes-desktop's approach. The underlying mechanism differs (WebSocket PTY vs. Electron IPC) but the visual model is the same.
**Agent status indicators in the inspector rail**
The health dot + status label pattern — a colored dot (green/amber/red) paired with a short text label — was adopted for Nexus's agent status display. hermes-desktop demonstrated this as a scannable, low-noise way to convey agent health at a glance without requiring the operator to parse log output.
### Adapted
_(none — the above adoptions were clean lifts; hermes-desktop patterns that didn't fit Nexus were rejected rather than adapted)_
### Rejected
**Electron-specific IPC for shell access**
hermes-desktop uses Electron's `ipcMain`/`ipcRenderer` bridge to relay PTY I/O between the Node.js main process and the browser renderer. This is Electron-specific infrastructure. Nexus runs in a plain browser, not Electron. The PTY relay is instead handled by `cockpit_pty.py` over WebSocket (see ADR-001). The outcome is the same; the transport is different.
**Native OS notifications**
hermes-desktop hooks into the OS notification system (via Electron's `Notification` API) to surface agent events when the window is backgrounded. Nexus does not use this. The cockpit is a focused local operator tool; the operator is expected to be watching the UI. Backgrounded notifications add complexity without a clear benefit in the Nexus usage model.
---
## outsourc-e/hermes-workspace
### What Was Audited
- Inspector right rail with tabbed section navigation
- Git status badge and repository state display
- Multi-workspace switcher and workspace metadata
- Cloud sync hooks and session persistence model
- File browser panel integration
### Adapted
**Inspector right rail with tabbed sections**
hermes-workspace uses a right inspector rail with six tabs (Overview, Files, Git, Environment, Connections, Settings). This was adapted for Nexus: simplified to four tabs — SESSION, FILES, MEMORY, AGENT — reflecting that Nexus is 3D-world-first, not workspace-first. Tabs specific to multi-workspace management and cloud connections were removed. The tab rail layout itself (fixed-width right panel, icon + label tabs, scrollable tab body) is retained.
**Git status badge**
hermes-workspace renders a full inline diff view in the Git tab, including staged/unstaged file lists and diff hunks. This was adapted for Nexus: the git state display is reduced to a branch name + dirty flag indicator (`main *` or `main`). The Nexus cockpit is not a git client; operators who need full diff inspection use their normal tools. The branch/dirty indicator is sufficient for the cockpit context.
### Rejected
**Cloud sync for sessions**
hermes-workspace syncs session state to a remote store. Nexus is local-first by design. Session state lives on disk locally. No cloud sync infrastructure is introduced.
**Multi-workspace concept**
hermes-workspace is built around switching between multiple named workspaces. Nexus has a single sovereign workspace: the local Nexus installation. The workspace switcher, workspace metadata, and associated navigation are not applicable.
---
## nesquena/hermes-webui
### What Was Audited
- Session list sidebar and session lifecycle management
- Session taxonomy model (grouping, tagging, pinning, archiving)
- OAuth login flow and user account management
- User account switcher and multi-user session isolation
### Adopted
**Session taxonomy model**
hermes-webui treats group, tag, pin, and archive as first-class session primitives with explicit UI affordances for each. This taxonomy was adopted for the Nexus session list. Sessions (Timmy interaction threads) can be grouped by context, tagged with free-form labels, pinned to the top of the list, and archived out of the active view. The four-primitive model maps cleanly to the Nexus usage pattern without modification.
**Session list sidebar pattern**
The session list sidebar layout — fixed-width left panel, chronological session list with inline metadata (last active, tag chips, pin indicator), and a new-session affordance at the top — was adopted. hermes-webui demonstrated this as an effective pattern for navigating a growing list of agent sessions. Nexus uses the same structural layout.
### Rejected
**OAuth / login flow**
hermes-webui implements a full OAuth login flow (provider selection, token exchange, session persistence tied to user identity). Nexus is a single-operator local tool. There is no authentication layer. The operator is the machine owner; the local-only bind addresses on `server.py` and `cockpit_pty.py` are the security boundary. No login flow is implemented or needed.
**User accounts and multi-user session isolation**
hermes-webui supports multiple user accounts with isolated session namespaces. Nexus operates under a single-operator model. Session ownership, user switching, and per-user isolation are not applicable concepts. All sessions belong to the local operator.
---
## Summary Table
| Pattern | Source | Decision | Notes |
|---|---|---|---|
| Terminal panel split-view layout | hermes-desktop | Adopted | xterm.js + resizable split panel |
| Agent status health dot + label | hermes-desktop | Adopted | In inspector rail |
| Electron IPC for shell relay | hermes-desktop | Rejected | Nexus uses WebSocket PTY |
| Native OS notifications | hermes-desktop | Rejected | Not needed for local cockpit |
| Inspector right rail (tabbed) | hermes-workspace | Adapted | 6 tabs → 4 (SESSION, FILES, MEMORY, AGENT) |
| Git status display | hermes-workspace | Adapted | Full diff → branch + dirty flag only |
| Cloud sync for sessions | hermes-workspace | Rejected | Local-first only |
| Multi-workspace switcher | hermes-workspace | Rejected | Single sovereign workspace |
| Session taxonomy (group/tag/pin/archive) | hermes-webui | Adopted | Direct lift |
| Session list sidebar | hermes-webui | Adopted | Direct lift |
| OAuth / login flow | hermes-webui | Rejected | No auth needed; local-only tool |
| User accounts / multi-user isolation | hermes-webui | Rejected | Single-operator model |
---
## See Also
- `ADR-cockpit-shell-boundary.md` — transport and shell boundary decision
- `server.py` — main nexus WebSocket bridge (port 8765)
- `cockpit_pty.py` — PTY sidecar (port 8766)

View File

@@ -0,0 +1,189 @@
# Preventing Rubber-Stamping of PRs
**Issue:** #1445 - process: Prevent rubber-stamping of PRs with no changes
**Problem:** PRs with no changes (zombie PRs) are being approved without actual review
## What is Rubber-Stamping?
Rubber-stamping occurs when:
1. A PR has 0 additions, 0 deletions, and 0 files changed (zombie PR)
2. Reviewers approve the PR without noticing it has no changes
3. The PR gets merged despite adding no value
This is a serious process issue because:
- It wastes reviewer time
- It creates false sense of review quality
- It allows zombie PRs to appear reviewed
- It clutters the PR backlog
## Prevention Measures
### 1. CI Check (`.gitea/workflows/check-pr-changes.yml`)
Automated check that runs on every PR:
- Detects PRs with no changes
- Blocks merge if PR is a zombie
- Provides clear error messages
**What it checks:**
- PR has additions, deletions, or file changes
- Commits contain actual changes
- No empty diffs
**When it runs:**
- On PR open
- On PR synchronize (new commits)
- On PR reopen
### 2. PR Template (`.github/PULL_REQUEST_TEMPLATE.md`)
Updated PR template with reviewer guidelines:
- Clear checklist for reviewers
- Explicit instructions to check for changes
- Warning against rubber-stamping
**Reviewer requirements:**
1. Verify PR has actual changes
2. Changes match description
3. Code quality review
4. Tests are adequate
5. Documentation is updated
### 3. Zombie PR Detection Script (`bin/check_zombie_prs.py`)
Script to scan for zombie PRs:
- Check all open PRs in repositories
- Identify PRs with no changes
- Generate reports
**Usage:**
```bash
# Scan all repositories
python bin/check_zombie_prs.py
# Scan specific repositories
python bin/check_zombie_prs.py --repos the-nexus timmy-home
# Generate report
python bin/check_zombie_prs.py --report
# JSON output
python bin/check_zombie_prs.py --json
```
## How to Use
### For CI/CD
The workflow runs automatically on all PRs. No setup needed.
### For Developers
1. **Before creating PR:**
- Ensure you have actual changes
- Test your changes locally
- Don't create PRs with no changes
2. **When reviewing PRs:**
- Check that PR has additions, deletions, or file changes
- Verify changes match the PR description
- Don't approve PRs with no changes
3. **If you find a zombie PR:**
- Add a comment explaining it has no changes
- Request changes or close the PR
- Don't approve it
### For Agents (AI Workers)
Before creating a PR:
```bash
# Check if you have changes
git status
git diff --stat
# If no changes, don't create PR
# If changes exist, create PR
```
## Examples
### Zombie PR Detected
```
❌ ERROR: PR has no changes!
This PR has 0 additions, 0 deletions, and 0 files changed.
This is a 'zombie PR' that should not be merged.
Rubber-stamping (approving PRs with no changes) is prohibited.
Reviewers must verify that PRs contain actual changes.
If this is a mistake, please add actual changes to the PR.
If this PR is not needed, please close it.
```
### Valid PR
```
✅ PR has changes:
README.md | 10 ++++++++++
1 file changed, 10 insertions(+)
Summary:
Files changed: 1
Additions: 10
Deletions: 0
```
## Related Issues
- **Issue #1127:** Perplexity Evening Pass triage (identified rubber-stamping)
- **Issue #1445:** This implementation
- **PR #359:** Example of rubber-stamping (3 approvals on empty PR)
## Prevention Strategy
### 1. **Automated Checks**
- CI workflow blocks zombie PRs
- Pre-commit hooks validate changes
- Automated scanning for zombie PRs
### 2. **Process Guidelines**
- Updated PR template with reviewer guidelines
- Clear instructions to check for changes
- Training on rubber-stamping prevention
### 3. **Monitoring**
- Regular scans for zombie PRs
- Reports on rubber-stamping incidents
- Continuous improvement of prevention measures
## Files Added
1. `.gitea/workflows/check-pr-changes.yml` - CI workflow
2. `.github/PULL_REQUEST_TEMPLATE.md` - Updated PR template
3. `bin/check_zombie_prs.py` - Zombie PR detection script
4. `docs/rubber-stamping-prevention.md` - This documentation
## Testing
Test the CI workflow:
```bash
# Create a test PR with no changes
git checkout -b test/zombie-pr
git commit --allow-empty -m "test: empty commit"
git push origin test/zombie-pr
# Create PR and watch CI fail
```
Test the detection script:
```bash
python bin/check_zombie_prs.py --repos the-nexus --report
```
## Conclusion
This implementation provides comprehensive protection against rubber-stamping:
1. **Automated CI checks** block zombie PRs
2. **Updated PR template** guides reviewers
3. **Detection script** identifies existing zombie PRs
4. **Documentation** explains the problem and solution
**Result:** No more rubber-stamping of PRs with no changes.
## License
Part of the Timmy Foundation project.

View File

@@ -23,7 +23,6 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&family=Orbitron:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="./style.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css">
<link rel="manifest" href="./manifest.json">
<script type="importmap">
{
@@ -166,18 +165,6 @@
<!-- Top Right: Agent Log, Atlas & SOUL Toggle -->
<div class="hud-top-right">
<!-- Cockpit toolbar: git status + inspector + terminal toggles -->
<div class="cockpit-toolbar">
<div id="cockpit-git-status" title="Git workspace status"></div>
<button id="cockpit-inspector-toggle" class="cockpit-icon-btn" title="Toggle operator inspector rail">
<span></span>
<span>INSPECT</span>
</button>
<button id="cockpit-terminal-toggle" class="cockpit-icon-btn" title="Toggle operator terminal">
<span></span>
<span>SHELL</span>
</button>
</div>
<button id="atlas-toggle-btn" class="hud-icon-btn" title="World Directory">
<button id="soul-toggle-btn" class="hud-icon-btn" title="Timmy's SOUL">
<span class="hud-icon"></span>
@@ -407,35 +394,9 @@
<div id="memory-inspect-panel" class="memory-inspect-panel" style="display:none;" aria-label="Memory Inspect Panel"></div>
<div id="memory-connections-panel" class="memory-connections-panel" style="display:none;" aria-label="Memory Connections Panel"></div>
<!-- ══════════════════════════════════════════════════════════════
COCKPIT — Operator Inspector Rail (issue #1695)
Pattern sources: dodo-reach/hermes-desktop, outsourc-e/hermes-workspace,
nesquena/hermes-webui
See: docs/ATLAS_COCKPIT_PATTERNS.md, docs/ADR-cockpit-shell-boundary.md
══════════════════════════════════════════════════════════════ -->
<!-- Inspector / Right Rail -->
<div id="cockpit-inspector-rail" aria-label="Operator Inspector Rail" role="complementary"></div>
<!-- Terminal Panel (slide-up from bottom) -->
<div id="cockpit-terminal-panel" aria-label="Operator Shell Terminal">
<div class="terminal-panel-header">
<span class="terminal-panel-title">⌨ OPERATOR SHELL — native PTY via cockpit_pty.py</span>
<button id="cockpit-terminal-start" class="terminal-panel-btn" title="Start shell session">▶ Start</button>
<button class="terminal-panel-close" onclick="Cockpit.hideTerminal()" title="Close terminal" aria-label="Close terminal"></button>
</div>
<div id="cockpit-terminal-body"></div>
</div>
<!-- xterm.js + FitAddon (CDN, local-first — no npm required) -->
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
<script src="./boot.js"></script>
<script src="./cockpit.js"></script>
<script src="./avatar-customization.js"></script>
<script src="./lod-system.js"></script>
<script src="./portal-hot-reload.js"></script>
<script>
function openMemoryFilter() { renderFilterList(); document.getElementById('memory-filter').style.display = 'flex'; }
function closeMemoryFilter() { document.getElementById('memory-filter').style.display = 'none'; }

View File

@@ -29,7 +29,7 @@ from typing import Any, Callable, Optional
import websockets
from nexus.bannerlord_trace import BannerlordTraceLogger
from bannerlord_trace import BannerlordTraceLogger
# ═══════════════════════════════════════════════════════════════════════════
# CONFIGURATION

View File

@@ -304,43 +304,6 @@ async def inject_event(event_type: str, ws_url: str, **kwargs):
sys.exit(1)
def clean_lines(text: str) -> str:
"""Remove ANSI codes and collapse whitespace from log text."""
import re
text = strip_ansi(text)
text = re.sub(r'\s+', ' ', text).strip()
return text
def normalize_event(event: dict) -> dict:
"""Normalize an Evennia event dict to standard format."""
return {
"type": event.get("type", "unknown"),
"actor": event.get("actor", event.get("name", "")),
"room": event.get("room", event.get("location", "")),
"message": event.get("message", event.get("text", "")),
"timestamp": event.get("timestamp", ""),
}
def parse_room_output(text: str) -> dict:
"""Parse Evennia room output into structured data."""
import re
lines = text.strip().split("\n")
result = {"name": "", "description": "", "exits": [], "objects": []}
if lines:
result["name"] = strip_ansi(lines[0]).strip()
if len(lines) > 1:
result["description"] = strip_ansi(lines[1]).strip()
for line in lines[2:]:
line = strip_ansi(line).strip()
if line.startswith("Exits:"):
result["exits"] = [e.strip() for e in line[6:].split(",") if e.strip()]
elif line.startswith("You see:"):
result["objects"] = [o.strip() for o in line[8:].split(",") if o.strip()]
return result
def main():
parser = argparse.ArgumentParser(description="Evennia -> Nexus WebSocket Bridge")
sub = parser.add_subparsers(dest="mode")

View File

@@ -1,105 +0,0 @@
/**
* Portal Hot-Reload for The Nexus
*
* Watches portals.json for changes and hot-reloads portal list
* without server restart. Existing connections unaffected.
*
* Usage:
* PortalHotReload.start(intervalMs);
* PortalHotReload.stop();
* PortalHotReload.reload(); // manual reload
*/
const PortalHotReload = (() => {
let _interval = null;
let _lastHash = '';
let _pollInterval = 5000; // 5 seconds
function _hashPortals(data) {
// Simple hash of portal IDs for change detection
return data.map(p => p.id || p.name).sort().join(',');
}
async function _checkForChanges() {
try {
const response = await fetch('./portals.json?t=' + Date.now());
if (!response.ok) return;
const data = await response.json();
const hash = _hashPortals(data);
if (hash !== _lastHash) {
console.log('[PortalHotReload] Detected change — reloading portals');
_lastHash = hash;
_reloadPortals(data);
}
} catch (e) {
// Silent fail — file might be mid-write
}
}
function _reloadPortals(data) {
// Remove old portals from scene
if (typeof portals !== 'undefined' && Array.isArray(portals)) {
portals.forEach(p => {
if (p.group && typeof scene !== 'undefined' && scene) {
scene.remove(p.group);
}
});
portals.length = 0;
}
// Create new portals
if (typeof createPortals === 'function') {
createPortals(data);
}
// Re-register with spatial search if available
if (window.SpatialSearch && typeof portals !== 'undefined') {
portals.forEach(p => {
if (p.config && p.config.name && p.group) {
SpatialSearch.register('portal', p, p.config.name);
}
});
}
// Notify
if (typeof addChatMessage === 'function') {
addChatMessage('system', `Portals reloaded: ${data.length} portals active`);
}
console.log(`[PortalHotReload] Reloaded ${data.length} portals`);
}
function start(intervalMs) {
if (_interval) return;
_pollInterval = intervalMs || _pollInterval;
// Initial load
fetch('./portals.json').then(r => r.json()).then(data => {
_lastHash = _hashPortals(data);
}).catch(() => {});
_interval = setInterval(_checkForChanges, _pollInterval);
console.log(`[PortalHotReload] Watching portals.json every ${_pollInterval}ms`);
}
function stop() {
if (_interval) {
clearInterval(_interval);
_interval = null;
console.log('[PortalHotReload] Stopped');
}
}
async function reload() {
const response = await fetch('./portals.json?t=' + Date.now());
const data = await response.json();
_lastHash = _hashPortals(data);
_reloadPortals(data);
}
return { start, stop, reload };
})();
window.PortalHotReload = PortalHotReload;

486
style.css
View File

@@ -2928,493 +2928,9 @@ body.operator-mode #mode-label {
.reasoning-trace {
width: 280px;
}
.trace-content {
max-height: 200px;
}
}
/* ═══════════════════════════════════════════════════════════════════════════
COCKPIT — Operator Inspector Rail, Git Status, Terminal Panel
Issue #1695 — Atlas cockpit patterns
═══════════════════════════════════════════════════════════════════════════ */
/* ── Cockpit toolbar buttons (placed in hud-top-right) ─────────────────── */
.cockpit-toolbar {
display: flex;
align-items: center;
gap: var(--space-2);
margin-left: var(--space-2);
}
.cockpit-icon-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--panel-radius);
color: var(--color-text-muted);
font-family: var(--font-body);
font-size: var(--text-xs);
cursor: pointer;
transition: border-color var(--transition-ui), color var(--transition-ui), background var(--transition-ui);
}
.cockpit-icon-btn:hover {
border-color: var(--color-border-bright);
color: var(--color-primary);
background: rgba(74, 240, 192, 0.06);
}
.cockpit-icon-btn.active {
border-color: var(--color-primary);
color: var(--color-primary);
background: rgba(74, 240, 192, 0.1);
}
/* ── Git status badge ───────────────────────────────────────────────────── */
#cockpit-git-status {
display: inline-flex;
align-items: center;
font-family: var(--font-body);
font-size: var(--text-xs);
}
.git-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
border-radius: 4px;
border: 1px solid;
font-size: var(--text-xs);
white-space: nowrap;
}
.git-badge--clean {
border-color: rgba(74, 240, 192, 0.35);
color: var(--color-primary);
background: rgba(74, 240, 192, 0.06);
}
.git-badge--dirty {
border-color: rgba(255, 170, 34, 0.4);
color: var(--color-warning);
background: rgba(255, 170, 34, 0.07);
}
.git-badge--error {
border-color: rgba(255, 68, 102, 0.4);
color: var(--color-danger);
background: rgba(255, 68, 102, 0.07);
}
.git-branch-icon { opacity: 0.7; font-size: 12px; }
.git-branch-name { font-weight: 500; letter-spacing: 0.03em; }
.git-dirty-badge {
font-size: 10px;
color: var(--color-warning);
font-weight: 600;
}
.git-clean-badge { font-size: 10px; color: var(--color-primary); }
.git-ahead-behind { font-size: 10px; opacity: 0.7; margin-left: 2px; }
/* ── Inspector Rail ─────────────────────────────────────────────────────── */
#cockpit-inspector-rail {
position: fixed;
top: 0;
right: -320px; /* hidden off-screen */
width: 300px;
height: 100vh;
background: rgba(8, 12, 28, 0.96);
border-left: 1px solid var(--color-border);
backdrop-filter: blur(var(--panel-blur));
display: flex;
flex-direction: column;
z-index: 900;
transition: right var(--transition-ui);
font-size: var(--text-sm);
}
#cockpit-inspector-rail.rail--visible {
right: 0;
}
.rail-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
.rail-tabs {
display: flex;
gap: 2px;
}
.rail-tab {
padding: 4px 10px;
border: 1px solid transparent;
border-radius: 4px;
background: none;
color: var(--color-text-muted);
font-family: var(--font-body);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.06em;
cursor: pointer;
transition: color var(--transition-ui), border-color var(--transition-ui), background var(--transition-ui);
}
.rail-tab:hover { color: var(--color-text); }
.rail-tab.active {
border-color: var(--color-border-bright);
color: var(--color-primary);
background: rgba(74, 240, 192, 0.07);
}
.rail-close-btn {
background: none;
border: none;
color: var(--color-text-muted);
cursor: pointer;
font-size: 14px;
padding: 2px 6px;
transition: color var(--transition-ui);
}
.rail-close-btn:hover { color: var(--color-danger); }
.rail-body {
flex: 1;
overflow-y: auto;
padding: var(--space-3);
scrollbar-width: thin;
scrollbar-color: var(--color-border) transparent;
}
.rail-pane { animation: fade-in 0.15s ease; }
@keyframes fade-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
.rail-section {
margin-bottom: var(--space-4);
}
.rail-section-header {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
color: var(--color-text-muted);
text-transform: uppercase;
margin-bottom: var(--space-2);
padding-bottom: 4px;
border-bottom: 1px solid var(--color-border);
}
.rail-action-btn {
padding: 2px 8px;
background: rgba(74, 240, 192, 0.08);
border: 1px solid var(--color-border);
border-radius: 4px;
color: var(--color-primary);
font-family: var(--font-body);
font-size: 10px;
cursor: pointer;
transition: background var(--transition-ui), border-color var(--transition-ui);
}
.rail-action-btn:hover {
background: rgba(74, 240, 192, 0.16);
border-color: var(--color-border-bright);
}
.rail-empty {
font-size: var(--text-xs);
color: var(--color-text-muted);
font-style: italic;
padding: var(--space-2) 0;
line-height: 1.5;
}
.rail-empty--hint { opacity: 0.7; }
/* ── Session cards ──────────────────────────────────────────────────────── */
.sess-card {
background: rgba(255,255,255,0.02);
border: 1px solid var(--color-border);
border-radius: 6px;
padding: var(--space-2) var(--space-3);
margin-bottom: 6px;
transition: border-color var(--transition-ui), background var(--transition-ui);
}
.sess-card:hover { border-color: rgba(74,240,192,0.3); background: rgba(74,240,192,0.03); }
.sess-card--pinned { border-color: rgba(74,240,192,0.25); background: rgba(74,240,192,0.04); }
.sess-card--archived { opacity: 0.6; }
.sess-card-name {
font-size: var(--text-sm);
font-weight: 500;
color: var(--color-text);
margin-bottom: 4px;
word-break: break-word;
}
.sess-card-meta {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 6px;
}
.sess-group-mini {
font-size: 10px;
padding: 1px 6px;
border-radius: 3px;
background: rgba(123, 92, 255, 0.15);
border: 1px solid rgba(123, 92, 255, 0.3);
color: var(--color-secondary);
}
.sess-tag-mini {
font-size: 10px;
padding: 1px 5px;
border-radius: 3px;
background: rgba(74,240,192,0.07);
border: 1px solid rgba(74,240,192,0.2);
color: var(--color-primary);
}
.sess-card-actions {
display: flex;
gap: 4px;
justify-content: flex-end;
}
.sess-act-btn {
padding: 2px 5px;
background: none;
border: 1px solid transparent;
border-radius: 3px;
color: var(--color-text-muted);
cursor: pointer;
font-size: 12px;
transition: color var(--transition-ui), border-color var(--transition-ui);
}
.sess-act-btn:hover { color: var(--color-primary); border-color: var(--color-border); }
.sess-group-label {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-muted);
margin: var(--space-2) 0 4px;
}
.sess-archive-details summary { cursor: pointer; }
.sess-archive-summary {
font-size: 11px;
color: var(--color-text-muted);
padding: 4px 0;
list-style: none;
}
.sess-group-tag, .sess-tag-badge {
display: inline-block;
margin: 2px;
padding: 2px 8px;
border-radius: 4px;
font-size: 10px;
cursor: pointer;
transition: background var(--transition-ui);
}
.sess-group-tag {
background: rgba(123,92,255,0.12);
border: 1px solid rgba(123,92,255,0.25);
color: var(--color-secondary);
}
.sess-tag-badge {
background: rgba(74,240,192,0.07);
border: 1px solid rgba(74,240,192,0.2);
color: var(--color-primary);
}
/* ── Artifacts ──────────────────────────────────────────────────────────── */
.rail-artifact-list { display: flex; flex-direction: column; gap: 4px; }
.rail-artifact-item {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 6px;
border-radius: 4px;
background: rgba(255,255,255,0.02);
border: 1px solid var(--color-border);
font-size: var(--text-xs);
}
.artifact-type-badge {
font-size: 10px;
padding: 1px 5px;
border-radius: 3px;
font-weight: 600;
text-transform: uppercase;
}
.artifact-type--file { background: rgba(74,240,192,0.1); color: var(--color-primary); }
.artifact-type--image { background: rgba(123,92,255,0.1); color: var(--color-secondary); }
.artifact-type--report { background: rgba(255,170,34,0.1); color: var(--color-warning); }
.artifact-type--code { background: rgba(255,68,102,0.1); color: var(--color-danger); }
.artifact-name { flex: 1; color: var(--color-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.artifact-ref { color: var(--color-primary); text-decoration: none; }
/* ── Memory ─────────────────────────────────────────────────────────────── */
.rail-mem-item {
display: flex;
flex-direction: column;
gap: 2px;
padding: 4px 6px;
border-left: 2px solid var(--color-secondary);
margin-bottom: 4px;
background: rgba(123,92,255,0.04);
}
.mem-key { font-size: 10px; font-weight: 600; color: var(--color-secondary); text-transform: uppercase; }
.mem-summary { font-size: var(--text-xs); color: var(--color-text-muted); }
/* ── Agent health ───────────────────────────────────────────────────────── */
.agent-health-card {
display: flex;
flex-direction: column;
gap: 6px;
padding: var(--space-2);
background: rgba(255,255,255,0.02);
border: 1px solid var(--color-border);
border-radius: 6px;
}
.agent-health-row {
display: flex;
align-items: center;
gap: 8px;
}
.agent-health-label {
flex: 1;
font-size: var(--text-xs);
color: var(--color-text-muted);
}
.agent-health-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.dot--ok { background: var(--color-primary); box-shadow: 0 0 4px var(--color-primary); }
.dot--warn { background: var(--color-warning); box-shadow: 0 0 4px var(--color-warning); }
.dot--unknown { background: var(--color-text-muted); }
.agent-health-status { font-size: 10px; color: var(--color-text-muted); min-width: 50px; }
.agent-session-info { display: flex; flex-direction: column; gap: 4px; }
.agent-info-row {
display: flex;
justify-content: space-between;
font-size: var(--text-xs);
color: var(--color-text-muted);
padding: 2px 0;
border-bottom: 1px solid rgba(255,255,255,0.04);
}
.agent-info-row span:last-child { color: var(--color-text); font-weight: 500; }
/* ── Terminal Panel ─────────────────────────────────────────────────────── */
#cockpit-terminal-panel {
position: fixed;
bottom: -100%;
left: 0;
right: 0;
height: 340px;
background: rgba(5, 8, 18, 0.97);
border-top: 1px solid var(--color-border);
backdrop-filter: blur(var(--panel-blur));
display: flex;
flex-direction: column;
z-index: 800;
transition: bottom var(--transition-ui);
}
#cockpit-terminal-panel.panel--visible {
bottom: 0;
}
.terminal-panel-header {
display: flex;
align-items: center;
gap: var(--space-2);
padding: 6px var(--space-3);
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
.terminal-panel-title {
flex: 1;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--color-text-muted);
}
.terminal-panel-btn {
padding: 2px 8px;
background: rgba(74,240,192,0.08);
border: 1px solid var(--color-border);
border-radius: 4px;
color: var(--color-primary);
font-family: var(--font-body);
font-size: 10px;
cursor: pointer;
transition: background var(--transition-ui);
}
.terminal-panel-btn:hover { background: rgba(74,240,192,0.16); }
.terminal-panel-close {
background: none;
border: none;
color: var(--color-text-muted);
cursor: pointer;
font-size: 14px;
padding: 2px 6px;
transition: color var(--transition-ui);
}
.terminal-panel-close:hover { color: var(--color-danger); }
#cockpit-terminal-body {
flex: 1;
overflow: hidden;
padding: var(--space-2);
}
.terminal-unavailable {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: var(--space-2);
color: var(--color-text-muted);
font-size: var(--text-sm);
}
.terminal-unavailable-icon { font-size: 24px; opacity: 0.5; }
/* ── xterm.js overrides ─────────────────────────────────────────────────── */
.xterm { height: 100% !important; }
.xterm-viewport { border-radius: 0; }
.xterm-screen { font-feature-settings: "liga" 0; }