From 23a9040b99102376366577430207ff364e5c70c1 Mon Sep 17 00:00:00 2001 From: Arjun Nayak Date: Sun, 26 Apr 2026 04:57:23 +0530 Subject: [PATCH 1/4] docs: add blog media asset tracking and future content backlog - Inventory of GIFs, screenshots, and videos needed for blog posts - Tracks pending assets: audit trail screenshot, data source GIF, multi-agent delegation GIF, WhatsApp interaction GIF - Blog backlog: architecture deep dive, SDK tutorial, deployment guide - Links to existing setup demo GIF and voice AI blog content --- docs/BLOG-MEDIA-TODO.md | 49 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 docs/BLOG-MEDIA-TODO.md diff --git a/docs/BLOG-MEDIA-TODO.md b/docs/BLOG-MEDIA-TODO.md new file mode 100644 index 0000000..a302d70 --- /dev/null +++ b/docs/BLOG-MEDIA-TODO.md @@ -0,0 +1,49 @@ +# Blog & Media Content Todo + +> Tracking media assets, blog content, and video strategy for OpenZosma. + +## Assets We Already Have + +| Asset | Location | Used In | +| -------------------- | --------------------------------- | ------------------------------------ | +| Setup demo GIF | `assets/setup-demo.gif` | README, Blog 6 (Deploy in 5 Minutes) | +| Architecture diagram | `assets/diagram-architecture.png` | README, ARCHITECTURE.md | +| Hierarchy diagram | `assets/diagram-hierarchy.png` | README | + +## Assets Needed + +### For Blog 6 (Deploy Your First AI Agent in 5 Minutes) + +- [ ] **Agent audit trail screenshot** — `public/images/blogs/agent-audit-trail.jpeg` on website + - Should show: SQL query, database response, timing, data source name + - Record from actual dashboard during a real query + - Style: Clean browser screenshot, no personal data visible + +### GIFs for Future Blogs + +- [ ] **Data source connection GIF** — 10-15s showing "Add Connection" form being filled and saved +- [ ] **Multi-agent delegation GIF** — showing CEO Agent delegating to Sales + Support agents +- [ ] **WhatsApp interaction GIF** — asking a question from phone, getting answer back + +### Video Content (Later Stage) + +- [ ] **2-minute setup video** — Narrated walkthrough of `pnpm create openzosma` from start to finish +- [ ] **5-minute feature tour** — Dashboard walkthrough, data sources, audit trail, agent config +- [ ] **Architecture explainer** — 3-minute whiteboard-style video explaining gateway → orchestrator → sandbox flow + +## Blog Content Backlog + +| # | Blog | Status | Notes | +| --- | ---------------------------------------------------- | ---------------- | -------------------------------------- | +| 6 | Deploy Your First AI Agent in 5 Minutes | 📝 Draft written | Needs audit trail image, GIF thumbnail | +| — | OpenZosma Architecture Deep Dive | 📋 Planned | Technical post for HN/dev.to | +| — | Building with the OpenZosma SDK | 📋 Planned | Tutorial for developers | +| — | From Local to Production: OpenZosma Deployment Guide | 📋 Planned | Docker, K8s, orchestrator mode | + +## Notes on Media Strategy + +- **GIF thumbnails** work great for technical tutorials — they signal "this is hands-on" before the reader clicks +- **Demo videos** should be short (<2 min) and silent with captions (works on mobile without sound) +- **Screenshots** should use consistent browser framing (same window size, no bookmarks bar, clean desktop) +- Record on a dark or light theme consistently — the dashboard supports both, pick one for all media +- When recording GIFs, use a tool like Screen Studio or LICEcap at 15fps, 800px width max for fast loading From b15ec1021d7cd052851130e98d3209c6de35e3b7 Mon Sep 17 00:00:00 2001 From: Arjun Nayak Date: Sun, 26 Apr 2026 07:34:38 +0530 Subject: [PATCH 2/4] feat(pi-harness): standalone headless agent server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add @openzosma/pi-harness — a lightweight, deployable HTTP/SSE server that wraps pi-coding-agent in headless SDK mode. No TUI, no database, no auth complexity. One Node.js process serves multiple sessions. Features: - Full CLI with subcommands: start, stop, status, setup, tui, logs - First-run auto-detection with interactive setup wizard - Native daemon mode (--daemon) with PID tracking - Real-time SSE streaming of agent events - Per-session workspace isolation with configurable limits - Idle session cleanup and health monitoring - esbuild bundling for standalone npm install (-g) - Dynamic imports split heavy deps from lightweight CLI commands Also update root README with pi-harness references in 5 places: - Standalone harness callout section - Architecture table callout - Project structure listing - Roadmap entry - Documentation table Closes standalone deployment use case for pi-coding-agent. --- .gitignore | 3 + README.md | 98 +-- packages/pi-harness/README.md | 609 +++++++++++++++++++ packages/pi-harness/package.json | 56 ++ packages/pi-harness/scripts/build-bundle.mjs | 67 ++ packages/pi-harness/scripts/install.sh | 365 +++++++++++ packages/pi-harness/scripts/setup.sh | 275 +++++++++ packages/pi-harness/src/cli.ts | 331 ++++++++++ packages/pi-harness/src/commands.ts | 168 +++++ packages/pi-harness/src/config.ts | 89 +++ packages/pi-harness/src/index.ts | 52 ++ packages/pi-harness/src/server.ts | 243 ++++++++ packages/pi-harness/src/session-manager.ts | 228 +++++++ packages/pi-harness/src/tui.ts | 561 +++++++++++++++++ packages/pi-harness/src/types.ts | 44 ++ packages/pi-harness/tsconfig.json | 11 + pnpm-lock.yaml | 313 +++++++++- 17 files changed, 3466 insertions(+), 47 deletions(-) create mode 100644 packages/pi-harness/README.md create mode 100644 packages/pi-harness/package.json create mode 100644 packages/pi-harness/scripts/build-bundle.mjs create mode 100755 packages/pi-harness/scripts/install.sh create mode 100755 packages/pi-harness/scripts/setup.sh create mode 100644 packages/pi-harness/src/cli.ts create mode 100644 packages/pi-harness/src/commands.ts create mode 100644 packages/pi-harness/src/config.ts create mode 100644 packages/pi-harness/src/index.ts create mode 100644 packages/pi-harness/src/server.ts create mode 100644 packages/pi-harness/src/session-manager.ts create mode 100644 packages/pi-harness/src/tui.ts create mode 100644 packages/pi-harness/src/types.ts create mode 100644 packages/pi-harness/tsconfig.json diff --git a/.gitignore b/.gitignore index c26e417..0071cd8 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,6 @@ data/ .knowledge-base LAUNCH-PLAYBOOK.md + +.obsidian/ +.pi-lens/ diff --git a/README.md b/README.md index d2fade9..f9bf387 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,19 @@ Delegate tasks through natural conversation from WhatsApp, Slack, or a web dashb --- +## ⚡ Looking for a Standalone Agent Harness? + +If you want a **lightweight, deployable agent server** without the full platform (no PostgreSQL, no dashboard, no auth complexity), check out **[@openzosma/pi-harness](./packages/pi-harness)**: + +```bash +npm install -g @openzosma/pi-harness +pi-harness # First run auto-configures, then starts the server +``` + +It runs `pi-coding-agent` headlessly as a background HTTP/SSE server — perfect for low-end hardware, background services, custom integrations, or when you just want the agent without the platform. [Learn more →](./packages/pi-harness) + +--- + ## Get Started One command sets up everything -- cloning, environment, Docker services, database migrations, and the first build: @@ -127,7 +140,7 @@ You define a hierarchy of agents that mirrors your organization. Each agent has Agent Hierarchy -**Example:** You message your CEO Agent from WhatsApp: *"What were last week's sales numbers and are there any open support tickets over 48 hours?"* The CEO Agent delegates to the Sales Manager Agent and Support Agent in parallel. They query your connected systems, and you get a consolidated answer back -- all from a single message on your phone. +**Example:** You message your CEO Agent from WhatsApp: _"What were last week's sales numbers and are there any open support tickets over 48 hours?"_ The CEO Agent delegates to the Sales Manager Agent and Support Agent in parallel. They query your connected systems, and you get a consolidated answer back -- all from a single message on your phone. --- @@ -139,10 +152,12 @@ You define a hierarchy of agents that mirrors your organization. Each agent has The gateway runs in two modes controlled by `OPENZOSMA_SANDBOX_MODE`: -| Mode | How it works | Best for | -|------|-------------|----------| -| **`local`** (default) | pi-agent runs in-process inside the gateway. No OpenShell needed. | Development | -| **`orchestrator`** | Each user gets a persistent OpenShell sandbox. The orchestrator manages sandbox lifecycle and proxies messages via HTTP/SSE. | Production | +| Mode | How it works | Best for | +| --------------------- | ---------------------------------------------------------------------------------------------------------------------------- | ----------- | +| **`local`** (default) | pi-agent runs in-process inside the gateway. No OpenShell needed. | Development | +| **`orchestrator`** | Each user gets a persistent OpenShell sandbox. The orchestrator manages sandbox lifecycle and proxies messages via HTTP/SSE. | Production | + +> **Want just the agent, without the gateway?** [`pi-harness`](./packages/pi-harness) is the local mode extracted as a standalone, deployable package — no PostgreSQL, no dashboard, no auth complexity. One `npm install -g` and you're running. See [ARCHITECTURE.md](./ARCHITECTURE.md) for the full system design, data flow, and component details. @@ -175,31 +190,31 @@ See [infra/openshell/README.md](./infra/openshell/README.md) for sandbox image d ## Tech Stack -| Component | Technology | -|-----------|-----------| -| Runtime | Node.js 22 (TypeScript) | -| HTTP Server | Hono | -| Database | PostgreSQL (raw SQL via `pg`, migrations via `db-migrate`) | -| Auth | Better Auth | -| Sandbox | NVIDIA NemoClaw + OpenShell | -| Web Dashboard | Next.js 16, React 19, Tailwind CSS v4 | -| Mobile | React Native (planned) | -| Agent Protocol | [Google A2A](https://github.com/google/A2A) | -| Cache / Pub-Sub | Valkey (ioredis) | -| Internal Comms | HTTP / SSE (orchestrator to sandbox-server) | +| Component | Technology | +| --------------- | ---------------------------------------------------------- | +| Runtime | Node.js 22 (TypeScript) | +| HTTP Server | Hono | +| Database | PostgreSQL (raw SQL via `pg`, migrations via `db-migrate`) | +| Auth | Better Auth | +| Sandbox | NVIDIA NemoClaw + OpenShell | +| Web Dashboard | Next.js 16, React 19, Tailwind CSS v4 | +| Mobile | React Native (planned) | +| Agent Protocol | [Google A2A](https://github.com/google/A2A) | +| Cache / Pub-Sub | Valkey (ioredis) | +| Internal Comms | HTTP / SSE (orchestrator to sandbox-server) | --- ## Supported LLM Providers -| Provider | Default model | -|----------|--------------| -| Anthropic | Claude Sonnet 4 | -| OpenAI | GPT-4o | -| Google | Gemini 2.5 Flash | -| Groq | Llama 3.3 70B | -| xAI | Grok 3 | -| Mistral | Mistral Large | +| Provider | Default model | +| ----------- | --------------------------------------------------- | +| Anthropic | Claude Sonnet 4 | +| OpenAI | GPT-4o | +| Google | Gemini 2.5 Flash | +| Groq | Llama 3.3 70B | +| xAI | Grok 3 | +| Mistral | Mistral Large | | Local model | Any OpenAI-compatible endpoint (Ollama, vLLM, etc.) | --- @@ -215,6 +230,7 @@ openzosma/ gateway/ API gateway (REST + WebSocket + A2A) orchestrator/ Sandbox lifecycle, session proxying, health checks agents/ Agent provider interface + implementations + pi-harness/ Standalone headless agent server (lightweight deploy) sandbox/ OpenShell CLI wrapper sandbox-server/ HTTP server running inside sandbox containers db/ Database migrations and query module @@ -234,15 +250,16 @@ openzosma/ ## Roadmap -| Phase | Description | Status | -|-------|-------------|--------| -| [Phase 1](./docs/PHASE-1-MULTITENANT.md) | Multi-instance pi-agent refactor (in pi-mono) | Complete | -| [Phase 2](./docs/PHASE-2-MONOREPO.md) | Monorepo setup + DB schema + auth | Complete | -| [Phase 3](./docs/PHASE-3-GATEWAY.md) | API Gateway + A2A + auth | Complete | -| [Phase 4](./docs/PHASE-4-ORCHESTRATOR.md) | Orchestrator + OpenShell sandbox integration | In progress | -| [Phase 5](./docs/PHASE-5-ADAPTERS.md) | Channel adapters (Slack, WhatsApp) | Not started | -| [Phase 6](./docs/PHASE-6-SKILLS.md) | Enterprise skills (database tool, reports) | Not started | -| [Phase 7](./docs/PHASE-7-DASHBOARD.md) | Web dashboard | In progress (MVP) | +| Phase | Description | Status | +| ----------------------------------------- | ------------------------------------------------------------------- | ----------------- | +| [Phase 1](./docs/PHASE-1-MULTITENANT.md) | Multi-instance pi-agent refactor (in pi-mono) | Complete | +| [Phase 2](./docs/PHASE-2-MONOREPO.md) | Monorepo setup + DB schema + auth | Complete | +| [Phase 3](./docs/PHASE-3-GATEWAY.md) | API Gateway + A2A + auth | Complete | +| **Pi-Harness** | Standalone headless agent server ([package](./packages/pi-harness)) | Complete | +| [Phase 4](./docs/PHASE-4-ORCHESTRATOR.md) | Orchestrator + OpenShell sandbox integration | In progress | +| [Phase 5](./docs/PHASE-5-ADAPTERS.md) | Channel adapters (Slack, WhatsApp) | Not started | +| [Phase 6](./docs/PHASE-6-SKILLS.md) | Enterprise skills (database tool, reports) | Not started | +| [Phase 7](./docs/PHASE-7-DASHBOARD.md) | Web dashboard | In progress (MVP) | **MVP (Phases 1-4):** ~4 weeks  |  **Full platform (Phases 1-7):** ~10 weeks @@ -250,12 +267,13 @@ openzosma/ ## Documentation -| Document | Description | -|----------|-------------| -| [ARCHITECTURE.md](./ARCHITECTURE.md) | System design, component interactions, data flow | -| [CONTRIBUTING.md](./CONTRIBUTING.md) | Development setup, environment variables, conventions | -| [packages/db/README.md](./packages/db/README.md) | Database migrations, schemas, query module | -| [docs/](./docs/) | Phase-by-phase implementation plans | +| Document | Description | +| ---------------------------------------------------------------- | ----------------------------------------------------- | +| [ARCHITECTURE.md](./ARCHITECTURE.md) | System design, component interactions, data flow | +| [CONTRIBUTING.md](./CONTRIBUTING.md) | Development setup, environment variables, conventions | +| [packages/db/README.md](./packages/db/README.md) | Database migrations, schemas, query module | +| [packages/pi-harness/README.md](./packages/pi-harness/README.md) | Standalone headless agent server docs | +| [docs/](./docs/) | Phase-by-phase implementation plans | --- diff --git a/packages/pi-harness/README.md b/packages/pi-harness/README.md new file mode 100644 index 0000000..485d2bf --- /dev/null +++ b/packages/pi-harness/README.md @@ -0,0 +1,609 @@ +

