From b6483dedbc41ba47c468a6f768315599a2cb3f3f Mon Sep 17 00:00:00 2001 From: Chris Nennemann Date: Tue, 3 Mar 2026 14:42:21 +0100 Subject: [PATCH] chore: ROADMAP Phase 8, parallel AI team script, docker and infra updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ROADMAP.md: add Phase 8 — Freifunk / Community Mesh Networking with F0-F8 checkboxes; F0-F2 marked complete - scripts/ai_team.py: rewrite to support asyncio.gather parallel agent runs; add --sprint flag with predefined work packages (audit, phase1-hardening, phase2-tests, phase1-infra, status); add --parallel for ad-hoc concurrent agent invocations; output written to logs/ai_team/_/.md - scripts/dev-shell.sh: convenience development shell helper - docker: update Dockerfiles for quicproquo rename and new server flags - .gitignore: add qpq-state artifacts (*.bin, *.session, *.pending.ks, *.convdb*) --- .gitignore | 9 + ROADMAP.md | 73 ++++ docker/Dockerfile | 8 +- docker/Dockerfile.chat-test | 8 +- scripts/ai_team.py | 797 ++++++++++++++++++++++++++++++++++++ scripts/chat-test.sh | 54 ++- scripts/dev-shell.sh | 336 +++++++++++++++ 7 files changed, 1255 insertions(+), 30 deletions(-) create mode 100755 scripts/ai_team.py create mode 100755 scripts/dev-shell.sh diff --git a/.gitignore b/.gitignore index aa1c206..e651bd5 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,13 @@ docs/book/ # Server/client runtime data — do not commit certs, keys, or DBs data/ *.der +*.pem +*.db +*.bin +*.ks +*.session +*.convdb +*.convdb-shm +*.convdb-wal +*.pending.ks qpq-server.toml diff --git a/ROADMAP.md b/ROADMAP.md index 69736f4..1389ddc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -352,6 +352,79 @@ Long-term vision for wide adoption. --- +## Phase 8 — Freifunk / Community Mesh Networking + +Make qpq a first-class citizen on decentralised, community-operated wireless +networks (Freifunk, BATMAN-adv/Babel routing, OpenWrt). Multiple qpq nodes form +a federated mesh; clients auto-discover nearby nodes via mDNS; the network +functions without any central infrastructure or internet uplink. + +### Architecture + +``` + Client A ─── mDNS discovery ──► nearby qpq node (LAN / mesh) + │ + Cap'n Proto federation + │ + remote qpq node (across mesh) +``` + +- [x] **F0 — Re-include `quicproquo-p2p` in workspace; fix ALPN strings** + - Moved `crates/quicproquo-p2p` from `exclude` back into `[workspace] members` + - Fixed ALPN `b"quicnprotochat/p2p/1"` → `b"quicproquo/p2p/1"` (breaking wire change) + - Fixed federation ALPN `b"qnpc-fed"` → `b"quicproquo/federation/1"` + - Feature-gated behind `--features mesh` on client (keeps iroh out of default builds) + +- [x] **F1 — Federation routing in message delivery** + - `handle_enqueue` and `handle_batch_enqueue` call `federation::routing::resolve_destination()` + - Recipients with a remote home server are relayed via `FederationClient::relay_enqueue()` + - mTLS mutual authentication between nodes (both present client certs, validated against shared CA) + - Config: `QPQ_FEDERATION_LISTEN`, `QPQ_LOCAL_DOMAIN`, `QPQ_FEDERATION_CERT/KEY/CA` + +- [x] **F2 — mDNS local peer discovery** + - Server announces `_quicproquo._udp.local.` on startup via `mdns-sd` + - Client: `MeshDiscovery::start()` browses for nearby nodes (feature-gated) + - REPL commands: `/mesh peers` (scan + list), `/mesh server ` (note address) + - Nodes announce: `ver=1`, `server=`, `domain=` TXT records + +- [ ] **F3 — Self-sovereign mesh identity** + - Keypair = identity; OPAQUE password auth becomes optional (opt-in for managed deployments) + - `--mesh` startup mode: no AS required, nodes accept any verifiable keypair + - Bootstrap trust via out-of-band key fingerprint exchange (QR code or short code) + +- [ ] **F4 — Store-and-forward with TTL** + - Add `ttl_secs: u32` to `Envelope` in `node.capnp` + - Relay nodes hold messages for offline peers up to TTL, then discard + - Gossip-style propagation: each hop decrements a hop counter + - Enables asynchronous messaging across intermittently connected mesh segments + +- [ ] **F5 — Lightweight broadcast channels** + - No MLS overhead; symmetric group key distributed out-of-band + - Gossip delivery: node broadcasts to all peers, peers re-broadcast once + - Loop prevention via bloom filter on seen message IDs + - Suitable for community bulletin boards, emergency broadcasts on mesh + +- [ ] **F6 — Extended `/mesh` REPL commands** + - `/mesh dm ` — direct message to peer by key fingerprint (P2P path) + - `/mesh broadcast ` — publish to a symmetric broadcast channel + - `/mesh auto` — auto-select server with lowest RTT from discovered peers + - Auto-reconnect: if current server unreachable, fall back to next discovered peer + +- [ ] **F7 — OpenWrt cross-compilation guide** + - Musl static builds: `x86_64-unknown-linux-musl`, `armv7-unknown-linux-musleabihf`, `mips-unknown-linux-musl` + - Strip binary: `--release` + `strip` → target size < 5 MB for flash storage + - `opkg` package manifest for OpenWrt feed + - `procd` init script + `uci` config file for OpenWrt integration + - CI job: cross-compile and size-check on every release tag + +- [ ] **F8 — Traffic analysis resistance for mesh** + - Uniform message padding to nearest 256-byte boundary (hides message size) + - Configurable decoy traffic rate (fake messages to mask send timing) + - Optional onion routing: 3-hop relay through other mesh nodes (no Tor dependency) + - Ref: Phase 7.7 for server-side traffic analysis resistance + +--- + ## Summary Timeline | Phase | Focus | Estimated Effort | diff --git a/docker/Dockerfile b/docker/Dockerfile index a3c0d2e..7aadd15 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -14,7 +14,7 @@ WORKDIR /build COPY Cargo.toml Cargo.lock ./ COPY crates/quicproquo-core/Cargo.toml crates/quicproquo-core/Cargo.toml COPY crates/quicproquo-proto/Cargo.toml crates/quicproquo-proto/Cargo.toml -COPY crates/qpq-server/Cargo.toml crates/qpq-server/Cargo.toml +COPY crates/quicproquo-server/Cargo.toml crates/quicproquo-server/Cargo.toml COPY crates/quicproquo-client/Cargo.toml crates/quicproquo-client/Cargo.toml COPY crates/quicproquo-p2p/Cargo.toml crates/quicproquo-p2p/Cargo.toml @@ -23,10 +23,10 @@ COPY crates/quicproquo-p2p/Cargo.toml crates/quicproquo-p2p/Cargo.toml RUN mkdir -p \ crates/quicproquo-core/src \ crates/quicproquo-proto/src \ - crates/qpq-server/src \ + crates/quicproquo-server/src \ crates/quicproquo-client/src \ crates/quicproquo-p2p/src \ - && echo 'fn main() {}' > crates/qpq-server/src/main.rs \ + && echo 'fn main() {}' > crates/quicproquo-server/src/main.rs \ && echo 'fn main() {}' > crates/quicproquo-client/src/main.rs \ && touch crates/quicproquo-core/src/lib.rs \ && touch crates/quicproquo-proto/src/lib.rs \ @@ -46,7 +46,7 @@ RUN touch \ crates/quicproquo-core/src/lib.rs \ crates/quicproquo-proto/src/lib.rs \ crates/quicproquo-p2p/src/lib.rs \ - crates/qpq-server/src/main.rs \ + crates/quicproquo-server/src/main.rs \ crates/quicproquo-client/src/main.rs RUN cargo build --release --bin qpq-server diff --git a/docker/Dockerfile.chat-test b/docker/Dockerfile.chat-test index 4470b41..2d1b3b2 100644 --- a/docker/Dockerfile.chat-test +++ b/docker/Dockerfile.chat-test @@ -14,7 +14,7 @@ WORKDIR /build COPY Cargo.toml Cargo.lock ./ COPY crates/quicproquo-core/Cargo.toml crates/quicproquo-core/Cargo.toml COPY crates/quicproquo-proto/Cargo.toml crates/quicproquo-proto/Cargo.toml -COPY crates/qpq-server/Cargo.toml crates/qpq-server/Cargo.toml +COPY crates/quicproquo-server/Cargo.toml crates/quicproquo-server/Cargo.toml COPY crates/quicproquo-client/Cargo.toml crates/quicproquo-client/Cargo.toml COPY crates/quicproquo-p2p/Cargo.toml crates/quicproquo-p2p/Cargo.toml @@ -23,10 +23,10 @@ COPY crates/quicproquo-p2p/Cargo.toml crates/quicproquo-p2p/Cargo.toml RUN mkdir -p \ crates/quicproquo-core/src \ crates/quicproquo-proto/src \ - crates/qpq-server/src \ + crates/quicproquo-server/src \ crates/quicproquo-client/src \ crates/quicproquo-p2p/src \ - && echo 'fn main() {}' > crates/qpq-server/src/main.rs \ + && echo 'fn main() {}' > crates/quicproquo-server/src/main.rs \ && echo 'fn main() {}' > crates/quicproquo-client/src/main.rs \ && touch crates/quicproquo-core/src/lib.rs \ && touch crates/quicproquo-proto/src/lib.rs \ @@ -47,7 +47,7 @@ RUN touch \ crates/quicproquo-core/src/lib.rs \ crates/quicproquo-proto/src/lib.rs \ crates/quicproquo-p2p/src/lib.rs \ - crates/qpq-server/src/main.rs \ + crates/quicproquo-server/src/main.rs \ crates/quicproquo-client/src/main.rs RUN cargo build --release --bin qpq-server --bin qpq diff --git a/scripts/ai_team.py b/scripts/ai_team.py new file mode 100755 index 0000000..3dc8f45 --- /dev/null +++ b/scripts/ai_team.py @@ -0,0 +1,797 @@ + +#!/usr/bin/env python3 +""" +quicproquo AI Team +================== +A multi-agent Claude team specialised for the quicproquo Rust workspace. + +Usage: + python scripts/ai_team.py "" # orchestrator + python scripts/ai_team.py --agent "" # single agent + python scripts/ai_team.py --sprint # parallel sprint + python scripts/ai_team.py --parallel \\ + "rust-server-dev: Fix unwrap() in server" \\ + "security-auditor: Audit quicproquo-core" # ad-hoc parallel + python scripts/ai_team.py --list-agents + python scripts/ai_team.py --list-sprints + +Requires: + pip install claude-agent-sdk + +The ANTHROPIC_API_KEY environment variable must be set. +""" + +import argparse +import asyncio +import sys +import os +from datetime import datetime +from pathlib import Path + +try: + from claude_agent_sdk import ( + query, + ClaudeAgentOptions, + AgentDefinition, + ResultMessage, + SystemMessage, + AssistantMessage, + TextBlock, + CLINotFoundError, + CLIConnectionError, + ) +except ImportError: + print("ERROR: claude-agent-sdk not found.") + print("Install with: pip install claude-agent-sdk") + sys.exit(1) + +# ── Project root ─────────────────────────────────────────────────────────────── +PROJECT_ROOT = str(Path(__file__).parent.parent.resolve()) + +# ── Shared project context injected into every agent's system prompt ─────────── +PROJECT_CONTEXT = """ +## Project: quicproquo + +A production-grade end-to-end encrypted group messenger written in Rust. + +### Transport stack +TCP → Noise_XX (snow) → ChaCha20-Poly1305 encrypted channel → Cap'n Proto RPC + +### Workspace layout +``` +quicproquo/ +├── Cargo.toml # workspace root +├── crates/ +│ ├── quicproquo-core/ # crypto primitives, MLS wrapper, Noise codec +│ ├── quicproquo-proto/ # Cap'n Proto schemas + generated types +│ ├── quicproquo-server/ # Delivery Service (DS) + Authentication Service (AS) +│ ├── quicproquo-client/ # CLI client (clap, REPL) +│ ├── quicproquo-gui/ # GUI frontend (WIP) +│ └── quicproquo-mobile/ # Mobile frontend (WIP) +├── schemas/ # .capnp schema files (canonical source) +├── docker/ + docker-compose.yml +├── docs/ +├── scripts/ +├── ROADMAP.md # phased milestone plan +└── master-prompt.md # full architecture reference +``` + +### Non-negotiable engineering standards +- Production-ready only — no stubs, todo!(), unimplemented!(), or placeholder logic. +- YAGNI / KISS / DRY. +- Spec-first: doc comments before implementation. +- Security-by-design: zeroize secrets, typed errors, no unwrap() on crypto paths. +- Conventional commits: feat:, fix:, chore:, docs:, test:, refactor:. +- No Co-authored-by trailers. GPG-signed commits only. + +### Key dependencies (pinned majors) +openmls 0.5, openmls_rust_crypto 0.2, ml-kem 0.2, x25519-dalek 2, ed25519-dalek 2, +snow 0.9, chacha20poly1305 0.10, capnp 0.19, capnp-rpc 0.19, tokio 1, +tokio-util 0.7, dashmap 5, rusqlite (SQLite), tracing 0.1, anyhow 1, thiserror 1, clap 4. + +Always read ROADMAP.md and master-prompt.md before making architectural decisions. +""".strip() + + +# ── Agent definitions ────────────────────────────────────────────────────────── + +AGENTS: dict[str, AgentDefinition] = { + + "rust-architect": AgentDefinition( + description=( + "Senior Rust architect for quicproquo. Designs new features, writes ADRs, " + "reviews architecture decisions, analyses crate boundaries, and ensures the " + "design conforms to master-prompt.md. Does NOT write implementation code." + ), + prompt=f"""{PROJECT_CONTEXT} + +You are the **Rust Architect** for quicproquo. + +Responsibilities: +- Read ROADMAP.md and master-prompt.md to understand the current milestone and constraints. +- Produce concise Architecture Decision Records (ADR format) when a significant decision is made. +- Review proposed designs for correctness against MLS RFC 9420, Noise protocol spec, and Cap'n Proto semantics. +- Identify crate-boundary violations (e.g. I/O in quicproquo-proto, crypto in quicproquo-server). +- Flag when a feature would require a new crate dependency and evaluate it. +- Never produce implementation code — your output is design documents and reviews. + +Output format: +1. One-sentence summary of the architectural concern. +2. ADR (if applicable): Context → Decision → Consequences. +3. Concrete list of action items for the development agents. +""", + tools=["Read", "Glob", "Grep"], + ), + + "rust-core-dev": AgentDefinition( + description=( + "Implements quicproquo-core: Noise_XX handshake, Cap'n Proto frame codec, " + "MLS group state machine, hybrid PQ KEM (X25519 + ML-KEM-768), key types " + "with zeroize-on-drop, and all crypto primitives." + ), + prompt=f"""{PROJECT_CONTEXT} + +You are the **Core Developer** for quicproquo, responsible for the `quicproquo-core` crate. + +Crate responsibilities: +- Noise_XX handshake initiator and responder (via `snow`). +- Length-prefixed Cap'n Proto frame codec (Tokio Encoder/Decoder traits). +- MLS group state machine wrapper around `openmls`. +- Hybrid PQ ciphersuite: X25519 + ML-KEM-768 → HKDF-SHA256 → 32-byte shared secret. +- Key generation, zeroize-on-drop key types. +- OPAQUE password auth helper types. + +Before any edit: +1. Read the relevant source file(s) in full. +2. Check ROADMAP.md for the current milestone scope. +3. Confirm no new dependencies are needed or justify additions. + +After any edit: run `cargo check -p quicproquo-core` to verify compilation. + +Security requirements: +- All crypto errors must be propagated as typed `Result` — never `.unwrap()`. +- Key material structs must derive `Zeroize` and `ZeroizeOnDrop`. +- No secret bytes in log output. +""", + tools=["Read", "Glob", "Grep", "Edit", "Write", "Bash"], + ), + + "rust-server-dev": AgentDefinition( + description=( + "Implements quicproquo-server: TCP listener, Noise handshake per connection, " + "Cap'n Proto RPC server for the Authentication Service (AS) and Delivery " + "Service (DS), fan-out router, per-group message log, SQLite persistence." + ), + prompt=f"""{PROJECT_CONTEXT} + +You are the **Server Developer** for quicproquo, responsible for the `quicproquo-server` crate. + +Crate responsibilities: +- Tokio TCP listener; one task per connection. +- Noise_XX responder using quicproquo-core. +- Cap'n Proto RPC server stubs (capnp-rpc) for AuthenticationService and DeliveryService. +- Authentication Service: KeyPackage store (DashMap → SQLite at M6). +- Delivery Service: fan-out router, per-group append-only message log. +- Structured logging via `tracing`. + +Before any edit: +1. Read the relevant source file(s) in full. +2. Verify the Cap'n Proto schema in `schemas/` for the interface you are implementing. +3. Check ROADMAP.md for what is in scope. + +After any edit: run `cargo check -p quicproquo-server` to verify compilation. + +Security requirements: +- No `.unwrap()` on any lock or I/O operation in production paths. +- Auth tokens validated before any privileged operation. +- `QPQ_PRODUCTION=true` check: reject weak/default tokens on startup. +""", + tools=["Read", "Glob", "Grep", "Edit", "Write", "Bash"], + ), + + "rust-client-dev": AgentDefinition( + description=( + "Implements quicproquo-client: CLI (clap), interactive REPL, Noise handshake, " + "Cap'n Proto RPC client stubs, OPAQUE login/register, encrypted local state " + "(SQLCipher + Argon2id), conversation and session management." + ), + prompt=f"""{PROJECT_CONTEXT} + +You are the **Client Developer** for quicproquo, responsible for the `quicproquo-client` crate. + +Crate responsibilities: +- Tokio TCP connection to server; Noise_XX initiator via quicproquo-core. +- Cap'n Proto RPC client stubs. +- OPAQUE password-authenticated key exchange (register + login). +- CLI interface (clap) with subcommands and an interactive REPL. +- Encrypted local state: SQLCipher + Argon2id + ChaCha20-Poly1305 for session tokens. +- Conversation management, background polling, message history. + +Before any edit: +1. Read the relevant source file(s) in full. +2. Understand existing command handlers in `commands.rs` and state management in `state.rs`. +3. Check ROADMAP.md for the current milestone scope. + +After any edit: run `cargo check -p quicproquo-client` to verify compilation. + +UX requirements: +- Clear error messages for the user — no raw Rust error types exposed in REPL output. +- REPL prompt must show current context (server, active conversation). +""", + tools=["Read", "Glob", "Grep", "Edit", "Write", "Bash"], + ), + + "security-auditor": AgentDefinition( + description=( + "Security-focused auditor for quicproquo. Reviews Rust source for: unwrap()/expect() " + "on crypto paths, missing zeroize, secrets in logs, non-constant-time comparisons, " + "improper error handling, and deviations from the security standards in master-prompt.md. " + "Produces a prioritised finding report — does NOT edit files." + ), + prompt=f"""{PROJECT_CONTEXT} + +You are the **Security Auditor** for quicproquo. + +Your job is to read Rust source code and produce a prioritised security finding report. + +Audit checklist: +1. `.unwrap()` / `.expect()` in non-test code on crypto or I/O operations. +2. Key material types missing `Zeroize` / `ZeroizeOnDrop`. +3. Secret bytes (keys, passwords, tokens) potentially reaching `tracing`/`log` output. +4. Non-constant-time comparisons on authentication tags or tokens. +5. `panic!` / `unreachable!` in production paths. +6. `unsafe` blocks without documented safety invariants. +7. Missing `#[cfg(not(test))]` guards around debug-only logic. +8. Deviations from the engineering standards in master-prompt.md. +9. Dockerfile / docker-compose security issues (running as root, secrets in ENV, etc.). + +Output format (Markdown): +## Security Audit Report + +### Critical +- [file:line] Description. Remediation: ... + +### High +- ... + +### Medium +- ... + +### Low / Informational +- ... + +Do NOT edit any files. Findings only. +""", + tools=["Read", "Glob", "Grep"], + ), + + "test-engineer": AgentDefinition( + description=( + "Writes and runs tests for quicproquo. Adds unit tests, integration tests, " + "and property-based tests. Runs `cargo test` and interprets failures. " + "Knows the milestone-by-milestone test requirements from ROADMAP.md." + ), + prompt=f"""{PROJECT_CONTEXT} + +You are the **Test Engineer** for quicproquo. + +Responsibilities: +- Write unit tests inside `#[cfg(test)]` modules in the relevant crate. +- Write integration tests in `crates//tests/`. +- Run `cargo test --workspace` and interpret failures. +- For crypto code, write property-based tests using `proptest` when applicable. +- Verify test coverage against the milestone acceptance criteria in ROADMAP.md. + +Test naming convention: `test__` (snake_case). + +After writing tests, run them with Bash and report: +- Which tests pass / fail. +- Root cause of any failure. +- Suggested fix (but do not edit non-test files without instruction). +""", + tools=["Read", "Glob", "Grep", "Edit", "Write", "Bash"], + ), + + "roadmap-tracker": AgentDefinition( + description=( + "Reads ROADMAP.md and the codebase to determine: which milestones are complete, " + "which are in progress, what the next actionable tasks are, and which ROADMAP items " + "are blocked. Produces a concise status report — does NOT edit files." + ), + prompt=f"""{PROJECT_CONTEXT} + +You are the **Roadmap Tracker** for quicproquo. + +Your job is to read ROADMAP.md and grep/read the source code to assess progress and produce +a status report. + +Steps: +1. Read ROADMAP.md in full. +2. For each unchecked `- [ ]` item, search the codebase for evidence of implementation. +3. Identify blockers (e.g. a later item depending on an incomplete earlier item). +4. Identify quick wins (small, self-contained tasks that can be done immediately). + +Output format (Markdown): +## Roadmap Status Report + +### Completed ✅ +- Phase X, item Y: ... + +### In Progress 🔄 +- Phase X, item Y: partial — what exists vs what's missing. + +### Next Actionable Tasks (prioritised) +1. ... +2. ... + +### Blockers +- ... + +Do NOT edit any files. Analysis only. +""", + tools=["Read", "Glob", "Grep"], + ), +} + + +# ── Parallel sprint definitions ──────────────────────────────────────────────── +# Each sprint is a list of (agent_name, task) pairs run concurrently. +# Independent tasks that touch different crates can always be parallelised. +# Tasks that depend on each other (e.g. audit after code changes) should be +# run as separate sprints. + +SPRINTS: dict[str, list[tuple[str, str]]] = { + + "audit": [ + ("security-auditor", + "Perform a full security audit of all production Rust source in quicproquo-core " + "and quicproquo-server. Check every file for: .unwrap()/.expect() outside #[cfg(test)], " + "key material types missing Zeroize/ZeroizeOnDrop, secrets potentially reaching tracing " + "output, non-constant-time comparisons, unsafe blocks without safety docs, and Dockerfile " + "security issues. Produce a prioritised finding report in Markdown."), + ("roadmap-tracker", + "Read ROADMAP.md and the full codebase. Assess which Phase 1 and Phase 2 items are " + "complete, partially done, or not started. For each incomplete item search the source " + "for relevant code. Produce a concise status report with prioritised next actions."), + ], + + "phase1-hardening": [ + ("rust-server-dev", + "Fix Phase 1.1: eliminate all .unwrap() and .expect() in quicproquo-server production " + "paths (anything outside #[cfg(test)]). Read every .rs file in crates/quicproquo-server/src/. " + "Replace each .unwrap() with proper ? propagation or map_err. Replace .expect() with " + "a typed error or explicit match. Run `cargo check -p quicproquo-server` after each file. " + "Also check Phase 1.2 (QPQ_PRODUCTION=true startup validation) and implement if missing."), + ("rust-client-dev", + "Fix Phase 1.1: eliminate all .unwrap() and .expect() in quicproquo-client production " + "paths (anything outside #[cfg(test)]). Read every .rs file in crates/quicproquo-client/src/. " + "Replace each .unwrap() with proper ? propagation or map_err. Replace .expect() with " + "a typed error or explicit match. Run `cargo check -p quicproquo-client` after each file. " + "Pay special attention to AUTH_CONTEXT.read().expect() and any Mutex::lock().unwrap() calls."), + ("rust-core-dev", + "Fix Phase 1.1: check quicproquo-core for any .unwrap()/.expect() in non-test code. " + "Read all files in crates/quicproquo-core/src/. Replace any found instances with typed " + "Result propagation. Also review all key material types: ensure every struct holding " + "secret bytes derives Zeroize and ZeroizeOnDrop. Run `cargo check -p quicproquo-core`."), + ], + + "phase2-tests": [ + ("test-engineer", + "Implement Phase 2.1 E2E test coverage for auth failure scenarios. Add to " + "crates/quicproquo-client/tests/e2e.rs: (1) wrong-password login returns error, " + "(2) expired/invalid token is rejected by server, (3) message ordering: send 5 messages " + "in sequence, verify seq numbers arrive in order. Read the existing e2e.rs first to " + "match the test harness pattern (spawn_test_server, AUTH_LOCK). Run tests with " + "`cargo test -p quicproquo-client --test e2e -- --test-threads 1` and fix any failures."), + ("test-engineer", + "Implement Phase 2.2 unit tests for untested paths. Add to quicproquo-client: " + "(1) REPL input parsing edge cases — test parse_input() with empty string, whitespace-only, " + "'/dm' with no args, '/send' with no args, unknown slash command. " + "(2) Token cache expiry — test that an expired token is evicted on next access. " + "Read repl.rs and token_cache.rs first to understand the APIs. " + "Run `cargo test -p quicproquo-client` and fix any failures."), + ], + + "phase1-infra": [ + ("rust-server-dev", + "Fix Phase 1.3 and 1.4. " + "1.3 — Check .gitignore at project root. Add missing entries: data/, *.der, *.pem, " + "*.db, *.bin, *.ks, qpq-state.*, target/. Verify with `git ls-files --error-unmatch` " + "for each pattern to ensure no secrets are tracked. " + "1.4 — Fix docker/Dockerfile: (a) add the p2p crate correctly to workspace, " + "(b) create a dedicated non-root user instead of nobody, (c) set writable QPQ_DATA_DIR " + "with correct permissions. Test with `docker build -f docker/Dockerfile .`"), + ("rust-architect", + "Design the TLS certificate lifecycle for Phase 1.5. Read crates/quicproquo-server/src/tls.rs " + "and config.rs in full. Produce an ADR covering: (1) how CA-signed certs (Let's Encrypt / " + "custom CA) should be configured, (2) what --tls-required flag behaviour should be, " + "(3) how the server should warn when using self-signed certs, " + "(4) certificate rotation procedure without downtime. " + "Output: ADR + concrete action items for rust-server-dev."), + ], + + "status": [ + ("roadmap-tracker", + "Full roadmap status report. Read ROADMAP.md completely. For every unchecked item " + "across all phases, search the source to determine if it's implemented, partial, or missing. " + "Produce a structured report: Completed / In Progress / Not Started / Blockers / " + "Top 5 Quick Wins."), + ("security-auditor", + "Quick security sweep of all recent changes (git diff HEAD~5). Read the modified files " + "in full. Focus on: any new .unwrap()/.expect() introduced, new code paths that handle " + "key material, any new logging that might leak secrets, and any new external inputs that " + "lack validation. Produce a concise finding report."), + ], +} + + +# ── Orchestrator system prompt ───────────────────────────────────────────────── + +ORCHESTRATOR_PROMPT = f"""{PROJECT_CONTEXT} + +You are the **Orchestrator** for the quicproquo AI development team. + +Your team of specialist subagents: + +| Agent | Role | +|-------|------| +| rust-architect | Architecture design, ADRs, design reviews | +| rust-core-dev | quicproquo-core crate: crypto, MLS, Noise codec | +| rust-server-dev | quicproquo-server crate: AS, DS, RPC server | +| rust-client-dev | quicproquo-client crate: CLI, REPL, local state | +| security-auditor | Security review: unwrap(), zeroize, secrets in logs | +| test-engineer | Unit/integration tests, cargo test runs | +| roadmap-tracker | Roadmap progress assessment | + +Workflow: +1. Read the task carefully. +2. Decide which agent(s) are needed. For multi-step tasks, sequence them logically. +3. Call each required agent with a precise, scoped prompt. +4. Synthesise the agents' outputs into a final report or code deliverable. +5. Always end with: "Next suggested task: ..." based on the ROADMAP. + +Rules: +- Read master-prompt.md and ROADMAP.md before delegating significant tasks. +- Do NOT delegate everything to one agent — split by crate/concern. +- If a task touches security, always invoke security-auditor after code changes. +- If a task adds/modifies functionality, always invoke test-engineer last. +- Keep your synthesis concise — prefer structured output (headers, bullet lists). +""" + + +# ── Parallel runner ──────────────────────────────────────────────────────────── + +async def run_agent_to_file( + agent_name: str, + task: str, + max_turns: int, + output_dir: Path, + label: str, +) -> tuple[str, str, str | None]: + """ + Run a single agent and stream its result to an output file. + + Returns (agent_name, label, result_text_or_None). + `result_text` is None if the agent produced no ResultMessage. + """ + output_file = output_dir / f"{label}.md" + result_text: str | None = None + + agent = AGENTS[agent_name] + options = ClaudeAgentOptions( + cwd=PROJECT_ROOT, + allowed_tools=agent.tools or ["Read", "Glob", "Grep"], + system_prompt=agent.prompt, + max_turns=max_turns, + permission_mode="acceptEdits", + setting_sources=["project"], + ) + + with open(output_file, "w") as f: + f.write(f"# Agent: {agent_name}\n\n") + f.write(f"**Task:** {task}\n\n") + f.write(f"**Started:** {datetime.now().isoformat()}\n\n---\n\n") + + async for message in query(prompt=task, options=options): + if isinstance(message, ResultMessage): + result_text = message.result + f.write(f"## Result\n\n{result_text}\n") + f.write(f"\n**Finished:** {datetime.now().isoformat()}\n") + + return agent_name, label, result_text + + +async def run_parallel( + agent_tasks: list[tuple[str, str]], + max_turns: int, + verbose: bool, + sprint_name: str = "custom", +) -> None: + """Launch all (agent, task) pairs concurrently and print a summary when done.""" + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + output_dir = Path(PROJECT_ROOT) / "logs" / "ai_team" / f"{sprint_name}_{timestamp}" + output_dir.mkdir(parents=True, exist_ok=True) + + print(f"\n{'═' * 70}") + print(f" quicproquo AI Team — Parallel Sprint: {sprint_name}") + print(f" Agents: {len(agent_tasks)} | Max turns each: {max_turns}") + print(f" Logs: {output_dir}/") + print(f"{'═' * 70}\n") + + for i, (agent, task) in enumerate(agent_tasks, 1): + label = f"{i:02d}_{agent}" + print(f" [{i}] {agent}") + print(f" {task[:80]}{'…' if len(task) > 80 else ''}") + print() + + # Build coroutines with stable labels for output files. + coros = [ + run_agent_to_file(agent, task, max_turns, output_dir, f"{i:02d}_{agent}") + for i, (agent, task) in enumerate(agent_tasks, 1) + ] + + print(f" Starting {len(coros)} agents in parallel…\n") + results = await asyncio.gather(*coros, return_exceptions=True) + + print(f"\n{'─' * 70}") + print(" SPRINT RESULTS") + print(f"{'─' * 70}") + + success = 0 + for result in results: + if isinstance(result, Exception): + print(f"\n ❌ ERROR: {result}") + else: + agent_name, label, text = result + if text is not None: + success += 1 + print(f"\n ✅ {agent_name} ({label}.md)") + # Show first 300 chars of result as a preview. + preview = text.strip()[:300] + for line in preview.splitlines(): + print(f" {line}") + if len(text.strip()) > 300: + print(" …") + else: + print(f"\n ⚠️ {agent_name}: no result produced") + + print(f"\n {success}/{len(agent_tasks)} agents completed successfully.") + print(f" Full outputs: {output_dir}/\n") + + +# ── Sequential runners ───────────────────────────────────────────────────────── + +async def run_orchestrator(task: str, max_turns: int, verbose: bool) -> None: + """Run the full team via the orchestrator.""" + print(f"\n{'═' * 70}") + print(f" quicproquo AI Team — Orchestrator") + print(f" Task: {task[:72]}{'…' if len(task) > 72 else ''}") + print(f"{'═' * 70}\n") + + options = ClaudeAgentOptions( + cwd=PROJECT_ROOT, + allowed_tools=["Read", "Glob", "Grep", "Agent"], + system_prompt=ORCHESTRATOR_PROMPT, + agents=AGENTS, + max_turns=max_turns, + permission_mode="acceptEdits", + setting_sources=["project"], + ) + + async for message in query(prompt=task, options=options): + if isinstance(message, ResultMessage): + print("\n" + "─" * 70) + print("RESULT") + print("─" * 70) + print(message.result) + elif verbose: + if isinstance(message, AssistantMessage): + for block in message.content: + if isinstance(block, TextBlock) and block.text.strip(): + print(block.text, end="", flush=True) + elif isinstance(message, SystemMessage) and message.subtype == "init": + print(f"[Session: {message.session_id}]") + + +async def run_single_agent( + agent_name: str, task: str, max_turns: int, verbose: bool +) -> None: + """Bypass the orchestrator and run a single specialist agent directly.""" + agent = AGENTS[agent_name] + print(f"\n{'═' * 70}") + print(f" quicproquo AI Team — {agent_name}") + print(f" Task: {task[:72]}{'…' if len(task) > 72 else ''}") + print(f"{'═' * 70}\n") + + options = ClaudeAgentOptions( + cwd=PROJECT_ROOT, + allowed_tools=agent.tools or ["Read", "Glob", "Grep"], + system_prompt=agent.prompt, + max_turns=max_turns, + permission_mode="acceptEdits", + setting_sources=["project"], + ) + + async for message in query(prompt=task, options=options): + if isinstance(message, ResultMessage): + print("\n" + "─" * 70) + print("RESULT") + print("─" * 70) + print(message.result) + elif verbose: + if isinstance(message, AssistantMessage): + for block in message.content: + if isinstance(block, TextBlock) and block.text.strip(): + print(block.text, end="", flush=True) + + +# ── CLI ──────────────────────────────────────────────────────────────────────── + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="ai_team", + description="quicproquo multi-agent Claude team", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__, + ) + parser.add_argument( + "task", + nargs="?", + help="Task description for the orchestrator", + ) + parser.add_argument( + "--agent", "-a", + choices=list(AGENTS.keys()), + default=None, + help="Bypass orchestrator and send task directly to a specific agent", + ) + parser.add_argument( + "--sprint", "-s", + choices=list(SPRINTS.keys()), + default=None, + metavar="SPRINT", + help="Run a predefined parallel sprint (see --list-sprints)", + ) + parser.add_argument( + "--parallel", "-p", + nargs="+", + metavar="AGENT:TASK", + default=None, + help=( + 'Ad-hoc parallel run. Each argument is "agent-name: task description". ' + 'Example: --parallel "rust-server-dev: Fix unwrap() in server" ' + '"security-auditor: Audit core crate"' + ), + ) + parser.add_argument( + "--list-agents", "-l", + action="store_true", + help="List available agents and exit", + ) + parser.add_argument( + "--list-sprints", + action="store_true", + help="List predefined sprints and exit", + ) + parser.add_argument( + "--max-turns", + type=int, + default=60, + help="Maximum agentic turns per agent (default: 60)", + ) + parser.add_argument( + "--verbose", "-v", + action="store_true", + help="Print all message types (not just results)", + ) + return parser + + +def list_agents() -> None: + print("Available agents:\n") + for name, defn in AGENTS.items(): + print(f" {name}") + desc = defn.description + wrapped = "\n ".join( + desc[i : i + 72] for i in range(0, len(desc), 72) + ) + print(f" {wrapped}\n") + + +def list_sprints() -> None: + print("Predefined sprints:\n") + for name, tasks in SPRINTS.items(): + print(f" {name} ({len(tasks)} agents in parallel)") + for agent, task in tasks: + preview = task[:60] + ("…" if len(task) > 60 else "") + print(f" [{agent}] {preview}") + print() + + +def parse_parallel_args(args: list[str]) -> list[tuple[str, str]]: + """ + Parse --parallel arguments of the form "agent-name: task description". + The colon after the agent name is required. + """ + pairs: list[tuple[str, str]] = [] + valid = set(AGENTS.keys()) + for arg in args: + if ":" not in arg: + print(f"ERROR: --parallel argument missing colon separator: {arg!r}") + print(" Expected format: \"agent-name: task description\"") + sys.exit(1) + agent, _, task = arg.partition(":") + agent = agent.strip() + task = task.strip() + if agent not in valid: + print(f"ERROR: unknown agent {agent!r}. Valid: {', '.join(sorted(valid))}") + sys.exit(1) + if not task: + print(f"ERROR: empty task for agent {agent!r}") + sys.exit(1) + pairs.append((agent, task)) + return pairs + + +# ── Entry point ──────────────────────────────────────────────────────────────── + +async def main() -> None: + parser = build_parser() + args = parser.parse_args() + + if args.list_agents: + list_agents() + return + + if args.list_sprints: + list_sprints() + return + + if not os.environ.get("ANTHROPIC_API_KEY"): + print("ERROR: ANTHROPIC_API_KEY environment variable is not set.") + sys.exit(1) + + try: + if args.sprint: + agent_tasks = SPRINTS[args.sprint] + await run_parallel( + agent_tasks, args.max_turns, args.verbose, sprint_name=args.sprint + ) + + elif args.parallel: + agent_tasks = parse_parallel_args(args.parallel) + await run_parallel( + agent_tasks, args.max_turns, args.verbose, sprint_name="custom" + ) + + elif args.agent: + if not args.task: + print("ERROR: --agent requires a task argument.") + sys.exit(1) + await run_single_agent( + args.agent, args.task, args.max_turns, args.verbose + ) + + elif args.task: + await run_orchestrator(args.task, args.max_turns, args.verbose) + + else: + parser.print_help() + sys.exit(1) + + except CLINotFoundError: + print( + "\nERROR: Claude Code CLI not found.\n" + "Install with: pip install claude-agent-sdk" + ) + sys.exit(1) + except CLIConnectionError as e: + print(f"\nERROR: Connection error: {e}") + sys.exit(1) + except KeyboardInterrupt: + print("\n\nInterrupted.") + sys.exit(0) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/scripts/chat-test.sh b/scripts/chat-test.sh index 7f7efd5..e873069 100755 --- a/scripts/chat-test.sh +++ b/scripts/chat-test.sh @@ -7,7 +7,7 @@ # Usage: # ./scripts/chat-test.sh # -# Requirements: docker, docker compose, tmux +# Requirements: docker (or podman + podman-compose), tmux # Exit: Ctrl+D in both panes, or: tmux kill-session -t qpc-chat set -euo pipefail @@ -15,7 +15,6 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" COMPOSE_FILE="$PROJECT_ROOT/docker/docker-compose.chat-test.yml" -COMPOSE="docker compose -f $COMPOSE_FILE -p qpc-chat-test" # ── Colors ──────────────────────────────────────────────────────────────────── @@ -27,6 +26,37 @@ step() { echo -e "${GREEN}==> $1${NC}"; } info() { echo -e " ${CYAN}$1${NC}"; } error() { echo -e "${RED}ERROR: $1${NC}" >&2; } +# ── Preflight checks ───────────────────────────────────────────────────────── + +# Prefer docker; fall back to podman. +if command -v docker &>/dev/null; then + if ! docker compose version &>/dev/null; then + error "docker compose (v2 plugin) is required." + echo " See: https://docs.docker.com/compose/install/" + exit 1 + fi + COMPOSE_BIN="docker compose" +elif command -v podman &>/dev/null; then + if ! command -v podman-compose &>/dev/null; then + error "podman-compose is required when using podman." + echo " pip install podman-compose" + exit 1 + fi + COMPOSE_BIN="podman-compose" +else + error "docker or podman is required but neither is installed." + exit 1 +fi + +COMPOSE="$COMPOSE_BIN -f $COMPOSE_FILE -p qpc-chat-test" + +if ! command -v tmux &>/dev/null; then + error "tmux is required but not installed." + echo " macOS: brew install tmux" + echo " Linux: sudo apt-get install tmux" + exit 1 +fi + # ── Cleanup on exit ────────────────────────────────────────────────────────── cleanup() { @@ -36,26 +66,6 @@ cleanup() { } trap cleanup EXIT INT TERM -# ── Preflight checks ───────────────────────────────────────────────────────── - -if ! command -v docker &>/dev/null; then - error "docker is required but not installed." - exit 1 -fi - -if ! docker compose version &>/dev/null; then - error "docker compose (v2 plugin) is required." - echo " See: https://docs.docker.com/compose/install/" - exit 1 -fi - -if ! command -v tmux &>/dev/null; then - error "tmux is required but not installed." - echo " macOS: brew install tmux" - echo " Linux: sudo apt-get install tmux" - exit 1 -fi - # ── Step 1: Build ───────────────────────────────────────────────────────────── step "Building Docker image (server + client)..." diff --git a/scripts/dev-shell.sh b/scripts/dev-shell.sh new file mode 100755 index 0000000..854e996 --- /dev/null +++ b/scripts/dev-shell.sh @@ -0,0 +1,336 @@ +#!/usr/bin/env bash +# ── qpq Dev Shell ───────────────────────────────────────────────────────────── +# +# Builds qpq (if needed), starts a local server, registers Alice + Bob, then +# opens a tmux session with two side-by-side REPL panes and a server-log strip. +# +# Layout (window 0 — "chat"): +# +# ┌──────────[ ALICE user=alice pass=alice ]──┬──[ BOB user=bob pass=bob ]──┐ +# │ │ │ +# │ /dm bob ← start a DM here │ reply here │ +# │ /create-group g ← or create a group │ /join ← to accept invite │ +# │ │ │ +# ├──────────[ SERVER LOG ]────────────────────┴──────────────────────────────┤ +# │ live qpq-server stdout / stderr │ +# └───────────────────────────────────────────────────────────────────────────┘ +# +# Window 1 — "ref": full slash-command cheatsheet (read-only) +# +# Usage: +# ./scripts/dev-shell.sh build if needed, fresh session +# ./scripts/dev-shell.sh --rebuild force cargo build first +# ./scripts/dev-shell.sh --resume reuse existing state files + server data +# ./scripts/dev-shell.sh --help show this message +# +# Stop: Ctrl-C here OR tmux kill-session -t qpq-dev +# Ref: Ctrl-B 1 (inside tmux → cheatsheet window) +# Nav: Ctrl-B ←/→ (switch Alice ↔ Bob pane) +# Ctrl-B z (zoom current pane) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +BIN_DIR="$PROJECT_ROOT/target/debug" +SESSION="qpq-dev" +SERVER_PORT=7000 +SERVER_ADDR="127.0.0.1:$SERVER_PORT" +SERVER_NAME="localhost" + +# All runtime state lives in /tmp so the project tree stays clean +RUN_DIR="/tmp/qpq-devshell" +DATA_DIR="$RUN_DIR/server-data" # server stores TLS cert + OPAQUE data here +CA_CERT="$DATA_DIR/server-cert.der" +LOG_FILE="$RUN_DIR/server.log" + +QPQ="$BIN_DIR/qpq" +QPQS="$BIN_DIR/qpq-server" + +# ── Colours ──────────────────────────────────────────────────────────────────── +GRN='\033[0;32m'; CYN='\033[0;36m'; YLW='\033[1;33m'; RED='\033[0;31m'; NC='\033[0m' +step() { printf "\n${GRN}▶ %s${NC}\n" "$*"; } +info() { printf " ${CYN}%s${NC}\n" "$*"; } +warn() { printf " ${YLW}⚠ %s${NC}\n" "$*"; } +die() { printf "${RED}✗ %s${NC}\n" "$*" >&2; exit 1; } + +# ── Parse flags ──────────────────────────────────────────────────────────────── +REBUILD=false +RESUME=false +for arg in "$@"; do + case "$arg" in + -r|--rebuild) REBUILD=true ;; + --resume) RESUME=true ;; + -h|--help) + sed -n '2,/^[^#]/{ /^#/p }' "$0" | sed 's/^# \?//' + exit 0 + ;; + *) die "Unknown argument: $arg" ;; + esac +done + +# ── Preflight ────────────────────────────────────────────────────────────────── +step "Checking requirements..." +for cmd in cargo tmux; do + command -v "$cmd" &>/dev/null || die "'$cmd' is required but not installed." +done +info "cargo $(cargo --version 2>&1 | head -1)" +info "tmux $(tmux -V)" + +# ── Decide whether to clean state ───────────────────────────────────────────── +# By default we start fresh (server is always restarted, state must match). +# Pass --resume to reuse an existing consistent state from a previous run. +if $RESUME; then + info "Resume mode — keeping existing state in $RUN_DIR" + [[ -d "$RUN_DIR" ]] || die "--resume requires a previous dev-shell run (no $RUN_DIR)" +else + step "Cleaning previous run state..." + rm -rf "$RUN_DIR" + info "Cleared $RUN_DIR" +fi +mkdir -p "$RUN_DIR" "$DATA_DIR" + +# ── Build ────────────────────────────────────────────────────────────────────── +if $REBUILD || [[ ! -x "$QPQ" ]] || [[ ! -x "$QPQS" ]]; then + step "Building workspace (cargo build)..." + cd "$PROJECT_ROOT" + cargo build + info "Build complete." +else + info "Using cached binaries in $BIN_DIR" + info "(pass --rebuild to recompile)" +fi +[[ -x "$QPQ" ]] || die "Client binary not found: $QPQ" +[[ -x "$QPQS" ]] || die "Server binary not found: $QPQS" + +# ── Free the port ────────────────────────────────────────────────────────────── +step "Ensuring port $SERVER_PORT is free..." +SERVER_PID="" + +free_port() { + if command -v fuser &>/dev/null; then + fuser -k "${SERVER_PORT}/tcp" 2>/dev/null || true + elif command -v lsof &>/dev/null; then + local pids + pids=$(lsof -ti "tcp:${SERVER_PORT}" 2>/dev/null || true) + [[ -n "$pids" ]] && kill $pids 2>/dev/null || true + fi +} +free_port +sleep 0.3 + +# ── Cleanup on exit ──────────────────────────────────────────────────────────── +cleanup() { + printf "\n" + step "Shutting down..." + tmux kill-session -t "$SESSION" 2>/dev/null || true + if [[ -n "$SERVER_PID" ]] && kill -0 "$SERVER_PID" 2>/dev/null; then + info "Stopping qpq-server (PID $SERVER_PID)..." + kill "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + fi + free_port + info "Done." +} +trap cleanup EXIT INT TERM + +# ── Start server ─────────────────────────────────────────────────────────────── +step "Starting qpq-server on $SERVER_ADDR..." +"$QPQS" \ + --listen "$SERVER_ADDR" \ + --data-dir "$DATA_DIR" \ + --tls-cert "$DATA_DIR/server-cert.der" \ + --tls-key "$DATA_DIR/server-key.der" \ + --allow-insecure-auth \ + >"$LOG_FILE" 2>&1 & +SERVER_PID=$! +info "PID $SERVER_PID log → $LOG_FILE" + +# ── Wait for TLS cert (written by server on first boot) ──────────────────────── +step "Waiting for server to initialise..." +for i in $(seq 1 20); do + if [[ -f "$CA_CERT" ]]; then + info "Server ready after ${i}s (cert present)." + break + fi + if ! kill -0 "$SERVER_PID" 2>/dev/null; then + warn "Server exited early. Last log output:" + tail -30 "$LOG_FILE" >&2 + die "Server failed to start." + fi + sleep 1 + if [[ $i -eq 20 ]]; then + tail -30 "$LOG_FILE" >&2 + die "Server did not produce TLS cert within 20s." + fi +done +sleep 0.5 # brief pause for the QUIC listener to open + +QPQ_GLOBAL=(--ca-cert "$CA_CERT" --server-name "$SERVER_NAME") + +# ── Build REPL command strings ───────────────────────────────────────────────── +# Registration is handled automatically by the REPL on first launch: +# 1. load_or_init_state creates state file + identity key (if missing) +# 2. opaque_register sends the identity key → server binds username→identity_key +# 3. opaque_login returns a session token → cached for future runs +# Pre-registering here (without an identity key) would break /dm by preventing +# the identity key from ever being bound on a subsequent REPL launch. +repl_cmd() { + local user="$1" pass="$2" + echo "$QPQ ${QPQ_GLOBAL[*]} repl \ +--state $RUN_DIR/${user}.bin \ +--server $SERVER_ADDR \ +--username $user \ +--password $pass" +} + +ALICE_CMD=$(repl_cmd alice alice) +BOB_CMD=$(repl_cmd bob bob) + +# ── Build tmux session ───────────────────────────────────────────────────────── +step "Creating tmux session '$SESSION'..." +tmux kill-session -t "$SESSION" 2>/dev/null || true + +# ─ Window 0 "chat" layout: +# top-left → Alice REPL +# top-right → Bob REPL +# bottom → server log (full width, 30% height) +# +# tmux 3.x renumbers pane indices after each split, so we capture pane IDs +# with -P -F '#{pane_id}' instead of relying on 0.0 / 0.1 / 0.2 arithmetic. + +tmux new-session -d -s "$SESSION" -n "chat" -x 220 -y 55 + +# The initial pane is always %0 / pane 0 — that's Alice. +PANE_ALICE="${SESSION}:0.0" + +# Split bottom strip for server log; capture the new pane's stable ID. +PANE_LOG=$(tmux split-window -v -t "$PANE_ALICE" -p 30 -P -F '#{pane_id}') + +# Split top-right for Bob from Alice's pane; capture ID. +tmux select-pane -t "$PANE_ALICE" +PANE_BOB=$(tmux split-window -h -t "$PANE_ALICE" -P -F '#{pane_id}') + +# Send commands to each pane by stable ID — immune to index renumbering. +tmux send-keys -t "$PANE_LOG" \ + "printf '\\033[0;36m[server log]\\033[0m\\n' && tail -F '$LOG_FILE'" \ + Enter + +tmux send-keys -t "$PANE_BOB" \ + "sleep 1.5 && $BOB_CMD" \ + Enter + +tmux send-keys -t "$PANE_ALICE" \ + "sleep 0.8 && $ALICE_CMD" \ + Enter + +# Pane border labels (tmux ≥ 2.6) +tmux select-pane -t "$PANE_ALICE" -T " ✉ ALICE │ user=alice pass=alice " +tmux select-pane -t "$PANE_LOG" -T " ⚙ SERVER LOG " +tmux select-pane -t "$PANE_BOB" -T " ✉ BOB │ user=bob pass=bob " + +tmux set-option -t "$SESSION" pane-border-status top 2>/dev/null || true +tmux set-option -t "$SESSION" \ + pane-border-format \ + "#{?pane_active,#[bold fg=colour226],#[fg=colour244]} #{pane_title} " \ + 2>/dev/null || true + +# Focus Alice to start +tmux select-pane -t "$PANE_ALICE" + +# ─ Window 1 "ref" — slash-command cheatsheet ────────────────────────────────── +tmux new-window -t "${SESSION}:1" -n "ref" +tmux send-keys -t "${SESSION}:1" "clear" Enter +# Heredoc piped through cat so it renders immediately and stays visible +tmux send-keys -t "${SESSION}:1" "cat << 'CHEAT' +╔══════════════════════════════════════════════════════════════════════════╗ +║ qpq REPL ─ Slash Command Cheatsheet ║ +╠══════════════════════════════════════════════════════════════════════════╣ +║ GENERAL ║ +║ /help show all commands in the REPL ║ +║ /whoami identity key + hybrid key fingerprint ║ +║ /quit /q /exit exit the REPL ║ +║ ║ +║ CONVERSATIONS ║ +║ /list /ls list all open conversations ║ +║ /switch @username make a DM the active conversation ║ +║ /switch #groupname make a group the active conversation ║ +║ /history [N] print last N messages (default: 20) ║ +║ /members list all members of the current conv. ║ +║ ║ +║ DIRECT MESSAGES ║ +║ /dm open or create an encrypted 1:1 DM ║ +║ ║ +║ MLS GROUPS ║ +║ /create-group create a new MLS group (you are admin) ║ +║ /cg alias for /create-group ║ +║ /invite invite someone into the current group ║ +║ /join accept a pending group Welcome message ║ +║ /leave leave the currently active group ║ +║ /remove remove (kick) a member from the group ║ +║ /kick alias for /remove ║ +║ ║ +╠══════════════════════════════════════════════════════════════════════════╣ +║ QUICK START — 1:1 DM TEST ║ +║ ║ +║ [Alice] /dm bob creates encrypted DM channel ║ +║ [Bob] (Welcome arrives) background poller picks it up auto ║ +║ [Alice] Hello Bob! send your first message ║ +║ [Bob] Hey Alice! reply ║ +║ [Alice] /history verify messages are stored ║ +║ [Alice] /whoami check identity + hybrid key status ║ +║ ║ +║ QUICK START — GROUP CHAT TEST ║ +║ ║ +║ [Alice] /create-group devtest create an MLS group ║ +║ [Alice] /invite bob send a Welcome to Bob ║ +║ [Bob] /join accept the Welcome ║ +║ [Alice] Hello everyone! send to group ║ +║ [Bob] Hi Alice! reply in group ║ +║ [Alice] /members verify both Alice + Bob listed ║ +║ [Alice] /history 50 dump full message log ║ +║ [Alice] /remove bob kick Bob (test admin ops) ║ +║ [Bob] (removed from group) ║ +║ ║ +╠══════════════════════════════════════════════════════════════════════════╣ +║ TMUX NAVIGATION ║ +║ Ctrl-B 0 window 0 — chat panes (Alice / Bob / log) ║ +║ Ctrl-B 1 window 1 — this cheatsheet ║ +║ Ctrl-B ← → move between panes in the chat window ║ +║ Ctrl-B z zoom current pane to fullscreen (toggle) ║ +║ Ctrl-B [ scroll mode — use arrows / PgUp/PgDn (q exits) ║ +║ Ctrl-B d detach (session stays alive in background) ║ +║ ║ +║ EXIT / STOP ║ +║ Ctrl-B :kill-session Enter kill tmux + triggers script cleanup ║ +║ tmux kill-session -t qpq-dev from any other terminal ║ +║ /quit (in Alice or Bob pane) exit that REPL only ║ +╚══════════════════════════════════════════════════════════════════════════╝ +CHEAT" Enter + +# Return focus to the chat window, Alice pane +tmux select-window -t "${SESSION}:0" +tmux select-pane -t "${SESSION}:0.0" + +# ── Print startup summary ────────────────────────────────────────────────────── +printf "\n" +printf "${GRN}╔══════════════════════════════════════════════════════╗${NC}\n" +printf "${GRN}║${NC} ${GRN}qpq dev shell — ready${NC} ${GRN}║${NC}\n" +printf "${GRN}╠══════════════════════════════════════════════════════╣${NC}\n" +printf "${GRN}║${NC} Session ${CYN}%s${NC}\n" "$SESSION ${GRN}║${NC}" +printf "${GRN}║${NC} Server ${CYN}%s${NC}\n" "$SERVER_ADDR (log → $LOG_FILE) ${GRN}║${NC}" +printf "${GRN}║${NC} Alice user=${CYN}alice${NC} pass=${CYN}alice${NC} ${GRN}║${NC}\n" +printf "${GRN}║${NC} Bob user=${CYN}bob${NC} pass=${CYN}bob${NC} ${GRN}║${NC}\n" +printf "${GRN}║${NC} ${GRN}║${NC}\n" +printf "${GRN}║${NC} Quick DM: ${CYN}[Alice pane]${NC} type: /dm bob ${GRN}║${NC}\n" +printf "${GRN}║${NC} Cheatsheet: Ctrl-B 1 (inside tmux) ${GRN}║${NC}\n" +printf "${GRN}║${NC} Exit: Ctrl-B :kill-session Enter ${GRN}║${NC}\n" +printf "${GRN}║${NC} or from another terminal: ${GRN}║${NC}\n" +printf "${GRN}║${NC} tmux kill-session -t qpq-dev ${GRN}║${NC}\n" +printf "${GRN}╚══════════════════════════════════════════════════════╝${NC}\n" +printf "\n" + +# ── Attach ───────────────────────────────────────────────────────────────────── +tmux attach-session -t "$SESSION" + +step "Dev shell exited."