+ Pi-Harness +

+ +# Pi-Harness ⚡ + +> _"Standing on the shoulders of giants — pi-coding-agent by [Mario Zechner](https://github.com/badlogic) is the real hero here. We're just building the saddle."_ + +**Pi-Harness** is the top-level harness for the [Pi ecosystem](https://github.com/badlogic/pi-coding-agent). It takes the extraordinary power of `pi-coding-agent` — Mario Zechner's thoughtful, fast, and capable coding agent — and wraps it as a **headless HTTP/SSE server** that fits into _your_ workflow, not the other way around. + +Unlike generic platforms that force companies to adapt to their opinions, Pi-Harness is designed to be **deeply customizable**. Configure your own models, tools, system prompts, extensions, and skills. Run it on a $5 VPS, a Kubernetes cluster, or even your phone via Termux. One process serves many users. Inference happens in the cloud. Your hardware just needs Node.js. + + + + + + + + +
🚀 Standalone & HeadlessNo TUI, no database, no auth complexity. Just a clean HTTP API over pi-coding-agent's core SDK.
🏢 Company-First DesignYour workflow, your rules. Custom system prompts, tool allowlists, extensions, and skills per-deployment.
📡 Real-Time SSE StreamingEvery thought, tool call, and token streams to clients via Server-Sent Events. Build reactive UIs.
🔧 Full Pi Ecosystem SupportNot just coding agent — designed to harness all Pi packages: pi-ai, pi-agent-core, pi-tui, pi-lens, and more.
🧩 Extension & Skill RegistryOfficial skills and extensions published via git. Drop them in and they work. Community-driven ecosystem.
📱 Runs AnywhereLinux, macOS, WSL2, Docker, Kubernetes, Termux on Android. Minimal resource footprint.
+ +--- + +## 🙏 With Gratitude + +**Pi-Harness would not exist without [pi-coding-agent](https://github.com/badlogic/pi-coding-agent) by Mario Zechner.** The Pi ecosystem — `pi-ai`, `pi-agent-core`, `pi-coding-agent`, `pi-tui`, `pi-lens` — represents some of the most thoughtful agent infrastructure built for developers. Mario's work on session management, the tool loop, the event system, and the TUI is the foundation everything here rests on. + +**We are building this with his blessing and with deep respect.** Pi-Harness is not a fork, not a replacement, and not a competitor. It is a **deployment layer** — a way to run Pi's incredible agent logic in contexts where the interactive TUI isn't the right fit: background services, multi-user servers, embedded devices, automated pipelines, and company-specific integrations. + +If you haven't tried `pi-coding-agent` directly, you should. It's beautiful. + +```bash +npm install -g @mariozechner/pi-coding-agent +pi # experience the TUI firsthand +``` + +--- + +## ⚡ Quick Start + +### Install (Recommended) + +```bash +npm install -g @openzosma/pi-harness +``` + +Requires Node.js 22+. That's it — no cloning, no monorepo, no pnpm. + +### First Run + +```bash +pi-harness +``` + +On first run, pi-harness detects you're not configured and walks you through an interactive setup wizard. It asks for your LLM provider, API key, model preferences, and server settings. Your config is saved to `~/.pi-harness/.env`. + +After setup, the server starts automatically. + +### Daily Usage + +```bash +pi-harness # Start server (foreground) +pi-harness start --daemon # Start in background +pi-harness status # Check if running +pi-harness logs # Tail server logs +pi-harness stop # Stop background daemon +pi-harness tui # Connect with interactive client +pi-harness setup # Re-run setup wizard +pi-harness --help # Show all commands +``` + +### One-Liner Install (Alternative) + +Prefer curl? We got you: + +```bash +curl -fsSL https://raw.githubusercontent.com/zosmaai/openzosma/main/packages/pi-harness/scripts/install.sh | bash +``` + +This installs Node.js (if needed), pnpm, clones the repo, builds everything, and adds `pi-harness` to your PATH. + +### Manual Setup (OpenZosma Monorepo) + +For development within the openzosma repo: + +```bash +cd /path/to/openzosma +pnpm install +pnpm --filter @openzosma/pi-harness build +pnpm --filter @openzosma/pi-harness start +``` + +--- + +## 📖 What Is Pi-Harness? + +Pi-coding-agent has three run modes: + +1. **Interactive mode** — The TUI (`pi` command). Beautiful, immersive, local. +2. **Print mode** — Single-shot CLI output. +3. **SDK mode** — Programmatic via `AgentSession`, `createAgentSession()`. + +**Pi-Harness uses SDK mode.** The TUI (`pi-tui`) is never loaded. The core agent logic runs headlessly, streaming events through an async generator that the HTTP server consumes and forwards as SSE. + +This means: + +- **No GPU required locally** — inference happens at your chosen API endpoint +- **Multiple sessions, one process** — concurrent users share a lightweight Node.js runtime +- **Any client** — web dashboard, mobile app, CLI, Discord bot, CI pipeline +- **Tiny footprint** — ~50-100 MB base + ~10-30 MB per session + +### Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Your Client │ +│ (Web dashboard / CLI / Mobile / Discord / CI) │ +└──────────────────────┬──────────────────────────────────────┘ + │ HTTP + SSE + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Pi-Harness (Node.js) │ +│ │ +│ ┌─────────────┐ ┌─────────────────────────────────────┐ │ +│ │ Hono HTTP │───▶│ HarnessSessionManager │ │ +│ │ Server │ │ • Multi-session in-process │ │ +│ │ :8080 │◀───│ • Per-session workspace isolation │ │ +│ └─────────────┘ └─────────────────────────────────────┘ │ +│ │ │ │ +│ │ ▼ │ +│ │ ┌──────────────────┐ │ +│ │ │ @openzosma/agents │ │ +│ │ │ (PiAgentProvider) │ │ +│ │ └────────┬─────────┘ │ +│ │ │ │ +│ ▼ ▼ │ +│ REST + SSE @mariozechner/ │ +│ /sessions pi-coding-agent │ +│ /sessions/:id/messages (headless SDK mode) │ +│ NO TUI — core only │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ + Cloud LLM APIs (OpenRouter, OpenAI, + Anthropic, OpenCode Go, Ollama, etc.) +``` + +--- + +## 🔌 API Reference + +### Session Management + +| Method | Path | Description | +| -------- | --------------- | ------------------------------------- | +| `GET` | `/health` | Health check + uptime + session count | +| `POST` | `/sessions` | Create a new session | +| `GET` | `/sessions` | List active session IDs | +| `GET` | `/sessions/:id` | Get session metadata | +| `DELETE` | `/sessions/:id` | End a session | + +### Messaging + +| Method | Path | Description | +| ------ | ------------------------ | -------------------------- | +| `POST` | `/sessions/:id/messages` | Send message → SSE stream | +| `POST` | `/sessions/:id/steer` | Steering message mid-turn | +| `POST` | `/sessions/:id/followup` | Queue follow-up after turn | +| `POST` | `/sessions/:id/cancel` | Cancel active turn | + +### Create Session + +```bash +curl -X POST http://localhost:8080/sessions \ + -H "Content-Type: application/json" \ + -H "x-api-key: dev-secret" \ + -d '{ + "model": "claude-sonnet-4", + "systemPromptPrefix": "You are a senior Rust engineer.", + "toolsEnabled": ["read", "bash", "write"] + }' +``` + +Response: + +```json +{ "sessionId": "abc-123-..." } +``` + +### Send Message (SSE) + +```bash +curl -N -X POST http://localhost:8080/sessions/abc-123/messages \ + -H "Content-Type: application/json" \ + -H "x-api-key: dev-secret" \ + -d '{"content": "Write a hello world in Rust"}' +``` + +Response (SSE stream): + +``` +event: turn_start +data: {"type":"turn_start","id":"..."} + +event: message_start +data: {"type":"message_start","id":"..."} + +event: message_update +data: {"type":"message_update","id":"...","text":"I'll"} + +event: message_update +data: {"type":"message_update","id":"...","text":" create"} + +event: tool_call_start +data: {"type":"tool_call_start","toolCallId":"...","toolName":"write","toolArgs":"{...}"} + +event: tool_call_end +data: {"type":"tool_call_end","toolCallId":"...","toolName":"write","toolResult":"File written."} + +event: turn_end +data: {"type":"turn_end","id":"..."} +``` + +--- + +## ⚙️ Configuration + +All configuration is via environment variables. No config files, no database, no ceremony. + +### Required + +| Variable | Description | Example | +| --------------------------- | --------------------------------- | ------------------------------- | +| `OPENROUTER_API_KEY` | OpenRouter API key | `sk-or-v1-...` | +| `OPENAI_API_KEY` | OpenAI API key | `sk-...` | +| `ANTHROPIC_API_KEY` | Anthropic API key | `sk-ant-...` | +| `OPENZOSMA_LOCAL_MODEL_URL` | Custom OpenAI-compatible endpoint | `https://opencode.ai/zen/go/v1` | + +You only need **one** provider key. Pi-Harness auto-detects which one you have. + +### Server Settings + +| Variable | Default | Description | +| --------------------------------- | --------------- | -------------------------- | +| `PI_HARNESS_PORT` | `8080` | HTTP server port | +| `PI_HARNESS_HOST` | `0.0.0.0` | Host to bind to | +| `PI_HARNESS_API_KEY` | _(none)_ | Require `x-api-key` header | +| `PI_HARNESS_MAX_SESSIONS` | `0` (unlimited) | Max concurrent sessions | +| `PI_HARNESS_IDLE_TIMEOUT_MINUTES` | `30` | Auto-cleanup idle sessions | +| `PI_HARNESS_WORKSPACE` | `./workspace` | Session workspace root | +| `PI_HARNESS_MAX_BODY_SIZE` | `10MB` | Request body limit | + +### Agent Defaults + +| Variable | Description | Example | +| --------------------------------- | ------------------------------------- | ----------------------------------- | +| `PI_HARNESS_PROVIDER` | Default LLM provider | `openrouter`, `anthropic`, `openai` | +| `PI_HARNESS_MODEL` | Default model ID | `claude-sonnet-4`, `gpt-4o` | +| `PI_HARNESS_TOOLS` | Default tools (comma-separated) | `read,bash,write,edit` | +| `PI_HARNESS_SYSTEM_PROMPT_PREFIX` | Prefix for all sessions | `"You work at Acme Corp..."` | +| `PI_HARNESS_SYSTEM_PROMPT_SUFFIX` | Suffix for all sessions | `"Always write tests."` | +| `PI_HARNESS_EXTENSIONS_DIR` | Load pi-coding-agent extensions | `/path/to/extensions` | +| `PI_HARNESS_SKILLS_DIR` | _(future)_ Load skills from directory | `/path/to/skills` | +| `PI_HARNESS_VERBOSE` | `false` | Enable verbose logging | + +### Full Example + +```bash +# Provider: OpenCode Go (affordable bundled models) +export OPENZOSMA_LOCAL_MODEL_URL="https://opencode.ai/zen/go/v1" +export OPENZOSMA_LOCAL_MODEL_API_KEY="sk-..." +export OPENZOSMA_LOCAL_MODEL_ID="qwen3.6-plus" + +# Server +export PI_HARNESS_PORT=8080 +export PI_HARNESS_API_KEY="company-secret" +export PI_HARNESS_MAX_SESSIONS=50 +export PI_HARNESS_IDLE_TIMEOUT_MINUTES=30 + +# Agent defaults for your company +export PI_HARNESS_PROVIDER="local" +export PI_HARNESS_MODEL="qwen3.6-plus" +export PI_HARNESS_TOOLS="read,bash,write,edit,grep,find,ls" +export PI_HARNESS_SYSTEM_PROMPT_PREFIX="You are a senior engineer at Acme Corp. Follow our style guide at https://acme.dev/style. Use TypeScript, prefer functional patterns, and always write tests." +export PI_HARNESS_EXTENSIONS_DIR="/opt/pi-harness/extensions" + +# Run +pnpm --filter @openzosma/pi-harness start +``` + +--- + +## 🖥️ Running in Background + +### Built-in Daemon (Easiest) + +Pi-Harness has native daemon support — no external tools needed: + +```bash +pi-harness start --daemon # Start in background +pi-harness status # Check if running +pi-harness logs # Tail server logs +pi-harness stop # Stop daemon +``` + +Your config and logs live in `~/.pi-harness/`. + +### systemd (Linux Servers) + +Create `/etc/systemd/system/pi-harness.service`: + +```ini +[Unit] +Description=Pi-Harness Agent Server +After=network.target + +[Service] +Type=simple +User=pi-harness +WorkingDirectory=/opt/pi-harness +EnvironmentFile=/opt/pi-harness/.env +ExecStart=/usr/bin/pi-harness start +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target +``` + +```bash +sudo systemctl daemon-reload +sudo systemctl enable pi-harness +sudo systemctl start pi-harness +sudo systemctl status pi-harness +``` + +### pm2 (Node.js Process Manager) + +```bash +npm install -g pm2 + +pm2 start "pi-harness start" --name pi-harness + +pm2 save +pm2 startup + +# Logs +pm2 logs pi-harness + +# Restart +pm2 restart pi-harness +``` + +### Docker Compose + +```yaml +services: + pi-harness: + build: ./packages/pi-harness + ports: + - "8080:8080" + environment: + - OPENROUTER_API_KEY=${OPENROUTER_API_KEY} + - PI_HARNESS_API_KEY=${PI_HARNESS_API_KEY} + - PI_HARNESS_MAX_SESSIONS=50 + - PI_HARNESS_SYSTEM_PROMPT_PREFIX=${PI_HARNESS_SYSTEM_PROMPT_PREFIX} + volumes: + - ./workspace:/app/workspace + - ./extensions:/app/extensions + restart: unless-stopped +``` + +### Kubernetes + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: pi-harness +spec: + replicas: 2 + selector: + matchLabels: + app: pi-harness + template: + metadata: + labels: + app: pi-harness + spec: + containers: + - name: pi-harness + image: openzosma/pi-harness:latest + ports: + - containerPort: 8080 + env: + - name: OPENROUTER_API_KEY + valueFrom: + secretKeyRef: + name: llm-secrets + key: openrouter + - name: PI_HARNESS_API_KEY + valueFrom: + secretKeyRef: + name: harness-secrets + key: api-key + - name: PI_HARNESS_MAX_SESSIONS + value: "100" + resources: + requests: + memory: "256Mi" + cpu: "250m" + limits: + memory: "1Gi" + cpu: "1000m" +``` + +--- + +## 🧩 Extensions & Skills + +### Pi-Coding-Agent Extensions + +Pi-coding-agent supports extensions loaded from a directory. Pi-Harness exposes this via `PI_HARNESS_EXTENSIONS_DIR`. + +Extensions are JavaScript/TypeScript modules that hook into the agent lifecycle. They can add tools, modify prompts, or react to events. Place them in the extensions directory and they'll be loaded automatically on session creation. + +```bash +export PI_HARNESS_EXTENSIONS_DIR="/opt/pi-harness/extensions" +``` + +Directory structure: + +``` +extensions/ +├── my-company-tool/ +│ ├── package.json +│ └── index.js # exports extension hooks +└── custom-validator/ + └── index.js +``` + +### Skills (Coming Soon) + +Skills are higher-level procedural memory — reusable task patterns the agent can invoke by name. Think of them as functions the LLM can call that encode multi-step workflows. + +We're building an official skills registry at `github.com/zosmaai/pi-harness-skills`. Drop a skill directory into `PI_HARNESS_SKILLS_DIR` and the agent gains new capabilities: + +``` +skills/ +├── deploy-to-vercel/ +│ ├── skill.json # metadata, parameters, description +│ └── workflow.md # step-by-step instructions for the agent +├── run-security-audit/ +│ ├── skill.json +│ └── workflow.md +└── generate-changelog/ + ├── skill.json + └── workflow.md +``` + +Skills follow the open [AgentSkills](https://agentskills.io) standard where possible. + +--- + +## 🌐 Ecosystem Vision + +Pi-Harness is not just a coding agent server. It is the **top-level harness** for the entire Pi ecosystem. + +### What This Means + +| Package | What It Does | How Pi-Harness Uses It | +| ----------------- | ------------------------------------------------------- | ---------------------------------------------------------------- | +| `pi-coding-agent` | The core coding agent with tools and session management | **Primary engine** — runs headlessly via SDK mode | +| `pi-ai` | LLM abstraction layer | **Model routing** — supports any provider via pi-ai's registry | +| `pi-agent-core` | Agent loop, event system, tool framework | **Event streaming** — SSE events originate from pi-agent-core | +| `pi-tui` | Terminal UI framework | **Client option** — TUI client can be built on pi-tui components | +| `pi-lens` | Code intelligence and navigation | **Future** — LSP-powered code context for agent sessions | + +### Our Philosophy + +**Big tech platforms make you fit into their workflow.** They decide which models you can use, which tools are available, how auth works, and how much it costs. They own your data and your configuration. + +**Pi-Harness makes the platform fit into your workflow.** You choose: + +- Which LLM provider (or your own endpoint) +- Which tools are enabled per-deployment +- What the agent knows about your company +- Where it runs and who has access +- How it integrates with your existing systems + +This is **infrastructure as code** for AI agents. Deploy it, configure it, extend it. It's yours. + +--- + +## 📊 Resource Usage + +Pi-Harness is designed to run on minimal hardware: + +| Component | Usage | +| ---------------------- | ---------------------------------------- | +| Node.js runtime | ~50-100 MB base | +| Per-session overhead | ~10-30 MB (depends on history) | +| 50 concurrent sessions | ~1-2 GB RAM total | +| CPU between turns | Near zero | +| CPU during streaming | Spikes during LLM I/O and tool execution | + +**No GPU required.** All inference happens at your configured API endpoint. + +--- + +## 🔗 Comparison + +| | Pi-Harness | Gateway + Orchestrator | sandbox-server | +| ----------------- | ----------------------- | ------------------------ | ---------------- | +| **Weight** | Light | Heavy | Medium | +| **Database** | None | PostgreSQL + Better Auth | None | +| **Auth** | Optional API key | Full user auth | None | +| **Sandbox** | No | Yes (OpenShell) | Yes (OpenShell) | +| **Dashboard** | No | Yes (Next.js) | No | +| **A2A Protocol** | No | Yes | No | +| **Multi-tenancy** | Sessions | Users + Orgs | Sessions | +| **Best For** | Standalone agent server | Full platform | Sandboxed agents | + +Use **Pi-Harness** when you want a simple, deployable agent server without platform complexity. + +Use **Gateway** when you need user auth, billing, persistent history, and a web dashboard. + +--- + +## 🛠️ Development + +```bash +git clone https://github.com/zosmaai/openzosma.git +cd openzosma +pnpm install +pnpm --filter @openzosma/pi-harness build +pnpm --filter @openzosma/pi-harness check # TypeScript check +pnpm --filter @openzosma/pi-harness dev # tsx watch mode +``` + +### Project Structure + +``` +packages/pi-harness/ +├── src/ +│ ├── cli.ts # CLI router (lightweight, no heavy deps) +│ ├── commands.ts # Heavy command implementations (start, tui) +│ ├── index.ts # Server entry point (daemon/foreground) +│ ├── server.ts # Hono HTTP server (REST + SSE) +│ ├── session-manager.ts # Multi-session lifecycle manager +│ ├── config.ts # Environment configuration +│ ├── types.ts # Shared TypeScript types +│ └── tui.ts # Terminal client +├── scripts/ +│ ├── install.sh # One-liner installer +│ ├── setup.sh # Interactive setup wizard +│ └── build-bundle.mjs # esbuild bundler for standalone npm +├── dist/ # Bundled + compiled output +├── README.md +└── package.json +``` + +--- + +## 🗺️ Roadmap + +- [x] Headless HTTP/SSE server +- [x] Multi-session management +- [x] TUI client (`pi-harness-tui`) +- [x] One-liner install script +- [x] Interactive setup wizard +- [x] Default tools, prompts, and extensions +- [ ] Session persistence (SQLite/JSON) for restart survival +- [ ] WebSocket transport option +- [ ] gRPC server mode +- [ ] Metrics endpoint (Prometheus) +- [ ] Official skills registry (git-based) +- [ ] Extension marketplace +- [ ] A2A protocol endpoint +- [ ] Horizontal pod autoscaling example +- [ ] Termux/Android automated install +- [ ] pi-lens integration for code intelligence + +--- + +## 🤝 Contributing + +We welcome contributions! This is an open-source project by [Zosma AI](https://zosma.ai), built with deep respect for the Pi ecosystem. + +- 🐛 [Open an issue](https://github.com/zosmaai/openzosma/issues) +- 💡 [Start a discussion](https://github.com/zosmaai/openzosma/discussions) +- 🔀 [Submit a PR](https://github.com/zosmaai/openzosma/pulls) + +Special thanks to **Mario Zechner** for creating the Pi ecosystem and for his blessing in building this harness. + +--- + +## 📜 License + +MIT — see the [OpenZosma LICENSE](../../LICENSE). + +The underlying `pi-coding-agent` and Pi packages are licensed under their respective licenses (MIT). All credit for the agent intelligence belongs to Mario Zechner and the Pi contributors. + +--- + +

+ Built with 💜 by Zosma AI
+ Standing on the shoulders of Mario Zechner's Pi ecosystem +

diff --git a/packages/pi-harness/package.json b/packages/pi-harness/package.json new file mode 100644 index 0000000..00ead60 --- /dev/null +++ b/packages/pi-harness/package.json @@ -0,0 +1,56 @@ +{ + "name": "@openzosma/pi-harness", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "The top-level harness for the Pi ecosystem. Run pi-coding-agent and other Pi packages headlessly as a background HTTP/SSE server. Built with gratitude for Mario Zechner's pi-mono.", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "bin": { + "pi-harness": "dist/cli.js" + }, + "files": [ + "dist", + "scripts" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./server": { + "types": "./dist/server.d.ts", + "import": "./dist/server.js" + }, + "./types": { + "types": "./dist/types.d.ts", + "import": "./dist/types.js" + } + }, + "scripts": { + "build": "tsc && pnpm build:bundle", + "build:bundle": "node scripts/build-bundle.mjs", + "check": "tsc --noEmit", + "start": "node dist/cli.js start", + "dev": "tsx src/cli.ts start", + "tui": "node dist/cli.js tui", + "setup": "bash scripts/setup.sh", + "setup:quick": "bash scripts/setup.sh --quick" + }, + "dependencies": { + "@hono/node-server": "^1.19.13", + "@openzosma/agents": "workspace:*", + "@openzosma/logger": "workspace:*", + "chalk": "^5.5.0", + "hono": "^4.12.12" + }, + "devDependencies": { + "@types/node": "^22.15.2", + "esbuild": "^0.25.0", + "tsx": "^4.21.0", + "typescript": "^5.7.3" + }, + "engines": { + "node": ">=22" + } +} diff --git a/packages/pi-harness/scripts/build-bundle.mjs b/packages/pi-harness/scripts/build-bundle.mjs new file mode 100644 index 0000000..f83f43d --- /dev/null +++ b/packages/pi-harness/scripts/build-bundle.mjs @@ -0,0 +1,67 @@ +#!/usr/bin/env node +/** + * Build self-contained bundles for the pi-harness CLI and server. + * + * This bundles all workspace-local code (@openzosma/*) into the output + * while keeping external npm packages as regular dependencies. Dynamic + * imports create separate chunks so lightweight commands (help, version, + * status) don't trigger resolution of heavy deps like pi-coding-agent. + * + * Usage: + * node scripts/build-bundle.mjs + */ + +import * as esbuild from "esbuild" +import { readFileSync, writeFileSync } from "node:fs" +import { resolve } from "node:path" +import { fileURLToPath } from "node:url" + +const __dirname = fileURLToPath(new URL(".", import.meta.url)) +const outDir = resolve(__dirname, "../dist") + +// External packages — these remain as npm dependencies +const EXTERNAL = [ + "hono", + "@hono/*", + "chalk", + "@mariozechner/*", + "dotenv", + "uuid", + "pg", + "@sinclair/typebox", + "@types/pg", +] + +async function bundleEntry(entry, outName) { + console.log(`📦 Bundling ${entry}...`) + await esbuild.build({ + entryPoints: [resolve(__dirname, `../src/${entry}`)], + bundle: true, + platform: "node", + format: "esm", + target: "node22", + outdir: outDir, + splitting: true, + external: EXTERNAL, + minify: false, + sourcemap: true, + }) + + // Prepend shebang + const outPath = resolve(outDir, outName) + const content = readFileSync(outPath, "utf-8") + writeFileSync(outPath, "#!/usr/bin/env node\n" + content, { mode: 0o755 }) +} + +async function main() { + await bundleEntry("cli.ts", "cli.js") + await bundleEntry("index.ts", "index.js") + + console.log(`\n✅ Bundles created in ${outDir}`) + console.log(` External deps: ${EXTERNAL.join(", ")}`) +} + +main().catch((err) => { + console.error("❌ Bundle failed:", err) + process.exit(1) +}) diff --git a/packages/pi-harness/scripts/install.sh b/packages/pi-harness/scripts/install.sh new file mode 100755 index 0000000..63b63c9 --- /dev/null +++ b/packages/pi-harness/scripts/install.sh @@ -0,0 +1,365 @@ +#!/bin/bash +# ============================================================================ +# Pi-Harness Installer +# ============================================================================ +# One-liner install for the Pi-Harness — a standalone headless agent server +# built on top of pi-coding-agent by Mario Zechner. +# +# Usage: +# curl -fsSL https://raw.githubusercontent.com/zosmaai/openzosma/main/packages/pi-harness/scripts/install.sh | bash +# +# ============================================================================ + +set -e + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +BLUE='\033[0;34m' +MAGENTA='\033[0;35m' +CYAN='\033[0;36m' +NC='\033[0m' +BOLD='\033[1m' + +# Configuration +REPO_URL="https://github.com/zosmaai/openzosma.git" +INSTALL_DIR="${PI_HARNESS_INSTALL_DIR:-$HOME/.pi-harness}" +NODE_MIN_VERSION=22 + +# Options +SKIP_SETUP=false +BRANCH="main" + +# Parse arguments +while [[ $# -gt 0 ]]; do + case $1 in + --skip-setup) + SKIP_SETUP=true + shift + ;; + --branch) + BRANCH="$2" + shift 2 + ;; + --dir) + INSTALL_DIR="$2" + shift 2 + ;; + -h | --help) + echo "Pi-Harness Installer" + echo "" + echo "Usage: install.sh [OPTIONS]" + echo "" + echo "Options:" + echo " --skip-setup Skip interactive setup wizard" + echo " --branch NAME Git branch to install (default: main)" + echo " --dir PATH Installation directory (default: ~/.pi-harness)" + echo " -h, --help Show this help" + exit 0 + ;; + *) + echo "Unknown option: $1" + exit 1 + ;; + esac +done + +# ============================================================================ +# Helper functions +# ============================================================================ + +print_banner() { + echo "" + echo -e "${MAGENTA}${BOLD}" + echo "┌─────────────────────────────────────────────────────────┐" + echo "│ ⚡ Pi-Harness Installer │" + echo "├─────────────────────────────────────────────────────────┤" + echo "│ Standalone headless agent harness for pi-coding-agent │" + echo "│ Built with gratitude for Mario Zechner's pi-mono │" + echo "└─────────────────────────────────────────────────────────┘" + echo -e "${NC}" +} + +log_info() { + echo -e "${CYAN}→${NC} $1" +} + +log_success() { + echo -e "${GREEN}✓${NC} $1" +} + +log_warn() { + echo -e "${YELLOW}⚠${NC} $1" +} + +log_error() { + echo -e "${RED}✗${NC} $1" +} + +# ============================================================================ +# System detection +# ============================================================================ + +detect_os() { + case "$(uname -s)" in + Linux*) + OS="linux" + ;; + Darwin*) + OS="macos" + ;; + *) + OS="unknown" + log_warn "Unknown operating system" + ;; + esac + log_success "Detected: $OS" +} + +# ============================================================================ +# Dependency checks +# ============================================================================ + +check_node() { + log_info "Checking Node.js..." + + if command -v node &>/dev/null; then + NODE_VERSION=$(node --version | sed 's/v//') + NODE_MAJOR=$(echo "$NODE_VERSION" | cut -d. -f1) + if [ "$NODE_MAJOR" -ge "$NODE_MIN_VERSION" ]; then + log_success "Node.js v$NODE_VERSION found" + return 0 + else + log_warn "Node.js v$NODE_VERSION found, but v$NODE_MIN_VERSION+ required" + fi + fi + + log_error "Node.js $NODE_MIN_VERSION+ is required but not found" + log_info "Install Node.js $NODE_MIN_VERSION LTS:" + log_info " macOS: brew install node@$NODE_MIN_VERSION" + log_info " Ubuntu: curl -fsSL https://deb.nodesource.com/setup_${NODE_MIN_VERSION}.x | sudo -E bash - && sudo apt install -y nodejs" + log_info " Or visit: https://nodejs.org/en/download/" + exit 1 +} + +check_pnpm() { + log_info "Checking pnpm..." + + if command -v pnpm &>/dev/null; then + PNPM_VERSION=$(pnpm --version) + log_success "pnpm $PNPM_VERSION found" + return 0 + fi + + log_info "Installing pnpm..." + if curl -fsSL https://get.pnpm.io/install.sh | sh -; then + export PNPM_HOME="$HOME/.local/share/pnpm" + export PATH="$PNPM_HOME:$PATH" + log_success "pnpm installed" + else + log_error "Failed to install pnpm" + log_info "Install manually: https://pnpm.io/installation" + exit 1 + fi +} + +check_git() { + log_info "Checking Git..." + + if command -v git &>/dev/null; then + log_success "Git found" + return 0 + fi + + log_error "Git is required" + exit 1 +} + +# ============================================================================ +# Installation +# ============================================================================ + +clone_repo() { + log_info "Installing to $INSTALL_DIR..." + + if [ -d "$INSTALL_DIR" ]; then + if [ -d "$INSTALL_DIR/.git" ]; then + log_info "Existing installation found, updating..." + cd "$INSTALL_DIR" + git fetch origin + git checkout "$BRANCH" + git pull --ff-only origin "$BRANCH" + log_success "Updated to latest $BRANCH" + else + log_error "Directory exists but is not a git repository: $INSTALL_DIR" + log_info "Remove it or choose a different directory with --dir" + exit 1 + fi + else + log_info "Cloning openzosma repository..." + git clone --depth 1 --branch "$BRANCH" "$REPO_URL" "$INSTALL_DIR" + log_success "Repository cloned" + fi + + cd "$INSTALL_DIR" +} + +install_deps() { + log_info "Installing dependencies (this may take a minute)..." + pnpm install --no-frozen-lockfile + log_success "Dependencies installed" +} + +build_harness() { + log_info "Building pi-harness..." + pnpm --filter @openzosma/pi-harness build + log_success "Pi-harness built successfully" +} + +setup_path() { + log_info "Setting up pi-harness command..." + + mkdir -p "$HOME/.local/bin" + + # Create wrapper script + cat >"$HOME/.local/bin/pi-harness" <<'EOF' +#!/bin/bash +# Pi-Harness wrapper script +# Auto-loads .env from ~/.pi-harness/ if present + +PI_HARNESS_DIR="${PI_HARNESS_DIR:-$HOME/.pi-harness/openzosma}" +ENV_FILE="$HOME/.pi-harness/.env" + +if [ -f "$ENV_FILE" ]; then + set -a + source "$ENV_FILE" + set +a +fi + +cd "$PI_HARNESS_DIR" +exec pnpm --filter @openzosma/pi-harness start "$@" +EOF + chmod +x "$HOME/.local/bin/pi-harness" + + # Create TUI wrapper + cat >"$HOME/.local/bin/pi-harness-tui" <<'EOF' +#!/bin/bash +# Pi-Harness TUI client wrapper + +PI_HARNESS_DIR="${PI_HARNESS_DIR:-$HOME/.pi-harness/openzosma}" +ENV_FILE="$HOME/.pi-harness/.env" + +if [ -f "$ENV_FILE" ]; then + set -a + source "$ENV_FILE" + set +a +fi + +cd "$PI_HARNESS_DIR" +exec pnpm --filter @openzosma/pi-harness tui "$@" +EOF + chmod +x "$HOME/.local/bin/pi-harness-tui" + + # Ensure ~/.local/bin is on PATH + if ! echo "$PATH" | tr ':' '\n' | grep -q "^$HOME/.local/bin$"; then + SHELL_CONFIG="" + case "$(basename "$SHELL")" in + zsh) + SHELL_CONFIG="$HOME/.zshrc" + ;; + bash) + SHELL_CONFIG="$HOME/.bashrc" + ;; + esac + + if [ -n "$SHELL_CONFIG" ]; then + echo "" >>"$SHELL_CONFIG" + echo "# Pi-Harness — ensure ~/.local/bin is on PATH" >>"$SHELL_CONFIG" + echo 'export PATH="$HOME/.local/bin:$PATH"' >>"$SHELL_CONFIG" + log_success "Added ~/.local/bin to PATH in $SHELL_CONFIG" + fi + fi + + export PATH="$HOME/.local/bin:$PATH" + log_success "Commands ready: pi-harness, pi-harness-tui" +} + +run_setup_wizard() { + if [ "$SKIP_SETUP" = true ]; then + log_info "Skipping setup wizard (--skip-setup)" + return 0 + fi + + if ! [ -e /dev/tty ]; then + log_info "Setup wizard skipped (no terminal available)" + log_info "Run setup later: $INSTALL_DIR/packages/pi-harness/scripts/setup.sh" + return 0 + fi + + echo "" + log_info "Starting setup wizard..." + echo "" + + bash "$INSTALL_DIR/packages/pi-harness/scripts/setup.sh" >"$ENV_FILE" + echo "PI_HARNESS_PROVIDER=openrouter" >>"$ENV_FILE" + echo "PI_HARNESS_MODEL=$MODEL" >>"$ENV_FILE" + ;; + +opencode | OpenCode | opencode-go) + echo "" + echo -e "${BLUE}OpenCode Go${NC} — Affordable API with bundled models" + echo "Get your key at: https://opencode.ai" + echo "" + API_KEY=$(ask_secret "Enter your OpenCode API key") + MODEL=$(ask "Default model?" "qwen3.6-plus") + echo "OPENZOSMA_LOCAL_MODEL_URL=https://opencode.ai/zen/go/v1" >>"$ENV_FILE" + echo "OPENZOSMA_LOCAL_MODEL_API_KEY=$API_KEY" >>"$ENV_FILE" + echo "OPENZOSMA_LOCAL_MODEL_ID=$MODEL" >>"$ENV_FILE" + echo "PI_HARNESS_PROVIDER=local" >>"$ENV_FILE" + echo "PI_HARNESS_MODEL=$MODEL" >>"$ENV_FILE" + ;; + +openai | OpenAI) + echo "" + echo -e "${BLUE}OpenAI${NC} — GPT-4, o3, and more" + echo "Get your key at: https://platform.openai.com/api-keys" + echo "" + API_KEY=$(ask_secret "Enter your OpenAI API key") + MODEL=$(ask "Default model?" "gpt-4o") + echo "OPENAI_API_KEY=$API_KEY" >>"$ENV_FILE" + echo "PI_HARNESS_PROVIDER=openai" >>"$ENV_FILE" + echo "PI_HARNESS_MODEL=$MODEL" >>"$ENV_FILE" + ;; + +anthropic | Anthropic | claude) + echo "" + echo -e "${BLUE}Anthropic${NC} — Claude 4 Sonnet, Opus, and more" + echo "Get your key at: https://console.anthropic.com/settings/keys" + echo "" + API_KEY=$(ask_secret "Enter your Anthropic API key") + MODEL=$(ask "Default model?" "claude-sonnet-4-20250514") + echo "ANTHROPIC_API_KEY=$API_KEY" >>"$ENV_FILE" + echo "PI_HARNESS_PROVIDER=anthropic" >>"$ENV_FILE" + echo "PI_HARNESS_MODEL=$MODEL" >>"$ENV_FILE" + ;; + +ollama | Ollama | local) + echo "" + echo -e "${BLUE}Ollama${NC} — Run models locally" + echo "Make sure Ollama is running: https://ollama.com" + echo "" + OLLAMA_URL=$(ask "Ollama base URL?" "http://localhost:11434/v1") + MODEL=$(ask "Default model?" "llama3.2") + echo "OPENZOSMA_LOCAL_MODEL_URL=$OLLAMA_URL" >>"$ENV_FILE" + echo "OPENZOSMA_LOCAL_MODEL_ID=$MODEL" >>"$ENV_FILE" + echo "PI_HARNESS_PROVIDER=local" >>"$ENV_FILE" + echo "PI_HARNESS_MODEL=$MODEL" >>"$ENV_FILE" + ;; + +*) + echo "" + echo -e "${BLUE}Custom Provider${NC}" + echo "" + BASE_URL=$(ask "Base URL (OpenAI-compatible)?") + API_KEY=$(ask_secret "Enter your API key") + MODEL=$(ask "Default model?") + echo "OPENZOSMA_LOCAL_MODEL_URL=$BASE_URL" >>"$ENV_FILE" + echo "OPENZOSMA_LOCAL_MODEL_API_KEY=$API_KEY" >>"$ENV_FILE" + echo "OPENZOSMA_LOCAL_MODEL_ID=$MODEL" >>"$ENV_FILE" + echo "PI_HARNESS_PROVIDER=local" >>"$ENV_FILE" + echo "PI_HARNESS_MODEL=$MODEL" >>"$ENV_FILE" + ;; +esac + +echo "" +echo -e "${GREEN}✓${NC} Provider configured: $PROVIDER" + +# ============================================================================ +# Server Configuration +# ============================================================================ + +echo "" +echo -e "${BOLD}Step 2: Server Configuration${NC}" +echo "" + +PORT=$(ask "Port to run on?" "8080") +HOST=$(ask "Host to bind to?" "0.0.0.0") + +echo "PI_HARNESS_PORT=$PORT" >>"$ENV_FILE" +echo "PI_HARNESS_HOST=$HOST" >>"$ENV_FILE" + +AUTH_ENABLED=$(ask_yesno "Enable API key authentication?" "y") + +if [ "$AUTH_ENABLED" = "yes" ]; then + API_KEY_SECRET=$(ask "API key for clients?" "$(openssl rand -hex 16 2>/dev/null || date +%s | sha256sum | head -c 32)") + echo "PI_HARNESS_API_KEY=$API_KEY_SECRET" >>"$ENV_FILE" + echo "" + echo -e "${GREEN}✓${NC} API key set: ${YELLOW}$API_KEY_SECRET${NC}" + echo -e " Save this — you'll need it for clients to connect." +fi + +# ============================================================================ +# Advanced Options +# ============================================================================ + +echo "" +if [ "$(ask_yesno "Configure advanced options?" "n")" = "yes" ]; then + echo "" + echo -e "${BOLD}Advanced Configuration${NC}" + echo "" + + MAX_SESSIONS=$(ask "Max concurrent sessions? (0 = unlimited)" "0") + IDLE_TIMEOUT=$(ask "Idle session timeout in minutes? (0 = none)" "30") + WORKSPACE=$(ask "Workspace directory?" "$HOME/.pi-harness/workspace") + + echo "PI_HARNESS_MAX_SESSIONS=$MAX_SESSIONS" >>"$ENV_FILE" + echo "PI_HARNESS_IDLE_TIMEOUT_MINUTES=$IDLE_TIMEOUT" >>"$ENV_FILE" + echo "PI_HARNESS_WORKSPACE=$WORKSPACE" >>"$ENV_FILE" + + # Tools + echo "" + echo "Available tools: read, bash, edit, write, grep, find, ls" + TOOLS=$(ask "Tools to enable? (blank = all)" "") + if [ -n "$TOOLS" ]; then + echo "PI_HARNESS_TOOLS=$TOOLS" >>"$ENV_FILE" + fi + + # Extensions + EXT_DIR=$(ask "Extensions directory? (blank = none)" "") + if [ -n "$EXT_DIR" ]; then + echo "PI_HARNESS_EXTENSIONS_DIR=$EXT_DIR" >>"$ENV_FILE" + fi + + # System prompt prefix + echo "" + echo "You can set a default system prompt prefix that applies to all sessions." + echo "Great for company context, coding standards, or persona." + PREFIX=$(ask "Default system prompt prefix? (blank = none)" "") + if [ -n "$PREFIX" ]; then + echo "PI_HARNESS_SYSTEM_PROMPT_PREFIX=$PREFIX" >>"$ENV_FILE" + fi +fi + +# ============================================================================ +# Summary +# ============================================================================ + +echo "" +echo -e "${GREEN}${BOLD}" +echo "┌─────────────────────────────────────────────────────────┐" +echo "│ ✓ Setup Complete! │" +echo "└─────────────────────────────────────────────────────────┘" +echo -e "${NC}" +echo "" + +echo -e "${CYAN}${BOLD}Configuration saved to:${NC} ${YELLOW}$ENV_FILE${NC}" +echo "" +echo -e "${CYAN}To start the server:${NC}" +echo -e " ${GREEN}pi-harness${NC}" +echo "" +echo -e "${CYAN}To connect with the TUI:${NC}" +echo -e " ${GREEN}pi-harness-tui${NC}" +echo "" +echo -e "${CYAN}To run in background (with nohup):${NC}" +echo -e " ${GREEN}nohup pi-harness > ~/.pi-harness/server.log 2>&1 &${NC}" +echo "" +echo -e "${CYAN}To run as a systemd service:${NC}" +echo -e " See: ${YELLOW}https://github.com/zosmaai/openzosma/tree/main/packages/pi-harness#systemd${NC}" +echo "" diff --git a/packages/pi-harness/src/cli.ts b/packages/pi-harness/src/cli.ts new file mode 100644 index 0000000..1fac751 --- /dev/null +++ b/packages/pi-harness/src/cli.ts @@ -0,0 +1,331 @@ +/** + * Pi-Harness CLI + * + * The primary entry point for the pi-harness global command. + * + * Usage: + * pi-harness Start server (runs setup on first use) + * pi-harness start Start server in foreground + * pi-harness start -d Start server as background daemon + * pi-harness stop Stop background daemon + * pi-harness status Check daemon status + * pi-harness setup Run interactive setup wizard + * pi-harness tui Launch TUI client + * pi-harness logs Tail server logs + * pi-harness --help Show this help + * pi-harness --version Show version + */ + +import { execSync } from "node:child_process" +import { existsSync, readFileSync, unlinkSync, mkdirSync } from "node:fs" +import { homedir } from "node:os" +import { resolve, join } from "node:path" +import { fileURLToPath } from "node:url" +import { parseArgs } from "node:util" + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const CONFIG_DIR = join(homedir(), ".pi-harness") +const ENV_FILE = join(CONFIG_DIR, ".env") +const PID_FILE = join(CONFIG_DIR, "server.pid") +const LOG_FILE = join(CONFIG_DIR, "server.log") +const SETUP_SCRIPT = resolve(fileURLToPath(import.meta.url), "../../scripts/setup.sh") + +// Get version from package.json +function getVersion(): string { + try { + const pkgPath = resolve(fileURLToPath(import.meta.url), "../../package.json") + const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) + return pkg.version ?? "0.1.0" + } catch { + return "0.1.0" + } +} + +const VERSION = getVersion() + +// Colors +const C = { + reset: "\x1b[0m", + bold: "\x1b[1m", + red: "\x1b[31m", + green: "\x1b[32m", + yellow: "\x1b[33m", + blue: "\x1b[34m", + magenta: "\x1b[35m", + cyan: "\x1b[36m", + dim: "\x1b[2m", +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function printHelp(): void { + console.log(` +${C.bold}${C.magenta}Pi-Harness${C.reset} ${C.cyan}v${VERSION}${C.reset} +${C.dim}The top-level harness for the Pi ecosystem${C.reset} + +${C.bold}Usage:${C.reset} pi-harness [options] + +${C.bold}Commands:${C.reset} + ${C.green}start${C.reset} [options] Start the server + ${C.yellow}-d, --daemon${C.reset} Run in background + ${C.yellow}-p, --port${C.reset} Override port + ${C.yellow}--host${C.reset} Override host + + ${C.green}stop${C.reset} Stop the background daemon + ${C.green}status${C.reset} Check if daemon is running + ${C.green}setup${C.reset} Run interactive setup wizard + ${C.green}tui${C.reset} Launch TUI client + ${C.green}logs${C.reset} Tail server logs + +${C.bold}Options:${C.reset} + ${C.yellow}-h, --help${C.reset} Show this help + ${C.yellow}-v, --version${C.reset} Show version + +${C.bold}Quick Start:${C.reset} + ${C.cyan}pi-harness${C.reset} First run → setup → start server + ${C.cyan}pi-harness start --daemon${C.reset} Start in background + ${C.cyan}pi-harness tui${C.reset} Connect with TUI client +`) +} + +function isConfigured(): boolean { + return existsSync(ENV_FILE) +} + +function ensureConfigDir(): void { + if (!existsSync(CONFIG_DIR)) { + mkdirSync(CONFIG_DIR, { recursive: true }) + } +} + +function readPid(): number | null { + try { + const pid = Number.parseInt(readFileSync(PID_FILE, "utf-8").trim(), 10) + return Number.isNaN(pid) ? null : pid + } catch { + return null + } +} + +function isDaemonRunning(): boolean { + const pid = readPid() + if (!pid) return false + try { + process.kill(pid, 0) + return true + } catch { + return false + } +} + +function printBanner(): void { + console.log(` +${C.magenta}${C.bold} +┌─────────────────────────────────────────────────────────┐ +│ ⚡ Pi-Harness v${VERSION.padEnd(43, " ").slice(0, 43)}│ +├─────────────────────────────────────────────────────────┤ +│ Standalone headless agent harness for pi-coding-agent │ +│ Built with gratitude for Mario Zechner's pi-mono │ +└─────────────────────────────────────────────────────────┘ +${C.reset}`) +} + +// --------------------------------------------------------------------------- +// Lightweight commands (no heavy deps) +// --------------------------------------------------------------------------- + +async function cmdSetup(): Promise { + ensureConfigDir() + printBanner() + + if (!existsSync(SETUP_SCRIPT)) { + console.error(`${C.red}✗ Setup script not found:${C.reset} ${SETUP_SCRIPT}`) + console.error(` Run setup manually or reinstall pi-harness.`) + process.exit(1) + } + + try { + execSync(`bash "${SETUP_SCRIPT}"`, { + stdio: "inherit", + env: { ...process.env, ENV_FILE }, + }) + } catch { + process.exit(1) + } +} + +async function cmdStop(): Promise { + const pid = readPid() + if (!pid) { + console.log(`${C.yellow}⚠ No daemon PID found.${C.reset}`) + return + } + + if (!isDaemonRunning()) { + console.log(`${C.yellow}⚠ Daemon not running (stale PID file).${C.reset}`) + try { + unlinkSync(PID_FILE) + } catch { + /* ignore */ + } + return + } + + try { + process.kill(pid, "SIGTERM") + console.log(`${C.green}✓ Daemon stopped${C.reset} (PID: ${pid})`) + try { + unlinkSync(PID_FILE) + } catch { + /* ignore */ + } + } catch (err) { + console.error(`${C.red}✗ Failed to stop daemon:${C.reset}`, err) + process.exit(1) + } +} + +async function cmdStatus(): Promise { + const pid = readPid() + if (!pid) { + console.log(`${C.yellow}● Daemon: not running${C.reset}`) + return + } + + try { + process.kill(pid, 0) + console.log(`${C.green}● Daemon: running${C.reset} (PID: ${pid})`) + console.log(`${C.dim} →${C.reset} Logs: ${LOG_FILE}`) + } catch { + console.log(`${C.red}● Daemon: not running${C.reset} (stale PID: ${pid})`) + try { + unlinkSync(PID_FILE) + } catch { + /* ignore */ + } + } +} + +async function cmdLogs(): Promise { + if (!existsSync(LOG_FILE)) { + console.log(`${C.yellow}⚠ No log file found.${C.reset}`) + return + } + + try { + execSync(`tail -f "${LOG_FILE}"`, { stdio: "inherit" }) + } catch { + // User pressed Ctrl+C + } +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +async function main(): Promise { + const { values, positionals } = parseArgs({ + options: { + help: { type: "boolean", short: "h" }, + version: { type: "boolean", short: "v" }, + daemon: { type: "boolean", short: "d" }, + port: { type: "string", short: "p" }, + host: { type: "string" }, + }, + allowPositionals: true, + }) + + if (values.help) { + printHelp() + process.exit(0) + } + + if (values.version) { + console.log(VERSION) + process.exit(0) + } + + const command = positionals[0] ?? "start" + + switch (command) { + case "start": { + // First-run setup + if (!isConfigured()) { + console.log(`${C.yellow}⚠ First run detected. Let's set up pi-harness.${C.reset}\n`) + await cmdSetup() + console.log("") + } + // Load env file + if (existsSync(ENV_FILE)) { + const envContent = readFileSync(ENV_FILE, "utf-8") + for (const line of envContent.split("\n")) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith("#")) continue + const eq = trimmed.indexOf("=") + if (eq > 0) { + const key = trimmed.slice(0, eq) + const value = trimmed.slice(eq + 1) + if (!process.env[key]) process.env[key] = value + } + } + } + // Dynamic import of heavy commands + const { cmdStart } = await import("./commands.js") + await cmdStart({ + daemon: values.daemon, + port: values.port ? Number.parseInt(values.port, 10) : undefined, + host: values.host, + }) + break + } + case "stop": + await cmdStop() + break + case "status": + await cmdStatus() + break + case "setup": + await cmdSetup() + break + case "tui": { + if (!isConfigured()) { + console.log(`${C.yellow}⚠ Not configured yet. Run:${C.reset} pi-harness setup`) + process.exit(1) + } + // Load env + if (existsSync(ENV_FILE)) { + const envContent = readFileSync(ENV_FILE, "utf-8") + for (const line of envContent.split("\n")) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith("#")) continue + const eq = trimmed.indexOf("=") + if (eq > 0) { + const key = trimmed.slice(0, eq) + const value = trimmed.slice(eq + 1) + if (!process.env[key]) process.env[key] = value + } + } + } + const { cmdTui } = await import("./commands.js") + await cmdTui() + break + } + case "logs": + await cmdLogs() + break + default: + console.error(`${C.red}Unknown command:${C.reset} ${command}`) + console.error(`Run ${C.cyan}pi-harness --help${C.reset} for usage.`) + process.exit(1) + } +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) diff --git a/packages/pi-harness/src/commands.ts b/packages/pi-harness/src/commands.ts new file mode 100644 index 0000000..faf6c91 --- /dev/null +++ b/packages/pi-harness/src/commands.ts @@ -0,0 +1,168 @@ +/** + * Heavy command implementations for the pi-harness CLI. + * + * This module is dynamically imported by cli.ts only when needed, + * so that lightweight commands (help, version, status) don't trigger + * module resolution for heavy dependencies like pi-coding-agent. + */ + +import { spawn } from "node:child_process" +import { existsSync, readFileSync, writeFileSync, openSync, closeSync } from "node:fs" +import { resolve } from "node:path" +import { fileURLToPath } from "node:url" +import { serve } from "@hono/node-server" +import { createLogger } from "@openzosma/logger" +import { loadConfig } from "./config.js" +import { createHarnessApp } from "./server.js" + +const C = { + reset: "\x1b[0m", + bold: "\x1b[1m", + green: "\x1b[32m", + yellow: "\x1b[33m", + cyan: "\x1b[36m", + dim: "\x1b[2m", +} + +const CONFIG_DIR = resolve(process.env.HOME ?? "/tmp", ".pi-harness") +const ENV_FILE = resolve(CONFIG_DIR, ".env") +const PID_FILE = resolve(CONFIG_DIR, "server.pid") +const LOG_FILE = resolve(CONFIG_DIR, "server.log") + +function printBanner(): void { + console.log(` +${C.cyan}${C.bold} +┌─────────────────────────────────────────────────────────┐ +│ ⚡ Pi-Harness │ +├─────────────────────────────────────────────────────────┤ +│ Standalone headless agent harness for pi-coding-agent │ +│ Built with gratitude for Mario Zechner's pi-mono │ +└─────────────────────────────────────────────────────────┘ +${C.reset}`) +} + +export async function cmdStart(options: { + daemon?: boolean + port?: number + host?: string +}): Promise { + // Load env file + if (existsSync(ENV_FILE)) { + const envContent = readFileSync(ENV_FILE, "utf-8") + for (const line of envContent.split("\n")) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith("#")) continue + const eq = trimmed.indexOf("=") + if (eq > 0) { + const key = trimmed.slice(0, eq) + const value = trimmed.slice(eq + 1) + if (!process.env[key]) process.env[key] = value + } + } + } + + // Override from CLI + if (options.port) process.env.PI_HARNESS_PORT = String(options.port) + if (options.host) process.env.PI_HARNESS_HOST = options.host + + const port = process.env.PI_HARNESS_PORT ?? "8080" + const host = process.env.PI_HARNESS_HOST ?? "0.0.0.0" + + if (options.daemon) { + // Stop existing daemon first + try { + const pid = Number.parseInt(readFileSync(PID_FILE, "utf-8").trim(), 10) + if (!Number.isNaN(pid)) { + try { + process.kill(pid, 0) + console.log(`${C.yellow}⚠ Daemon already running. Stopping first...${C.reset}`) + process.kill(pid, "SIGTERM") + try { + require("node:fs").unlinkSync(PID_FILE) + } catch { + /* ignore */ + } + } catch { + /* not running */ + } + } + } catch { + /* no pid file */ + } + + const out = openSync(LOG_FILE, "a") + const err = openSync(LOG_FILE, "a") + + const serverPath = resolve(fileURLToPath(import.meta.url), "../index.js") + const child = spawn(process.execPath, [serverPath], { + detached: true, + stdio: ["ignore", out, err], + env: process.env, + }) + + closeSync(out) + closeSync(err) + + child.unref() + writeFileSync(PID_FILE, String(child.pid)) + + console.log(`${C.green}✓ Daemon started${C.reset} (PID: ${child.pid})`) + console.log(`${C.cyan} →${C.reset} http://${host}:${port}`) + console.log(`${C.cyan} →${C.reset} Logs: ${LOG_FILE}`) + console.log(`${C.dim} →${C.reset} Stop: pi-harness stop`) + } else { + // Foreground mode + printBanner() + console.log(`${C.cyan}→${C.reset} Starting server on http://${host}:${port}\n`) + + const config = loadConfig() + const app = createHarnessApp(config) + const log = createLogger({ component: "pi-harness" }) + + serve( + { + fetch: app.fetch, + port: config.port, + hostname: config.host, + }, + (info) => { + log.info(`Pi-harness listening on http://${info.address}:${info.port}`) + }, + ) + + const shutdown = (signal: string) => { + log.info(`Received ${signal}, shutting down...`) + process.exit(0) + } + process.on("SIGTERM", () => shutdown("SIGTERM")) + process.on("SIGINT", () => shutdown("SIGINT")) + } +} + +export async function cmdTui(): Promise { + // Load env + if (existsSync(ENV_FILE)) { + const envContent = readFileSync(ENV_FILE, "utf-8") + for (const line of envContent.split("\n")) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith("#")) continue + const eq = trimmed.indexOf("=") + if (eq > 0) { + const key = trimmed.slice(0, eq) + const value = trimmed.slice(eq + 1) + if (!process.env[key]) process.env[key] = value + } + } + } + + // Spawn TUI as child process (needs its own stdin/stdout) + const tuiPath = resolve(fileURLToPath(import.meta.url), "../tui.js") + const child = spawn(process.execPath, [tuiPath], { + stdio: "inherit", + env: process.env, + }) + + await new Promise((resolve) => { + child.on("exit", () => resolve()) + }) +} diff --git a/packages/pi-harness/src/config.ts b/packages/pi-harness/src/config.ts new file mode 100644 index 0000000..ca9646c --- /dev/null +++ b/packages/pi-harness/src/config.ts @@ -0,0 +1,89 @@ +/** + * Environment configuration for pi-harness. + * + * All settings are loaded from environment variables with sensible defaults. + * This keeps the harness zero-config for local use while allowing full + * customization in production deployments. + */ + +export interface HarnessConfig { + /** HTTP server port */ + port: number + /** Host to bind to */ + host: string + /** Optional API key for simple auth */ + apiKey: string | undefined + /** Default workspace root directory */ + workspaceRoot: string + /** Default LLM provider */ + defaultProvider: string | undefined + /** Default model */ + defaultModel: string | undefined + /** Maximum concurrent sessions (0 = unlimited) */ + maxSessions: number + /** Session idle timeout in minutes (0 = no timeout) */ + sessionIdleTimeoutMinutes: number + /** Whether to persist sessions to disk */ + persistSessions: boolean + /** Directory for session persistence */ + persistenceDir: string + /** Request body size limit in bytes */ + maxBodySize: number + /** Default tools enabled (comma-separated, e.g. "read,bash,write") */ + defaultTools: string[] | undefined + /** Default system prompt prefix (persona, company context) */ + defaultSystemPromptPrefix: string | undefined + /** Default system prompt suffix (session context, integrations) */ + defaultSystemPromptSuffix: string | undefined + /** Directory to load pi-coding-agent extensions from */ + extensionsDir: string | undefined + /** Directory to load skills from */ + skillsDir: string | undefined + /** Enable verbose logging */ + verbose: boolean +} + +function getEnv(key: string, defaultValue?: string): string | undefined { + return process.env[key] ?? defaultValue +} + +function getEnvInt(key: string, defaultValue: number): number { + const raw = process.env[key] + if (!raw) return defaultValue + const parsed = Number.parseInt(raw, 10) + return Number.isNaN(parsed) ? defaultValue : parsed +} + +function getEnvBool(key: string, defaultValue: boolean): boolean { + const raw = process.env[key] + if (!raw) return defaultValue + return raw === "1" || raw.toLowerCase() === "true" +} + +/** + * Load configuration from environment variables. + */ +export function loadConfig(): HarnessConfig { + return { + port: getEnvInt("PI_HARNESS_PORT", 8080), + host: getEnv("PI_HARNESS_HOST", "0.0.0.0")!, + apiKey: getEnv("PI_HARNESS_API_KEY"), + workspaceRoot: getEnv("PI_HARNESS_WORKSPACE", "./workspace")!, + defaultProvider: getEnv("PI_HARNESS_PROVIDER"), + defaultModel: getEnv("PI_HARNESS_MODEL"), + maxSessions: getEnvInt("PI_HARNESS_MAX_SESSIONS", 0), + sessionIdleTimeoutMinutes: getEnvInt("PI_HARNESS_IDLE_TIMEOUT_MINUTES", 30), + persistSessions: getEnvBool("PI_HARNESS_PERSIST", false), + persistenceDir: getEnv("PI_HARNESS_PERSISTENCE_DIR", "./.pi-harness")!, + maxBodySize: getEnvInt("PI_HARNESS_MAX_BODY_SIZE", 10 * 1024 * 1024), // 10MB + defaultTools: getEnv("PI_HARNESS_TOOLS") + ?.split(",") + .map((t) => t.trim()) + .filter(Boolean), + defaultSystemPromptPrefix: getEnv("PI_HARNESS_SYSTEM_PROMPT_PREFIX"), + defaultSystemPromptSuffix: getEnv("PI_HARNESS_SYSTEM_PROMPT_SUFFIX"), + extensionsDir: getEnv("PI_HARNESS_EXTENSIONS_DIR"), + skillsDir: getEnv("PI_HARNESS_SKILLS_DIR"), + verbose: getEnvBool("PI_HARNESS_VERBOSE", false), + } +} diff --git a/packages/pi-harness/src/index.ts b/packages/pi-harness/src/index.ts new file mode 100644 index 0000000..70e2dd0 --- /dev/null +++ b/packages/pi-harness/src/index.ts @@ -0,0 +1,52 @@ +#!/usr/bin/env node +/** + * Pi-Harness: Standalone headless pi-coding-agent server. + * + * Usage: + * node dist/index.js + * PI_HARNESS_PORT=9000 node dist/index.js + * PI_HARNESS_API_KEY=secret node dist/index.js + * + * Environment variables: see src/config.ts + */ +import { serve } from "@hono/node-server" +import { createLogger } from "@openzosma/logger" +import { loadConfig } from "./config.js" +import { createHarnessApp } from "./server.js" + +const log = createLogger({ component: "pi-harness" }) + +function main() { + const config = loadConfig() + const app = createHarnessApp(config) + + log.info("Starting pi-harness", { + port: config.port, + host: config.host, + maxSessions: config.maxSessions || "unlimited", + idleTimeout: config.sessionIdleTimeoutMinutes || "none", + auth: config.apiKey ? "api-key" : "none", + }) + + serve( + { + fetch: app.fetch, + port: config.port, + hostname: config.host, + }, + (info) => { + log.info(`Pi-harness listening on http://${info.address}:${info.port}`) + }, + ) + + // Graceful shutdown + const shutdown = (signal: string) => { + log.info(`Received ${signal}, shutting down...`) + process.exit(0) + } + + process.on("SIGTERM", () => shutdown("SIGTERM")) + process.on("SIGINT", () => shutdown("SIGINT")) +} + +main() diff --git a/packages/pi-harness/src/server.ts b/packages/pi-harness/src/server.ts new file mode 100644 index 0000000..b55914c --- /dev/null +++ b/packages/pi-harness/src/server.ts @@ -0,0 +1,243 @@ +import { Hono } from "hono" +import { streamSSE } from "hono/streaming" +import { createLogger } from "@openzosma/logger" +import type { HarnessConfig } from "./config.js" +import { HarnessSessionManager } from "./session-manager.js" +import type { CreateSessionRequest, SendMessageRequest } from "./types.js" + +const log = createLogger({ component: "pi-harness-server" }) + +/** + * Create the Hono HTTP server for the pi-harness standalone agent. + * + * Exposes a minimal REST + SSE API for creating sessions and streaming + * agent events. Designed to be wrapped by a gateway or consumed directly + * by clients (dashboard, CLI, mobile apps). + */ +export function createHarnessApp(config: HarnessConfig): Hono { + const app = new Hono() + const sessions = new HarnessSessionManager(config) + + // ----------------------------------------------------------------------- + // Optional API key middleware + // ----------------------------------------------------------------------- + if (config.apiKey) { + app.use("/*", async (c, next) => { + // Skip auth for health check + if (c.req.path === "/health") return next() + + const provided = c.req.header("x-api-key") + if (provided !== config.apiKey) { + return c.json({ error: "Unauthorized" }, 401) + } + return next() + }) + } + + // ----------------------------------------------------------------------- + // Health check + // ----------------------------------------------------------------------- + app.get("/health", (c) => { + return c.json({ + status: "ok", + sessions: sessions.getSessionCount(), + uptime: process.uptime(), + version: process.env.npm_package_version ?? "0.1.0", + }) + }) + + // ----------------------------------------------------------------------- + // Session management + // ----------------------------------------------------------------------- + + /** + * POST /sessions -- create a new agent session. + */ + app.post("/sessions", async (c) => { + const body = await c.req.json().catch(() => ({}) as CreateSessionRequest) + + log.info("POST /sessions", { + hasSystemPromptPrefix: !!body.systemPromptPrefix, + systemPromptPrefixLength: body.systemPromptPrefix?.length ?? 0, + }) + + try { + const sessionId = sessions.createSession({ + sessionId: body.sessionId, + provider: body.provider, + model: body.model, + systemPrompt: body.systemPrompt, + systemPromptPrefix: body.systemPromptPrefix, + systemPromptSuffix: body.systemPromptSuffix, + toolsEnabled: body.toolsEnabled, + workspaceDir: body.workspaceDir, + }) + + return c.json({ sessionId }, 201) + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "Unknown error creating session" + log.error("POST /sessions failed", { error: message }) + return c.json({ error: message }, 500) + } + }) + + /** + * GET /sessions/:id -- get session metadata. + */ + app.get("/sessions/:id", (c) => { + const sessionId = c.req.param("id") + const session = sessions.getSession(sessionId) + if (!session) { + return c.json({ error: "Session not found" }, 404) + } + return c.json({ + sessionId: session.sessionId, + status: "active", + createdAt: session.createdAt, + workspaceDir: session.workspaceDir, + }) + }) + + /** + * DELETE /sessions/:id -- end and remove a session. + */ + app.delete("/sessions/:id", (c) => { + const sessionId = c.req.param("id") + const deleted = sessions.deleteSession(sessionId) + if (!deleted) { + return c.json({ error: "Session not found" }, 404) + } + return c.json({ ok: true }) + }) + + /** + * GET /sessions -- list all active sessions. + */ + app.get("/sessions", (c) => { + const ids = sessions.listSessions() + return c.json({ sessions: ids }) + }) + + // ----------------------------------------------------------------------- + // Message handling (SSE streaming) + // ----------------------------------------------------------------------- + + /** + * POST /sessions/:id/messages -- send a user message and stream agent events. + * + * Returns an SSE stream. Each event is a JSON-encoded AgentStreamEvent. + * The stream ends when the agent finishes its turn. + */ + app.post("/sessions/:id/messages", (c) => { + const sessionId = c.req.param("id") + + if (!sessions.hasSession(sessionId)) { + return c.json({ error: "Session not found" }, 404) + } + + return streamSSE(c, async (stream) => { + const abort = new AbortController() + stream.onAbort(() => { + log.info("SSE stream aborted by client", { sessionId }) + abort.abort() + }) + + let body: SendMessageRequest + try { + body = await c.req.json() + } catch { + await stream.writeSSE({ event: "error", data: JSON.stringify({ error: "Invalid request body" }) }) + return + } + + if (!body.content) { + await stream.writeSSE({ event: "error", data: JSON.stringify({ error: "content is required" }) }) + return + } + + log.info("Sending message", { sessionId, contentLength: body.content.length }) + const msgStartTime = Date.now() + let eventCount = 0 + + try { + for await (const event of sessions.sendMessage(sessionId, body.content, abort.signal)) { + eventCount++ + await stream.writeSSE({ + event: event.type, + data: JSON.stringify(event), + }) + } + log.info("Stream completed", { sessionId, eventCount, durationMs: Date.now() - msgStartTime }) + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "Unknown error" + log.error("Stream error", { sessionId, error: message, eventCount }) + if (!abort.signal.aborted) { + await stream.writeSSE({ + event: "error", + data: JSON.stringify({ type: "error", error: message }), + }) + } + } + }) + }) + + // ----------------------------------------------------------------------- + // Steering and control + // ----------------------------------------------------------------------- + + /** + * POST /sessions/:id/steer -- deliver a steering message mid-turn. + */ + app.post("/sessions/:id/steer", async (c) => { + const sessionId = c.req.param("id") + if (!sessions.hasSession(sessionId)) { + return c.json({ error: "Session not found" }, 404) + } + let body: { content: string } + try { + body = await c.req.json<{ content: string }>() + } catch { + return c.json({ error: "Invalid request body" }, 400) + } + if (!body.content) { + return c.json({ error: "content is required" }, 400) + } + await sessions.steer(sessionId, body.content) + return c.json({ ok: true }) + }) + + /** + * POST /sessions/:id/followup -- queue a follow-up for after the turn. + */ + app.post("/sessions/:id/followup", async (c) => { + const sessionId = c.req.param("id") + if (!sessions.hasSession(sessionId)) { + return c.json({ error: "Session not found" }, 404) + } + let body: { content: string } + try { + body = await c.req.json<{ content: string }>() + } catch { + return c.json({ error: "Invalid request body" }, 400) + } + if (!body.content) { + return c.json({ error: "content is required" }, 400) + } + await sessions.followUp(sessionId, body.content) + return c.json({ ok: true }) + }) + + /** + * POST /sessions/:id/cancel -- cancel the active turn. + */ + app.post("/sessions/:id/cancel", (c) => { + const sessionId = c.req.param("id") + if (!sessions.hasSession(sessionId)) { + return c.json({ error: "Session not found" }, 404) + } + sessions.cancelSession(sessionId) + return c.json({ ok: true }) + }) + + return app +} diff --git a/packages/pi-harness/src/session-manager.ts b/packages/pi-harness/src/session-manager.ts new file mode 100644 index 0000000..bc08219 --- /dev/null +++ b/packages/pi-harness/src/session-manager.ts @@ -0,0 +1,228 @@ +import { randomUUID } from "node:crypto" +import { existsSync, mkdirSync } from "node:fs" +import { resolve } from "node:path" +import type { AgentSession, AgentStreamEvent } from "@openzosma/agents" +import { PiAgentProvider } from "@openzosma/agents" +import { createLogger } from "@openzosma/logger" +import type { HarnessConfig } from "./config.js" + +const log = createLogger({ component: "pi-harness" }) + +/** Per-session state tracked by the harness. */ +interface HarnessSession { + agentSession: AgentSession + sessionId: string + workspaceDir: string + createdAt: string + lastActiveAt: number +} + +/** + * Manages agent sessions for the pi-harness standalone server. + * + * Each session gets its own pi-coding-agent instance with an isolated + * workspace directory. Sessions run concurrently in the same Node.js + * process — pi-coding-agent was refactored for this in Phase 1. + */ +export class HarnessSessionManager { + private provider = new PiAgentProvider() + private sessions = new Map() + private config: HarnessConfig + + constructor(config: HarnessConfig) { + this.config = config + // Ensure workspace root exists + if (!existsSync(config.workspaceRoot)) { + mkdirSync(config.workspaceRoot, { recursive: true }) + } + // Start idle cleanup loop if configured + if (config.sessionIdleTimeoutMinutes > 0) { + this.startIdleCleanup() + } + } + + /** + * Create a new agent session. + * + * Returns the session ID. The session is ready to receive messages + * immediately. + */ + createSession(opts?: { + sessionId?: string + provider?: string + model?: string + systemPrompt?: string + systemPromptPrefix?: string + systemPromptSuffix?: string + toolsEnabled?: string[] + workspaceDir?: string + }): string { + // Enforce max sessions limit + if (this.config.maxSessions > 0 && this.sessions.size >= this.config.maxSessions) { + throw new Error(`Maximum sessions reached (${this.config.maxSessions})`) + } + + const sessionId = opts?.sessionId ?? randomUUID() + + // Derive workspace directory + const workspaceDir = opts?.workspaceDir + ? resolve(opts.workspaceDir) + : resolve(this.config.workspaceRoot, "sessions", sessionId) + + mkdirSync(workspaceDir, { recursive: true }) + + // Use configured defaults if not overridden + const provider = opts?.provider ?? this.config.defaultProvider + const model = opts?.model ?? this.config.defaultModel + const toolsEnabled = opts?.toolsEnabled ?? this.config.defaultTools + const systemPromptPrefix = opts?.systemPromptPrefix ?? this.config.defaultSystemPromptPrefix + const systemPromptSuffix = opts?.systemPromptSuffix ?? this.config.defaultSystemPromptSuffix + + // Configure extensions directory if set + if (this.config.extensionsDir) { + process.env.PI_EXTENSIONS_DIR = this.config.extensionsDir + } + + log.info("Creating session", { sessionId, provider, model, workspaceDir, toolsEnabled: toolsEnabled?.length }) + + const agentSession = this.provider.createSession({ + sessionId, + workspaceDir, + provider, + model, + systemPrompt: opts?.systemPrompt, + systemPromptPrefix, + systemPromptSuffix, + toolsEnabled, + }) + + const now = Date.now() + this.sessions.set(sessionId, { + agentSession, + sessionId, + workspaceDir, + createdAt: new Date(now).toISOString(), + lastActiveAt: now, + }) + + return sessionId + } + + /** + * Send a message to a session and yield streamed agent events. + */ + async *sendMessage(sessionId: string, content: string, signal?: AbortSignal): AsyncGenerator { + const session = this.sessions.get(sessionId) + if (!session) { + throw new Error(`Session ${sessionId} not found`) + } + + // Update last activity + session.lastActiveAt = Date.now() + + try { + for await (const event of session.agentSession.sendMessage(content, signal)) { + yield event + } + } finally { + session.lastActiveAt = Date.now() + } + } + + /** + * Deliver a steering message to an active session turn. + */ + async steer(sessionId: string, content: string): Promise { + const session = this.sessions.get(sessionId) + if (!session) throw new Error(`Session ${sessionId} not found`) + await session.agentSession.steer(content) + } + + /** + * Queue a follow-up message for after the current turn ends. + */ + async followUp(sessionId: string, content: string): Promise { + const session = this.sessions.get(sessionId) + if (!session) throw new Error(`Session ${sessionId} not found`) + await session.agentSession.followUp(content) + } + + /** + * Cancel the active turn for a session. + */ + async cancelSession(sessionId: string): Promise { + const session = this.sessions.get(sessionId) + if (!session) return false + // The agent session's sendMessage accepts an AbortSignal. + // Cancellation is handled by the caller aborting that signal. + // This method is a no-op at the harness level — provided for API symmetry. + return true + } + + /** + * Check if a session exists. + */ + hasSession(sessionId: string): boolean { + return this.sessions.has(sessionId) + } + + /** + * Get session metadata. + */ + getSession(sessionId: string): { sessionId: string; createdAt: string; workspaceDir: string } | undefined { + const session = this.sessions.get(sessionId) + if (!session) return undefined + return { + sessionId: session.sessionId, + createdAt: session.createdAt, + workspaceDir: session.workspaceDir, + } + } + + /** + * End and remove a session. + */ + deleteSession(sessionId: string): boolean { + const existed = this.sessions.delete(sessionId) + if (existed) { + log.info("Session ended", { sessionId }) + } + return existed + } + + /** + * List all active session IDs. + */ + listSessions(): string[] { + return [...this.sessions.keys()] + } + + /** + * Get count of active sessions. + */ + getSessionCount(): number { + return this.sessions.size + } + + /** + * Background loop that cleans up idle sessions. + */ + private startIdleCleanup(): void { + const intervalMs = 60_000 // Check every minute + const timeoutMs = this.config.sessionIdleTimeoutMinutes * 60_000 + + setInterval(() => { + const now = Date.now() + for (const [sessionId, session] of this.sessions) { + if (now - session.lastActiveAt > timeoutMs) { + log.info("Idle session timed out", { sessionId, idleMinutes: this.config.sessionIdleTimeoutMinutes }) + this.deleteSession(sessionId) + } + } + }, intervalMs) + + // Prevent the interval from keeping the process alive + // (the HTTP server keeps it alive anyway, but this is good hygiene) + // Note: we intentionally don't unref — the HTTP server is the primary keepalive + } +} diff --git a/packages/pi-harness/src/tui.ts b/packages/pi-harness/src/tui.ts new file mode 100644 index 0000000..7184b66 --- /dev/null +++ b/packages/pi-harness/src/tui.ts @@ -0,0 +1,561 @@ +#!/usr/bin/env node +/** + * Pi-Harness TUI Client + * + * An interactive terminal chat client for pi-harness. + * Connects to a running pi-harness server via HTTP/SSE. + * + * Usage: + * pi-harness-tui # Connect to http://localhost:8080 + * pi-harness-tui --url http://host:9000 --key my-secret + * pi-harness-tui --session + */ +import chalk from "chalk" +import * as readline from "node:readline" +import { stdin as input, stdout as output } from "node:process" + +// --------------------------------------------------------------------------- +// Config +// --------------------------------------------------------------------------- +interface TuiConfig { + baseUrl: string + apiKey?: string + sessionId?: string +} + +function parseArgs(): TuiConfig { + const args = process.argv.slice(2) + const config: TuiConfig = { + baseUrl: process.env.PI_HARNESS_URL ?? "http://localhost:8080", + } + + for (let i = 0; i < args.length; i++) { + switch (args[i]) { + case "--url": + case "-u": + config.baseUrl = args[++i] + break + case "--key": + case "-k": + config.apiKey = args[++i] + break + case "--session": + case "-s": + config.sessionId = args[++i] + break + case "--help": + case "-h": + console.log(` +Pi-Harness TUI Client + +Usage: pi-harness-tui [options] + +Options: + -u, --url Server URL (default: http://localhost:8080) + -k, --key API key for authentication + -s, --session Connect to existing session + -h, --help Show this help + +Environment: + PI_HARNESS_URL Default server URL + PI_HARNESS_API_KEY Default API key +`) + process.exit(0) + } + } + + if (!config.apiKey) { + config.apiKey = process.env.PI_HARNESS_API_KEY + } + + return config +} + +// --------------------------------------------------------------------------- +// API Client +// --------------------------------------------------------------------------- +class HarnessClient { + private baseUrl: string + private apiKey?: string + + constructor(config: TuiConfig) { + this.baseUrl = config.baseUrl.replace(/\/$/, "") + this.apiKey = config.apiKey + } + + private headers(): Record { + const h: Record = { "Content-Type": "application/json" } + if (this.apiKey) h["x-api-key"] = this.apiKey + return h + } + + async health(): Promise<{ status: string; sessions: number; uptime: number }> { + const res = await fetch(`${this.baseUrl}/health`) + if (!res.ok) throw new Error(`Health check failed: ${res.status}`) + return res.json() as Promise<{ status: string; sessions: number; uptime: number }> + } + + async createSession(opts?: { + systemPromptPrefix?: string + toolsEnabled?: string[] + model?: string + provider?: string + }): Promise<{ sessionId: string }> { + const res = await fetch(`${this.baseUrl}/sessions`, { + method: "POST", + headers: this.headers(), + body: JSON.stringify(opts ?? {}), + }) + if (!res.ok) { + const err = (await res.json().catch(() => ({ error: res.statusText }))) as { error?: string } + throw new Error(err.error ?? `Failed to create session: ${res.status}`) + } + return res.json() as Promise<{ sessionId: string }> + } + + async listSessions(): Promise<{ sessions: string[] }> { + const res = await fetch(`${this.baseUrl}/sessions`, { headers: this.headers() }) + if (!res.ok) throw new Error(`Failed to list sessions: ${res.status}`) + return res.json() as Promise<{ sessions: string[] }> + } + + async *sendMessage(sessionId: string, content: string, signal?: AbortSignal): AsyncGenerator { + const res = await fetch(`${this.baseUrl}/sessions/${sessionId}/messages`, { + method: "POST", + headers: this.headers(), + body: JSON.stringify({ content }), + signal, + }) + + if (!res.ok || !res.body) { + const err = (await res.json().catch(() => ({ error: res.statusText }))) as { error?: string } + throw new Error(err.error ?? `Failed to send message: ${res.status}`) + } + + const reader = res.body.getReader() + const decoder = new TextDecoder() + let buffer = "" + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split("\n") + buffer = lines.pop() ?? "" + + for (const line of lines) { + if (line.startsWith("data: ")) { + const data = line.slice(6).trim() + if (data) { + try { + yield JSON.parse(data) + } catch { + yield { type: "raw", data } + } + } + } + } + } + } finally { + reader.releaseLock() + } + } + + async deleteSession(sessionId: string): Promise { + await fetch(`${this.baseUrl}/sessions/${sessionId}`, { + method: "DELETE", + headers: this.headers(), + }) + } +} + +// --------------------------------------------------------------------------- +// Terminal UI +// --------------------------------------------------------------------------- +interface ChatMessage { + role: "user" | "assistant" | "system" | "tool" + content: string + toolName?: string + toolResult?: string + isError?: boolean +} + +class Tui { + private client: HarnessClient + private config: TuiConfig + private sessionId?: string + private messages: ChatMessage[] = [] + private rl: readline.Interface + private isStreaming = false + private currentAssistantText = "" + private abortController?: AbortController + private inputHistory: string[] = [] + + constructor(config: TuiConfig) { + this.config = config + this.client = new HarnessClient(config) + this.rl = readline.createInterface({ input, output }) + } + + async start(): Promise { + this.clearScreen() + this.printHeader() + + // Verify server is reachable + try { + const health = await this.client.health() + this.printSystem( + `Connected to ${this.config.baseUrl} | Active sessions: ${health.sessions} | Uptime: ${Math.floor(health.uptime)}s`, + ) + } catch (err: unknown) { + this.printError(`Cannot connect to ${this.config.baseUrl}`) + this.printError(err instanceof Error ? err.message : String(err)) + process.exit(1) + } + + // Create or connect to session + if (this.config.sessionId) { + this.sessionId = this.config.sessionId + this.printSystem(`Connected to session: ${this.sessionId}`) + } else { + try { + const session = await this.client.createSession() + this.sessionId = session.sessionId + this.printSystem(`New session created: ${this.sessionId}`) + } catch (err: unknown) { + this.printError(`Failed to create session: ${err instanceof Error ? err.message : String(err)}`) + process.exit(1) + } + } + + this.printHelpHint() + this.prompt() + + this.rl.on("line", (line) => this.handleInput(line)) + this.rl.on("close", () => this.shutdown()) + + // Handle Ctrl+C gracefully + input.on("keypress", (_str, key) => { + if (key.ctrl && key.name === "c" && this.isStreaming) { + this.cancelStream() + } + }) + } + + private clearScreen(): void { + output.write("\x1b[2J\x1b[H") + } + + private printHeader(): void { + const title = "═".repeat(60) + output.write(`\n${chalk.cyan(title)}\n`) + output.write(chalk.cyan.bold(" 🤖 Pi-Harness TUI Client\n")) + output.write(`${chalk.cyan(title)}\n\n`) + } + + private printHelpHint(): void { + this.printSystem("Type /help for commands, /quit to exit. Ctrl+C to cancel streaming.") + } + + private printSystem(text: string): void { + this.messages.push({ role: "system", content: text }) + output.write(`${chalk.gray("ℹ ")}${chalk.gray(text)}\n`) + } + + private printError(text: string): void { + this.messages.push({ role: "system", content: text }) + output.write(`${chalk.red("✖ ")}${chalk.red(text)}\n`) + } + + private printUser(text: string): void { + this.messages.push({ role: "user", content: text }) + output.write(`\n${chalk.green.bold("> ")}${chalk.white(text)}\n`) + } + + private printAssistantStart(): void { + output.write(`\n${chalk.blue.bold("🤖 ")}`) + } + + private printAssistantChunk(text: string): void { + output.write(chalk.white(text)) + } + + private printAssistantEnd(): void { + output.write("\n") + } + + private printToolStart(toolName: string, toolArgs?: string): void { + const args = toolArgs ? chalk.gray(` ${toolArgs.slice(0, 80)}${toolArgs.length > 80 ? "..." : ""}`) : "" + output.write(`\n ${chalk.yellow("▶")} ${chalk.yellow.bold(toolName)}${args}\n`) + } + + private printToolEnd(toolName: string, result: string, isError?: boolean): void { + const icon = isError ? chalk.red("✖") : chalk.green("✓") + const resultPreview = result.slice(0, 200).replace(/\n/g, " ") + const suffix = result.length > 200 ? "..." : "" + output.write( + ` ${icon} ${chalk.gray(`${toolName}`)} ${isError ? chalk.red(resultPreview + suffix) : chalk.gray(resultPreview + suffix)}\n`, + ) + } + + private prompt(): void { + this.rl.prompt() + } + + private async handleInput(line: string): Promise { + const trimmed = line.trim() + if (!trimmed) { + this.prompt() + return + } + + // Add to history + if (this.inputHistory.length === 0 || this.inputHistory[this.inputHistory.length - 1] !== trimmed) { + this.inputHistory.push(trimmed) + } + // Handle commands + if (trimmed.startsWith("/")) { + await this.handleCommand(trimmed) + return + } + + if (!this.sessionId) { + this.printError("No active session") + this.prompt() + return + } + + this.printUser(trimmed) + await this.streamResponse(trimmed) + } + + private async handleCommand(cmd: string): Promise { + const parts = cmd.split(" ") + const command = parts[0] + const args = parts.slice(1) + + switch (command) { + case "/help": + case "/h": + this.printHelp() + break + + case "/quit": + case "/q": + this.shutdown() + return + + case "/new": + try { + const session = await this.client.createSession() + this.sessionId = session.sessionId + this.messages = [] + this.printSystem(`New session: ${this.sessionId}`) + } catch (err: unknown) { + this.printError(`Failed to create session: ${err instanceof Error ? err.message : String(err)}`) + } + break + + case "/sessions": + try { + const { sessions } = await this.client.listSessions() + if (sessions.length === 0) { + this.printSystem("No active sessions") + } else { + this.printSystem(`Active sessions (${sessions.length}):`) + sessions.forEach((id) => { + const marker = id === this.sessionId ? chalk.cyan(" → ") : " " + output.write(`${marker}${chalk.gray(id)}\n`) + }) + } + } catch (err: unknown) { + this.printError(`Failed to list sessions: ${err instanceof Error ? err.message : String(err)}`) + } + break + + case "/switch": + if (!args[0]) { + this.printError("Usage: /switch ") + } else { + this.sessionId = args[0] + this.messages = [] + this.printSystem(`Switched to session: ${this.sessionId}`) + } + break + + case "/clear": + this.messages = [] + this.clearScreen() + this.printHeader() + this.printSystem("Chat history cleared") + break + + case "/model": + if (!args[0]) { + this.printError("Usage: /model ") + } else { + this.printSystem(`Model preference set to: ${args[0]} (applies to new sessions)`) + } + break + + default: + this.printError(`Unknown command: ${command}. Type /help for available commands.`) + } + + this.prompt() + } + + private printHelp(): void { + output.write(` +${chalk.cyan.bold("Commands:")} + ${chalk.green("/help")}, ${chalk.green("/h")} Show this help + ${chalk.green("/quit")}, ${chalk.green("/q")} Exit the TUI + ${chalk.green("/new")} Create a new session + ${chalk.green("/sessions")} List all active sessions + ${chalk.green("/switch ")} Switch to another session + ${chalk.green("/clear")} Clear chat history + ${chalk.green("/model ")} Set model for new sessions + +${chalk.cyan.bold("Shortcuts:")} + ${chalk.yellow("Ctrl+C")} Cancel streaming response + ${chalk.yellow("↑ / ↓")} Navigate input history + +`) + } + + private async streamResponse(content: string): Promise { + if (!this.sessionId) return + + this.isStreaming = true + this.abortController = new AbortController() + this.currentAssistantText = "" + + let assistantStarted = false + let currentToolName = "" + let currentToolArgs = "" + + try { + for await (const event of this.client.sendMessage(this.sessionId, content, this.abortController.signal)) { + const e = event as Record + + switch (e.type) { + case "turn_start": + assistantStarted = false + break + + case "message_start": + if (!assistantStarted) { + this.printAssistantStart() + assistantStarted = true + } + break + + case "message_update": + if (!assistantStarted) { + this.printAssistantStart() + assistantStarted = true + } + if (e.text) { + this.currentAssistantText += String(e.text) + this.printAssistantChunk(String(e.text)) + } + break + + case "message_end": + this.printAssistantEnd() + this.messages.push({ + role: "assistant", + content: this.currentAssistantText, + }) + this.currentAssistantText = "" + break + + case "tool_call_start": + currentToolName = String(e.toolName ?? "") + currentToolArgs = String(e.toolArgs ?? "") + this.printToolStart(currentToolName, currentToolArgs) + break + + case "tool_call_end": + this.printToolEnd(String(e.toolName ?? currentToolName), String(e.toolResult ?? ""), Boolean(e.isToolError)) + this.messages.push({ + role: "tool", + toolName: String(e.toolName ?? currentToolName), + content: String(e.toolResult ?? ""), + isError: Boolean(e.isToolError), + }) + break + + case "thinking_update": + if (e.text) { + output.write(chalk.gray(String(e.text))) + } + break + + case "error": + this.printError(String(e.error ?? "Unknown error")) + break + + case "turn_end": + this.isStreaming = false + this.prompt() + return + + case "auto_retry_start": + output.write(`\n ${chalk.yellow("⟳")} ${chalk.yellow("Retrying...")}\n`) + break + + case "auto_compaction_start": + output.write(`\n ${chalk.yellow("⚡")} ${chalk.yellow("Compacting conversation...")}\n`) + break + } + } + } catch (err: unknown) { + if (err instanceof Error && err.name === "AbortError") { + output.write(`\n ${chalk.yellow("⏹")} ${chalk.yellow("Cancelled")}\n`) + } else { + this.printError(err instanceof Error ? err.message : String(err)) + } + } finally { + this.isStreaming = false + this.abortController = undefined + this.prompt() + } + } + + private cancelStream(): void { + if (this.abortController) { + this.abortController.abort() + } + } + + private async shutdown(): Promise { + output.write(`\n${chalk.gray("Shutting down...")}\n`) + this.rl.close() + if (this.sessionId) { + try { + await this.client.deleteSession(this.sessionId) + output.write(`${chalk.gray("Session ended.")}\n`) + } catch { + // Ignore cleanup errors + } + } + process.exit(0) + } +} + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- +async function main() { + const config = parseArgs() + const tui = new Tui(config) + await tui.start() +} + +main().catch((err: unknown) => { + console.error(chalk.red("Fatal error:"), err) + process.exit(1) +}) diff --git a/packages/pi-harness/src/types.ts b/packages/pi-harness/src/types.ts new file mode 100644 index 0000000..cd1ae36 --- /dev/null +++ b/packages/pi-harness/src/types.ts @@ -0,0 +1,44 @@ +/** + * Types for the pi-harness standalone agent server. + */ + +export interface CreateSessionRequest { + /** Optional session ID (UUID generated if omitted) */ + sessionId?: string + /** LLM provider identifier */ + provider?: string + /** Model identifier */ + model?: string + /** Optional system prompt override */ + systemPrompt?: string + /** Optional prefix prepended to the system prompt */ + systemPromptPrefix?: string + /** Optional suffix appended to the system prompt */ + systemPromptSuffix?: string + /** Tool names to enable (omit for all) */ + toolsEnabled?: string[] + /** Workspace directory for this session */ + workspaceDir?: string +} + +export interface SendMessageRequest { + /** User message content */ + content: string +} + +export interface SessionResponse { + sessionId: string + status: "active" | "ended" + createdAt: string +} + +export interface HealthResponse { + status: "ok" + sessions: number + uptime: number + version: string +} + +export interface ErrorResponse { + error: string +} diff --git a/packages/pi-harness/tsconfig.json b/packages/pi-harness/tsconfig.json new file mode 100644 index 0000000..796c4ed --- /dev/null +++ b/packages/pi-harness/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 934c16d..721ba6d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -630,6 +630,37 @@ importers: specifier: ^5.7.3 version: 5.9.3 + packages/pi-harness: + dependencies: + '@hono/node-server': + specifier: ^1.19.13 + version: 1.19.13(hono@4.12.12) + '@openzosma/agents': + specifier: workspace:* + version: link:../agents + '@openzosma/logger': + specifier: workspace:* + version: link:../logger + chalk: + specifier: ^5.5.0 + version: 5.6.2 + hono: + specifier: ^4.12.12 + version: 4.12.12 + devDependencies: + '@types/node': + specifier: ^22.15.2 + version: 22.19.17 + esbuild: + specifier: ^0.25.0 + version: 0.25.12 + tsx: + specifier: ^4.21.0 + version: 4.21.0 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + packages/sandbox: dependencies: '@openzosma/logger': @@ -1073,156 +1104,312 @@ packages: '@emnapi/runtime@1.9.0': resolution: {integrity: sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==} + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.27.4': resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.27.4': resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.27.4': resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.27.4': resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.27.4': resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.27.4': resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.27.4': resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.27.4': resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.27.4': resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.27.4': resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.27.4': resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.27.4': resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.27.4': resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.27.4': resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.27.4': resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.27.4': resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.27.4': resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==} engines: {node: '>=18'} cpu: [x64] os: [linux] + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-arm64@0.27.4': resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.27.4': resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-arm64@0.27.4': resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.27.4': resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/openharmony-arm64@0.27.4': resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.27.4': resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.27.4': resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.27.4': resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.27.4': resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==} engines: {node: '>=18'} @@ -4435,6 +4622,11 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + esbuild@0.27.4: resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==} engines: {node: '>=18'} @@ -7634,81 +7826,159 @@ snapshots: tslib: 2.8.1 optional: true + '@esbuild/aix-ppc64@0.25.12': + optional: true + '@esbuild/aix-ppc64@0.27.4': optional: true + '@esbuild/android-arm64@0.25.12': + optional: true + '@esbuild/android-arm64@0.27.4': optional: true + '@esbuild/android-arm@0.25.12': + optional: true + '@esbuild/android-arm@0.27.4': optional: true + '@esbuild/android-x64@0.25.12': + optional: true + '@esbuild/android-x64@0.27.4': optional: true + '@esbuild/darwin-arm64@0.25.12': + optional: true + '@esbuild/darwin-arm64@0.27.4': optional: true + '@esbuild/darwin-x64@0.25.12': + optional: true + '@esbuild/darwin-x64@0.27.4': optional: true + '@esbuild/freebsd-arm64@0.25.12': + optional: true + '@esbuild/freebsd-arm64@0.27.4': optional: true + '@esbuild/freebsd-x64@0.25.12': + optional: true + '@esbuild/freebsd-x64@0.27.4': optional: true + '@esbuild/linux-arm64@0.25.12': + optional: true + '@esbuild/linux-arm64@0.27.4': optional: true + '@esbuild/linux-arm@0.25.12': + optional: true + '@esbuild/linux-arm@0.27.4': optional: true + '@esbuild/linux-ia32@0.25.12': + optional: true + '@esbuild/linux-ia32@0.27.4': optional: true + '@esbuild/linux-loong64@0.25.12': + optional: true + '@esbuild/linux-loong64@0.27.4': optional: true + '@esbuild/linux-mips64el@0.25.12': + optional: true + '@esbuild/linux-mips64el@0.27.4': optional: true + '@esbuild/linux-ppc64@0.25.12': + optional: true + '@esbuild/linux-ppc64@0.27.4': optional: true + '@esbuild/linux-riscv64@0.25.12': + optional: true + '@esbuild/linux-riscv64@0.27.4': optional: true + '@esbuild/linux-s390x@0.25.12': + optional: true + '@esbuild/linux-s390x@0.27.4': optional: true + '@esbuild/linux-x64@0.25.12': + optional: true + '@esbuild/linux-x64@0.27.4': optional: true + '@esbuild/netbsd-arm64@0.25.12': + optional: true + '@esbuild/netbsd-arm64@0.27.4': optional: true + '@esbuild/netbsd-x64@0.25.12': + optional: true + '@esbuild/netbsd-x64@0.27.4': optional: true + '@esbuild/openbsd-arm64@0.25.12': + optional: true + '@esbuild/openbsd-arm64@0.27.4': optional: true + '@esbuild/openbsd-x64@0.25.12': + optional: true + '@esbuild/openbsd-x64@0.27.4': optional: true + '@esbuild/openharmony-arm64@0.25.12': + optional: true + '@esbuild/openharmony-arm64@0.27.4': optional: true + '@esbuild/sunos-x64@0.25.12': + optional: true + '@esbuild/sunos-x64@0.27.4': optional: true + '@esbuild/win32-arm64@0.25.12': + optional: true + '@esbuild/win32-arm64@0.27.4': optional: true + '@esbuild/win32-ia32@0.25.12': + optional: true + '@esbuild/win32-ia32@0.27.4': optional: true + '@esbuild/win32-x64@0.25.12': + optional: true + '@esbuild/win32-x64@0.27.4': optional: true @@ -9210,14 +9480,14 @@ snapshots: '@slack/logger@4.0.1': dependencies: - '@types/node': 22.19.15 + '@types/node': 22.19.17 '@slack/oauth@3.0.5': dependencies: '@slack/logger': 4.0.1 '@slack/web-api': 7.15.0 '@types/jsonwebtoken': 9.0.10 - '@types/node': 22.19.15 + '@types/node': 22.19.17 jsonwebtoken: 9.0.3 transitivePeerDependencies: - debug @@ -9226,7 +9496,7 @@ snapshots: dependencies: '@slack/logger': 4.0.1 '@slack/web-api': 7.15.0 - '@types/node': 22.19.15 + '@types/node': 22.19.17 '@types/ws': 8.18.1 eventemitter3: 5.0.4 ws: 8.19.0 @@ -9241,7 +9511,7 @@ snapshots: dependencies: '@slack/logger': 4.0.1 '@slack/types': 2.20.1 - '@types/node': 22.19.15 + '@types/node': 22.19.17 '@types/retry': 0.12.0 axios: 1.13.6 eventemitter3: 5.0.4 @@ -10092,7 +10362,7 @@ snapshots: '@types/jsonwebtoken@9.0.10': dependencies: '@types/ms': 2.1.0 - '@types/node': 22.19.15 + '@types/node': 22.19.17 '@types/katex@0.16.8': {} @@ -10177,7 +10447,7 @@ snapshots: '@types/yauzl@2.10.3': dependencies: - '@types/node': 22.19.15 + '@types/node': 22.19.17 optional: true '@typescript/vfs@1.6.4(typescript@5.4.5)': @@ -11133,6 +11403,35 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.2 + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + esbuild@0.27.4: optionalDependencies: '@esbuild/aix-ppc64': 0.27.4 @@ -13041,7 +13340,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.0 - '@types/node': 22.19.15 + '@types/node': 22.19.17 long: 5.3.2 proxy-addr@2.0.7: From f411aafe848f684a54577b3a479a7ecd9ddb8829 Mon Sep 17 00:00:00 2001 From: Arjun Nayak Date: Sun, 26 Apr 2026 07:49:51 +0530 Subject: [PATCH 3/4] ci(release): publish pi-harness to npm on version tag Extend the release workflow to also bump version and publish @openzosma/pi-harness alongside create-openzosma when a v* tag is pushed. --- .github/workflows/release.yaml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 2a274bb..6be1372 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -37,6 +37,7 @@ jobs: echo "VERSION=$VERSION" >> "$GITHUB_ENV" echo "Publishing version: $VERSION" npm --prefix packages/create-openzosma version "$VERSION" --no-git-tag-version --allow-same-version + npm --prefix packages/pi-harness version "$VERSION" --no-git-tag-version --allow-same-version - name: Install dependencies run: pnpm install --no-frozen-lockfile @@ -53,6 +54,12 @@ jobs: env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: Publish pi-harness + run: pnpm publish --access public --no-git-checks + working-directory: packages/pi-harness + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: From aa97abfb05743b8a63267aa93ab10f7089784e53 Mon Sep 17 00:00:00 2001 From: Arjun Nayak Date: Sun, 26 Apr 2026 07:58:50 +0530 Subject: [PATCH 4/4] style(pi-harness): biome lint fixes and npm publish cleanup - Fix biome formatting in package.json (files field) - Sort imports across cli.ts, commands.ts, server.ts, tui.ts - Fix noUnusedTemplateLiteral in cli.ts - Fix useTemplate in build-bundle.mjs - Add fsevents and *.node to esbuild externals (upstream dep change) - Add .npmignore to exclude source maps from published package - Remove stale .pi-lens cache files --- packages/pi-harness/.npmignore | 2 ++ packages/pi-harness/package.json | 5 +---- packages/pi-harness/scripts/build-bundle.mjs | 6 ++++-- packages/pi-harness/src/cli.ts | 6 +++--- packages/pi-harness/src/commands.ts | 2 +- packages/pi-harness/src/server.ts | 2 +- packages/pi-harness/src/tui.ts | 4 ++-- 7 files changed, 14 insertions(+), 13 deletions(-) create mode 100644 packages/pi-harness/.npmignore diff --git a/packages/pi-harness/.npmignore b/packages/pi-harness/.npmignore new file mode 100644 index 0000000..cbd4f1c --- /dev/null +++ b/packages/pi-harness/.npmignore @@ -0,0 +1,2 @@ +# Exclude source maps from published package +*.map diff --git a/packages/pi-harness/package.json b/packages/pi-harness/package.json index 00ead60..f6b32ff 100644 --- a/packages/pi-harness/package.json +++ b/packages/pi-harness/package.json @@ -9,10 +9,7 @@ "bin": { "pi-harness": "dist/cli.js" }, - "files": [ - "dist", - "scripts" - ], + "files": ["dist", "scripts"], "exports": { ".": { "types": "./dist/index.d.ts", diff --git a/packages/pi-harness/scripts/build-bundle.mjs b/packages/pi-harness/scripts/build-bundle.mjs index f83f43d..85952c0 100644 --- a/packages/pi-harness/scripts/build-bundle.mjs +++ b/packages/pi-harness/scripts/build-bundle.mjs @@ -11,10 +11,10 @@ * node scripts/build-bundle.mjs */ -import * as esbuild from "esbuild" import { readFileSync, writeFileSync } from "node:fs" import { resolve } from "node:path" import { fileURLToPath } from "node:url" +import * as esbuild from "esbuild" const __dirname = fileURLToPath(new URL(".", import.meta.url)) const outDir = resolve(__dirname, "../dist") @@ -30,6 +30,8 @@ const EXTERNAL = [ "pg", "@sinclair/typebox", "@types/pg", + "fsevents", + "*.node", ] async function bundleEntry(entry, outName) { @@ -50,7 +52,7 @@ async function bundleEntry(entry, outName) { // Prepend shebang const outPath = resolve(outDir, outName) const content = readFileSync(outPath, "utf-8") - writeFileSync(outPath, "#!/usr/bin/env node\n" + content, { mode: 0o755 }) + writeFileSync(outPath, `#!/usr/bin/env node\n${content}`, { mode: 0o755 }) } async function main() { diff --git a/packages/pi-harness/src/cli.ts b/packages/pi-harness/src/cli.ts index 1fac751..a8c93e9 100644 --- a/packages/pi-harness/src/cli.ts +++ b/packages/pi-harness/src/cli.ts @@ -17,9 +17,9 @@ */ import { execSync } from "node:child_process" -import { existsSync, readFileSync, unlinkSync, mkdirSync } from "node:fs" +import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs" import { homedir } from "node:os" -import { resolve, join } from "node:path" +import { join, resolve } from "node:path" import { fileURLToPath } from "node:url" import { parseArgs } from "node:util" @@ -145,7 +145,7 @@ async function cmdSetup(): Promise { if (!existsSync(SETUP_SCRIPT)) { console.error(`${C.red}✗ Setup script not found:${C.reset} ${SETUP_SCRIPT}`) - console.error(` Run setup manually or reinstall pi-harness.`) + console.error(" Run setup manually or reinstall pi-harness.") process.exit(1) } diff --git a/packages/pi-harness/src/commands.ts b/packages/pi-harness/src/commands.ts index faf6c91..f2dcfb2 100644 --- a/packages/pi-harness/src/commands.ts +++ b/packages/pi-harness/src/commands.ts @@ -7,7 +7,7 @@ */ import { spawn } from "node:child_process" -import { existsSync, readFileSync, writeFileSync, openSync, closeSync } from "node:fs" +import { closeSync, existsSync, openSync, readFileSync, writeFileSync } from "node:fs" import { resolve } from "node:path" import { fileURLToPath } from "node:url" import { serve } from "@hono/node-server" diff --git a/packages/pi-harness/src/server.ts b/packages/pi-harness/src/server.ts index b55914c..3069868 100644 --- a/packages/pi-harness/src/server.ts +++ b/packages/pi-harness/src/server.ts @@ -1,6 +1,6 @@ +import { createLogger } from "@openzosma/logger" import { Hono } from "hono" import { streamSSE } from "hono/streaming" -import { createLogger } from "@openzosma/logger" import type { HarnessConfig } from "./config.js" import { HarnessSessionManager } from "./session-manager.js" import type { CreateSessionRequest, SendMessageRequest } from "./types.js" diff --git a/packages/pi-harness/src/tui.ts b/packages/pi-harness/src/tui.ts index 7184b66..b8a4c26 100644 --- a/packages/pi-harness/src/tui.ts +++ b/packages/pi-harness/src/tui.ts @@ -1,4 +1,6 @@ #!/usr/bin/env node +import { stdin as input, stdout as output } from "node:process" +import * as readline from "node:readline" /** * Pi-Harness TUI Client * @@ -11,8 +13,6 @@ * pi-harness-tui --session */ import chalk from "chalk" -import * as readline from "node:readline" -import { stdin as input, stdout as output } from "node:process" // --------------------------------------------------------------------------- // Config