fix: orchestrator button + Pi SDK session error
- Move leftSidebarMode from URL state to local useState in unified-shell,
avoiding force-dynamic router round-trip that made the button appear broken - Replace fileURLToPath(new URL(..., import.meta.url)) with process.cwd()
in bb-pi-bootstrap.ts — import.meta.url is a webpack:// URL in Next.js,
causing cross-realm TypeError when passed to Node.js fileURLToPath()
This commit is contained in:
parent
643fa299dd
commit
d335e5bf71
98 changed files with 17851 additions and 944 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -50,3 +50,7 @@ test-*.ts
|
||||||
.qodo/
|
.qodo/
|
||||||
.gemini/
|
.gemini/
|
||||||
.kilocode/
|
.kilocode/
|
||||||
|
|
||||||
|
# Dolt database files (added by bd init)
|
||||||
|
.dolt/
|
||||||
|
*.db
|
||||||
|
|
|
||||||
87
AGENTS.md
87
AGENTS.md
|
|
@ -208,3 +208,90 @@ Dolt SQL server at `127.0.0.1:3307`. Read path: `readIssuesViaDolt()` → Dolt (
|
||||||
networkingMode=mirrored
|
networkingMode=mirrored
|
||||||
```
|
```
|
||||||
Then `wsl --shutdown`. Not required for single-platform setups.
|
Then `wsl --shutdown`. Not required for single-platform setups.
|
||||||
|
|
||||||
|
<!-- BEGIN BEADS INTEGRATION -->
|
||||||
|
## Issue Tracking with bd (beads)
|
||||||
|
|
||||||
|
**IMPORTANT**: This project uses **bd (beads)** for ALL issue tracking. Do NOT use markdown TODOs, task lists, or other tracking methods.
|
||||||
|
|
||||||
|
### Why bd?
|
||||||
|
|
||||||
|
- Dependency-aware: Track blockers and relationships between issues
|
||||||
|
- Git-friendly: Dolt-powered version control with native sync
|
||||||
|
- Agent-optimized: JSON output, ready work detection, discovered-from links
|
||||||
|
- Prevents duplicate tracking systems and confusion
|
||||||
|
|
||||||
|
### Quick Start
|
||||||
|
|
||||||
|
**Check for ready work:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bd ready --json
|
||||||
|
```
|
||||||
|
|
||||||
|
**Create new issues:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bd create "Issue title" --description="Detailed context" -t bug|feature|task -p 0-4 --json
|
||||||
|
bd create "Issue title" --description="What this issue is about" -p 1 --deps discovered-from:bd-123 --json
|
||||||
|
```
|
||||||
|
|
||||||
|
**Claim and update:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bd update <id> --claim --json
|
||||||
|
bd update bd-42 --priority 1 --json
|
||||||
|
```
|
||||||
|
|
||||||
|
**Complete work:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bd close bd-42 --reason "Completed" --json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Issue Types
|
||||||
|
|
||||||
|
- `bug` - Something broken
|
||||||
|
- `feature` - New functionality
|
||||||
|
- `task` - Work item (tests, docs, refactoring)
|
||||||
|
- `epic` - Large feature with subtasks
|
||||||
|
- `chore` - Maintenance (dependencies, tooling)
|
||||||
|
|
||||||
|
### Priorities
|
||||||
|
|
||||||
|
- `0` - Critical (security, data loss, broken builds)
|
||||||
|
- `1` - High (major features, important bugs)
|
||||||
|
- `2` - Medium (default, nice-to-have)
|
||||||
|
- `3` - Low (polish, optimization)
|
||||||
|
- `4` - Backlog (future ideas)
|
||||||
|
|
||||||
|
### Workflow for AI Agents
|
||||||
|
|
||||||
|
1. **Check ready work**: `bd ready` shows unblocked issues
|
||||||
|
2. **Claim your task atomically**: `bd update <id> --claim`
|
||||||
|
3. **Work on it**: Implement, test, document
|
||||||
|
4. **Discover new work?** Create linked issue:
|
||||||
|
- `bd create "Found bug" --description="Details about what was found" -p 1 --deps discovered-from:<parent-id>`
|
||||||
|
5. **Complete**: `bd close <id> --reason "Done"`
|
||||||
|
|
||||||
|
### Auto-Sync
|
||||||
|
|
||||||
|
bd automatically syncs via Dolt:
|
||||||
|
|
||||||
|
- Each write auto-commits to Dolt history
|
||||||
|
- Use `bd dolt push`/`bd dolt pull` for remote sync
|
||||||
|
- No manual export/import needed!
|
||||||
|
|
||||||
|
### Important Rules
|
||||||
|
|
||||||
|
- ✅ Use bd for ALL task tracking
|
||||||
|
- ✅ Always use `--json` flag for programmatic use
|
||||||
|
- ✅ Link discovered work with `discovered-from` dependencies
|
||||||
|
- ✅ Check `bd ready` before asking "what should I work on?"
|
||||||
|
- ❌ Do NOT create markdown TODO lists
|
||||||
|
- ❌ Do NOT use external issue trackers
|
||||||
|
- ❌ Do NOT duplicate tracking systems
|
||||||
|
|
||||||
|
For more details, see README.md and docs/QUICKSTART.md.
|
||||||
|
|
||||||
|
<!-- END BEADS INTEGRATION -->
|
||||||
|
|
|
||||||
64
docs/adr/2026-03-05-bb-daemon-attachment-model.md
Normal file
64
docs/adr/2026-03-05-bb-daemon-attachment-model.md
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
# ADR: BeadBoard Daemon Attachment Model
|
||||||
|
|
||||||
|
- Date: 2026-03-05
|
||||||
|
- Status: Accepted
|
||||||
|
- Scope: Host-resident daemon topology for BeadBoard runtime execution
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
BeadBoard is evolving from a coordination surface into a coordination-plus-execution product. The product now needs a durable runtime anchor that survives browser reloads, keeps project runtime state on the user's machine, and can eventually serve both local and remote frontends.
|
||||||
|
|
||||||
|
Earlier embedded runtime scaffolding inside the Next app is useful as a transition, but it is not the target architecture.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
BeadBoard will use a **user-owned, host-resident `bb` daemon** as the execution anchor.
|
||||||
|
|
||||||
|
This means:
|
||||||
|
|
||||||
|
- each user runs their own BeadBoard daemon on their own machine
|
||||||
|
- the daemon is long-lived while the host machine is on
|
||||||
|
- runtime ownership stays with the user and the user's environment
|
||||||
|
- the frontend attaches to daemon state and control APIs instead of owning execution directly
|
||||||
|
- this is **not a centralized hosted runtime** model for the current product architecture
|
||||||
|
|
||||||
|
## Attachment model
|
||||||
|
|
||||||
|
The canonical relationship is:
|
||||||
|
|
||||||
|
1. the user starts `bb daemon start`
|
||||||
|
2. the daemon becomes the durable runtime anchor for project execution
|
||||||
|
3. a frontend attaches to daemon APIs and event streams
|
||||||
|
4. the frontend acts as a client/control surface, not the runtime owner
|
||||||
|
|
||||||
|
The phrase **frontend attaches to daemon** is intentional. It applies whether the frontend is:
|
||||||
|
|
||||||
|
- running locally on the same machine
|
||||||
|
- deployed remotely by the user
|
||||||
|
- eventually deployed in a different frontend environment that still points at the same daemon
|
||||||
|
|
||||||
|
## Local vs remote frontend
|
||||||
|
|
||||||
|
Near-term implementation may use local co-residency and in-process attachment seams for simplicity.
|
||||||
|
|
||||||
|
Long-term architecture still treats the frontend and daemon as distinct roles:
|
||||||
|
|
||||||
|
- **daemon** = execution, orchestration, durable runtime state
|
||||||
|
- **frontend** = interaction, inspection, control, visualization
|
||||||
|
|
||||||
|
Remote attachment is therefore an extension of the same architecture, not a different one.
|
||||||
|
|
||||||
|
## Non-decision: later B2B hosting
|
||||||
|
|
||||||
|
A future B2B deployment may run `bb` on a cloud VM. That is a separate later PRD and does not change the current architectural standard:
|
||||||
|
|
||||||
|
- current product direction is still user-owned daemon first
|
||||||
|
- later hosted or VM-backed deployment must build on the same daemon attachment contract rather than replacing it with a centralized runtime assumption
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- daemon lifecycle becomes a first-class CLI/runtime concern
|
||||||
|
- Next.js runtime routes should evolve into daemon-backed adapters
|
||||||
|
- the frontend must gradually become a daemon client rather than an in-app runtime owner
|
||||||
|
- Pi integration should sit behind a BeadBoard-owned daemon/runtime boundary
|
||||||
|
- persistence, reconnect behavior, and event streaming should attach to the daemon contract rather than ephemeral browser state
|
||||||
179
docs/manual-test-phase-1-worker-spawning.md
Normal file
179
docs/manual-test-phase-1-worker-spawning.md
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
# Phase 1: Worker Spawning - Manual E2E Test
|
||||||
|
|
||||||
|
**Date:** 2026-03-06
|
||||||
|
**Status:** Ready for manual testing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. ✅ Dev server is running (user has it running)
|
||||||
|
2. ✅ Pi SDK is available (`bb daemon bootstrap-pi` should have been run)
|
||||||
|
3. ✅ All code changes are committed and applied
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Scenarios
|
||||||
|
|
||||||
|
### Scenario 1: Basic Worker Spawn
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. Open BeadBoard in browser (http://localhost:3000)
|
||||||
|
2. Open left panel (orchestrator mode)
|
||||||
|
3. Send this prompt to orchestrator:
|
||||||
|
```
|
||||||
|
Spawn a worker to read the README.md file and tell me what it says.
|
||||||
|
```
|
||||||
|
4. Observe the response and check:
|
||||||
|
- [ ] Orchestrator calls `bb_spawn_worker` tool
|
||||||
|
- [ ] Worker spawns with task context
|
||||||
|
- [ ] `worker.spawned` event appears in runtime console (with "Worker" badge)
|
||||||
|
- [ ] Worker event shows task ID: "read-the-readme.md-file-and-tell-me-what-it-says"
|
||||||
|
- [ ] Worker status tool response is shown
|
||||||
|
|
||||||
|
**Expected Result:** Worker appears in console with "Worker" badge, shows "WORKING" status.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Scenario 2: Worker Status Check
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. From left panel, send:
|
||||||
|
```
|
||||||
|
Check the status of the worker you just spawned.
|
||||||
|
```
|
||||||
|
2. Observe:
|
||||||
|
- [ ] Orchestrator calls `bb_worker_status` tool
|
||||||
|
- [ ] Worker status is displayed with correct emoji
|
||||||
|
- [ ] Shows "WORKING", "COMPLETED", or "FAILED" as appropriate
|
||||||
|
- [ ] Task ID matches the spawned task
|
||||||
|
|
||||||
|
**Expected Result:** Current worker status shown with helpful message.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Scenario 3: Worker Completion
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. Wait for the worker to complete (should happen within ~30 seconds for README read)
|
||||||
|
2. Observe:
|
||||||
|
- [ ] `worker.updated` or `worker.completed` event appears
|
||||||
|
- [ ] If completed: shows "COMPLETED" with ✅ emoji
|
||||||
|
- [ ] Result summary is shown (first 200 chars)
|
||||||
|
- [ ] Worker no longer shows as "WORKING"
|
||||||
|
|
||||||
|
**Expected Result:** Worker successfully completes and result is displayed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Scenario 4: Multiple Workers
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. Send prompt to orchestrator:
|
||||||
|
```
|
||||||
|
Spawn 3 workers in parallel:
|
||||||
|
- Worker 1: Read package.json
|
||||||
|
- Worker 2: List all files in src/
|
||||||
|
- Worker 3: Read .env.example file
|
||||||
|
```
|
||||||
|
2. Observe:
|
||||||
|
- [ ] Three separate `worker.spawned` events appear
|
||||||
|
- [ ] All three workers show "WORKING" status
|
||||||
|
- [ ] Each worker has a unique ID
|
||||||
|
- [ ] Task contexts are correct for each
|
||||||
|
|
||||||
|
**Expected Result:** Multiple workers run in parallel, each with unique identity and task.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Scenario 5: Worker with Archetype
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. Send prompt to orchestrator:
|
||||||
|
```
|
||||||
|
Spawn a worker with archetype "coder" to add a new test file.
|
||||||
|
```
|
||||||
|
2. Observe:
|
||||||
|
- [ ] Worker spawns with "Archetype: coder" in the detail
|
||||||
|
- [ ] Worker system prompt includes the archetype context
|
||||||
|
|
||||||
|
**Expected Result:** Worker behavior is guided by archetype (though actual behavior is same - this is v1).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Scenario 6: Worker Error Handling
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. Send prompt to orchestrator with invalid task:
|
||||||
|
```
|
||||||
|
Spawn a worker to read a file that does not exist: /nonexistent/path/to/file.txt
|
||||||
|
```
|
||||||
|
2. Observe:
|
||||||
|
- [ ] Worker attempts the task
|
||||||
|
- [ ] Worker fails and reports error
|
||||||
|
- [ ] `worker.failed` event appears with ❌ emoji
|
||||||
|
- [ ] Error message explains what went wrong
|
||||||
|
|
||||||
|
**Expected Result:** Worker failures are captured and reported clearly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Scenario 7: Runtime Console Worker Badge
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. Spawn a worker
|
||||||
|
2. Look at runtime console (bottom panel)
|
||||||
|
3. Observe:
|
||||||
|
- [ ] Worker events have a purple "Worker" badge
|
||||||
|
- [ ] Orchestrator events do NOT have the badge
|
||||||
|
- [ ] Badge says "Worker Agent Event" on hover
|
||||||
|
|
||||||
|
**Expected Result:** Visual distinction between orchestrator and worker events is clear.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Scenario 8: Left Panel Chat Integration
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. Observe the orchestrator conversation in left panel
|
||||||
|
2. During worker spawn, check:
|
||||||
|
- [ ] Tool calls appear inline (like they do for `bb_dolt_read`)
|
||||||
|
- [ ] Worker spawn response includes worker ID
|
||||||
|
- [ ] Chat remains readable and not cluttered
|
||||||
|
|
||||||
|
**Expected Result:** Orchestrator chat surface handles worker interactions natively.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
A scenario passes when:
|
||||||
|
- Worker events appear in runtime console
|
||||||
|
- Worker events have "Worker" badge
|
||||||
|
- Status changes (spawning → working → completed) are visible
|
||||||
|
- Tool calls are logged and returned to orchestrator
|
||||||
|
- Multiple workers can run in parallel
|
||||||
|
- Worker completion/failure is captured
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bug Report Form
|
||||||
|
|
||||||
|
If any test fails, capture:
|
||||||
|
```
|
||||||
|
Test Scenario: [number]
|
||||||
|
Steps Taken: [what you did]
|
||||||
|
Expected Result: [what should happen]
|
||||||
|
Actual Result: [what actually happened]
|
||||||
|
Error Messages: [any errors in console]
|
||||||
|
Screenshot/Notes: [any additional details]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## After Testing
|
||||||
|
|
||||||
|
Once all scenarios pass:
|
||||||
|
1. Review success criteria in `docs/plans/2026-03-06-phase-1-worker-spawning.md`
|
||||||
|
2. Update roadmap to mark Phase 1 as complete
|
||||||
|
3. Move to Phase 2: Archetype-backed execution configs
|
||||||
1080
docs/plans/2026-03-05-embedded-pi-prd.md
Normal file
1080
docs/plans/2026-03-05-embedded-pi-prd.md
Normal file
File diff suppressed because it is too large
Load diff
373
docs/plans/2026-03-05-embedded-pi-roadmap.md
Normal file
373
docs/plans/2026-03-05-embedded-pi-roadmap.md
Normal file
|
|
@ -0,0 +1,373 @@
|
||||||
|
# BeadBoard Embedded Pi Roadmap
|
||||||
|
|
||||||
|
**Date:** 2026-03-05
|
||||||
|
**Last Updated:** 2026-03-07
|
||||||
|
**Companion PRD:** `docs/plans/2026-03-05-embedded-pi-prd.md`
|
||||||
|
**Purpose:** Track what has already shipped for Embedded Pi in BeadBoard, what is partially complete, and what remains to reach the full PRD vision.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current status
|
||||||
|
|
||||||
|
**Phase 3 COMPLETE - Testing in progress.**
|
||||||
|
|
||||||
|
We now have:
|
||||||
|
- ✅ Working embedded orchestrator with BeadBoard tools
|
||||||
|
- ✅ Worker spawning with numbered instances (Engineer 01, etc.)
|
||||||
|
- ✅ Agent types (architect, engineer, reviewer, tester, investigator, shipper)
|
||||||
|
- ✅ Template-based team spawning
|
||||||
|
- ✅ **Bead-required workflow** - every worker task has a bead
|
||||||
|
- ✅ **Async worker coordination** - non-blocking spawn, check status, read results
|
||||||
|
- ✅ **File verification pattern** - orchestrator reads actual files to verify work
|
||||||
|
|
||||||
|
**Currently testing:**
|
||||||
|
- Worker claims bead, updates, closes correctly
|
||||||
|
- Orchestrator can check worker status mid-task
|
||||||
|
- Orchestrator can get results and verify via file reads
|
||||||
|
|
||||||
|
**Biggest remaining gaps:**
|
||||||
|
- Phase 4: Launch-anywhere UX (spawn from task cards, graph nodes)
|
||||||
|
- Phase 5: Agent presence in social/graph views
|
||||||
|
- Tests and verification
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What is done
|
||||||
|
|
||||||
|
### Done: Runtime substrate / managed Pi foundation
|
||||||
|
- Managed Pi bootstrap exists via `src/lib/bb-pi-bootstrap.ts`
|
||||||
|
- Pi runtime detection exists via `src/lib/pi-runtime-detection.ts`
|
||||||
|
- BeadBoard-specific Pi settings + agent dir setup exist
|
||||||
|
- Embedded daemon/orchestrator substrate exists via:
|
||||||
|
- `src/lib/embedded-daemon.ts`
|
||||||
|
- `src/lib/bb-daemon.ts`
|
||||||
|
- `src/lib/pi-daemon-adapter.ts`
|
||||||
|
|
||||||
|
### Done: Orchestrator session integration
|
||||||
|
- Per-project orchestrator session can be created and reused
|
||||||
|
- Pi SDK session is initialized through the embedded adapter
|
||||||
|
- The orchestrator can receive prompts from the frontend
|
||||||
|
- The orchestrator can execute BeadBoard tools from the frontend path
|
||||||
|
|
||||||
|
### Done: BeadBoard-aware orchestrator context
|
||||||
|
- Dynamic system prompt exists in `src/tui/system-prompt.ts`
|
||||||
|
- Prompt includes:
|
||||||
|
- active tasks
|
||||||
|
- archetypes
|
||||||
|
- templates
|
||||||
|
- Deviation recording tool exists:
|
||||||
|
- `src/tui/tools/bb-deviation.ts`
|
||||||
|
|
||||||
|
### Done: Frontend prompt + telemetry plumbing
|
||||||
|
- Prompt route exists: `src/app/api/runtime/prompt/route.ts`
|
||||||
|
- Runtime event stream exists: `src/app/api/runtime/stream/route.ts`
|
||||||
|
- Runtime events endpoint exists: `src/app/api/runtime/events/route.ts`
|
||||||
|
- Left-panel orchestrator UI exists and can submit prompts
|
||||||
|
- Bottom runtime console exists and is now minimizable
|
||||||
|
|
||||||
|
### Done: Left-panel orchestrator chat UX foundation
|
||||||
|
- Left panel supports orchestrator mode
|
||||||
|
- Left panel now renders chat-style bubbles instead of raw telemetry cards
|
||||||
|
- User prompts can appear immediately in chat
|
||||||
|
- Assistant replies are projected from Pi session/runtime events
|
||||||
|
- Session/runtime errors are kept out of the main chat transcript surface
|
||||||
|
|
||||||
|
### Done: Realtime / event-ingestion hardening
|
||||||
|
- Duplicate runtime-event ingestion was debugged and deduped in the app shell
|
||||||
|
- Activity panel merging was deduped to stop repeated React key collisions
|
||||||
|
- Prompt path was changed so frontend requests return immediately instead of blocking on full agent completion
|
||||||
|
- Runtime stream now continuously surfaces new daemon events without requiring manual refresh for each turn
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Partially complete
|
||||||
|
|
||||||
|
### Partial: Sessions / conversation model
|
||||||
|
What works now:
|
||||||
|
- one embedded project orchestrator
|
||||||
|
- left-panel conversation surface
|
||||||
|
- runtime console telemetry
|
||||||
|
|
||||||
|
What is still missing:
|
||||||
|
- robust multi-session model
|
||||||
|
- explicit worker-session UI
|
||||||
|
- Full activity panel integration for orchestrator + worker histories
|
||||||
|
- clearer separation of orchestrator conversation vs mission/worker conversation surfaces
|
||||||
|
|
||||||
|
### Partial: Runtime observability
|
||||||
|
What works now:
|
||||||
|
- tool execution visibility
|
||||||
|
- runtime stream
|
||||||
|
- prompt submission + visible orchestrator progress
|
||||||
|
|
||||||
|
What is still missing:
|
||||||
|
- stronger stuck-session diagnostics
|
||||||
|
- clearer "thinking vs waiting vs blocked vs completed" state presentation
|
||||||
|
- better recovery/restart UX when a live session fails
|
||||||
|
|
||||||
|
### Partial: Launch plumbing
|
||||||
|
What works now:
|
||||||
|
- orchestrator prompt flow in the left panel
|
||||||
|
- UI-triggered launch route exists
|
||||||
|
|
||||||
|
What is still missing:
|
||||||
|
- launch-anywhere completion across all PRD surfaces
|
||||||
|
- more complete task/graph/swarm/mission launch affordances
|
||||||
|
- better contextual launch packaging per surface
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Remaining roadmap
|
||||||
|
|
||||||
|
## Phase 1 - Worker agents / sub-agents
|
||||||
|
**Status:** ✅ DONE (2026-03-06)
|
||||||
|
**Plan:** `docs/plans/2026-03-06-phase-1-worker-spawning.md`
|
||||||
|
|
||||||
|
### Shipped
|
||||||
|
- ✅ Worker spawning tool (`bb_spawn_worker`)
|
||||||
|
- ✅ Worker status tool (`bb_worker_status`)
|
||||||
|
- ✅ Worker session manager with isolated sessions
|
||||||
|
- ✅ Worker events in runtime console with "Worker" badge
|
||||||
|
- ✅ Worker lifecycle (spawning → working → completed/failed)
|
||||||
|
- ✅ Multiple parallel workers supported
|
||||||
|
- ✅ Archetype parameter support
|
||||||
|
- ✅ Worker results merge back to orchestrator chat
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 - Archetypes as executable agent types
|
||||||
|
**Status:** ✅ DONE (2026-03-06)
|
||||||
|
**Plan:** `docs/plans/2026-03-06-phase-2-archetype-configs.md`
|
||||||
|
|
||||||
|
### Shipped
|
||||||
|
- ✅ Archetype CRUD tools (`bb_list_archetypes`, `bb_create_archetype`, etc.)
|
||||||
|
- ✅ Template CRUD tools (`bb_list_templates`, `bb_create_template`, etc.)
|
||||||
|
- ✅ Worker session manager loads archetype config
|
||||||
|
- ✅ Capabilities mapped to tool access (full vs read-only)
|
||||||
|
- ✅ System prompt injection per archetype
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3 - Agent-based orchestration
|
||||||
|
**Status:** ✅ DONE (2026-03-07) - **Testing in progress**
|
||||||
|
**Plan:** `docs/plans/2026-03-07-phase-3-agent-orchestration.md`
|
||||||
|
|
||||||
|
### Shipped
|
||||||
|
- ✅ Renamed "archetype" → "agent" everywhere user-facing
|
||||||
|
- ✅ Agent instances with numbered display names (Engineer 01, etc.)
|
||||||
|
- ✅ Agent status panel in right panel
|
||||||
|
- ✅ Agent state persistence (`.beads/agents.jsonl`)
|
||||||
|
- ✅ Template spawning tool (`bb_spawn_team`)
|
||||||
|
- ✅ Natural language task descriptions (no task_id required)
|
||||||
|
- ✅ Auto-template selection from description keywords
|
||||||
|
- ✅ Decision tree in orchestrator prompt
|
||||||
|
- ✅ Agent assignment to beads (`bb_assign_agent`)
|
||||||
|
|
||||||
|
### Additional (2026-03-07)
|
||||||
|
- ✅ **Bead-required workflow** - every worker task has a bead
|
||||||
|
- `bb_create`, `bb_update`, `bb_close`, `bb_show`, `bb_ready` tools
|
||||||
|
- Workers must claim bead → update progress → close with summary
|
||||||
|
- ✅ **Async worker coordination** - non-blocking spawn
|
||||||
|
- `bb_worker_results` tool - get results from completed workers
|
||||||
|
- Orchestrator can check status mid-task
|
||||||
|
- Continue conversation while workers run
|
||||||
|
- ✅ **File verification pattern** - orchestrator reads actual files
|
||||||
|
- Not just result strings, but actual implementation
|
||||||
|
- Makes orchestrator a true reviewer
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4 - Multi-surface launch-anywhere UX
|
||||||
|
**Status:** Partially done
|
||||||
|
|
||||||
|
### Remaining work
|
||||||
|
1. Complete launch affordances on:
|
||||||
|
- task cards
|
||||||
|
- graph nodes
|
||||||
|
- swarm views
|
||||||
|
- mission inspectors
|
||||||
|
- sessions contexts
|
||||||
|
- blocked triage contexts
|
||||||
|
2. Ensure each launch path packages the right local context automatically
|
||||||
|
3. Make orchestrator interactions feel consistent across surfaces
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5 - Agent presence in social/graph/activity views
|
||||||
|
**Status:** Not done
|
||||||
|
|
||||||
|
### Remaining work
|
||||||
|
1. Make orchestrator + worker sessions visible in social cards, graph nodes, and activity panel
|
||||||
|
2. Support switching between active workers via left-panel orchestrator
|
||||||
|
3. Preserve longer conversation history cleanly
|
||||||
|
4. Add intervention / redirection UX for active worker sessions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6 - Runtime hardening and persistence
|
||||||
|
**Status:** Partially done
|
||||||
|
|
||||||
|
### Remaining work
|
||||||
|
1. Reduce drift between TUI Pi loader path and embedded Pi loader path
|
||||||
|
2. Harden reconnect/restart behavior for embedded sessions
|
||||||
|
3. Improve stuck/hung agent diagnostics
|
||||||
|
4. Clarify true host-daemon vs in-process lifecycle direction
|
||||||
|
5. Strengthen project-scoped persistence and restoration guarantees
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 7 — Tests and verification
|
||||||
|
**Status:** Mostly not done
|
||||||
|
|
||||||
|
### Remaining work
|
||||||
|
1. Unit coverage for runtime path resolution / event projection / chat projection
|
||||||
|
2. Contract tests for adapter and runtime event schemas
|
||||||
|
3. Integration tests for orchestrator session creation + prompt flow
|
||||||
|
4. UI tests for left-panel orchestrator chat behavior
|
||||||
|
5. End-to-end tests for prompt → tool → reply flow
|
||||||
|
6. Failure-path tests for runtime import/session/tool errors
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 8 — Unified Settings System (Future)
|
||||||
|
**Status:** Not started, documented in PRD Section 24
|
||||||
|
|
||||||
|
### Goal
|
||||||
|
Comprehensive settings for CLI and frontend: model selection, provider auth, UI preferences, runtime config.
|
||||||
|
|
||||||
|
### See
|
||||||
|
`docs/plans/2026-03-05-embedded-pi-prd.md` Section 24 for full requirements.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 9 — Holistic Skill Update (After All Phases Complete)
|
||||||
|
**Status:** Not started, depends on Phases 1-8
|
||||||
|
|
||||||
|
### Goal
|
||||||
|
Update `skills/beadboard-driver/` to reflect the new agent-based architecture.
|
||||||
|
|
||||||
|
### Why This Is Needed
|
||||||
|
The skill documentation was written before Phase 1-3 decisions:
|
||||||
|
- Archetypes were renamed to Agents
|
||||||
|
- Agent instances get numbered display names (Engineer 01, etc.)
|
||||||
|
- Templates are how teams are composed
|
||||||
|
- Workers spawn via `bb_spawn_worker(description)` not `bd create`
|
||||||
|
- Natural language task descriptions, not task_id requirements
|
||||||
|
|
||||||
|
### Files to Update
|
||||||
|
|
||||||
|
**Core Skill:**
|
||||||
|
- `skills/beadboard-driver/SKILL.md` - Main runbook
|
||||||
|
|
||||||
|
**References:**
|
||||||
|
- `skills/beadboard-driver/references/archetypes-templates-swarms.md` - Rename archetypes → agents
|
||||||
|
- `skills/beadboard-driver/references/command-matrix.md` - Add new agent tools
|
||||||
|
- `skills/beadboard-driver/references/agent-state-liveness.md` - Update for numbered instances
|
||||||
|
- `skills/beadboard-driver/references/session-lifecycle.md` - Update worker spawn flow
|
||||||
|
- `skills/beadboard-driver/references/coordination-system.md` - May need updates
|
||||||
|
- `skills/beadboard-driver/references/creating-beads.md` - May need updates
|
||||||
|
|
||||||
|
### Key Changes to Document
|
||||||
|
|
||||||
|
1. **Agent Types (was Archetypes)**
|
||||||
|
- 6 built-in: architect, engineer, reviewer, tester, investigator, shipper
|
||||||
|
- CRUD tools: `bb_list_agents`, `bb_create_agent`, etc.
|
||||||
|
- Each has capabilities that determine tool access
|
||||||
|
|
||||||
|
2. **Agent Instances**
|
||||||
|
- Numbered display names: "Engineer 01", "Engineer 02"
|
||||||
|
- Unique instance IDs: `{type}-{number}-{random}`
|
||||||
|
- Status panel shows active instances
|
||||||
|
|
||||||
|
3. **Templates**
|
||||||
|
- Named compositions: feature-dev, bug-fix, etc.
|
||||||
|
- Spawn via `bb_spawn_team(description)` or `bb_spawn_team(description, template)`
|
||||||
|
- Auto-select template from description keywords
|
||||||
|
|
||||||
|
4. **Worker Spawning**
|
||||||
|
- Natural language: `bb_spawn_worker(description: "Fix the login bug")`
|
||||||
|
- No task_id required - auto-generated from description
|
||||||
|
- Optional `bead_id` to assign to existing bead
|
||||||
|
|
||||||
|
5. **Orchestrator Decision Tree**
|
||||||
|
- Small task → spawn 1 agent
|
||||||
|
- Medium task → spawn 2-3 agents
|
||||||
|
- Large task → use template
|
||||||
|
|
||||||
|
### Scripts to Review
|
||||||
|
- `scripts/generate-agent-name.mjs` - May need update for new naming
|
||||||
|
- `scripts/session-preflight.mjs` - May reference old concepts
|
||||||
|
|
||||||
|
### Effort
|
||||||
|
~2-3 hours
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Suggested next build order
|
||||||
|
|
||||||
|
1. **Worker spawning tool + worker session model**
|
||||||
|
2. **Archetype-backed execution config**
|
||||||
|
3. **Template-first orchestration behavior**
|
||||||
|
4. **Activity panel integration for orchestrator/workers**
|
||||||
|
5. **Launch-anywhere UX completion**
|
||||||
|
6. **Runtime hardening + automated tests**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files most relevant to current Embedded Pi implementation
|
||||||
|
|
||||||
|
### Core runtime
|
||||||
|
- `src/lib/bb-daemon.ts`
|
||||||
|
- `src/lib/embedded-daemon.ts`
|
||||||
|
- `src/lib/pi-daemon-adapter.ts`
|
||||||
|
- `src/lib/pi-runtime-detection.ts`
|
||||||
|
- `src/lib/bb-pi-bootstrap.ts`
|
||||||
|
|
||||||
|
### TUI / shared Pi integration references
|
||||||
|
- `src/tui/bb-agent-tui.ts`
|
||||||
|
- `src/tui/system-prompt.ts`
|
||||||
|
- `src/tui/tools/bb-dolt-read.ts`
|
||||||
|
- `src/tui/tools/bb-deviation.ts`
|
||||||
|
- `src/tui/tools/bb-mailbox.ts`
|
||||||
|
- `src/tui/tools/bb-presence.ts`
|
||||||
|
- `src/tui/tools/bb-spawn-worker.ts`
|
||||||
|
- `src/tui/tools/bb-spawn-template.ts`
|
||||||
|
- `src/tui/tools/bb-worker-status.ts`
|
||||||
|
- `src/tui/tools/bb-worker-results.ts`
|
||||||
|
- `src/tui/tools/bb-bead-crud.ts`
|
||||||
|
- `src/tui/tools/bb-list-agents.ts`
|
||||||
|
- `src/tui/tools/bb-create-agent.ts`
|
||||||
|
- `src/tui/tools/bb-assign-agent.ts`
|
||||||
|
|
||||||
|
### Frontend surfaces
|
||||||
|
- `src/components/shared/orchestrator-panel.tsx`
|
||||||
|
- `src/components/shared/runtime-console.tsx`
|
||||||
|
- `src/components/shared/unified-shell.tsx`
|
||||||
|
- `src/components/shared/left-panel-new.tsx`
|
||||||
|
- `src/lib/orchestrator-chat.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
**Phase 3 COMPLETE - Testing in progress.**
|
||||||
|
|
||||||
|
What is proven now:
|
||||||
|
- ✅ Embedded orchestrator runtime
|
||||||
|
- ✅ Frontend prompt path
|
||||||
|
- ✅ Realtime telemetry
|
||||||
|
- ✅ Left-panel orchestrator chat
|
||||||
|
- ✅ BeadBoard-aware tool execution
|
||||||
|
- ✅ Worker spawning with numbered instances
|
||||||
|
- ✅ Agent types with capabilities
|
||||||
|
- ✅ Template-based team spawning
|
||||||
|
- ✅ Bead-required workflow
|
||||||
|
- ✅ Async worker coordination
|
||||||
|
|
||||||
|
What remains:
|
||||||
|
- Phase 4: Launch-anywhere UX (spawn from task cards, graph nodes)
|
||||||
|
- Phase 5: Agent presence in social/graph views
|
||||||
|
- Phase 6: Runtime hardening
|
||||||
|
- Phase 7: Tests and verification
|
||||||
|
- Phase 8: Unified Settings
|
||||||
|
- Phase 9: Holistic skill update
|
||||||
419
docs/plans/2026-03-06-phase-1-worker-spawning.md
Normal file
419
docs/plans/2026-03-06-phase-1-worker-spawning.md
Normal file
|
|
@ -0,0 +1,419 @@
|
||||||
|
# Phase 1: Worker Agent Spawning
|
||||||
|
|
||||||
|
**Date:** 2026-03-06
|
||||||
|
**Status:** Ready for implementation
|
||||||
|
**PRD Reference:** `docs/plans/2026-03-05-embedded-pi-prd.md`
|
||||||
|
**Roadmap Reference:** `docs/plans/2026-03-05-embedded-pi-roadmap.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Enable the orchestrator Pi to spawn real worker Pi instances for parallel task execution.
|
||||||
|
|
||||||
|
This is the biggest gap between "embedded chat" and "true BeadBoard execution substrate."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
- One orchestrator Pi per project (working)
|
||||||
|
- Orchestrator can receive prompts and execute tools
|
||||||
|
- BeadBoard tools exist: `bb_dolt_read`, `bb_mailbox`, `bb_presence`, `bb_deviation`
|
||||||
|
- Runtime events flow to frontend via SSE
|
||||||
|
- Left panel shows orchestrator chat
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Target State
|
||||||
|
|
||||||
|
- Orchestrator can spawn worker Pi instances
|
||||||
|
- Each worker has isolated session/context
|
||||||
|
- Workers execute tasks independently
|
||||||
|
- Worker status/progress visible in UI
|
||||||
|
- Worker results merge back to orchestrator
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Session Model
|
||||||
|
|
||||||
|
```
|
||||||
|
Project
|
||||||
|
├── Orchestrator Session (long-lived)
|
||||||
|
│ └── Spawns workers, coordinates, reviews results
|
||||||
|
│
|
||||||
|
├── Worker Session 1 (task-scoped, ephemeral)
|
||||||
|
│ └── Executes specific task, reports back
|
||||||
|
│
|
||||||
|
├── Worker Session 2 (task-scoped, ephemeral)
|
||||||
|
│ └── Executes specific task, reports back
|
||||||
|
│
|
||||||
|
└── Worker Session N...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Distinctions
|
||||||
|
|
||||||
|
| Aspect | Orchestrator | Worker |
|
||||||
|
|--------|--------------|--------|
|
||||||
|
| Lifetime | Long-lived (project-scoped) | Ephemeral (task-scoped) |
|
||||||
|
| Context | Full project context | Task-specific context |
|
||||||
|
| Tools | All tools + spawn tool | Subset (no spawn) |
|
||||||
|
| Status | Always visible | Visible while running |
|
||||||
|
| Session | Reused across prompts | Created per task, destroyed on completion |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Tasks
|
||||||
|
|
||||||
|
### Task 1: Worker Session Manager
|
||||||
|
|
||||||
|
**File:** `src/lib/worker-session-manager.ts`
|
||||||
|
|
||||||
|
**Purpose:** Manage worker Pi session lifecycle separate from orchestrator.
|
||||||
|
|
||||||
|
**What to build:**
|
||||||
|
```typescript
|
||||||
|
interface WorkerSession {
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
|
taskId: string;
|
||||||
|
status: 'spawning' | 'working' | 'completed' | 'failed';
|
||||||
|
session: any; // Pi SDK session
|
||||||
|
createdAt: string;
|
||||||
|
completedAt: string | null;
|
||||||
|
result: string | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
class WorkerSessionManager {
|
||||||
|
private workers = new Map<string, WorkerSession>();
|
||||||
|
|
||||||
|
async spawnWorker(params: {
|
||||||
|
projectRoot: string;
|
||||||
|
taskId: string;
|
||||||
|
taskContext: string;
|
||||||
|
archetype?: string;
|
||||||
|
}): Promise<WorkerSession>;
|
||||||
|
|
||||||
|
getWorker(workerId: string): WorkerSession | undefined;
|
||||||
|
listWorkers(projectRoot: string): WorkerSession[];
|
||||||
|
terminateWorker(workerId: string): Promise<void>;
|
||||||
|
waitForWorker(workerId: string): Promise<string>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Test file:** `tests/lib/worker-session-manager.test.ts`
|
||||||
|
|
||||||
|
**Commands:**
|
||||||
|
```bash
|
||||||
|
cd /home/clawdbot/clawd/repos/beadboard
|
||||||
|
touch src/lib/worker-session-manager.ts
|
||||||
|
touch tests/lib/worker-session-manager.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Worker Spawning Tool
|
||||||
|
|
||||||
|
**File:** `src/tui/tools/bb-spawn-worker.ts`
|
||||||
|
|
||||||
|
**Purpose:** Tool that orchestrator calls to spawn a worker.
|
||||||
|
|
||||||
|
**What to build:**
|
||||||
|
```typescript
|
||||||
|
import { Type } from '@sinclair/typebox';
|
||||||
|
import type { CustomAgentTool } from '@mariozechner/pi-coding-agent';
|
||||||
|
|
||||||
|
export function createSpawnWorkerTool(projectRoot: string): CustomAgentTool {
|
||||||
|
return {
|
||||||
|
name: 'bb_spawn_worker',
|
||||||
|
label: 'Spawn Worker Agent',
|
||||||
|
description: 'Spawn a worker agent to execute a specific task in parallel. The worker will work independently and report back results.',
|
||||||
|
parameters: Type.Object({
|
||||||
|
task_id: Type.String({ description: 'The ID of the task for the worker to work on' }),
|
||||||
|
task_context: Type.String({ description: 'Context/instructions for the worker' }),
|
||||||
|
archetype: Type.Optional(Type.String({ description: 'Optional archetype for worker behavior (e.g., "coder", "reviewer", "tester")' })),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params: any) {
|
||||||
|
// 1. Validate task exists
|
||||||
|
// 2. Spawn worker session via WorkerSessionManager
|
||||||
|
// 3. Emit worker.spawned event
|
||||||
|
// 4. Return worker ID and status
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Test file:** `tests/tui/tools/bb-spawn-worker.test.ts`
|
||||||
|
|
||||||
|
**Commands:**
|
||||||
|
```bash
|
||||||
|
cd /home/clawdbot/clawd/repos/beadboard
|
||||||
|
touch src/tui/tools/bb-spawn-worker.ts
|
||||||
|
touch tests/tui/tools/bb-spawn-worker.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Worker Status Tool
|
||||||
|
|
||||||
|
**File:** `src/tui/tools/bb-worker-status.ts`
|
||||||
|
|
||||||
|
**Purpose:** Tool for orchestrator to check worker status.
|
||||||
|
|
||||||
|
**What to build:**
|
||||||
|
```typescript
|
||||||
|
export function createWorkerStatusTool(projectRoot: string): CustomAgentTool {
|
||||||
|
return {
|
||||||
|
name: 'bb_worker_status',
|
||||||
|
label: 'Check Worker Status',
|
||||||
|
description: 'Check the status of a spawned worker agent.',
|
||||||
|
parameters: Type.Object({
|
||||||
|
worker_id: Type.String({ description: 'The ID of the worker to check' }),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params: any) {
|
||||||
|
// Return worker status, progress, result (if completed)
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Test file:** `tests/tui/tools/bb-worker-status.test.ts`
|
||||||
|
|
||||||
|
**Commands:**
|
||||||
|
```bash
|
||||||
|
cd /home/clawdbot/clawd/repos/beadboard
|
||||||
|
touch src/tui/tools/bb-worker-status.ts
|
||||||
|
touch tests/tui/tools/bb-worker-status.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Update Pi Daemon Adapter for Workers
|
||||||
|
|
||||||
|
**File:** `src/lib/pi-daemon-adapter.ts`
|
||||||
|
|
||||||
|
**Changes:**
|
||||||
|
1. Import worker tools
|
||||||
|
2. Pass worker session manager reference
|
||||||
|
3. Add worker events to session subscription
|
||||||
|
|
||||||
|
**Specific edits:**
|
||||||
|
|
||||||
|
After line 64 (tools array), add:
|
||||||
|
```typescript
|
||||||
|
// Import worker tools
|
||||||
|
const { createSpawnWorkerTool } = await import('../tui/tools/bb-spawn-worker');
|
||||||
|
const { createWorkerStatusTool } = await import('../tui/tools/bb-worker-status');
|
||||||
|
```
|
||||||
|
|
||||||
|
In customTools array, add:
|
||||||
|
```typescript
|
||||||
|
{ tool: createSpawnWorkerTool(projectRoot) },
|
||||||
|
{ tool: createWorkerStatusTool(projectRoot) },
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Runtime Event Types for Workers
|
||||||
|
|
||||||
|
**File:** `src/lib/embedded-runtime.ts`
|
||||||
|
|
||||||
|
**Already has:**
|
||||||
|
- `worker.spawned`
|
||||||
|
- `worker.updated`
|
||||||
|
- `worker.completed`
|
||||||
|
- `worker.failed`
|
||||||
|
|
||||||
|
**Verify these are used correctly in event emission.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Worker Events in Daemon
|
||||||
|
|
||||||
|
**File:** `src/lib/embedded-daemon.ts`
|
||||||
|
|
||||||
|
**Add helper method:**
|
||||||
|
```typescript
|
||||||
|
appendWorkerEvent(projectRoot: string, workerId: string, event: {
|
||||||
|
kind: 'worker.spawned' | 'worker.updated' | 'worker.completed' | 'worker.failed';
|
||||||
|
title: string;
|
||||||
|
detail: string;
|
||||||
|
status?: RuntimeConsoleEvent['status'];
|
||||||
|
}): void {
|
||||||
|
this.appendEvent(projectRoot, {
|
||||||
|
kind: event.kind,
|
||||||
|
title: event.title,
|
||||||
|
detail: event.detail,
|
||||||
|
status: event.status,
|
||||||
|
metadata: { workerId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Frontend Worker Status Display
|
||||||
|
|
||||||
|
**File:** `src/components/shared/runtime-console.tsx`
|
||||||
|
|
||||||
|
**Changes:**
|
||||||
|
1. Add worker event rendering (distinct from orchestrator events)
|
||||||
|
2. Show worker spawn/complete/fail with visual indicators
|
||||||
|
3. Display worker ID and task association
|
||||||
|
|
||||||
|
**File:** `src/components/shared/orchestrator-panel.tsx`
|
||||||
|
|
||||||
|
**Changes:**
|
||||||
|
1. Show active workers count
|
||||||
|
2. List workers with status badges
|
||||||
|
3. Click to expand worker details
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: Worker Isolation
|
||||||
|
|
||||||
|
**File:** `src/lib/worker-session-manager.ts`
|
||||||
|
|
||||||
|
**Ensure:**
|
||||||
|
1. Workers use separate session from orchestrator
|
||||||
|
2. Worker context is task-scoped, not project-scoped
|
||||||
|
3. Worker cannot spawn more workers (no recursion)
|
||||||
|
4. Worker results are captured, not lost
|
||||||
|
|
||||||
|
**Worker system prompt:**
|
||||||
|
```typescript
|
||||||
|
const workerPrompt = `
|
||||||
|
You are a worker agent for BeadBoard. Your job is to execute a specific task.
|
||||||
|
|
||||||
|
Task ID: ${taskId}
|
||||||
|
Task Context: ${taskContext}
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Focus only on this task
|
||||||
|
- Report progress via bb_presence tool
|
||||||
|
- When complete, summarize what you did
|
||||||
|
- If blocked, report why
|
||||||
|
- You cannot spawn more workers
|
||||||
|
`;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 9: Integration Tests
|
||||||
|
|
||||||
|
**File:** `tests/integration/worker-spawning.test.ts`
|
||||||
|
|
||||||
|
**Test scenarios:**
|
||||||
|
1. Orchestrator spawns worker successfully
|
||||||
|
2. Worker executes task and reports completion
|
||||||
|
3. Worker failure is captured and reported
|
||||||
|
4. Multiple workers can run in parallel
|
||||||
|
5. Worker status tool returns correct state
|
||||||
|
6. Events flow to frontend correctly
|
||||||
|
|
||||||
|
**Commands:**
|
||||||
|
```bash
|
||||||
|
cd /home/clawdbot/clawd/repos/beadboard
|
||||||
|
mkdir -p tests/integration
|
||||||
|
touch tests/integration/worker-spawning.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 10: Manual E2E Test
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. Start BeadBoard dev server
|
||||||
|
2. Open left panel orchestrator chat
|
||||||
|
3. Send prompt: "Spawn a worker to read the README.md and summarize it"
|
||||||
|
4. Verify:
|
||||||
|
- `worker.spawned` event appears in console
|
||||||
|
- Worker status shows in UI
|
||||||
|
- Worker completes with result
|
||||||
|
- `worker.completed` event appears
|
||||||
|
- Orchestrator receives result summary
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files Summary
|
||||||
|
|
||||||
|
| File | Action | Purpose |
|
||||||
|
|------|--------|---------|
|
||||||
|
| `src/lib/worker-session-manager.ts` | Create | Manage worker lifecycle |
|
||||||
|
| `src/tui/tools/bb-spawn-worker.ts` | Create | Tool for spawning |
|
||||||
|
| `src/tui/tools/bb-worker-status.ts` | Create | Tool for status checks |
|
||||||
|
| `src/lib/pi-daemon-adapter.ts` | Edit | Add worker tools |
|
||||||
|
| `src/lib/embedded-daemon.ts` | Edit | Add worker event helper |
|
||||||
|
| `src/components/shared/runtime-console.tsx` | Edit | Display worker events |
|
||||||
|
| `src/components/shared/orchestrator-panel.tsx` | Edit | Show worker list |
|
||||||
|
| `tests/lib/worker-session-manager.test.ts` | Create | Unit tests |
|
||||||
|
| `tests/tui/tools/bb-spawn-worker.test.ts` | Create | Tool tests |
|
||||||
|
| `tests/tui/tools/bb-worker-status.test.ts` | Create | Tool tests |
|
||||||
|
| `tests/integration/worker-spawning.test.ts` | Create | Integration tests |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
- [ ] Orchestrator can call `bb_spawn_worker` tool
|
||||||
|
- [ ] Worker session is created and tracked
|
||||||
|
- [ ] Worker executes task independently
|
||||||
|
- [ ] Worker events appear in runtime console
|
||||||
|
- [ ] Worker status is queryable via `bb_worker_status`
|
||||||
|
- [ ] Worker completion/failure is captured
|
||||||
|
- [ ] Multiple workers can run in parallel
|
||||||
|
- [ ] Workers cannot spawn more workers
|
||||||
|
- [ ] All tests pass
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|------|------------|
|
||||||
|
| Worker context bleeds into orchestrator | Separate session objects, isolated state |
|
||||||
|
| Too many workers spawn | Limit max concurrent workers per project |
|
||||||
|
| Worker hangs | Add timeout, auto-terminate stuck workers |
|
||||||
|
| Events duplicate | Use dedupe logic already in place |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Estimated Effort
|
||||||
|
|
||||||
|
- Tasks 1-3 (Core): 2-3 hours
|
||||||
|
- Tasks 4-6 (Integration): 1-2 hours
|
||||||
|
- Tasks 7-8 (UI + Isolation): 2-3 hours
|
||||||
|
- Tasks 9-10 (Testing): 1-2 hours
|
||||||
|
|
||||||
|
**Total:** 6-10 hours
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Execution Order
|
||||||
|
|
||||||
|
Recommended sequence:
|
||||||
|
1. Task 1 → 2 → 3 (Core tools)
|
||||||
|
2. Task 4 → 5 → 6 (Integration)
|
||||||
|
3. Task 8 (Isolation - critical before testing)
|
||||||
|
4. Task 7 (UI)
|
||||||
|
5. Task 9 → 10 (Testing)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Pi SDK (already integrated)
|
||||||
|
- Existing daemon/adapter infrastructure
|
||||||
|
- Runtime event system (already working)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next After Phase 1
|
||||||
|
|
||||||
|
Once workers can spawn and execute:
|
||||||
|
- **Phase 2:** Archetype-backed execution configs
|
||||||
|
- **Phase 3:** Template-first orchestration with workers
|
||||||
|
- **Phase 5:** Show workers in social/graph views
|
||||||
496
docs/plans/2026-03-06-phase-2-archetype-configs.md
Normal file
496
docs/plans/2026-03-06-phase-2-archetype-configs.md
Normal file
|
|
@ -0,0 +1,496 @@
|
||||||
|
# Phase 2: Archetype Execution Configs
|
||||||
|
|
||||||
|
**Date:** 2026-03-06
|
||||||
|
**Status:** Ready for implementation
|
||||||
|
**PRD Reference:** `docs/plans/2026-03-05-embedded-pi-prd.md`
|
||||||
|
**Depends on:** Phase 1 (Worker Spawning) ✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
1. Link existing archetype system to worker behavior
|
||||||
|
2. Give orchestrator CRUD tools for archetypes and templates
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
- **Frontend** has full archetype system:
|
||||||
|
- Schema: `AgentArchetype` with `id`, `name`, `systemPrompt`, `capabilities[]`, `color`
|
||||||
|
- Storage: `.beads/archetypes/*.json`
|
||||||
|
- UI: `archetype-inspector.tsx` for create/edit/clone
|
||||||
|
- Seed archetypes: architect, coder, reviewer, tester, researcher
|
||||||
|
- **Templates** also exist:
|
||||||
|
- Schema: `SwarmTemplate` with `team: { archetypeId, count }[]`
|
||||||
|
- Storage: `.beads/templates/*.json`
|
||||||
|
- **Backend** (`beads-fs.ts`) has all CRUD functions
|
||||||
|
- **Worker spawning** passes `archetype` but doesn't use it
|
||||||
|
- **Orchestrator** has NO tools to manage archetypes/templates
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Target State
|
||||||
|
|
||||||
|
- Orchestrator can CRUD archetypes and templates via tools
|
||||||
|
- Workers load archetype config and behave accordingly:
|
||||||
|
- `capabilities` → which tools worker gets
|
||||||
|
- `systemPrompt` → injected into worker prompt
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Capability → Tool Mapping
|
||||||
|
|
||||||
|
| Capability | Tools Granted |
|
||||||
|
|------------|---------------|
|
||||||
|
| `coding`, `implementation` | read, bash, edit, write, dolt-read |
|
||||||
|
| `planning`, `design_docs` | read, dolt-read (read-only) |
|
||||||
|
| `review`, `arch_review` | read, dolt-read (read-only) |
|
||||||
|
| `testing` | read, bash, edit, write, dolt-read |
|
||||||
|
| `research` | read, dolt-read, bash (limited) |
|
||||||
|
| All others | read, dolt-read (default read-only) |
|
||||||
|
|
||||||
|
**Rule:** If `capabilities` includes `coding` or `implementation` or `testing` → full tools. Otherwise → read-only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
### Task 1: Create Archetype CRUD Tools
|
||||||
|
|
||||||
|
**File:** `src/tui/tools/bb-list-archetypes.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createTool } from '@mariozechner/pi-coding-agent';
|
||||||
|
import { getArchetypes } from '../../lib/server/beads-fs';
|
||||||
|
|
||||||
|
export function bbListArchetypes(projectRoot: string) {
|
||||||
|
return createTool('bb_list_archetypes', {
|
||||||
|
description: 'List all available archetypes. Returns id, name, description, capabilities for each.',
|
||||||
|
parameters: {},
|
||||||
|
handler: async () => {
|
||||||
|
const archetypes = await getArchetypes();
|
||||||
|
return {
|
||||||
|
archetypes: archetypes.map(a => ({
|
||||||
|
id: a.id,
|
||||||
|
name: a.name,
|
||||||
|
description: a.description,
|
||||||
|
capabilities: a.capabilities,
|
||||||
|
color: a.color,
|
||||||
|
isBuiltIn: a.isBuiltIn,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**File:** `src/tui/tools/bb-create-archetype.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createTool } from '@mariozechner/pi-coding-agent';
|
||||||
|
import { saveArchetype } from '../../lib/server/beads-fs';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export function bbCreateArchetype(projectRoot: string) {
|
||||||
|
return createTool('bb_create_archetype', {
|
||||||
|
description: 'Create a new archetype. Requires name, description, systemPrompt, capabilities, color.',
|
||||||
|
parameters: z.object({
|
||||||
|
name: z.string().describe('Display name for the archetype'),
|
||||||
|
description: z.string().describe('What this archetype does'),
|
||||||
|
systemPrompt: z.string().describe('System prompt injected into workers with this archetype'),
|
||||||
|
capabilities: z.array(z.string()).describe('List of capabilities (e.g., ["coding", "testing"])'),
|
||||||
|
color: z.string().default('#3b82f6').describe('Hex color for display'),
|
||||||
|
}),
|
||||||
|
handler: async (params) => {
|
||||||
|
const archetype = await saveArchetype({
|
||||||
|
name: params.name,
|
||||||
|
description: params.description,
|
||||||
|
systemPrompt: params.systemPrompt,
|
||||||
|
capabilities: params.capabilities,
|
||||||
|
color: params.color,
|
||||||
|
isBuiltIn: false,
|
||||||
|
});
|
||||||
|
return { ok: true, archetype };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**File:** `src/tui/tools/bb-update-archetype.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createTool } from '@mariozechner/pi-coding-agent';
|
||||||
|
import { saveArchetype } from '../../lib/server/beads-fs';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export function bbUpdateArchetype(projectRoot: string) {
|
||||||
|
return createTool('bb_update_archetype', {
|
||||||
|
description: 'Update an existing archetype. Cannot modify built-in archetypes.',
|
||||||
|
parameters: z.object({
|
||||||
|
id: z.string().describe('Archetype ID to update'),
|
||||||
|
name: z.string().optional().describe('New display name'),
|
||||||
|
description: z.string().optional().describe('New description'),
|
||||||
|
systemPrompt: z.string().optional().describe('New system prompt'),
|
||||||
|
capabilities: z.array(z.string()).optional().describe('New capabilities list'),
|
||||||
|
color: z.string().optional().describe('New hex color'),
|
||||||
|
}),
|
||||||
|
handler: async (params) => {
|
||||||
|
const archetype = await saveArchetype({
|
||||||
|
id: params.id,
|
||||||
|
name: params.name ?? '',
|
||||||
|
description: params.description ?? '',
|
||||||
|
systemPrompt: params.systemPrompt ?? '',
|
||||||
|
capabilities: params.capabilities ?? [],
|
||||||
|
color: params.color ?? '#3b82f6',
|
||||||
|
});
|
||||||
|
return { ok: true, archetype };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**File:** `src/tui/tools/bb-delete-archetype.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createTool } from '@mariozechner/pi-coding-agent';
|
||||||
|
import { deleteArchetype } from '../../lib/server/beads-fs';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export function bbDeleteArchetype(projectRoot: string) {
|
||||||
|
return createTool('bb_delete_archetype', {
|
||||||
|
description: 'Delete an archetype. Cannot delete built-in archetypes.',
|
||||||
|
parameters: z.object({
|
||||||
|
id: z.string().describe('Archetype ID to delete'),
|
||||||
|
}),
|
||||||
|
handler: async (params) => {
|
||||||
|
await deleteArchetype(params.id);
|
||||||
|
return { ok: true, deletedId: params.id };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Create Template CRUD Tools
|
||||||
|
|
||||||
|
**File:** `src/tui/tools/bb-list-templates.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createTool } from '@mariozechner/pi-coding-agent';
|
||||||
|
import { getTemplates } from '../../lib/server/beads-fs';
|
||||||
|
|
||||||
|
export function bbListTemplates(projectRoot: string) {
|
||||||
|
return createTool('bb_list_templates', {
|
||||||
|
description: 'List all swarm templates. Returns team composition for each.',
|
||||||
|
parameters: {},
|
||||||
|
handler: async () => {
|
||||||
|
const templates = await getTemplates();
|
||||||
|
return {
|
||||||
|
templates: templates.map(t => ({
|
||||||
|
id: t.id,
|
||||||
|
name: t.name,
|
||||||
|
description: t.description,
|
||||||
|
team: t.team,
|
||||||
|
isBuiltIn: t.isBuiltIn,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**File:** `src/tui/tools/bb-create-template.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createTool } from '@mariozechner/pi-coding-agent';
|
||||||
|
import { saveTemplate } from '../../lib/server/beads-fs';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export function bbCreateTemplate(projectRoot: string) {
|
||||||
|
return createTool('bb_create_template', {
|
||||||
|
description: 'Create a new swarm template. Defines team composition by archetype.',
|
||||||
|
parameters: z.object({
|
||||||
|
name: z.string().describe('Display name for the template'),
|
||||||
|
description: z.string().describe('What this template is for'),
|
||||||
|
team: z.array(z.object({
|
||||||
|
archetypeId: z.string().describe('Archetype ID'),
|
||||||
|
count: z.number().describe('Number of workers with this archetype'),
|
||||||
|
})).describe('Team composition'),
|
||||||
|
color: z.string().default('#f59e0b').describe('Hex color for display'),
|
||||||
|
}),
|
||||||
|
handler: async (params) => {
|
||||||
|
const template = await saveTemplate({
|
||||||
|
name: params.name,
|
||||||
|
description: params.description,
|
||||||
|
team: params.team,
|
||||||
|
color: params.color,
|
||||||
|
isBuiltIn: false,
|
||||||
|
});
|
||||||
|
return { ok: true, template };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**File:** `src/tui/tools/bb-update-template.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createTool } from '@mariozechner/pi-coding-agent';
|
||||||
|
import { saveTemplate } from '../../lib/server/beads-fs';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export function bbUpdateTemplate(projectRoot: string) {
|
||||||
|
return createTool('bb_update_template', {
|
||||||
|
description: 'Update an existing swarm template.',
|
||||||
|
parameters: z.object({
|
||||||
|
id: z.string().describe('Template ID to update'),
|
||||||
|
name: z.string().optional().describe('New display name'),
|
||||||
|
description: z.string().optional().describe('New description'),
|
||||||
|
team: z.array(z.object({
|
||||||
|
archetypeId: z.string(),
|
||||||
|
count: z.number(),
|
||||||
|
})).optional().describe('New team composition'),
|
||||||
|
color: z.string().optional().describe('New hex color'),
|
||||||
|
}),
|
||||||
|
handler: async (params) => {
|
||||||
|
const template = await saveTemplate({
|
||||||
|
id: params.id,
|
||||||
|
name: params.name ?? '',
|
||||||
|
description: params.description ?? '',
|
||||||
|
team: params.team ?? [],
|
||||||
|
color: params.color ?? '#f59e0b',
|
||||||
|
});
|
||||||
|
return { ok: true, template };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**File:** `src/tui/tools/bb-delete-template.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createTool } from '@mariozechner/pi-coding-agent';
|
||||||
|
import { deleteTemplate } from '../../lib/server/beads-fs';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export function bbDeleteTemplate(projectRoot: string) {
|
||||||
|
return createTool('bb_delete_template', {
|
||||||
|
description: 'Delete a swarm template. Cannot delete built-in templates.',
|
||||||
|
parameters: z.object({
|
||||||
|
id: z.string().describe('Template ID to delete'),
|
||||||
|
}),
|
||||||
|
handler: async (params) => {
|
||||||
|
await deleteTemplate(params.id);
|
||||||
|
return { ok: true, deletedId: params.id };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Link Archetype to Worker Behavior
|
||||||
|
|
||||||
|
**File:** `src/lib/worker-session-manager.ts`
|
||||||
|
|
||||||
|
**Changes:**
|
||||||
|
|
||||||
|
1. Import archetype loading:
|
||||||
|
```typescript
|
||||||
|
import { getArchetypes, type AgentArchetype } from './server/beads-fs';
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Add capability → tool mapping:
|
||||||
|
```typescript
|
||||||
|
function getToolsForCapabilities(capabilities: string[]): {
|
||||||
|
allowEdit: boolean;
|
||||||
|
allowWrite: boolean;
|
||||||
|
allowBash: boolean;
|
||||||
|
} {
|
||||||
|
const fullAccess = ['coding', 'implementation', 'testing'];
|
||||||
|
const hasFullAccess = capabilities.some(c => fullAccess.includes(c));
|
||||||
|
|
||||||
|
if (hasFullAccess) {
|
||||||
|
return { allowEdit: true, allowWrite: true, allowBash: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read-only for planning, review, research
|
||||||
|
return { allowEdit: false, allowWrite: false, allowBash: false };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Load archetype in `createWorkerSession`:
|
||||||
|
```typescript
|
||||||
|
async createWorkerSession(
|
||||||
|
worker: WorkerInfo,
|
||||||
|
taskContext: string,
|
||||||
|
archetypeId?: string
|
||||||
|
): Promise<void> {
|
||||||
|
// Load archetype config
|
||||||
|
let archetype: AgentArchetype | undefined;
|
||||||
|
if (archetypeId) {
|
||||||
|
const archetypes = await getArchetypes();
|
||||||
|
archetype = archetypes.find(a => a.id === archetypeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const capabilities = archetype?.capabilities ?? [];
|
||||||
|
const toolAccess = getToolsForCapabilities(capabilities);
|
||||||
|
|
||||||
|
// Build tools based on capabilities
|
||||||
|
const tools = [];
|
||||||
|
tools.push(this.sdk.createReadTool(this.projectRoot));
|
||||||
|
tools.push(this.sdk.createMailboxTool(this.projectRoot));
|
||||||
|
tools.push(this.sdk.createPresenceTool(this.projectRoot));
|
||||||
|
|
||||||
|
if (toolAccess.allowBash) {
|
||||||
|
tools.push(this.sdk.createBashTool(this.projectRoot));
|
||||||
|
}
|
||||||
|
if (toolAccess.allowEdit) {
|
||||||
|
tools.push(this.sdk.createEditTool(this.projectRoot));
|
||||||
|
}
|
||||||
|
if (toolAccess.allowWrite) {
|
||||||
|
tools.push(this.sdk.createWriteTool(this.projectRoot));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always allow dolt-read for context
|
||||||
|
const { createDoltReadTool } = await import('../tui/tools/bb-dolt-read');
|
||||||
|
tools.push(createDoltReadTool(this.projectRoot));
|
||||||
|
|
||||||
|
// Build prompt with archetype system prompt
|
||||||
|
const systemPrompt = this.buildWorkerPrompt(
|
||||||
|
worker.taskId,
|
||||||
|
taskContext,
|
||||||
|
archetype?.systemPrompt
|
||||||
|
);
|
||||||
|
|
||||||
|
// ... rest of session creation
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Update `buildWorkerPrompt` to accept optional archetype prompt:
|
||||||
|
```typescript
|
||||||
|
buildWorkerPrompt(taskId: string, taskContext: string, archetypePrompt?: string): string {
|
||||||
|
return `You are a BeadBoard worker agent.
|
||||||
|
|
||||||
|
Task ID: ${taskId}
|
||||||
|
|
||||||
|
${taskContext}
|
||||||
|
|
||||||
|
${archetypePrompt ? `## Your Specialization\n\n${archetypePrompt}` : ''}
|
||||||
|
|
||||||
|
## Instructions
|
||||||
|
|
||||||
|
Complete your assigned task. Report progress. Ask for help if blocked.
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Register Tools in Orchestrator
|
||||||
|
|
||||||
|
**File:** `src/lib/pi-daemon-adapter.ts`
|
||||||
|
|
||||||
|
**Changes:**
|
||||||
|
|
||||||
|
Add imports and register tools:
|
||||||
|
```typescript
|
||||||
|
import { bbListArchetypes } from '../tui/tools/bb-list-archetypes';
|
||||||
|
import { bbCreateArchetype } from '../tui/tools/bb-create-archetype';
|
||||||
|
import { bbUpdateArchetype } from '../tui/tools/bb-update-archetype';
|
||||||
|
import { bbDeleteArchetype } from '../tui/tools/bb-delete-archetype';
|
||||||
|
import { bbListTemplates } from '../tui/tools/bb-list-templates';
|
||||||
|
import { bbCreateTemplate } from '../tui/tools/bb-create-template';
|
||||||
|
import { bbUpdateTemplate } from '../tui/tools/bb-update-template';
|
||||||
|
import { bbDeleteTemplate } from '../tui/tools/bb-delete-template';
|
||||||
|
|
||||||
|
// In createTools():
|
||||||
|
tools.push(
|
||||||
|
bbListArchetypes(this.projectRoot),
|
||||||
|
bbCreateArchetype(this.projectRoot),
|
||||||
|
bbUpdateArchetype(this.projectRoot),
|
||||||
|
bbDeleteArchetype(this.projectRoot),
|
||||||
|
bbListTemplates(this.projectRoot),
|
||||||
|
bbCreateTemplate(this.projectRoot),
|
||||||
|
bbUpdateTemplate(this.projectRoot),
|
||||||
|
bbDeleteTemplate(this.projectRoot),
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Tests
|
||||||
|
|
||||||
|
**File:** `tests/tui/tools/bb-archetype-crud.test.ts`
|
||||||
|
|
||||||
|
Test:
|
||||||
|
- List archetypes returns seed data
|
||||||
|
- Create archetype saves to `.beads/archetypes/`
|
||||||
|
- Update archetype modifies file
|
||||||
|
- Delete archetype removes file
|
||||||
|
- Cannot delete built-in archetypes
|
||||||
|
|
||||||
|
**File:** `tests/tui/tools/bb-template-crud.test.ts`
|
||||||
|
|
||||||
|
Test:
|
||||||
|
- List templates returns seed data
|
||||||
|
- Create template saves to `.beads/templates/`
|
||||||
|
- Update template modifies file
|
||||||
|
- Delete template removes file
|
||||||
|
- Cannot delete built-in templates
|
||||||
|
|
||||||
|
**File:** `tests/lib/worker-session-manager.test.ts`
|
||||||
|
|
||||||
|
Test:
|
||||||
|
- Coder archetype gets full tools
|
||||||
|
- Reviewer archetype gets read-only tools
|
||||||
|
- Unknown archetype defaults to read-only
|
||||||
|
- Archetype prompt injected into system prompt
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files Summary
|
||||||
|
|
||||||
|
| File | Action |
|
||||||
|
|------|--------|
|
||||||
|
| `src/tui/tools/bb-list-archetypes.ts` | Create |
|
||||||
|
| `src/tui/tools/bb-create-archetype.ts` | Create |
|
||||||
|
| `src/tui/tools/bb-update-archetype.ts` | Create |
|
||||||
|
| `src/tui/tools/bb-delete-archetype.ts` | Create |
|
||||||
|
| `src/tui/tools/bb-list-templates.ts` | Create |
|
||||||
|
| `src/tui/tools/bb-create-template.ts` | Create |
|
||||||
|
| `src/tui/tools/bb-update-template.ts` | Create |
|
||||||
|
| `src/tui/tools/bb-delete-template.ts` | Create |
|
||||||
|
| `src/lib/worker-session-manager.ts` | Edit |
|
||||||
|
| `src/lib/pi-daemon-adapter.ts` | Edit |
|
||||||
|
| `tests/tui/tools/bb-archetype-crud.test.ts` | Create |
|
||||||
|
| `tests/tui/tools/bb-template-crud.test.ts` | Create |
|
||||||
|
| `tests/lib/worker-session-manager.test.ts` | Create |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Estimated Effort
|
||||||
|
|
||||||
|
2-3 hours
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
- [ ] Orchestrator can list/create/update/delete archetypes
|
||||||
|
- [ ] Orchestrator can list/create/update/delete templates
|
||||||
|
- [ ] Worker with coder archetype gets edit/write/bash tools
|
||||||
|
- [ ] Worker with reviewer archetype gets read-only tools
|
||||||
|
- [ ] Archetype systemPrompt injected into worker prompt
|
||||||
|
- [ ] All tests pass
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Future Enhancements (Not Now)
|
||||||
|
|
||||||
|
- Fine-grained capability → tool mapping config
|
||||||
|
- Custom tool sets per archetype
|
||||||
|
- Model selection per archetype
|
||||||
|
- Archetype inheritance
|
||||||
1143
docs/plans/2026-03-07-phase-3-agent-orchestration.md
Normal file
1143
docs/plans/2026-03-07-phase-3-agent-orchestration.md
Normal file
File diff suppressed because it is too large
Load diff
137
docs/plans/2026-03-07-phase-4-launch-anywhere.md
Normal file
137
docs/plans/2026-03-07-phase-4-launch-anywhere.md
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
# Phase 4 - Multi-Surface Launch-Anywhere UX
|
||||||
|
|
||||||
|
**Status:** Planning
|
||||||
|
**Created:** 2026-03-07
|
||||||
|
**Goal:** Add spawn affordances to all UI surfaces so users can launch agents from anywhere
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Surfaces
|
||||||
|
|
||||||
|
| Surface | Location | Purpose |
|
||||||
|
|---------|----------|---------|
|
||||||
|
| Social cards | `src/components/social/` | Show beads with status, labels, assignments |
|
||||||
|
| Graph nodes | `src/components/graph/` | Show beads in dependency graph |
|
||||||
|
| Right panel | `src/components/shared/right-panel.tsx` | Contextual details when bead selected |
|
||||||
|
| Agent status panel | `src/components/agents/agent-status-panel.tsx` | Show active agents (already has spawn in orchestrator) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Discovery Tasks
|
||||||
|
|
||||||
|
### Task 1: Analyze Social Card Components
|
||||||
|
- Find all social card components
|
||||||
|
- Identify current actions/buttons
|
||||||
|
- Determine where spawn button fits
|
||||||
|
- Document available context (beadId, status, assignee, labels)
|
||||||
|
|
||||||
|
### Task 2: Analyze Graph Node Components
|
||||||
|
- Find all graph node components
|
||||||
|
- Identify current interactions
|
||||||
|
- Determine where spawn button fits
|
||||||
|
- Document available context
|
||||||
|
|
||||||
|
### Task 3: Analyze Right Panel
|
||||||
|
- Find contextual details components
|
||||||
|
- Identify where spawn makes sense
|
||||||
|
- Document available context
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
### Step 1: Create Spawn Button Component
|
||||||
|
Create reusable spawn button that:
|
||||||
|
- Accepts beadId and context
|
||||||
|
- Shows agent type selector dropdown
|
||||||
|
- Calls orchestrator to spawn agent
|
||||||
|
- Shows loading state
|
||||||
|
- Links to Agent Status panel
|
||||||
|
|
||||||
|
**File:** `src/components/shared/spawn-agent-button.tsx`
|
||||||
|
|
||||||
|
### Step 2: Add to Social Cards
|
||||||
|
- Add spawn button to social card
|
||||||
|
- Pass beadId from card data
|
||||||
|
- Show when status is "open" or "in_progress" with no assignee
|
||||||
|
|
||||||
|
**Files:** `src/components/social/social-card.tsx`
|
||||||
|
|
||||||
|
### Step 3: Add to Graph Nodes
|
||||||
|
- Add spawn button to graph node context menu or hover
|
||||||
|
- Pass beadId from node data
|
||||||
|
- Show for unassigned beads
|
||||||
|
|
||||||
|
**Files:** `src/components/graph/graph-view.tsx`, node components
|
||||||
|
|
||||||
|
### Step 4: Add to Right Panel
|
||||||
|
- Add spawn section when viewing bead details
|
||||||
|
- Show spawn options for task/epic beads
|
||||||
|
|
||||||
|
**Files:** `src/components/shared/right-panel.tsx`
|
||||||
|
|
||||||
|
### Step 5: Wire Spawn Actions to Orchestrator
|
||||||
|
- Create API endpoint or event for spawn requests from UI
|
||||||
|
- Orchestrator receives spawn request and executes
|
||||||
|
- Update agent status panel
|
||||||
|
|
||||||
|
**Files:** `src/app/api/runtime/spawn/route.ts` or extend existing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UI/UX Considerations
|
||||||
|
|
||||||
|
### Spawn Button Placement
|
||||||
|
- **Social card:** Bottom of card, next to status badge
|
||||||
|
- **Graph node:** Context menu on right-click, or hover tooltip
|
||||||
|
- **Right panel:** New section above details, when bead is unassigned
|
||||||
|
|
||||||
|
### Spawn Flow
|
||||||
|
1. User clicks "Spawn Agent" button
|
||||||
|
2. Dropdown shows agent types (Engineer, Reviewer, etc.)
|
||||||
|
3. User selects type or "Auto" (let orchestrator decide)
|
||||||
|
4. Button shows spawning state
|
||||||
|
5. Agent appears in status panel
|
||||||
|
6. Button changes to "View Agent" link
|
||||||
|
|
||||||
|
### Context Packaging
|
||||||
|
When spawning from a surface, package:
|
||||||
|
- beadId
|
||||||
|
- bead title/description
|
||||||
|
- bead status
|
||||||
|
- current assignee (if any)
|
||||||
|
- relevant labels
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Blocked Items
|
||||||
|
|
||||||
|
None identified yet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
- [ ] Spawn button appears on social cards for unassigned beads
|
||||||
|
- [ ] Spawn option available on graph nodes
|
||||||
|
- [ ] Right panel shows spawn section for task details
|
||||||
|
- [ ] Spawning from any surface creates bead and agent
|
||||||
|
- [ ] Agent status panel updates with new agent
|
||||||
|
- [ ] User can continue chatting with orchestrator while agent works
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Estimated Effort
|
||||||
|
|
||||||
|
4-6 hours
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. ✅ Create plan document
|
||||||
|
2. 🔲 Run discovery on social card components
|
||||||
|
3. 🔲 Run discovery on graph node components
|
||||||
|
4. 🔲 Run discovery on right panel components
|
||||||
|
5. 🔲 Create spawn button component
|
||||||
|
6. 🔲 Integrate into surfaces
|
||||||
76
docs/plans/2026-03-07-phase-5-agent-presence.md
Normal file
76
docs/plans/2026-03-07-phase-5-agent-presence.md
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
# Phase 5 - Agent Presence in Social/Graph Views
|
||||||
|
|
||||||
|
**Status:** Planning
|
||||||
|
**Created:** 2026-03-07
|
||||||
|
**Goal:** Show active agents in social cards, graph nodes, and make agent activity visible across the UI
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
- Agent status panel exists in right panel (for epic/swarm context)
|
||||||
|
- Agents have display names (Engineer 01, etc.)
|
||||||
|
- Agent instances tracked in `.beads/agents.jsonl`
|
||||||
|
- No visibility in main social/graph views
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
### Step 1: Agent Badge on Social Cards
|
||||||
|
When a bead has an active agent assigned, show:
|
||||||
|
- Agent icon/avatar
|
||||||
|
- Display name (Engineer 01)
|
||||||
|
- Status indicator (working, blocked, etc.)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `src/components/social/social-card.tsx` - add agent badge
|
||||||
|
- `src/lib/types.ts` - ensure agentInstanceId on BeadIssue
|
||||||
|
|
||||||
|
### Step 2: Agent Indicator on Graph Nodes
|
||||||
|
Show agent presence on graph nodes:
|
||||||
|
- Small icon when agent assigned
|
||||||
|
- Tooltip shows agent name and status
|
||||||
|
- Color coding by status
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `src/components/graph/graph-view.tsx`
|
||||||
|
- Node rendering logic
|
||||||
|
|
||||||
|
### Step 3: Agent Activity in Activity Panel
|
||||||
|
If activity panel exists, show agent events:
|
||||||
|
- "Engineer 01 started working on BEAD-001"
|
||||||
|
- "Reviewer 01 completed review of BEAD-005"
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Check if activity panel still exists or needs rebuilding
|
||||||
|
|
||||||
|
### Step 4: Agent Filter/View
|
||||||
|
Add filter option to show only beads with active agents:
|
||||||
|
- "Show my agents" filter in social view
|
||||||
|
- Highlight beads with active work
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `src/components/social/social-page.tsx`
|
||||||
|
- Filter controls
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Blocked Items
|
||||||
|
|
||||||
|
None identified.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
- [ ] Social cards show agent badge when agent assigned
|
||||||
|
- [ ] Graph nodes show agent indicator
|
||||||
|
- [ ] Agent status visible at a glance
|
||||||
|
- [ ] Can filter by "has active agent"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Estimated Effort
|
||||||
|
|
||||||
|
2-3 hours
|
||||||
75
docs/plans/2026-03-07-phase-6-runtime-hardening.md
Normal file
75
docs/plans/2026-03-07-phase-6-runtime-hardening.md
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
# Phase 6 - Runtime Hardening
|
||||||
|
|
||||||
|
**Status:** Planning
|
||||||
|
**Created:** 2026-03-07
|
||||||
|
**Goal:** Improve robustness of embedded Pi runtime, reconnect behavior, and error recovery
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Issues
|
||||||
|
|
||||||
|
1. Session disconnect requires manual restart
|
||||||
|
2. Stuck/hung agents have no clear diagnostics
|
||||||
|
3. Drift between TUI Pi loader and embedded Pi loader
|
||||||
|
4. No automatic recovery from failures
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
### Step 1: Session Health Monitoring
|
||||||
|
- Add heartbeat check for orchestrator session
|
||||||
|
- Detect when session is unresponsive
|
||||||
|
- Show clear status in UI
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `src/lib/pi-daemon-adapter.ts` - health check
|
||||||
|
- `src/lib/embedded-daemon.ts` - monitoring
|
||||||
|
|
||||||
|
### Step 2: Automatic Reconnect
|
||||||
|
- On session disconnect, attempt reconnect
|
||||||
|
- Preserve conversation history
|
||||||
|
- Show reconnect status to user
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `src/lib/pi-daemon-adapter.ts`
|
||||||
|
- `src/components/shared/left-panel.tsx` - reconnect UI
|
||||||
|
|
||||||
|
### Step 3: Stuck Agent Diagnostics
|
||||||
|
- Detect agents stuck in "spawning" for too long
|
||||||
|
- Provide diagnostic information
|
||||||
|
- Allow user to cancel/retry
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `src/lib/worker-session-manager.ts`
|
||||||
|
- `src/components/agents/agent-status-panel.tsx`
|
||||||
|
|
||||||
|
### Step 4: Error Recovery UX
|
||||||
|
- Clear error messages when things fail
|
||||||
|
- Retry buttons for failed operations
|
||||||
|
- Logs for debugging
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Error handling across runtime components
|
||||||
|
- UI for error display
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Blocked Items
|
||||||
|
|
||||||
|
None identified.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
- [ ] Session health monitored and shown in UI
|
||||||
|
- [ ] Automatic reconnect on disconnect
|
||||||
|
- [ ] Stuck agents detected and reported
|
||||||
|
- [ ] Clear error messages with recovery options
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Estimated Effort
|
||||||
|
|
||||||
|
3-4 hours
|
||||||
95
docs/plans/2026-03-07-phase-7-tests.md
Normal file
95
docs/plans/2026-03-07-phase-7-tests.md
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
# Phase 7 - Tests and Verification
|
||||||
|
|
||||||
|
**Status:** Planning
|
||||||
|
**Created:** 2026-03-07
|
||||||
|
**Goal:** Add comprehensive tests for embedded Pi runtime and agent system
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Categories
|
||||||
|
|
||||||
|
### 1. Unit Tests
|
||||||
|
|
||||||
|
**Runtime Path Resolution:**
|
||||||
|
- `pi-runtime-detection.test.ts` - SDK detection, path resolution
|
||||||
|
- `bb-pi-bootstrap.test.ts` - bootstrap process
|
||||||
|
|
||||||
|
**Event Projection:**
|
||||||
|
- `embedded-daemon.test.ts` - event emission, deduplication
|
||||||
|
- `orchestrator-chat.test.ts` - message projection
|
||||||
|
|
||||||
|
**Worker Session:**
|
||||||
|
- `worker-session-manager.test.ts` - spawn, status, lifecycle
|
||||||
|
|
||||||
|
### 2. Contract Tests
|
||||||
|
|
||||||
|
**Adapter Schemas:**
|
||||||
|
- Pi SDK adapter input/output contracts
|
||||||
|
- Runtime event schema validation
|
||||||
|
|
||||||
|
### 3. Integration Tests
|
||||||
|
|
||||||
|
**Orchestrator Flow:**
|
||||||
|
- Session creation
|
||||||
|
- Prompt submission
|
||||||
|
- Tool execution
|
||||||
|
- Response handling
|
||||||
|
|
||||||
|
**Worker Flow:**
|
||||||
|
- Worker spawn
|
||||||
|
- Bead claim/update/close
|
||||||
|
- Result retrieval
|
||||||
|
|
||||||
|
### 4. UI Tests
|
||||||
|
|
||||||
|
**Left Panel:**
|
||||||
|
- Chat rendering
|
||||||
|
- Message projection
|
||||||
|
- Error display
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
### Step 1: Unit Test Setup
|
||||||
|
- Configure test runner (Jest or Vitest)
|
||||||
|
- Create test utilities for mocking Pi SDK
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `tests/setup.ts`
|
||||||
|
- `tests/mocks/pi-sdk.ts`
|
||||||
|
|
||||||
|
### Step 2: Runtime Unit Tests
|
||||||
|
- Test path detection
|
||||||
|
- Test bootstrap process
|
||||||
|
- Test event deduplication
|
||||||
|
|
||||||
|
### Step 3: Worker Unit Tests
|
||||||
|
- Test spawn flow
|
||||||
|
- Test status tracking
|
||||||
|
- Test result collection
|
||||||
|
|
||||||
|
### Step 4: Integration Tests
|
||||||
|
- Test full orchestrator flow
|
||||||
|
- Test worker-to-bead workflow
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Blocked Items
|
||||||
|
|
||||||
|
None identified.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
- [ ] Unit tests for runtime components pass
|
||||||
|
- [ ] Unit tests for worker session manager pass
|
||||||
|
- [ ] Integration tests for orchestrator flow pass
|
||||||
|
- [ ] Integration tests for worker flow pass
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Estimated Effort
|
||||||
|
|
||||||
|
4-5 hours
|
||||||
159
docs/plans/2026-03-08-phase-4-handoff.md
Normal file
159
docs/plans/2026-03-08-phase-4-handoff.md
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
# Phase 4 Handoff Summary
|
||||||
|
|
||||||
|
**Branch:** `docs/embedded-pi-prd`
|
||||||
|
**PR:** https://github.com/zenchantlive/beadboard/pull/new/docs/embedded-pi-prd
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Status
|
||||||
|
|
||||||
|
### Phase 4: Launch-Anywhere UX - **IN PROGRESS**
|
||||||
|
|
||||||
|
**Completed by subagents (9 of 17 tasks):**
|
||||||
|
| Task | Description | Status |
|
||||||
|
|------|-------------|--------|
|
||||||
|
| 1 | useAgentStatus hook | ✅ Done |
|
||||||
|
| 2 | useSpawnAgent hook | ✅ Done |
|
||||||
|
| 3 | Hooks index | ✅ Done |
|
||||||
|
| 4 | AgentPickerPopup | ✅ Done |
|
||||||
|
| 5 | AgentAssignButton | ✅ Done |
|
||||||
|
| 6 | AgentSpawnButton | ✅ Done |
|
||||||
|
| 7 | AgentActionRow | ✅ Done |
|
||||||
|
| 8 | Agents index | ✅ Done |
|
||||||
|
| 9 | SocialCard integration | ✅ Done |
|
||||||
|
| 10 | GraphNodeCard integration | ⏳ NOT DONE |
|
||||||
|
| 11 | BlockedTriageModal integration | ⏳ NOT DONE |
|
||||||
|
| 12 | Spawn API endpoint | ✅ Done |
|
||||||
|
| 13 | Assign-agent API | ✅ Done |
|
||||||
|
| 14 | Update useAgentStatus (real data) | ⏳ NOT DONE |
|
||||||
|
| 15 | Worker status API | ✅ Done |
|
||||||
|
| 16 | BeadIssue type update | ✅ Done |
|
||||||
|
| 17 | Testing | ⏳ NOT DONE |
|
||||||
|
|
||||||
|
### Remaining Tasks
|
||||||
|
|
||||||
|
**Task 10: Add AgentActionRow to GraphNodeCard**
|
||||||
|
- File: `src/components/graph/graph-node-card.tsx`
|
||||||
|
- Add `import { AgentActionRow } from '../agents';`
|
||||||
|
- Add `projectRoot` prop
|
||||||
|
- Replace old rocket with `<AgentActionRow />`
|
||||||
|
|
||||||
|
**Task 11: Add AgentActionRow to BlockedTriageModal**
|
||||||
|
- File: `src/components/shared/blocked-triage-modal.tsx`
|
||||||
|
- Add import and component
|
||||||
|
|
||||||
|
**Task 14: Update useAgentStatus to fetch real data**
|
||||||
|
- File: `src/components/agents/hooks/use-agent-status.ts`
|
||||||
|
- Poll `/api/runtime/worker-status?beadId=X` every 5 seconds
|
||||||
|
|
||||||
|
**Task 17: Test the complete flow**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files Created (Phase 4)
|
||||||
|
|
||||||
|
```
|
||||||
|
src/components/agents/
|
||||||
|
├── agent-action-row.tsx # Combined assign + spawn
|
||||||
|
├── agent-assign-button.tsx # 👤 Icon + picker
|
||||||
|
├── agent-spawn-button.tsx # 🚀 Icon with colors
|
||||||
|
├── agent-picker-popup.tsx # Agent selection dropdown
|
||||||
|
├── index.ts # Exports
|
||||||
|
└── hooks/
|
||||||
|
├── use-agent-status.ts # Worker status tracking
|
||||||
|
├── use-spawn-agent.ts # Spawn API calls
|
||||||
|
└── index.ts
|
||||||
|
|
||||||
|
src/app/api/runtime/
|
||||||
|
├── spawn/route.ts # POST to spawn worker
|
||||||
|
└── worker-status/route.ts # GET worker status by beadId
|
||||||
|
|
||||||
|
src/app/api/beads/
|
||||||
|
└── assign-agent/route.ts # POST to assign agent type
|
||||||
|
```
|
||||||
|
|
||||||
|
## Files Modified (Phase 4)
|
||||||
|
|
||||||
|
```
|
||||||
|
src/components/social/social-card.tsx # Integrated AgentActionRow
|
||||||
|
src/components/social/social-page.tsx # Pass projectRoot
|
||||||
|
src/lib/social-cards.ts # Added agentTypeId
|
||||||
|
src/lib/types.ts # Added agentTypeId/agentInstanceId
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How the Two-Icon System Works
|
||||||
|
|
||||||
|
**Icon 1: 👤 Assign (UserPlus)**
|
||||||
|
- Opens agent picker popup
|
||||||
|
- Select agent type → assigns to bead
|
||||||
|
- Planning action (no spawn)
|
||||||
|
|
||||||
|
**Icon 2: 🚀 Spawn (Rocket)**
|
||||||
|
- **Blue** = Ready to spawn (has agentTypeId)
|
||||||
|
- **Green pulsing** = Worker active
|
||||||
|
- **Red** = Worker blocked
|
||||||
|
- **Checkmark** = Worker completed
|
||||||
|
- Click spawns the worker
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rocket Colors
|
||||||
|
|
||||||
|
| Color | State | Meaning |
|
||||||
|
|-------|-------|---------|
|
||||||
|
| Hidden | No agentTypeId | No agent assigned |
|
||||||
|
| Blue | idle + agentTypeId | Ready to spawn |
|
||||||
|
| Blue pulsing | spawning | Spawning in progress |
|
||||||
|
| Green pulsing | working | Worker actively working |
|
||||||
|
| Red | blocked | Worker stuck |
|
||||||
|
| Green checkmark | completed | Worker finished |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Session Tasks
|
||||||
|
|
||||||
|
1. **Task 10:** Add AgentActionRow to graph-node-card.tsx
|
||||||
|
2. **Task 11:** Add AgentActionRow to blocked-triage-modal.tsx
|
||||||
|
3. **Task 14:** Make useAgentStatus poll real API
|
||||||
|
4. **Task 17:** Test complete flow
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Files to Read
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `docs/plans/2026-03-08-phase-4-implementation.md` | Full implementation plan |
|
||||||
|
| `docs/plans/2026-03-05-embedded-pi-roadmap.md` | Overall roadmap |
|
||||||
|
| `src/components/agents/agent-action-row.tsx` | Main component |
|
||||||
|
| `src/components/agents/hooks/use-agent-status.ts` | Status tracking |
|
||||||
|
| `src/app/api/runtime/spawn/route.ts` | Spawn API |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pre-existing TypeScript Errors (Not Related to Phase 4)
|
||||||
|
|
||||||
|
```
|
||||||
|
src/components/shared/left-panel-new.tsx - LeftPanelFilters not exported
|
||||||
|
src/components/shared/unified-shell.tsx - Type mismatches
|
||||||
|
src/tui/tools/bb-deviation.ts - RuntimeEventKind mismatch
|
||||||
|
```
|
||||||
|
|
||||||
|
These existed before Phase 4 work and should be fixed separately.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commit History (Phase 4)
|
||||||
|
|
||||||
|
```
|
||||||
|
4a7425c feat: integrate AgentActionRow into SocialCard
|
||||||
|
547b565 feat: add agents components index
|
||||||
|
209807e feat: add AgentSpawnButton with color states
|
||||||
|
aedeb07 feat: add spawn API endpoint
|
||||||
|
20a3eb1 feat: add worker-status API endpoint
|
||||||
|
09928a8 feat: add useSpawnAgent hook
|
||||||
|
4e03654 feat: add useAgentStatus hook interface
|
||||||
|
d84770c docs: add Phase 4 implementation plan
|
||||||
|
```
|
||||||
1092
docs/plans/2026-03-08-phase-4-implementation.md
Normal file
1092
docs/plans/2026-03-08-phase-4-implementation.md
Normal file
File diff suppressed because it is too large
Load diff
3576
package-lock.json
generated
3576
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -19,6 +19,9 @@
|
||||||
"video:thumbnail": "remotion still src/video/index.ts Main out/thumbnail.png --frame=60"
|
"video:thumbnail": "remotion still src/video/index.ts Main out/thumbnail.png --frame=60"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@mariozechner/pi-agent-core": "^0.57.1",
|
||||||
|
"@mariozechner/pi-ai": "^0.57.1",
|
||||||
|
"@mariozechner/pi-coding-agent": "^0.57.1",
|
||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
|
@ -32,6 +35,7 @@
|
||||||
"@remotion/google-fonts": "^4.0.422",
|
"@remotion/google-fonts": "^4.0.422",
|
||||||
"@remotion/tailwind": "^4.0.422",
|
"@remotion/tailwind": "^4.0.422",
|
||||||
"@remotion/zod-types": "^4.0.422",
|
"@remotion/zod-types": "^4.0.422",
|
||||||
|
"@sinclair/typebox": "^0.34.48",
|
||||||
"@xyflow/react": "^12.10.0",
|
"@xyflow/react": "^12.10.0",
|
||||||
"chokidar": "^5.0.0",
|
"chokidar": "^5.0.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
|
|
|
||||||
38
src/app/api/runtime/agents/history/route.ts
Normal file
38
src/app/api/runtime/agents/history/route.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { workerSessionManager, type WorkerSession } from '../../../../../lib/worker-session-manager';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const projectRoot = searchParams.get('projectRoot');
|
||||||
|
|
||||||
|
if (!projectRoot) {
|
||||||
|
return NextResponse.json({ ok: false, error: 'projectRoot required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get completed/failed workers as history
|
||||||
|
const workers = workerSessionManager.listWorkers(projectRoot);
|
||||||
|
const history = workers
|
||||||
|
.filter((w: WorkerSession) => w.status === 'completed' || w.status === 'failed')
|
||||||
|
.sort((a: WorkerSession, b: WorkerSession) =>
|
||||||
|
new Date(b.completedAt || 0).getTime() - new Date(a.completedAt || 0).getTime()
|
||||||
|
)
|
||||||
|
.slice(0, 50)
|
||||||
|
.map((w: WorkerSession) => ({
|
||||||
|
id: w.agentInstanceId || w.id,
|
||||||
|
agentTypeId: w.agentTypeId || 'unknown',
|
||||||
|
displayName: w.displayName || `Worker ${w.id}`,
|
||||||
|
status: w.status,
|
||||||
|
currentBeadId: w.taskId,
|
||||||
|
startedAt: w.createdAt,
|
||||||
|
completedAt: w.completedAt,
|
||||||
|
result: w.result,
|
||||||
|
error: w.error,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
instances: history,
|
||||||
|
});
|
||||||
|
}
|
||||||
42
src/app/api/runtime/agents/route.ts
Normal file
42
src/app/api/runtime/agents/route.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { workerSessionManager, type WorkerSession } from '../../../../lib/worker-session-manager';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const projectRoot = searchParams.get('projectRoot');
|
||||||
|
|
||||||
|
if (!projectRoot) {
|
||||||
|
return NextResponse.json({ ok: false, error: 'projectRoot required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const workers = workerSessionManager.listWorkers(projectRoot);
|
||||||
|
|
||||||
|
const instances = workers.map((w: WorkerSession) => ({
|
||||||
|
id: w.agentInstanceId || w.id,
|
||||||
|
agentTypeId: w.agentTypeId || 'unknown',
|
||||||
|
displayName: w.displayName || `Worker ${w.id}`,
|
||||||
|
status: w.status,
|
||||||
|
currentBeadId: w.taskId,
|
||||||
|
startedAt: w.createdAt,
|
||||||
|
completedAt: w.completedAt,
|
||||||
|
result: w.result,
|
||||||
|
error: w.error,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const byType: Record<string, number> = {};
|
||||||
|
for (const w of workers) {
|
||||||
|
const typeId = w.agentTypeId || 'unknown';
|
||||||
|
byType[typeId] = (byType[typeId] || 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
status: {
|
||||||
|
totalActive: workers.filter((w: WorkerSession) => w.status === 'working' || w.status === 'spawning').length,
|
||||||
|
byType,
|
||||||
|
instances,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
25
src/app/api/runtime/bootstrap/route.ts
Normal file
25
src/app/api/runtime/bootstrap/route.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { bootstrapManagedPi } from '../../../../lib/bb-pi-bootstrap';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export async function POST(): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const result = await bootstrapManagedPi();
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
managedRoot: result.managedRoot,
|
||||||
|
sdkPath: result.sdkPath,
|
||||||
|
agentDir: result.agentDir,
|
||||||
|
alreadyInstalled: result.alreadyInstalled,
|
||||||
|
created: result.created,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Bootstrap API] Error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ ok: false, error: error instanceof Error ? error.message : 'Bootstrap failed' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/app/api/runtime/events/route.ts
Normal file
16
src/app/api/runtime/events/route.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { bbDaemon } from '../../../../lib/bb-daemon';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export async function GET(request: Request): Promise<Response> {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const projectRoot = searchParams.get('projectRoot');
|
||||||
|
|
||||||
|
if (!projectRoot) {
|
||||||
|
return NextResponse.json({ ok: false, error: 'projectRoot is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await bbDaemon.ensureRunning();
|
||||||
|
return NextResponse.json({ ok: true, lifecycle: bbDaemon.getLifecycle(), data: bbDaemon.listEvents(projectRoot) });
|
||||||
|
}
|
||||||
55
src/app/api/runtime/launch/route.ts
Normal file
55
src/app/api/runtime/launch/route.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { bbDaemon } from '../../../../lib/bb-daemon';
|
||||||
|
import type { LaunchSurface } from '../../../../lib/embedded-runtime';
|
||||||
|
import type { readIssuesFromDisk as ReadIssuesFromDisk } from '../../../../lib/read-issues';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
interface LaunchDeps {
|
||||||
|
readIssues?: typeof ReadIssuesFromDisk;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLaunchSurface(value: string): value is LaunchSurface {
|
||||||
|
return ['social', 'graph', 'swarm', 'sessions', 'activity', 'task'].includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleRuntimeLaunchPost(request: Request, deps: LaunchDeps = {}): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const projectRoot = typeof body?.projectRoot === 'string' ? body.projectRoot.trim() : '';
|
||||||
|
const taskId = typeof body?.taskId === 'string' ? body.taskId.trim() : '';
|
||||||
|
const origin = typeof body?.origin === 'string' && isLaunchSurface(body.origin) ? body.origin : null;
|
||||||
|
const swarmId = typeof body?.swarmId === 'string' ? body.swarmId : null;
|
||||||
|
|
||||||
|
if (!projectRoot || !taskId || !origin) {
|
||||||
|
return NextResponse.json({ ok: false, error: 'projectRoot, taskId, and origin are required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const read = deps.readIssues ?? (await import('../../../../lib/read-issues')).readIssuesFromDisk;
|
||||||
|
const issues = await read({ projectRoot, preferBd: true });
|
||||||
|
const issue = issues.find((entry) => entry.id === taskId);
|
||||||
|
|
||||||
|
if (!issue) {
|
||||||
|
return NextResponse.json({ ok: false, error: 'task not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const lifecycle = await bbDaemon.ensureRunning();
|
||||||
|
const result = await bbDaemon.launchFromIssue({
|
||||||
|
projectRoot,
|
||||||
|
issue,
|
||||||
|
origin,
|
||||||
|
swarmId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true, lifecycle, data: result });
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ ok: false, error: error instanceof Error ? error.message : 'Invalid request' },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request): Promise<Response> {
|
||||||
|
return handleRuntimeLaunchPost(request);
|
||||||
|
}
|
||||||
24
src/app/api/runtime/orchestrator/route.ts
Normal file
24
src/app/api/runtime/orchestrator/route.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { bbDaemon } from '../../../../lib/bb-daemon';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export async function POST(request: Request): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const projectRoot = typeof body?.projectRoot === 'string' ? body.projectRoot.trim() : '';
|
||||||
|
|
||||||
|
if (!projectRoot) {
|
||||||
|
return NextResponse.json({ ok: false, error: 'projectRoot is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const lifecycle = await bbDaemon.ensureRunning();
|
||||||
|
const orchestrator = await bbDaemon.ensureOrchestrator(projectRoot);
|
||||||
|
return NextResponse.json({ ok: true, lifecycle, data: orchestrator });
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ ok: false, error: error instanceof Error ? error.message : 'Invalid request' },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
src/app/api/runtime/prompt/route.ts
Normal file
29
src/app/api/runtime/prompt/route.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { bbDaemon } from '../../../../lib/bb-daemon';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export async function POST(request: Request): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const projectRoot = typeof body?.projectRoot === 'string' ? body.projectRoot.trim() : '';
|
||||||
|
const text = typeof body?.text === 'string' ? body.text.trim() : '';
|
||||||
|
|
||||||
|
if (!projectRoot || !text) {
|
||||||
|
return NextResponse.json({ ok: false, error: 'projectRoot and text are required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await bbDaemon.ensureRunning();
|
||||||
|
|
||||||
|
if (typeof (bbDaemon as any).prompt === 'function') {
|
||||||
|
void (bbDaemon as any).prompt(projectRoot, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ ok: false, error: error instanceof Error ? error.message : 'Invalid request' },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
49
src/app/api/runtime/spawn/route.ts
Normal file
49
src/app/api/runtime/spawn/route.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
// src/app/api/runtime/spawn/route.ts
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { workerSessionManager } from '../../../../lib/worker-session-manager';
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { projectRoot, beadId, agentTypeId } = body;
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!projectRoot) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ ok: false, error: 'projectRoot is required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!beadId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ ok: false, error: 'beadId is required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!agentTypeId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ ok: false, error: 'agentTypeId is required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn worker via session manager
|
||||||
|
const worker = await workerSessionManager.spawnWorker({
|
||||||
|
projectRoot,
|
||||||
|
taskId: beadId,
|
||||||
|
taskContext: `Work on ${beadId}`,
|
||||||
|
agentType: agentTypeId,
|
||||||
|
beadId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
workerId: worker.id,
|
||||||
|
displayName: worker.displayName,
|
||||||
|
agentTypeId: worker.agentTypeId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return NextResponse.json({ ok: false, error: message });
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/app/api/runtime/status/route.ts
Normal file
8
src/app/api/runtime/status/route.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { bbDaemon } from '../../../../lib/bb-daemon';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export async function GET(): Promise<Response> {
|
||||||
|
return NextResponse.json(bbDaemon.getStatus());
|
||||||
|
}
|
||||||
80
src/app/api/runtime/stream/route.ts
Normal file
80
src/app/api/runtime/stream/route.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
import { bbDaemon } from '../../../../lib/bb-daemon';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const HEARTBEAT_MS = 15_000;
|
||||||
|
const POLL_MS = 250;
|
||||||
|
|
||||||
|
function toRuntimeSseFrame(event: unknown): string {
|
||||||
|
return `event: runtime\ndata: ${JSON.stringify(event)}\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: Request): Promise<Response> {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const projectRoot = searchParams.get('projectRoot');
|
||||||
|
|
||||||
|
if (!projectRoot) {
|
||||||
|
return Response.json({ ok: false, error: 'projectRoot is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await bbDaemon.start();
|
||||||
|
|
||||||
|
let cleanup = () => {};
|
||||||
|
const stream = new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
let closed = false;
|
||||||
|
const write = (payload: string) => {
|
||||||
|
if (!closed) controller.enqueue(encoder.encode(payload));
|
||||||
|
};
|
||||||
|
|
||||||
|
write(': connected\n\n');
|
||||||
|
|
||||||
|
const seenIds = new Set<string>();
|
||||||
|
const seed = bbDaemon.listEvents(projectRoot);
|
||||||
|
for (const event of seed) {
|
||||||
|
seenIds.add(event.id);
|
||||||
|
write(toRuntimeSseFrame(event));
|
||||||
|
}
|
||||||
|
|
||||||
|
const poll = setInterval(() => {
|
||||||
|
const current = bbDaemon.listEvents(projectRoot);
|
||||||
|
const unseen = current.filter((event) => !seenIds.has(event.id));
|
||||||
|
for (const event of unseen.reverse()) {
|
||||||
|
seenIds.add(event.id);
|
||||||
|
write(toRuntimeSseFrame(event));
|
||||||
|
}
|
||||||
|
}, POLL_MS);
|
||||||
|
|
||||||
|
const heartbeat = setInterval(() => {
|
||||||
|
write(': heartbeat\n\n');
|
||||||
|
}, HEARTBEAT_MS);
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
if (closed) return;
|
||||||
|
closed = true;
|
||||||
|
clearInterval(heartbeat);
|
||||||
|
clearInterval(poll);
|
||||||
|
request.signal.removeEventListener('abort', close);
|
||||||
|
try {
|
||||||
|
controller.close();
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
cleanup = close;
|
||||||
|
request.signal.addEventListener('abort', close);
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
cleanup();
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||||
|
'Cache-Control': 'no-cache, no-transform',
|
||||||
|
Connection: 'keep-alive',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
37
src/app/api/runtime/worker-status/route.ts
Normal file
37
src/app/api/runtime/worker-status/route.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
// src/app/api/runtime/worker-status/route.ts
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { workerSessionManager } from '../../../../lib/worker-session-manager';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const beadId = searchParams.get('beadId');
|
||||||
|
|
||||||
|
if (!beadId) {
|
||||||
|
return NextResponse.json({ ok: false, error: 'beadId required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find worker for this bead
|
||||||
|
const workers = workerSessionManager.getAllWorkers();
|
||||||
|
const worker = workers.find(w => w.beadId === beadId);
|
||||||
|
|
||||||
|
if (!worker) {
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
workerStatus: 'idle',
|
||||||
|
agentTypeId: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
workerStatus: worker.status,
|
||||||
|
workerDisplayName: worker.displayName,
|
||||||
|
workerError: worker.error,
|
||||||
|
agentTypeId: worker.agentTypeId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return NextResponse.json({ ok: false, error: message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,8 +15,8 @@ export const metadata: Metadata = {
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" data-theme="aurora">
|
<html lang="en" data-theme="aurora" suppressHydrationWarning>
|
||||||
<body className={notoSans.variable}>{children}</body>
|
<body className={notoSans.variable} suppressHydrationWarning>{children}</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,14 @@
|
||||||
import { Suspense } from 'react';
|
import { Suspense } from 'react';
|
||||||
import { UnifiedShell } from '../components/shared/unified-shell';
|
import { UnifiedShell } from '../components/shared/unified-shell';
|
||||||
|
import { OnboardingWizard } from '../components/onboarding/onboarding-wizard';
|
||||||
import { readIssuesForScope } from '../lib/aggregate-read';
|
import { readIssuesForScope } from '../lib/aggregate-read';
|
||||||
import { resolveProjectScope } from '../lib/project-scope';
|
import { resolveProjectScope } from '../lib/project-scope';
|
||||||
import { listProjects } from '../lib/registry';
|
import { listProjects } from '../lib/registry';
|
||||||
|
import { bbDaemon } from '../lib/bb-daemon';
|
||||||
|
import { detectPiRuntimeStrategy } from '../lib/pi-runtime-detection';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import os from 'node:os';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
|
@ -10,10 +16,45 @@ interface PageProps {
|
||||||
searchParams?: Promise<Record<string, string | string[] | undefined>>;
|
searchParams?: Promise<Record<string, string | string[] | undefined>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function checkOnboardingStatus(): Promise<{
|
||||||
|
hasProjects: boolean;
|
||||||
|
piInstalled: boolean;
|
||||||
|
hasAuth: boolean;
|
||||||
|
}> {
|
||||||
|
// Check if any projects exist
|
||||||
|
const registryProjects = await listProjects();
|
||||||
|
const hasProjects = registryProjects.length > 0;
|
||||||
|
|
||||||
|
// Check if Pi is installed
|
||||||
|
const piResolution = await detectPiRuntimeStrategy();
|
||||||
|
const piInstalled = piResolution.installState === 'ready';
|
||||||
|
|
||||||
|
// Check if auth exists
|
||||||
|
const authPath = path.join(os.homedir(), '.beadboard', 'runtime', 'pi', 'agent', 'auth.json');
|
||||||
|
let hasAuth = false;
|
||||||
|
try {
|
||||||
|
const authContent = await fs.readFile(authPath, 'utf8');
|
||||||
|
const auth = JSON.parse(authContent);
|
||||||
|
hasAuth = Object.keys(auth.providers || {}).length > 0;
|
||||||
|
} catch {
|
||||||
|
hasAuth = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { hasProjects, piInstalled, hasAuth };
|
||||||
|
}
|
||||||
|
|
||||||
export default async function Page({ searchParams }: PageProps) {
|
export default async function Page({ searchParams }: PageProps) {
|
||||||
const params = (await searchParams) ?? {};
|
const params = (await searchParams) ?? {};
|
||||||
const requestedProjectKey = typeof params.project === 'string' ? params.project : null;
|
const requestedProjectKey = typeof params.project === 'string' ? params.project : null;
|
||||||
const requestedMode = typeof params.mode === 'string' ? params.mode : null;
|
const requestedMode = typeof params.mode === 'string' ? params.mode : null;
|
||||||
|
|
||||||
|
// Check if we should skip onboarding (user has seen it or explicitly skipped)
|
||||||
|
const skipOnboarding = params.onboarded === 'true' || params.skip === 'true';
|
||||||
|
|
||||||
|
// Check onboarding status
|
||||||
|
const onboardingStatus = await checkOnboardingStatus();
|
||||||
|
const needsOnboarding = !skipOnboarding && (!onboardingStatus.hasProjects || !onboardingStatus.piInstalled);
|
||||||
|
|
||||||
const registryProjects = await listProjects();
|
const registryProjects = await listProjects();
|
||||||
const scope = resolveProjectScope({
|
const scope = resolveProjectScope({
|
||||||
currentProjectRoot: process.cwd(),
|
currentProjectRoot: process.cwd(),
|
||||||
|
|
@ -30,6 +71,22 @@ export default async function Page({ searchParams }: PageProps) {
|
||||||
skipAgentFilter: true,
|
skipAgentFilter: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Start daemon in background
|
||||||
|
void bbDaemon.ensureRunning();
|
||||||
|
|
||||||
|
// Show onboarding wizard if needed
|
||||||
|
if (needsOnboarding) {
|
||||||
|
return (
|
||||||
|
<Suspense>
|
||||||
|
<OnboardingWizard
|
||||||
|
hasProjects={onboardingStatus.hasProjects}
|
||||||
|
piInstalled={onboardingStatus.piInstalled}
|
||||||
|
hasAuth={onboardingStatus.hasAuth}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<UnifiedShell
|
<UnifiedShell
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,10 @@ import {
|
||||||
statusAgentReservations,
|
statusAgentReservations,
|
||||||
type ReservationCommandResponse,
|
type ReservationCommandResponse,
|
||||||
} from '../lib/agent-reservations';
|
} from '../lib/agent-reservations';
|
||||||
|
import { bbDaemon } from '../lib/bb-daemon';
|
||||||
|
import { bootstrapManagedPi } from '../lib/bb-pi-bootstrap';
|
||||||
|
import { renderDaemonTuiText } from '../tui/bb-daemon-tui';
|
||||||
|
import { runBbAgentTui } from '../tui/bb-agent-tui';
|
||||||
|
|
||||||
export type CliResult = {
|
export type CliResult = {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
|
|
@ -276,6 +280,102 @@ async function runAgentCli(argv: string[], asJson: boolean): Promise<CliResult>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderDaemonHelpText(): string {
|
||||||
|
return [
|
||||||
|
'Usage: bb daemon <command> [options]',
|
||||||
|
'',
|
||||||
|
'Commands:',
|
||||||
|
' start Start the BeadBoard daemon lifecycle',
|
||||||
|
' status Show daemon lifecycle and project status',
|
||||||
|
' stop Stop the BeadBoard daemon lifecycle',
|
||||||
|
' bootstrap Install the BeadBoard agent runtime',
|
||||||
|
' tui Open the interactive BeadBoard agent TUI',
|
||||||
|
'',
|
||||||
|
'TUI options:',
|
||||||
|
' --project-root <path> Run the bb agent in an explicit external project workspace',
|
||||||
|
' --project-key <key> Run the bb agent in a registered BeadBoard project workspace',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runDaemonCli(argv: string[], asJson: boolean): Promise<CliResult> {
|
||||||
|
const subcommand = argv[0];
|
||||||
|
const projectRootFlagIndex = argv.indexOf('--project-root');
|
||||||
|
const projectKeyFlagIndex = argv.indexOf('--project-key');
|
||||||
|
const projectRoot = projectRootFlagIndex >= 0 ? argv[projectRootFlagIndex + 1] : undefined;
|
||||||
|
const projectKey = projectKeyFlagIndex >= 0 ? argv[projectKeyFlagIndex + 1] : undefined;
|
||||||
|
if (!subcommand || subcommand === '--help' || subcommand === '-h' || subcommand === 'help') {
|
||||||
|
return { ok: true, command: 'daemon help', text: renderDaemonHelpText() };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subcommand === 'start') {
|
||||||
|
const lifecycle = await bbDaemon.start();
|
||||||
|
const status = bbDaemon.getStatus();
|
||||||
|
const runtimeMode = status.piRuntime ? `${status.piRuntime.mode} / ${status.piRuntime.installState}` : 'unknown';
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
command: 'daemon start',
|
||||||
|
text: `✓ BeadBoard daemon started (${lifecycle.status}) using ${runtimeMode}`,
|
||||||
|
lifecycle,
|
||||||
|
status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subcommand === 'status') {
|
||||||
|
const status = bbDaemon.getStatus();
|
||||||
|
const runtimeMode = status.piRuntime ? `${status.piRuntime.mode} / ${status.piRuntime.installState}` : 'unknown';
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
command: 'daemon status',
|
||||||
|
text: `Daemon: ${status.lifecycle.status} (${status.daemon.projectCount} projects) · Agent runtime: ${runtimeMode}`,
|
||||||
|
status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subcommand === 'stop') {
|
||||||
|
const lifecycle = await bbDaemon.stop();
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
command: 'daemon stop',
|
||||||
|
text: `✓ BeadBoard daemon stopped (${lifecycle.status})`,
|
||||||
|
lifecycle,
|
||||||
|
status: bbDaemon.getStatus(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subcommand === 'bootstrap' || subcommand === 'bootstrap-pi') {
|
||||||
|
const result = await bootstrapManagedPi();
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
command: 'daemon bootstrap',
|
||||||
|
text: result.alreadyInstalled
|
||||||
|
? `✓ BeadBoard agent runtime ready at ${result.managedRoot}`
|
||||||
|
: `✓ BeadBoard agent runtime installed at ${result.managedRoot}`,
|
||||||
|
bootstrap: result,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subcommand === 'tui') {
|
||||||
|
await bbDaemon.ensureRunning();
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
command: 'daemon tui',
|
||||||
|
text: 'Starting interactive BeadBoard agent TUI...',
|
||||||
|
preview: renderDaemonTuiText(),
|
||||||
|
status: bbDaemon.getStatus(),
|
||||||
|
planned: false,
|
||||||
|
interactive: true,
|
||||||
|
projectRoot: projectRoot ?? null,
|
||||||
|
projectKey: projectKey ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = { code: 'CLI_ERROR', message: `Unknown daemon command: ${subcommand}` };
|
||||||
|
if (asJson) {
|
||||||
|
return { ok: false, command: `daemon ${subcommand}`, error };
|
||||||
|
}
|
||||||
|
return { ok: false, command: `daemon ${subcommand}`, text: `Error: ${error.message}`, error };
|
||||||
|
}
|
||||||
|
|
||||||
function parseVersion(env: NodeJS.ProcessEnv): string {
|
function parseVersion(env: NodeJS.ProcessEnv): string {
|
||||||
const raw = (env.BB_RUNTIME_VERSION || env.npm_package_version || '0.1.0').trim();
|
const raw = (env.BB_RUNTIME_VERSION || env.npm_package_version || '0.1.0').trim();
|
||||||
return raw.startsWith('v') ? raw.slice(1) : raw;
|
return raw.startsWith('v') ? raw.slice(1) : raw;
|
||||||
|
|
@ -297,6 +397,11 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
||||||
return runAgentCli(subArgs, asJson);
|
return runAgentCli(subArgs, asJson);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (command === 'daemon') {
|
||||||
|
const subArgs = commandIndex >= 0 ? args.slice(commandIndex + 1) : [];
|
||||||
|
return runDaemonCli(subArgs, asJson);
|
||||||
|
}
|
||||||
|
|
||||||
if (command === 'doctor') {
|
if (command === 'doctor') {
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|
@ -343,7 +448,8 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
command: 'help',
|
command: 'help',
|
||||||
usage: 'beadboard <doctor|self-update|uninstall> [--json] [--yes]',
|
usage: 'beadboard <agent|daemon|doctor|self-update|uninstall> [--json] [--yes]',
|
||||||
|
text: renderHelpText(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -356,6 +462,7 @@ function renderHelpText(): string {
|
||||||
' beadboard start [--dolt] Start BeadBoard runtime (optionally start Dolt first)',
|
' beadboard start [--dolt] Start BeadBoard runtime (optionally start Dolt first)',
|
||||||
' beadboard open Open BeadBoard in browser',
|
' beadboard open Open BeadBoard in browser',
|
||||||
' beadboard status [--json] Show runtime + bd diagnostics',
|
' beadboard status [--json] Show runtime + bd diagnostics',
|
||||||
|
' beadboard daemon <command> Control the BeadBoard daemon lifecycle',
|
||||||
' beadboard agent <command> Run coordination commands (register/send/inbox/ack/reserve/...)',
|
' beadboard agent <command> Run coordination commands (register/send/inbox/ack/reserve/...)',
|
||||||
'',
|
'',
|
||||||
'Management Commands:',
|
'Management Commands:',
|
||||||
|
|
@ -371,6 +478,24 @@ function renderHelpText(): string {
|
||||||
async function main() {
|
async function main() {
|
||||||
const argv = process.argv.slice(2);
|
const argv = process.argv.slice(2);
|
||||||
const asJson = argv.includes('--json');
|
const asJson = argv.includes('--json');
|
||||||
|
const isDaemonTui = argv[0] === 'daemon' && argv[1] === 'tui' && !asJson;
|
||||||
|
|
||||||
|
if (isDaemonTui) {
|
||||||
|
const projectRootFlagIndex = argv.indexOf('--project-root');
|
||||||
|
const projectKeyFlagIndex = argv.indexOf('--project-key');
|
||||||
|
const projectRoot = projectRootFlagIndex >= 0 ? argv[projectRootFlagIndex + 1] : undefined;
|
||||||
|
const projectKey = projectKeyFlagIndex >= 0 ? argv[projectKeyFlagIndex + 1] : undefined;
|
||||||
|
|
||||||
|
await bbDaemon.ensureRunning();
|
||||||
|
await runBbAgentTui({
|
||||||
|
cwd: process.cwd(),
|
||||||
|
projectRoot,
|
||||||
|
projectKey,
|
||||||
|
testMode: process.env.BB_TUI_TEST_MODE === '1',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const result = await runCli(argv);
|
const result = await runCli(argv);
|
||||||
if (!asJson && result.command === 'help') {
|
if (!asJson && result.command === 'help') {
|
||||||
process.stdout.write(`${renderHelpText()}\n`);
|
process.stdout.write(`${renderHelpText()}\n`);
|
||||||
|
|
|
||||||
|
|
@ -24,24 +24,24 @@ export type EventTone = {
|
||||||
idClass: string;
|
idClass: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface AgentRosterEntry {
|
interface AgentRosterEntry {
|
||||||
name: string;
|
name: string;
|
||||||
status: AgentStatus;
|
status: AgentStatus;
|
||||||
lastSeen: string | null;
|
lastSeen: string | null;
|
||||||
beadId: string;
|
beadId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CoordMessage {
|
interface CoordMessage {
|
||||||
message_id: string;
|
message_id: string;
|
||||||
bead_id: string;
|
bead_id: string;
|
||||||
from_agent: string;
|
from_agent: string;
|
||||||
to_agent: string;
|
to_agent: string;
|
||||||
category: 'HANDOFF' | 'BLOCKED' | 'DECISION' | 'INFO';
|
category: 'HANDOFF' | 'BLOCKED' | 'DECISION' | 'INFO';
|
||||||
subject: string;
|
subject: string;
|
||||||
state: 'unread' | 'read' | 'acked';
|
state: 'unread' | 'read' | 'acked';
|
||||||
created_at: string;
|
created_at: string;
|
||||||
acked_at: string | null;
|
acked_at: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ActivityPanelProps {
|
interface ActivityPanelProps {
|
||||||
issues: BeadIssue[];
|
issues: BeadIssue[];
|
||||||
|
|
@ -51,6 +51,21 @@ interface ActivityPanelProps {
|
||||||
|
|
||||||
const AGENT_LABEL = 'gt:agent';
|
const AGENT_LABEL = 'gt:agent';
|
||||||
|
|
||||||
|
function mergeUniqueActivities(existing: ActivityEvent[], incoming: ActivityEvent[]): ActivityEvent[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const merged: ActivityEvent[] = [];
|
||||||
|
|
||||||
|
for (const event of [...incoming, ...existing]) {
|
||||||
|
if (seen.has(event.id)) continue;
|
||||||
|
seen.add(event.id);
|
||||||
|
merged.push(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged
|
||||||
|
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
|
||||||
|
.slice(0, 50);
|
||||||
|
}
|
||||||
|
|
||||||
// Determine agent status based on last activity
|
// Determine agent status based on last activity
|
||||||
function deriveAgentStatus(lastSeenAt: string | null): AgentStatus {
|
function deriveAgentStatus(lastSeenAt: string | null): AgentStatus {
|
||||||
if (!lastSeenAt) return 'dead';
|
if (!lastSeenAt) return 'dead';
|
||||||
|
|
@ -152,23 +167,23 @@ function getAgentTone(status: AgentStatus): AgentTone {
|
||||||
}
|
}
|
||||||
|
|
||||||
// reopened=blue, closed=amber, created/opened=green, others semantic
|
// reopened=blue, closed=amber, created/opened=green, others semantic
|
||||||
export function getEventTone(kind: string): EventTone {
|
export function getEventTone(kind: string): EventTone {
|
||||||
const normalized = kind.toLowerCase();
|
const normalized = kind.toLowerCase();
|
||||||
const byKind: Record<string, EventTone> = {
|
const byKind: Record<string, EventTone> = {
|
||||||
coord_send: {
|
coord_send: {
|
||||||
label: 'Coord Send',
|
label: 'Coord Send',
|
||||||
labelClass: 'text-[#D4A574]',
|
labelClass: 'text-[#D4A574]',
|
||||||
dotClass: 'bg-[#D4A574]',
|
dotClass: 'bg-[#D4A574]',
|
||||||
cardClass: 'bg-[var(--status-in-progress)]',
|
cardClass: 'bg-[var(--status-in-progress)]',
|
||||||
idClass: 'text-[#DAB891]',
|
idClass: 'text-[#DAB891]',
|
||||||
},
|
},
|
||||||
coord_ack: {
|
coord_ack: {
|
||||||
label: 'Coord Ack',
|
label: 'Coord Ack',
|
||||||
labelClass: 'text-[#7CB97A]',
|
labelClass: 'text-[#7CB97A]',
|
||||||
dotClass: 'bg-[#7CB97A]',
|
dotClass: 'bg-[#7CB97A]',
|
||||||
cardClass: 'bg-[var(--status-ready)]',
|
cardClass: 'bg-[var(--status-ready)]',
|
||||||
idClass: 'text-[#9ACB98]',
|
idClass: 'text-[#9ACB98]',
|
||||||
},
|
},
|
||||||
created: {
|
created: {
|
||||||
label: 'Created',
|
label: 'Created',
|
||||||
labelClass: 'text-[#7CB97A]',
|
labelClass: 'text-[#7CB97A]',
|
||||||
|
|
@ -270,95 +285,95 @@ export function getInitials(name: string): string {
|
||||||
return name.split(/[-_\s]/).map(p => p[0]).join('').toUpperCase().slice(0, 2);
|
return name.split(/[-_\s]/).map(p => p[0]).join('').toUpperCase().slice(0, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ActivityPanel({ issues, collapsed = false, projectRoot }: ActivityPanelProps) {
|
export function ActivityPanel({ issues, collapsed = false, projectRoot }: ActivityPanelProps) {
|
||||||
const [activities, setActivities] = useState<ActivityEvent[]>([]);
|
const [activities, setActivities] = useState<ActivityEvent[]>([]);
|
||||||
const [coordActivities, setCoordActivities] = useState<ActivityEvent[]>([]);
|
const [coordActivities, setCoordActivities] = useState<ActivityEvent[]>([]);
|
||||||
const [reservationByAgent, setReservationByAgent] = useState<Record<string, string | undefined>>({});
|
const [reservationByAgent, setReservationByAgent] = useState<Record<string, string | undefined>>({});
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
const agentRoster = useMemo(() => buildAgentRoster(issues), [issues]);
|
const agentRoster = useMemo(() => buildAgentRoster(issues), [issues]);
|
||||||
|
|
||||||
// Fetch activity history
|
// Fetch activity history
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchActivity() {
|
async function fetchActivity() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/activity');
|
const response = await fetch('/api/activity');
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setActivities(data.slice(0, 50)); // Limit to 50 events
|
setActivities((prev) => mergeUniqueActivities(prev, data));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[ActivityPanel] Failed to fetch activity:', error);
|
console.error('[ActivityPanel] Failed to fetch activity:', error);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchActivity();
|
fetchActivity();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchCoordination = async () => {
|
const fetchCoordination = async () => {
|
||||||
if (agentRoster.length === 0) {
|
if (agentRoster.length === 0) {
|
||||||
setCoordActivities([]);
|
setCoordActivities([]);
|
||||||
setReservationByAgent({});
|
setReservationByAgent({});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use batch endpoints to reduce API calls from 2N to 2
|
// Use batch endpoints to reduce API calls from 2N to 2
|
||||||
const agentNames = agentRoster.map(a => a.name).join(',');
|
const agentNames = agentRoster.map(a => a.name).join(',');
|
||||||
|
|
||||||
const [mailResponse, reservationsResponse] = await Promise.all([
|
const [mailResponse, reservationsResponse] = await Promise.all([
|
||||||
fetch(`/api/agents/mail/batch?agents=${encodeURIComponent(agentNames)}&limit=15`),
|
fetch(`/api/agents/mail/batch?agents=${encodeURIComponent(agentNames)}&limit=15`),
|
||||||
fetch(`/api/agents/reservations/batch?agents=${encodeURIComponent(agentNames)}`),
|
fetch(`/api/agents/reservations/batch?agents=${encodeURIComponent(agentNames)}`),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const mailPayload = await mailResponse.json().catch(() => ({ ok: false, data: [] }));
|
const mailPayload = await mailResponse.json().catch(() => ({ ok: false, data: [] }));
|
||||||
const reservationsPayload = await reservationsResponse.json().catch(() => ({ ok: false, data: [] }));
|
const reservationsPayload = await reservationsResponse.json().catch(() => ({ ok: false, data: [] }));
|
||||||
|
|
||||||
// Collect all messages from all agents
|
// Collect all messages from all agents
|
||||||
const uniqueMessages = new Map<string, CoordMessage>();
|
const uniqueMessages = new Map<string, CoordMessage>();
|
||||||
if (mailPayload.ok && mailPayload.data) {
|
if (mailPayload.ok && mailPayload.data) {
|
||||||
for (const entry of mailPayload.data) {
|
for (const entry of mailPayload.data) {
|
||||||
for (const message of (entry.messages ?? [])) {
|
for (const message of (entry.messages ?? [])) {
|
||||||
uniqueMessages.set(message.message_id, message);
|
uniqueMessages.set(message.message_id, message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const mapped = [...uniqueMessages.values()]
|
const mapped = [...uniqueMessages.values()]
|
||||||
.map((message) => ({
|
.map((message) => ({
|
||||||
id: `coord-${message.message_id}`,
|
id: `coord-${message.message_id}`,
|
||||||
kind: (message.state === 'acked' ? 'coord_ack' : 'coord_send') as ActivityEvent['kind'],
|
kind: (message.state === 'acked' ? 'coord_ack' : 'coord_send') as ActivityEvent['kind'],
|
||||||
beadId: message.bead_id,
|
beadId: message.bead_id,
|
||||||
beadTitle: `${message.category}: ${message.subject}`,
|
beadTitle: `${message.category}: ${message.subject}`,
|
||||||
timestamp: message.state === 'acked' && message.acked_at ? message.acked_at : message.created_at,
|
timestamp: message.state === 'acked' && message.acked_at ? message.acked_at : message.created_at,
|
||||||
actor: message.state === 'acked' ? message.to_agent : message.from_agent,
|
actor: message.state === 'acked' ? message.to_agent : message.from_agent,
|
||||||
projectId: projectRoot,
|
projectId: projectRoot,
|
||||||
projectName: 'beadboard',
|
projectName: 'beadboard',
|
||||||
payload: {},
|
payload: {},
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
|
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
|
||||||
.slice(0, 25);
|
.slice(0, 25);
|
||||||
|
|
||||||
// Build reservation map
|
// Build reservation map
|
||||||
const reservationMap: Record<string, string | undefined> = {};
|
const reservationMap: Record<string, string | undefined> = {};
|
||||||
if (reservationsPayload.ok && reservationsPayload.data) {
|
if (reservationsPayload.ok && reservationsPayload.data) {
|
||||||
for (const entry of reservationsPayload.data) {
|
for (const entry of reservationsPayload.data) {
|
||||||
reservationMap[entry.agent] = entry.scope;
|
reservationMap[entry.agent] = entry.scope;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setCoordActivities(mapped);
|
setCoordActivities(mapped);
|
||||||
setReservationByAgent(reservationMap);
|
setReservationByAgent(reservationMap);
|
||||||
};
|
};
|
||||||
|
|
||||||
void fetchCoordination();
|
void fetchCoordination();
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
void fetchCoordination();
|
void fetchCoordination();
|
||||||
}, 15000);
|
}, 15000);
|
||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [agentRoster, projectRoot]);
|
}, [agentRoster, projectRoot]);
|
||||||
|
|
||||||
// Subscribe to real-time activity
|
// Subscribe to real-time activity
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -371,7 +386,7 @@ export function ActivityPanel({ issues, collapsed = false, projectRoot }: Activi
|
||||||
console.log('[ActivityPanel] Received activity event:', data);
|
console.log('[ActivityPanel] Received activity event:', data);
|
||||||
// data IS the activity event directly (not wrapped in { event: ... })
|
// data IS the activity event directly (not wrapped in { event: ... })
|
||||||
if (data?.beadId) {
|
if (data?.beadId) {
|
||||||
setActivities(prev => [data, ...prev].slice(0, 50));
|
setActivities(prev => mergeUniqueActivities(prev, [data]));
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Ignore parse errors
|
// Ignore parse errors
|
||||||
|
|
@ -387,13 +402,11 @@ export function ActivityPanel({ issues, collapsed = false, projectRoot }: Activi
|
||||||
};
|
};
|
||||||
}, [projectRoot]);
|
}, [projectRoot]);
|
||||||
|
|
||||||
const activeAgents = agentRoster.filter(a => a.status === 'active').length;
|
const activeAgents = agentRoster.filter(a => a.status === 'active').length;
|
||||||
const mergedActivities = useMemo(
|
const mergedActivities = useMemo(
|
||||||
() => [...coordActivities, ...activities]
|
() => mergeUniqueActivities(coordActivities, activities),
|
||||||
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
|
[activities, coordActivities],
|
||||||
.slice(0, 50),
|
);
|
||||||
[activities, coordActivities],
|
|
||||||
);
|
|
||||||
if (collapsed) {
|
if (collapsed) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-6 py-6 h-full bg-[var(--surface-secondary)]">
|
<div className="flex flex-col items-center gap-6 py-6 h-full bg-[var(--surface-secondary)]">
|
||||||
|
|
@ -425,7 +438,7 @@ export function ActivityPanel({ issues, collapsed = false, projectRoot }: Activi
|
||||||
|
|
||||||
{/* Activity Pulses */}
|
{/* Activity Pulses */}
|
||||||
<div className="flex flex-col gap-2 opacity-40">
|
<div className="flex flex-col gap-2 opacity-40">
|
||||||
{mergedActivities.slice(0, 8).map((act) => (
|
{mergedActivities.slice(0, 8).map((act) => (
|
||||||
<div key={act.id} className={cn(
|
<div key={act.id} className={cn(
|
||||||
"w-1 h-1 rounded-full",
|
"w-1 h-1 rounded-full",
|
||||||
getEventTone(act.kind).dotClass
|
getEventTone(act.kind).dotClass
|
||||||
|
|
@ -473,20 +486,20 @@ export function ActivityPanel({ issues, collapsed = false, projectRoot }: Activi
|
||||||
<div className="flex flex-col flex-1 min-w-0">
|
<div className="flex flex-col flex-1 min-w-0">
|
||||||
<span className="text-xs font-semibold text-text-primary group-hover:text-white transition-colors">{agent.name}</span>
|
<span className="text-xs font-semibold text-text-primary group-hover:text-white transition-colors">{agent.name}</span>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"text-[9px] uppercase tracking-wider font-bold",
|
"text-[9px] uppercase tracking-wider font-bold",
|
||||||
getAgentTone(agent.status).labelClass
|
getAgentTone(agent.status).labelClass
|
||||||
)}>
|
)}>
|
||||||
{agent.status}
|
{agent.status}
|
||||||
</span>
|
</span>
|
||||||
{reservationByAgent[agent.name] ? (
|
{reservationByAgent[agent.name] ? (
|
||||||
<span className="max-w-[140px] truncate rounded border border-cyan-500/30 bg-cyan-500/10 px-1 py-0.5 text-[9px] text-cyan-200" title={reservationByAgent[agent.name]}>
|
<span className="max-w-[140px] truncate rounded border border-cyan-500/30 bg-cyan-500/10 px-1 py-0.5 text-[9px] text-cyan-200" title={reservationByAgent[agent.name]}>
|
||||||
{reservationByAgent[agent.name]}
|
{reservationByAgent[agent.name]}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
<span className="text-[9px] text-text-muted/40 font-mono">
|
<span className="text-[9px] text-text-muted/40 font-mono">
|
||||||
{agent.lastSeen ? formatRelativeTime(agent.lastSeen) : 'N/A'}
|
{agent.lastSeen ? formatRelativeTime(agent.lastSeen) : 'N/A'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -508,13 +521,13 @@ export function ActivityPanel({ issues, collapsed = false, projectRoot }: Activi
|
||||||
<div className="w-4 h-4 border-2 border-teal-500 border-t-transparent rounded-full animate-spin" />
|
<div className="w-4 h-4 border-2 border-teal-500 border-t-transparent rounded-full animate-spin" />
|
||||||
<span className="text-[10px] font-mono text-text-muted">SYNCING...</span>
|
<span className="text-[10px] font-mono text-text-muted">SYNCING...</span>
|
||||||
</div>
|
</div>
|
||||||
) : mergedActivities.length === 0 ? (
|
) : mergedActivities.length === 0 ? (
|
||||||
<div className="p-10 text-center opacity-30">
|
<div className="p-10 text-center opacity-30">
|
||||||
<p className="text-[10px] font-mono">VOID_STREAM_NULL</p>
|
<p className="text-[10px] font-mono">VOID_STREAM_NULL</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-3 space-y-3">
|
<div className="p-3 space-y-3">
|
||||||
{mergedActivities.map((activity) => {
|
{mergedActivities.map((activity) => {
|
||||||
const eventTone = getEventTone(activity.kind);
|
const eventTone = getEventTone(activity.kind);
|
||||||
return (
|
return (
|
||||||
<div key={activity.id} className="group relative">
|
<div key={activity.id} className="group relative">
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { ActivityPanel } from './activity-panel';
|
||||||
import { SwarmCommandFeed } from './swarm-command-feed';
|
import { SwarmCommandFeed } from './swarm-command-feed';
|
||||||
import { ThreadDrawer } from '../shared/thread-drawer';
|
import { ThreadDrawer } from '../shared/thread-drawer';
|
||||||
import { MissionInspector } from '../mission/mission-inspector';
|
import { MissionInspector } from '../mission/mission-inspector';
|
||||||
|
import { AgentStatusPanel } from '../agents/agent-status-panel';
|
||||||
import { useSwarmList } from '../../hooks/use-swarm-list';
|
import { useSwarmList } from '../../hooks/use-swarm-list';
|
||||||
import { useUrlState } from '../../hooks/use-url-state';
|
import { useUrlState } from '../../hooks/use-url-state';
|
||||||
|
|
||||||
|
|
@ -58,6 +59,10 @@ export function ContextualRightPanel({ epicId, taskId, swarmId, issues, projectR
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* Agent Status for active epic */}
|
||||||
|
<div className="shrink-0 border-b border-[var(--border-subtle)] p-3">
|
||||||
|
<AgentStatusPanel projectRoot={projectRoot} />
|
||||||
|
</div>
|
||||||
<div className="min-h-0 flex-1 overflow-hidden">
|
<div className="min-h-0 flex-1 overflow-hidden">
|
||||||
<SwarmCommandFeed
|
<SwarmCommandFeed
|
||||||
epicId={epicId}
|
epicId={epicId}
|
||||||
|
|
@ -116,13 +121,21 @@ function SwarmIdBranch({ swarmId, projectRoot }: { swarmId: string; projectRoot:
|
||||||
const assignedAgents = swarm?.agents ?? [];
|
const assignedAgents = swarm?.agents ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MissionInspector
|
<div className="flex h-full flex-col overflow-hidden">
|
||||||
missionId={swarmId}
|
{/* Agent Status for active swarm */}
|
||||||
missionTitle={missionTitle}
|
<div className="shrink-0 border-b border-[var(--border-subtle)] p-3">
|
||||||
projectRoot={projectRoot}
|
<AgentStatusPanel projectRoot={projectRoot} />
|
||||||
assignedAgents={assignedAgents}
|
</div>
|
||||||
onClose={() => setSwarmId(null)}
|
<div className="min-h-0 flex-1 overflow-hidden">
|
||||||
onAssign={async () => {}}
|
<MissionInspector
|
||||||
/>
|
missionId={swarmId}
|
||||||
|
missionTitle={missionTitle}
|
||||||
|
projectRoot={projectRoot}
|
||||||
|
assignedAgents={assignedAgents}
|
||||||
|
onClose={() => setSwarmId(null)}
|
||||||
|
onAssign={async () => {}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
83
src/components/agents/agent-action-row.tsx
Normal file
83
src/components/agents/agent-action-row.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
// src/components/agents/agent-action-row.tsx
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { AgentAssignButton } from './agent-assign-button';
|
||||||
|
import { AgentSpawnButton } from './agent-spawn-button';
|
||||||
|
import { useAgentStatus, useSpawnAgent } from './hooks';
|
||||||
|
import type { AgentArchetype } from '../../lib/types-swarm';
|
||||||
|
|
||||||
|
export interface AgentActionRowProps {
|
||||||
|
beadId: string;
|
||||||
|
beadStatus: string;
|
||||||
|
agents: AgentArchetype[];
|
||||||
|
projectRoot: string;
|
||||||
|
currentAgentTypeId?: string;
|
||||||
|
onAgentAssigned?: (agentTypeId: string) => void;
|
||||||
|
onAgentSpawned?: (workerId: string, displayName: string) => void;
|
||||||
|
size?: 'sm' | 'md';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentActionRow({
|
||||||
|
beadId,
|
||||||
|
beadStatus,
|
||||||
|
agents,
|
||||||
|
projectRoot,
|
||||||
|
currentAgentTypeId,
|
||||||
|
onAgentAssigned,
|
||||||
|
onAgentSpawned,
|
||||||
|
size = 'sm',
|
||||||
|
}: AgentActionRowProps) {
|
||||||
|
const { workerStatus, workerDisplayName, workerError } = useAgentStatus(beadId);
|
||||||
|
const { spawn, isSpawning } = useSpawnAgent(projectRoot);
|
||||||
|
|
||||||
|
const handleAssign = async (agentTypeId: string) => {
|
||||||
|
// Call API to assign agent type to bead
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/beads/assign-agent', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ beadId, agentTypeId }),
|
||||||
|
});
|
||||||
|
if (response.ok && onAgentAssigned) {
|
||||||
|
onAgentAssigned(agentTypeId);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to assign agent:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSpawn = async () => {
|
||||||
|
if (!currentAgentTypeId) return;
|
||||||
|
|
||||||
|
const result = await spawn(beadId, currentAgentTypeId);
|
||||||
|
if (result.success && onAgentSpawned) {
|
||||||
|
onAgentSpawned(result.workerId!, result.displayName!);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Don't show for closed beads
|
||||||
|
if (beadStatus === 'closed') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<AgentAssignButton
|
||||||
|
beadId={beadId}
|
||||||
|
agents={agents}
|
||||||
|
currentAgentTypeId={currentAgentTypeId}
|
||||||
|
onAssign={handleAssign}
|
||||||
|
size={size}
|
||||||
|
/>
|
||||||
|
<AgentSpawnButton
|
||||||
|
beadId={beadId}
|
||||||
|
agentTypeId={currentAgentTypeId}
|
||||||
|
workerStatus={isSpawning ? 'spawning' : workerStatus}
|
||||||
|
workerDisplayName={workerDisplayName}
|
||||||
|
workerError={workerError}
|
||||||
|
onSpawn={handleSpawn}
|
||||||
|
size={size}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
src/components/agents/agent-assign-button.tsx
Normal file
79
src/components/agents/agent-assign-button.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
// src/components/agents/agent-assign-button.tsx
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { UserPlus } from 'lucide-react';
|
||||||
|
import { AgentPickerPopup } from './agent-picker-popup';
|
||||||
|
import type { AgentArchetype } from '../../lib/types-swarm';
|
||||||
|
|
||||||
|
export interface AgentAssignButtonProps {
|
||||||
|
beadId: string;
|
||||||
|
agents: AgentArchetype[];
|
||||||
|
currentAgentTypeId?: string;
|
||||||
|
onAssign: (agentTypeId: string) => void;
|
||||||
|
size?: 'sm' | 'md';
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentAssignButton({
|
||||||
|
beadId,
|
||||||
|
agents,
|
||||||
|
currentAgentTypeId,
|
||||||
|
onAssign,
|
||||||
|
size = 'sm',
|
||||||
|
disabled = false,
|
||||||
|
}: AgentAssignButtonProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
const sizeClasses = size === 'sm'
|
||||||
|
? 'h-6 w-6'
|
||||||
|
: 'h-7 w-7';
|
||||||
|
|
||||||
|
const iconSize = size === 'sm'
|
||||||
|
? 'w-3 h-3'
|
||||||
|
: 'w-3.5 h-3.5';
|
||||||
|
|
||||||
|
const isAssigned = !!currentAgentTypeId;
|
||||||
|
const assignedAgent = agents.find(a => a.id === currentAgentTypeId);
|
||||||
|
const bgColor = isAssigned && assignedAgent
|
||||||
|
? `${assignedAgent.color}30`
|
||||||
|
: 'var(--surface-tertiary)';
|
||||||
|
const iconColor = isAssigned && assignedAgent
|
||||||
|
? assignedAgent.color
|
||||||
|
: 'var(--text-tertiary)';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => !disabled && setIsOpen(true)}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`inline-flex ${sizeClasses} items-center justify-center rounded-md border transition-colors ${
|
||||||
|
disabled
|
||||||
|
? 'opacity-50 cursor-not-allowed'
|
||||||
|
: 'hover:opacity-80'
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: bgColor,
|
||||||
|
borderColor: isAssigned && assignedAgent
|
||||||
|
? `${assignedAgent.color}50`
|
||||||
|
: 'var(--border-subtle)',
|
||||||
|
}}
|
||||||
|
title={isAssigned ? `Assigned: ${assignedAgent?.name}` : 'Assign agent'}
|
||||||
|
>
|
||||||
|
<UserPlus className={iconSize} style={{ color: iconColor }} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<AgentPickerPopup
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={() => setIsOpen(false)}
|
||||||
|
agents={agents}
|
||||||
|
selectedAgentId={currentAgentTypeId}
|
||||||
|
onSelect={(agentId) => {
|
||||||
|
onAssign(agentId);
|
||||||
|
setIsOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
120
src/components/agents/agent-picker-popup.tsx
Normal file
120
src/components/agents/agent-picker-popup.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
// src/components/agents/agent-picker-popup.tsx
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { Rocket, Brain, Wrench, Search, CheckCircle, FlaskConical, Upload } from 'lucide-react';
|
||||||
|
import type { AgentArchetype } from '../../lib/types-swarm';
|
||||||
|
|
||||||
|
export interface AgentPickerPopupProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
agents: AgentArchetype[];
|
||||||
|
selectedAgentId?: string;
|
||||||
|
onSelect: (agentId: string) => void;
|
||||||
|
onSpawn?: (agentId: string) => void;
|
||||||
|
position?: { x: number; y: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
const AGENT_ICONS: Record<string, React.ReactNode> = {
|
||||||
|
architect: <Brain className="w-4 h-4" />,
|
||||||
|
engineer: <Wrench className="w-4 h-4" />,
|
||||||
|
investigator: <Search className="w-4 h-4" />,
|
||||||
|
reviewer: <CheckCircle className="w-4 h-4" />,
|
||||||
|
tester: <FlaskConical className="w-4 h-4" />,
|
||||||
|
shipper: <Upload className="w-4 h-4" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AgentPickerPopup({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
agents,
|
||||||
|
selectedAgentId,
|
||||||
|
onSelect,
|
||||||
|
onSpawn,
|
||||||
|
position,
|
||||||
|
}: AgentPickerPopupProps) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
}
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const style = position
|
||||||
|
? { position: 'absolute' as const, left: position.x, top: position.y + 8 }
|
||||||
|
: {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
style={style}
|
||||||
|
className="z-50 min-w-[180px] rounded-lg border border-[var(--border-subtle)] bg-[var(--surface-elevated)] p-1 shadow-lg"
|
||||||
|
>
|
||||||
|
{/* Orchestrator option */}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onSelect('orchestrator');
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
className={`flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition-colors ${
|
||||||
|
selectedAgentId === 'orchestrator'
|
||||||
|
? 'bg-[var(--accent-info)]/20 text-[var(--accent-info)]'
|
||||||
|
: 'text-[var(--text-primary)] hover:bg-[var(--surface-hover)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Rocket className="w-4 h-4" />
|
||||||
|
<span className="font-medium">Orchestrator</span>
|
||||||
|
<span className="ml-auto text-xs text-[var(--text-tertiary)]">auto</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="my-1 border-t border-[var(--border-subtle)]" />
|
||||||
|
|
||||||
|
{/* Agent types */}
|
||||||
|
{agents.map((agent) => (
|
||||||
|
<button
|
||||||
|
key={agent.id}
|
||||||
|
onClick={() => {
|
||||||
|
onSelect(agent.id);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
className={`flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition-colors ${
|
||||||
|
selectedAgentId === agent.id
|
||||||
|
? 'bg-[var(--accent-info)]/20 text-[var(--accent-info)]'
|
||||||
|
: 'text-[var(--text-primary)] hover:bg-[var(--surface-hover)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span style={{ color: agent.color }}>
|
||||||
|
{AGENT_ICONS[agent.id] || <Wrench className="w-4 h-4" />}
|
||||||
|
</span>
|
||||||
|
<span>{agent.name}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Spawn button */}
|
||||||
|
{onSpawn && selectedAgentId && (
|
||||||
|
<>
|
||||||
|
<div className="my-1 border-t border-[var(--border-subtle)]" />
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onSpawn(selectedAgentId);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
className="flex w-full items-center justify-center gap-2 rounded-md bg-emerald-500/20 px-3 py-2 text-sm font-medium text-emerald-400 transition-colors hover:bg-emerald-500/30"
|
||||||
|
>
|
||||||
|
<Rocket className="w-4 h-4" />
|
||||||
|
Spawn {agents.find(a => a.id === selectedAgentId)?.name || 'Agent'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
132
src/components/agents/agent-spawn-button.tsx
Normal file
132
src/components/agents/agent-spawn-button.tsx
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
// src/components/agents/agent-spawn-button.tsx
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Rocket, CheckCircle, AlertCircle, Loader2 } from 'lucide-react';
|
||||||
|
import type { WorkerStatus } from './hooks/use-agent-status';
|
||||||
|
|
||||||
|
export interface AgentSpawnButtonProps {
|
||||||
|
beadId: string;
|
||||||
|
agentTypeId?: string;
|
||||||
|
workerStatus: WorkerStatus;
|
||||||
|
workerDisplayName?: string;
|
||||||
|
workerError?: string;
|
||||||
|
onSpawn: () => void;
|
||||||
|
size?: 'sm' | 'md';
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_CONFIG: Record<WorkerStatus, {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
color: string;
|
||||||
|
bgColor: string;
|
||||||
|
borderColor: string;
|
||||||
|
title: string;
|
||||||
|
pulsing?: boolean;
|
||||||
|
}> = {
|
||||||
|
idle: {
|
||||||
|
icon: <Rocket className="w-3 h-3" />,
|
||||||
|
color: '#6b7280',
|
||||||
|
bgColor: 'rgba(107, 114, 128, 0.1)',
|
||||||
|
borderColor: 'rgba(107, 114, 128, 0.3)',
|
||||||
|
title: 'No agent assigned',
|
||||||
|
},
|
||||||
|
spawning: {
|
||||||
|
icon: <Loader2 className="w-3 h-3 animate-spin" />,
|
||||||
|
color: '#3b82f6',
|
||||||
|
bgColor: 'rgba(59, 130, 246, 0.1)',
|
||||||
|
borderColor: 'rgba(59, 130, 246, 0.3)',
|
||||||
|
title: 'Spawning...',
|
||||||
|
pulsing: true,
|
||||||
|
},
|
||||||
|
working: {
|
||||||
|
icon: <Rocket className="w-3 h-3" />,
|
||||||
|
color: '#22c55e',
|
||||||
|
bgColor: 'rgba(34, 197, 94, 0.1)',
|
||||||
|
borderColor: 'rgba(34, 197, 94, 0.3)',
|
||||||
|
title: 'Working',
|
||||||
|
pulsing: true,
|
||||||
|
},
|
||||||
|
blocked: {
|
||||||
|
icon: <AlertCircle className="w-3 h-3" />,
|
||||||
|
color: '#ef4444',
|
||||||
|
bgColor: 'rgba(239, 68, 68, 0.1)',
|
||||||
|
borderColor: 'rgba(239, 68, 68, 0.3)',
|
||||||
|
title: 'Blocked',
|
||||||
|
},
|
||||||
|
completed: {
|
||||||
|
icon: <CheckCircle className="w-3 h-3" />,
|
||||||
|
color: '#22c55e',
|
||||||
|
bgColor: 'rgba(34, 197, 94, 0.1)',
|
||||||
|
borderColor: 'rgba(34, 197, 94, 0.3)',
|
||||||
|
title: 'Completed',
|
||||||
|
},
|
||||||
|
failed: {
|
||||||
|
icon: <AlertCircle className="w-3 h-3" />,
|
||||||
|
color: '#ef4444',
|
||||||
|
bgColor: 'rgba(239, 68, 68, 0.1)',
|
||||||
|
borderColor: 'rgba(239, 68, 68, 0.3)',
|
||||||
|
title: 'Failed',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AgentSpawnButton({
|
||||||
|
beadId,
|
||||||
|
agentTypeId,
|
||||||
|
workerStatus,
|
||||||
|
workerDisplayName,
|
||||||
|
workerError,
|
||||||
|
onSpawn,
|
||||||
|
size = 'sm',
|
||||||
|
disabled = false,
|
||||||
|
}: AgentSpawnButtonProps) {
|
||||||
|
const config = STATUS_CONFIG[workerStatus];
|
||||||
|
const sizeClasses = size === 'sm' ? 'h-6 w-6' : 'h-7 w-7';
|
||||||
|
|
||||||
|
// No agent assigned - don't show button
|
||||||
|
if (!agentTypeId && workerStatus === 'idle') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canSpawn = workerStatus === 'idle' && agentTypeId;
|
||||||
|
const showTooltip = workerStatus === 'working' || workerStatus === 'blocked' || workerStatus === 'completed';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative group">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => canSpawn && !disabled && onSpawn()}
|
||||||
|
disabled={disabled || !canSpawn}
|
||||||
|
className={`inline-flex ${sizeClasses} items-center justify-center rounded-md border transition-colors ${
|
||||||
|
disabled || !canSpawn ? 'cursor-default' : 'hover:opacity-80'
|
||||||
|
} ${config.pulsing ? 'animate-pulse' : ''}`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: config.bgColor,
|
||||||
|
borderColor: config.borderColor,
|
||||||
|
color: config.color,
|
||||||
|
}}
|
||||||
|
title={workerDisplayName ? `${config.title}: ${workerDisplayName}` : config.title}
|
||||||
|
>
|
||||||
|
{config.icon}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Tooltip for active workers */}
|
||||||
|
{showTooltip && (
|
||||||
|
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 hidden group-hover:block z-50">
|
||||||
|
<div className="rounded-md bg-[var(--surface-elevated)] border border-[var(--border-subtle)] px-3 py-2 shadow-lg min-w-[160px]">
|
||||||
|
<p className="text-xs font-medium text-[var(--text-primary)]">
|
||||||
|
{workerDisplayName || 'Agent'}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-[var(--text-tertiary)] capitalize">
|
||||||
|
{workerStatus}
|
||||||
|
</p>
|
||||||
|
{workerError && (
|
||||||
|
<p className="text-[10px] text-red-400 mt-1 truncate">
|
||||||
|
{workerError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
158
src/components/agents/agent-status-panel.tsx
Normal file
158
src/components/agents/agent-status-panel.tsx
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import type { AgentInstance, AgentStatus } from '../../lib/agent-instance';
|
||||||
|
import { Activity, CheckCircle, XCircle, Loader2, Users } from 'lucide-react';
|
||||||
|
|
||||||
|
interface AgentStatusPanelProps {
|
||||||
|
projectRoot: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentStatusPanel({ projectRoot }: AgentStatusPanelProps) {
|
||||||
|
const [status, setStatus] = useState<AgentStatus | null>(null);
|
||||||
|
const [history, setHistory] = useState<AgentInstance[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Poll for agent status updates
|
||||||
|
const poll = async () => {
|
||||||
|
try {
|
||||||
|
const [statusRes, historyRes] = await Promise.all([
|
||||||
|
fetch(`/api/runtime/agents?projectRoot=${encodeURIComponent(projectRoot)}`),
|
||||||
|
fetch(`/api/runtime/agents/history?projectRoot=${encodeURIComponent(projectRoot)}`),
|
||||||
|
]);
|
||||||
|
const statusData = await statusRes.json();
|
||||||
|
const historyData = await historyRes.json();
|
||||||
|
|
||||||
|
if (statusData.ok) setStatus(statusData.status);
|
||||||
|
if (historyData.ok) setHistory(historyData.instances);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch agent status:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
poll();
|
||||||
|
const interval = setInterval(poll, 2000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [projectRoot]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin text-[var(--text-tertiary)]" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Active Agents Section */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider mb-2 flex items-center gap-2">
|
||||||
|
<Activity className="w-3.5 h-3.5" />
|
||||||
|
Active Agents ({status?.totalActive || 0})
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{!status || status.instances.length === 0 ? (
|
||||||
|
<p className="text-sm text-[var(--text-tertiary)] italic py-2">
|
||||||
|
No active agents. Spawn an agent to work on a task.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{status.instances.map(instance => (
|
||||||
|
<AgentInstanceCard key={instance.id} instance={instance} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Completions Section */}
|
||||||
|
{history.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider mb-2 flex items-center gap-2">
|
||||||
|
<Users className="w-3.5 h-3.5" />
|
||||||
|
Recent Completions
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-1 max-h-40 overflow-auto">
|
||||||
|
{history.slice(0, 10).map(instance => (
|
||||||
|
<AgentHistoryItem key={instance.id} instance={instance} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Summary by Type */}
|
||||||
|
{status && Object.keys(status.byType).length > 0 && (
|
||||||
|
<div className="pt-3 border-t border-[var(--border-subtle)]">
|
||||||
|
<div className="text-[10px] font-mono text-[var(--text-tertiary)] uppercase tracking-wider mb-2">
|
||||||
|
By Type
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{Object.entries(status.byType).map(([type, count]) => (
|
||||||
|
<div
|
||||||
|
key={type}
|
||||||
|
className="px-2 py-1 rounded bg-[var(--surface-quaternary)] text-xs"
|
||||||
|
>
|
||||||
|
<span className="font-medium capitalize">{type}</span>
|
||||||
|
<span className="text-[var(--text-tertiary)] ml-1">{count}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AgentInstanceCard({ instance }: { instance: AgentInstance }) {
|
||||||
|
const statusConfig = {
|
||||||
|
spawning: { color: 'bg-yellow-500', icon: Loader2, animate: true },
|
||||||
|
working: { color: 'bg-cyan-500', icon: Activity, animate: false },
|
||||||
|
idle: { color: 'bg-gray-500', icon: Activity, animate: false },
|
||||||
|
completed: { color: 'bg-green-500', icon: CheckCircle, animate: false },
|
||||||
|
failed: { color: 'bg-red-500', icon: XCircle, animate: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = statusConfig[instance.status];
|
||||||
|
const Icon = config.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 p-2 rounded bg-[var(--surface-quaternary)] border border-[var(--border-subtle)]">
|
||||||
|
<div className={`w-2 h-2 rounded-full ${config.color}`} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium">{instance.displayName}</span>
|
||||||
|
<span className="text-[10px] text-[var(--text-tertiary)] uppercase">
|
||||||
|
{instance.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{instance.currentBeadId && (
|
||||||
|
<div className="text-xs text-[var(--text-tertiary)] truncate">
|
||||||
|
→ {instance.currentBeadId}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Icon className={`w-4 h-4 text-[var(--text-tertiary)] ${config.animate ? 'animate-spin' : ''}`} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AgentHistoryItem({ instance }: { instance: AgentInstance }) {
|
||||||
|
const isSuccess = instance.status === 'completed';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 text-xs p-1.5 rounded bg-[var(--surface-tertiary)]">
|
||||||
|
{isSuccess ? (
|
||||||
|
<CheckCircle className="w-3 h-3 text-green-400" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="w-3 h-3 text-red-400" />
|
||||||
|
)}
|
||||||
|
<span className="font-medium">{instance.displayName}</span>
|
||||||
|
{instance.currentBeadId && (
|
||||||
|
<span className="text-[var(--text-tertiary)]">→ {instance.currentBeadId}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
3
src/components/agents/hooks/index.ts
Normal file
3
src/components/agents/hooks/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
// src/components/agents/hooks/index.ts
|
||||||
|
export { useAgentStatus, type AgentStatus, type WorkerStatus } from './use-agent-status';
|
||||||
|
export { useSpawnAgent, type SpawnResult } from './use-spawn-agent';
|
||||||
68
src/components/agents/hooks/use-agent-status.ts
Normal file
68
src/components/agents/hooks/use-agent-status.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
// src/components/agents/hooks/use-agent-status.ts
|
||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
export type WorkerStatus = 'idle' | 'spawning' | 'working' | 'blocked' | 'completed' | 'failed';
|
||||||
|
|
||||||
|
export interface AgentStatus {
|
||||||
|
agentTypeId?: string;
|
||||||
|
workerStatus: WorkerStatus;
|
||||||
|
workerDisplayName?: string;
|
||||||
|
workerError?: string;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = 5000;
|
||||||
|
|
||||||
|
export function useAgentStatus(beadId: string): AgentStatus {
|
||||||
|
const [status, setStatus] = useState<AgentStatus>({
|
||||||
|
workerStatus: 'idle',
|
||||||
|
isLoading: true,
|
||||||
|
});
|
||||||
|
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
const fetchStatus = async () => {
|
||||||
|
if (!beadId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/runtime/worker-status?beadId=${encodeURIComponent(beadId)}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
// If API returns 404 or error, no worker exists yet
|
||||||
|
setStatus({ workerStatus: 'idle', isLoading: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
setStatus({
|
||||||
|
workerStatus: data.status || 'idle',
|
||||||
|
workerDisplayName: data.displayName,
|
||||||
|
workerError: data.error,
|
||||||
|
agentTypeId: data.agentTypeId,
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch worker status:', error);
|
||||||
|
setStatus({ workerStatus: 'idle', isLoading: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!beadId) {
|
||||||
|
setStatus({ workerStatus: 'idle', isLoading: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial fetch
|
||||||
|
fetchStatus();
|
||||||
|
|
||||||
|
// Set up polling
|
||||||
|
intervalRef.current = setInterval(fetchStatus, POLL_INTERVAL_MS);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (intervalRef.current) {
|
||||||
|
clearInterval(intervalRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [beadId]);
|
||||||
|
|
||||||
|
return status;
|
||||||
|
}
|
||||||
41
src/components/agents/hooks/use-spawn-agent.ts
Normal file
41
src/components/agents/hooks/use-spawn-agent.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
// src/components/agents/hooks/use-spawn-agent.ts
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
export interface SpawnResult {
|
||||||
|
success: boolean;
|
||||||
|
workerId?: string;
|
||||||
|
displayName?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSpawnAgent(projectRoot: string) {
|
||||||
|
const [isSpawning, setIsSpawning] = useState(false);
|
||||||
|
|
||||||
|
const spawn = async (beadId: string, agentTypeId: string): Promise<SpawnResult> => {
|
||||||
|
setIsSpawning(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/runtime/spawn', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ projectRoot, beadId, agentTypeId }),
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!data.ok) {
|
||||||
|
return { success: false, error: data.error };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
workerId: data.workerId,
|
||||||
|
displayName: data.displayName,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: String(error) };
|
||||||
|
} finally {
|
||||||
|
setIsSpawning(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { spawn, isSpawning };
|
||||||
|
}
|
||||||
6
src/components/agents/index.ts
Normal file
6
src/components/agents/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
// src/components/agents/index.ts
|
||||||
|
export { AgentActionRow, type AgentActionRowProps } from './agent-action-row';
|
||||||
|
export { AgentAssignButton, type AgentAssignButtonProps } from './agent-assign-button';
|
||||||
|
export { AgentSpawnButton, type AgentSpawnButtonProps } from './agent-spawn-button';
|
||||||
|
export { AgentPickerPopup, type AgentPickerPopupProps } from './agent-picker-popup';
|
||||||
|
export * from './hooks';
|
||||||
|
|
@ -2,9 +2,9 @@
|
||||||
|
|
||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import { Zap, Users, FileCode2, Loader2, UserPlus, Clock, AlertCircle, ChevronDown, ChevronRight, Blocks, Layers } from 'lucide-react';
|
import { Zap, Users, FileCode2, Loader2, UserPlus, Clock, AlertCircle, ChevronDown, ChevronRight, Blocks, Layers } from 'lucide-react';
|
||||||
import { ArchetypeInspector } from '../swarm/archetype-inspector';
|
import { AgentInspector } from '../swarm/agent-inspector';
|
||||||
import { TemplateInspector } from '../swarm/template-inspector';
|
import { TemplateInspector } from '../swarm/template-inspector';
|
||||||
import { ArchetypePicker } from '../swarm/archetype-picker';
|
import { AgentPicker } from '../swarm/agent-picker';
|
||||||
import { TemplatePicker } from '../swarm/template-picker';
|
import { TemplatePicker } from '../swarm/template-picker';
|
||||||
import { useArchetypes } from '../../hooks/use-archetypes';
|
import { useArchetypes } from '../../hooks/use-archetypes';
|
||||||
import { useTemplates } from '../../hooks/use-templates';
|
import { useTemplates } from '../../hooks/use-templates';
|
||||||
|
|
@ -219,8 +219,8 @@ export function AssignmentPanel({ selectedIssue, projectRoot, issues, epicId, on
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const getArchetypeCountInTeam = (template: SwarmTemplate, archetypeId: string): number => {
|
const getArchetypeCountInTeam = (template: SwarmTemplate, agentTypeId: string): number => {
|
||||||
return template.team.filter(member => member.archetypeId === archetypeId).length;
|
return template.team.filter(member => member.agentTypeId === agentTypeId).length;
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderTaskItem = (issue: BeadIssue, showAssignButton: boolean = false, archetypeBadges: AgentArchetype[] = []) => (
|
const renderTaskItem = (issue: BeadIssue, showAssignButton: boolean = false, archetypeBadges: AgentArchetype[] = []) => (
|
||||||
|
|
@ -290,7 +290,7 @@ export function AssignmentPanel({ selectedIssue, projectRoot, issues, epicId, on
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ArchetypePicker
|
<AgentPicker
|
||||||
archetypes={archetypes}
|
archetypes={archetypes}
|
||||||
isOpen={showArchetypeList}
|
isOpen={showArchetypeList}
|
||||||
onClose={() => setShowArchetypeList(false)}
|
onClose={() => setShowArchetypeList(false)}
|
||||||
|
|
@ -361,12 +361,12 @@ export function AssignmentPanel({ selectedIssue, projectRoot, issues, epicId, on
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[10px] font-mono text-[var(--text-tertiary)] uppercase tracking-wider mb-2">Team Roster</div>
|
<div className="text-[10px] font-mono text-[var(--text-tertiary)] uppercase tracking-wider mb-2">Team Roster</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{Array.from(new Set(epicTemplate.team.map(m => m.archetypeId))).map(archetypeId => {
|
{Array.from(new Set(epicTemplate.team.map(m => m.agentTypeId))).map(agentTypeId => {
|
||||||
const archetype = archetypes.find((a: AgentArchetype) => a.id === archetypeId);
|
const archetype = archetypes.find((a: AgentArchetype) => a.id === agentTypeId);
|
||||||
const count = getArchetypeCountInTeam(epicTemplate, archetypeId);
|
const count = getArchetypeCountInTeam(epicTemplate, agentTypeId);
|
||||||
if (!archetype) return null;
|
if (!archetype) return null;
|
||||||
return (
|
return (
|
||||||
<div key={archetypeId} className="flex items-center gap-2 text-xs">
|
<div key={agentTypeId} className="flex items-center gap-2 text-xs">
|
||||||
<div
|
<div
|
||||||
className="h-4 w-4 rounded flex items-center justify-center text-[10px] font-bold"
|
className="h-4 w-4 rounded flex items-center justify-center text-[10px] font-bold"
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -557,7 +557,7 @@ export function AssignmentPanel({ selectedIssue, projectRoot, issues, epicId, on
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{inspectingArchetypeId !== null && (
|
{inspectingArchetypeId !== null && (
|
||||||
<ArchetypeInspector
|
<AgentInspector
|
||||||
archetype={archetypes.find((a: AgentArchetype) => a.id === inspectingArchetypeId)}
|
archetype={archetypes.find((a: AgentArchetype) => a.id === inspectingArchetypeId)}
|
||||||
onClose={() => setInspectingArchetypeId(null)}
|
onClose={() => setInspectingArchetypeId(null)}
|
||||||
onSave={saveArchetype}
|
onSave={saveArchetype}
|
||||||
|
|
|
||||||
113
src/components/onboarding/onboarding-wizard.tsx
Normal file
113
src/components/onboarding/onboarding-wizard.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { CheckCircle, ArrowRight, Play, MessageSquare, GitBranch, Zap } from 'lucide-react';
|
||||||
|
|
||||||
|
interface OnboardingWizardProps {
|
||||||
|
hasProjects: boolean;
|
||||||
|
piInstalled: boolean;
|
||||||
|
hasAuth: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OnboardingWizard({ hasProjects }: OnboardingWizardProps) {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleSkipToApp = () => {
|
||||||
|
window.location.href = '/?onboarded=true';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[var(--surface-primary)] flex items-center justify-center p-4">
|
||||||
|
<div className="max-w-2xl w-full">
|
||||||
|
<div className="bg-[var(--surface-elevated)] rounded-xl border border-[var(--border-subtle)] p-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-[var(--text-primary)] mb-2">
|
||||||
|
Welcome to BeadBoard
|
||||||
|
</h1>
|
||||||
|
<p className="text-[var(--text-secondary)]">
|
||||||
|
Multi-agent swarm coordination for dependency-constrained work
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Features */}
|
||||||
|
<div className="grid gap-4 mb-8">
|
||||||
|
<div className="flex items-start gap-3 p-4 bg-[var(--surface-quaternary)] rounded-lg">
|
||||||
|
<div className="p-2 rounded-lg bg-cyan-500/10">
|
||||||
|
<MessageSquare size={20} className="text-cyan-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-[var(--text-primary)]">Orchestrator Chat</h3>
|
||||||
|
<p className="text-sm text-[var(--text-secondary)]">
|
||||||
|
Left panel has a built-in AI orchestrator. Just send a message to get started.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-3 p-4 bg-[var(--surface-quaternary)] rounded-lg">
|
||||||
|
<div className="p-2 rounded-lg bg-cyan-500/10">
|
||||||
|
<Zap size={20} className="text-cyan-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-[var(--text-primary)]">Spawn Workers</h3>
|
||||||
|
<p className="text-sm text-[var(--text-secondary)]">
|
||||||
|
Tell the orchestrator to spawn workers for parallel task execution.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-3 p-4 bg-[var(--surface-quaternary)] rounded-lg">
|
||||||
|
<div className="p-2 rounded-lg bg-cyan-500/10">
|
||||||
|
<GitBranch size={20} className="text-cyan-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-[var(--text-primary)]">Task Graphs</h3>
|
||||||
|
<p className="text-sm text-[var(--text-secondary)]">
|
||||||
|
Visualize dependencies and coordinate work across your project.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Start */}
|
||||||
|
<div className="bg-emerald-500/10 border border-emerald-500/30 rounded-lg p-4 mb-6">
|
||||||
|
<h3 className="font-semibold text-emerald-400 mb-2 flex items-center gap-2">
|
||||||
|
<CheckCircle size={16} /> Everything is ready
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-[var(--text-secondary)]">
|
||||||
|
The agent runtime will install automatically when you send your first message.
|
||||||
|
No manual setup required!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Project hint if needed */}
|
||||||
|
{!hasProjects && (
|
||||||
|
<div className="bg-[var(--surface-quaternary)] rounded-lg p-4 mb-6">
|
||||||
|
<p className="text-sm text-[var(--text-secondary)] mb-2">
|
||||||
|
<strong>Tip:</strong> Add a project to coordinate work:
|
||||||
|
</p>
|
||||||
|
<code className="block bg-black/30 rounded p-2 text-cyan-300 font-mono text-sm">
|
||||||
|
bb project add /path/to/your/project
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center justify-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={handleSkipToApp}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="inline-flex items-center gap-2 px-6 py-3 bg-cyan-500 hover:bg-cyan-400 text-black font-semibold rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Play size={16} /> Start Using BeadBoard
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-center text-[var(--text-tertiary)] text-xs mt-4">
|
||||||
|
You can also run <code className="bg-black/30 px-1 rounded">bb --help</code> in terminal for CLI commands
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
404
src/components/shared/left-panel-new.tsx
Normal file
404
src/components/shared/left-panel-new.tsx
Normal file
|
|
@ -0,0 +1,404 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { ChevronDown, ChevronRight, Folder, FolderOpen, Pencil, Rocket, Star } from 'lucide-react';
|
||||||
|
|
||||||
|
import type { RuntimeInstance } from '../../lib/embedded-runtime';
|
||||||
|
import type { BeadIssue } from '../../lib/types';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
import { useUrlState, type LeftPanelFilters, type LeftPanelStatusFilter, type LeftPanelPriorityFilter, type LeftPanelPresetFilter, type LeftSidebarMode, type ViewType } from '../../hooks/use-url-state';
|
||||||
|
export type { LeftPanelFilters } from '../../hooks/use-url-state';
|
||||||
|
import { OrchestratorPanel } from './orchestrator-panel';
|
||||||
|
|
||||||
|
interface EpicEntry {
|
||||||
|
epic: BeadIssue;
|
||||||
|
children: BeadIssue[];
|
||||||
|
blockedCount: number;
|
||||||
|
activeCount: number;
|
||||||
|
readyCount: number;
|
||||||
|
deferredCount: number;
|
||||||
|
doneCount: number;
|
||||||
|
agentBlockedCount: number;
|
||||||
|
latestTimestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LeftPanelProps {
|
||||||
|
issues: BeadIssue[];
|
||||||
|
selectedEpicId?: string | null;
|
||||||
|
onEpicSelect?: (epicId: string | null) => void;
|
||||||
|
onEpicEdit?: (epicId: string) => void;
|
||||||
|
filters: LeftPanelFilters;
|
||||||
|
onFiltersChange: (filters: LeftPanelFilters) => void;
|
||||||
|
onAssignMode?: (epicId: string) => void;
|
||||||
|
sidebarMode?: LeftSidebarMode;
|
||||||
|
onSidebarModeChange?: (mode: LeftSidebarMode) => void;
|
||||||
|
orchestrator?: RuntimeInstance;
|
||||||
|
orchestratorThread?: import('../../lib/orchestrator-chat').OrchestratorChatMessage[];
|
||||||
|
projectRoot?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapStatus(task: BeadIssue): LeftPanelStatusFilter {
|
||||||
|
if (task.status === 'open') return 'ready';
|
||||||
|
if (task.status === 'in_progress') return 'in_progress';
|
||||||
|
if (task.status === 'blocked') return 'blocked';
|
||||||
|
if (task.status === 'deferred') return 'deferred';
|
||||||
|
if (task.status === 'closed' || task.status === 'tombstone') return 'done';
|
||||||
|
return 'all';
|
||||||
|
}
|
||||||
|
|
||||||
|
const views = [
|
||||||
|
{ id: 'social', label: 'Social' },
|
||||||
|
{ id: 'graph', label: 'Graph' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function isTaskMatch(task: BeadIssue, filters: LeftPanelFilters): boolean {
|
||||||
|
if (filters.query.trim()) {
|
||||||
|
const query = filters.query.toLowerCase();
|
||||||
|
if (!task.title.toLowerCase().includes(query) && !task.id.toLowerCase().includes(query)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.status !== 'all') {
|
||||||
|
if (mapStatus(task) !== filters.status) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.priority !== 'all') {
|
||||||
|
const priorityMap: Record<number, string> = { 0: 'P0', 1: 'P1', 2: 'P2', 3: 'P3', 4: 'P4' };
|
||||||
|
if (priorityMap[task.priority] !== filters.priority) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.preset === 'active') {
|
||||||
|
if (task.status !== 'open' && task.status !== 'in_progress') return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.preset === 'blocked_agents') {
|
||||||
|
if (!task.labels.includes('gt:agent') && !task.labels.includes('agent:blocked')) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.hideClosed) {
|
||||||
|
if (task.status === 'closed' || task.status === 'tombstone') return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowTone(entry: EpicEntry): string {
|
||||||
|
const { epic } = entry;
|
||||||
|
if (epic.status === 'closed') return 'bg-[var(--surface-tertiary)]';
|
||||||
|
return 'bg-[var(--surface-quaternary)]';
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldHideEpicEntry(params: {
|
||||||
|
epicStatus: BeadIssue['status'];
|
||||||
|
matchedChildrenCount: number;
|
||||||
|
totalChildrenCount: number;
|
||||||
|
isSelected: boolean;
|
||||||
|
filters: LeftPanelFilters;
|
||||||
|
}): boolean {
|
||||||
|
const { epicStatus, matchedChildrenCount, totalChildrenCount, isSelected, filters } = params;
|
||||||
|
const hasTaskFilters =
|
||||||
|
filters.query.trim().length > 0 ||
|
||||||
|
filters.status !== 'all' ||
|
||||||
|
filters.priority !== 'all' ||
|
||||||
|
filters.preset !== 'all';
|
||||||
|
const epicClosed = epicStatus === 'closed' || epicStatus === 'tombstone';
|
||||||
|
const noVisibleChildren = matchedChildrenCount === 0 && totalChildrenCount > 0;
|
||||||
|
const hiddenByTaskFilters = hasTaskFilters && noVisibleChildren;
|
||||||
|
const hiddenByHideClosed = filters.hideClosed && noVisibleChildren;
|
||||||
|
const hiddenByEpicClosed = filters.hideClosed && epicClosed;
|
||||||
|
|
||||||
|
if (hiddenByEpicClosed) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !isSelected && (hiddenByTaskFilters || hiddenByHideClosed);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LeftPanel({
|
||||||
|
issues,
|
||||||
|
selectedEpicId,
|
||||||
|
filters,
|
||||||
|
onFiltersChange,
|
||||||
|
sidebarMode = 'epics',
|
||||||
|
onSidebarModeChange,
|
||||||
|
orchestrator,
|
||||||
|
orchestratorThread,
|
||||||
|
projectRoot,
|
||||||
|
onEpicSelect,
|
||||||
|
}: LeftPanelProps) {
|
||||||
|
const urlState = useUrlState();
|
||||||
|
const { view, setView } = urlState;
|
||||||
|
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
const entries = useMemo(() => {
|
||||||
|
const epicMap = new Map<string, EpicEntry>();
|
||||||
|
const childrenMap = new Map<string, BeadIssue[]>();
|
||||||
|
|
||||||
|
for (const issue of issues) {
|
||||||
|
if (issue.labels.includes('gt:agent')) continue;
|
||||||
|
|
||||||
|
const parentEdge = issue.dependencies.find((dep) => dep.type === 'parent');
|
||||||
|
if (parentEdge) {
|
||||||
|
const children = childrenMap.get(parentEdge.target) ?? [];
|
||||||
|
children.push(issue);
|
||||||
|
childrenMap.set(parentEdge.target, children);
|
||||||
|
} else if (issue.issue_type === 'epic') {
|
||||||
|
epicMap.set(issue.id, {
|
||||||
|
epic: issue,
|
||||||
|
children: [],
|
||||||
|
blockedCount: 0,
|
||||||
|
activeCount: 0,
|
||||||
|
readyCount: 0,
|
||||||
|
deferredCount: 0,
|
||||||
|
doneCount: 0,
|
||||||
|
agentBlockedCount: 0,
|
||||||
|
latestTimestamp: issue.updated_at ?? issue.created_at ?? '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of epicMap.values()) {
|
||||||
|
entry.children = childrenMap.get(entry.epic.id) ?? [];
|
||||||
|
entry.blockedCount = entry.children.filter((t) => t.status === 'blocked').length;
|
||||||
|
entry.activeCount = entry.children.filter((t) => t.status === 'in_progress').length;
|
||||||
|
entry.readyCount = entry.children.filter((t) => t.status === 'open').length;
|
||||||
|
entry.deferredCount = entry.children.filter((t) => t.status === 'deferred').length;
|
||||||
|
entry.doneCount = entry.children.filter((t) => t.status === 'closed' || t.status === 'tombstone').length;
|
||||||
|
entry.agentBlockedCount = entry.children.filter((t) => t.labels.includes('agent:blocked')).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(epicMap.values())
|
||||||
|
.filter((entry) => !shouldHideEpicEntry({
|
||||||
|
epicStatus: entry.epic.status,
|
||||||
|
matchedChildrenCount: entry.children.length,
|
||||||
|
totalChildrenCount: entry.children.length,
|
||||||
|
isSelected: selectedEpicId === entry.epic.id,
|
||||||
|
filters,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.latestTimestamp.localeCompare(a.latestTimestamp));
|
||||||
|
}, [issues, selectedEpicId, filters]);
|
||||||
|
|
||||||
|
const handleEpicClick = (epicId: string) => {
|
||||||
|
setExpanded((prev) => ({ ...prev, [epicId]: !prev[epicId] }));
|
||||||
|
onEpicSelect?.(epicId);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="flex h-full min-h-0 overflow-hidden flex-col bg-[var(--surface-primary)] border-r border-[var(--border-strong)]" data-testid="left-panel">
|
||||||
|
{/* ORCHESTRATOR MODE: Only show mode switcher and chat */}
|
||||||
|
{sidebarMode === 'orchestrator' ? (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--border-subtle)]">
|
||||||
|
<p className="font-mono text-[10px] uppercase tracking-[0.14em] text-[var(--text-tertiary)]">Project Orchestrator</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSidebarModeChange?.('epics')}
|
||||||
|
className="rounded-lg px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.11em] transition-colors border border-[var(--border-subtle)] bg-[var(--surface-quaternary)] text-[var(--text-tertiary)] hover:text-[var(--text-primary)]"
|
||||||
|
title="Switch to Epics view"
|
||||||
|
>
|
||||||
|
Epics
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
{orchestrator ? (
|
||||||
|
<OrchestratorPanel
|
||||||
|
orchestrator={orchestrator}
|
||||||
|
thread={orchestratorThread ?? []}
|
||||||
|
projectRoot={projectRoot}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center px-4 py-3 text-sm text-[var(--text-tertiary)]">
|
||||||
|
Orchestrator not initialized. Run bd init to set up beads.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* EPICS MODE: Show filters and epic list */}
|
||||||
|
<div className="px-4 py-3 border-b border-[var(--border-subtle)]">
|
||||||
|
<div className="mb-3 flex items-center gap-1 rounded-xl bg-[var(--surface-tertiary)] p-1 border border-[var(--border-strong)]">
|
||||||
|
{views.map((item) => {
|
||||||
|
const active = view === item.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setView(item.id)}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 rounded-lg px-2 py-1 text-xs font-semibold uppercase tracking-[0.12em] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-info)]',
|
||||||
|
active
|
||||||
|
? 'bg-[var(--accent-info)]/20 text-[var(--accent-info)]'
|
||||||
|
: 'text-[var(--text-tertiary)] hover:text-[var(--text-primary)]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 rounded-xl bg-[var(--surface-quaternary)] p-2.5 border border-[var(--border-subtle)]">
|
||||||
|
<div className="grid grid-cols-1 gap-2">
|
||||||
|
<input
|
||||||
|
value={filters.query}
|
||||||
|
onChange={(event) => onFiltersChange({ ...filters, query: event.target.value })}
|
||||||
|
className="ui-field rounded-lg border-transparent px-2.5 py-2 text-xs shadow-[0_10px_20px_-16px_rgba(0,0,0,0.9)]"
|
||||||
|
placeholder="Filter Tasks…"
|
||||||
|
aria-label="Filter tasks"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<select
|
||||||
|
value={filters.status}
|
||||||
|
onChange={(event) => onFiltersChange({ ...filters, status: event.target.value as LeftPanelStatusFilter })}
|
||||||
|
className="ui-field ui-select rounded-lg border-transparent px-2.5 py-2 text-xs shadow-[0_10px_20px_-16px_rgba(0,0,0,0.9)]"
|
||||||
|
aria-label="Status filter"
|
||||||
|
>
|
||||||
|
<option className="ui-option" value="all">All Status</option>
|
||||||
|
<option className="ui-option" value="ready">Ready</option>
|
||||||
|
<option className="ui-option" value="in_progress">In Progress</option>
|
||||||
|
<option className="ui-option" value="blocked">Blocked</option>
|
||||||
|
<option className="ui-option" value="deferred">Deferred</option>
|
||||||
|
<option className="ui-option" value="done">Done</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={filters.priority}
|
||||||
|
onChange={(event) => onFiltersChange({ ...filters, priority: event.target.value as LeftPanelPriorityFilter })}
|
||||||
|
className="ui-field ui-select rounded-lg border-transparent px-2.5 py-2 text-xs shadow-[0_10px_20px_-16px_rgba(0,0,0,0.9)]"
|
||||||
|
aria-label="Priority filter"
|
||||||
|
>
|
||||||
|
<option className="ui-option" value="all">All Priority</option>
|
||||||
|
<option className="ui-option" value="P0">P0</option>
|
||||||
|
<option className="ui-option" value="P1">P1</option>
|
||||||
|
<option className="ui-option" value="P2">P2</option>
|
||||||
|
<option className="ui-option" value="P3">P3</option>
|
||||||
|
<option className="ui-option" value="P4">P4</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onFiltersChange({ ...filters, preset: filters.preset === 'active' ? 'all' : 'active' })}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 rounded-lg px-2 py-1.5 text-[10px] font-semibold uppercase tracking-[0.11em] transition-colors border',
|
||||||
|
filters.preset === 'active'
|
||||||
|
? 'bg-[var(--accent-warning)]/15 border-[var(--accent-warning)]/40 text-[var(--accent-warning)]'
|
||||||
|
: 'bg-[var(--surface-quaternary)] border-[var(--border-subtle)] text-[var(--text-tertiary)]',
|
||||||
|
)}
|
||||||
|
aria-pressed={filters.preset === 'active'}
|
||||||
|
>
|
||||||
|
Active
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onFiltersChange({ ...filters, preset: filters.preset === 'blocked_agents' ? 'all' : 'blocked_agents' })}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 rounded-lg px-2 py-1.5 text-[10px] font-semibold uppercase tracking-[0.11em] transition-colors border',
|
||||||
|
filters.preset === 'blocked_agents'
|
||||||
|
? 'bg-[var(--accent-danger)]/15 border-[var(--accent-danger)]/40 text-[var(--accent-danger)]'
|
||||||
|
: 'bg-[var(--surface-quaternary)] border-[var(--border-subtle)] text-[var(--text-tertiary)]',
|
||||||
|
)}
|
||||||
|
aria-pressed={filters.preset === 'blocked_agents'}
|
||||||
|
>
|
||||||
|
Agent Blocked
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onFiltersChange({ ...filters, hideClosed: !filters.hideClosed })}
|
||||||
|
className={cn(
|
||||||
|
'w-full rounded-lg px-2 py-1.5 text-[10px] font-semibold uppercase tracking-[0.11em] transition-colors border',
|
||||||
|
filters.hideClosed
|
||||||
|
? 'bg-[var(--accent-success)]/15 border-[var(--accent-success)]/40 text-[var(--accent-success)]'
|
||||||
|
: 'bg-[var(--surface-quaternary)] border-[var(--border-subtle)] text-[var(--text-tertiary)]',
|
||||||
|
)}
|
||||||
|
aria-pressed={filters.hideClosed}
|
||||||
|
>
|
||||||
|
Hide Closed
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 flex items-center gap-1 rounded-xl bg-[var(--surface-tertiary)] p-1 border border-[var(--border-subtle)]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSidebarModeChange?.('orchestrator')}
|
||||||
|
className="flex-1 rounded-lg px-2 py-1.5 text-[10px] font-semibold uppercase tracking-[0.11em] transition-colors border bg-[var(--accent-info)]/15 border-[var(--accent-info)]/40 text-[var(--accent-info)]"
|
||||||
|
aria-pressed
|
||||||
|
>
|
||||||
|
Orchestrator
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto px-3 py-3 custom-scrollbar">
|
||||||
|
{entries.length === 0 ? (
|
||||||
|
<p className="text-sm text-[var(--text-tertiary)]">No epics found.</p>
|
||||||
|
) : (
|
||||||
|
entries.map((entry) => {
|
||||||
|
const {
|
||||||
|
epic,
|
||||||
|
children,
|
||||||
|
blockedCount,
|
||||||
|
activeCount,
|
||||||
|
readyCount,
|
||||||
|
deferredCount,
|
||||||
|
doneCount,
|
||||||
|
agentBlockedCount,
|
||||||
|
latestTimestamp,
|
||||||
|
} = entry;
|
||||||
|
const matchedChildren = children.filter((task) => isTaskMatch(task, filters));
|
||||||
|
const total = children.length;
|
||||||
|
const isExpanded = expanded[epic.id] ?? false;
|
||||||
|
const isSelected = selectedEpicId === epic.id;
|
||||||
|
const rowBackground = rowTone(entry);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={epic.id} className="mb-2">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-xl px-3 py-3 transition-colors border border-[var(--border-subtle)]',
|
||||||
|
isSelected
|
||||||
|
? 'border-[var(--accent-info)] bg-[var(--accent-info)]/10'
|
||||||
|
: rowBackground,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpanded((prev) => ({ ...prev, [epic.id]: !prev[epic.id] }))}
|
||||||
|
className="rounded p-1 text-[var(--text-tertiary)] transition-colors hover:bg-[var(--alpha-white-low)] hover:text-[var(--text-primary)]"
|
||||||
|
aria-label={isExpanded ? 'Collapse epic' : 'Expand epic'}
|
||||||
|
aria-expanded={isExpanded}
|
||||||
|
>
|
||||||
|
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||||
|
</button>
|
||||||
|
<div className="flex min-w-0 items-center gap-1.5">
|
||||||
|
<FolderOpen className="h-3.5 w-3.5 text-[var(--accent-info)]" />
|
||||||
|
<span className="truncate text-sm font-semibold text-[var(--text-primary)]">{epic.title}</span>
|
||||||
|
{total > 0 ? (
|
||||||
|
<span className="shrink-0 rounded-full bg-[var(--surface-tertiary)] px-2 py-0.5 text-[10px] font-mono text-[var(--text-tertiary)]">
|
||||||
|
{matchedChildren.length}/{total}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -3,9 +3,11 @@
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { ChevronDown, ChevronRight, Folder, FolderOpen, Pencil, Rocket, Star } from 'lucide-react';
|
import { ChevronDown, ChevronRight, Folder, FolderOpen, Pencil, Rocket, Star } from 'lucide-react';
|
||||||
|
|
||||||
|
import type { RuntimeConsoleEvent, RuntimeInstance } from '../../lib/embedded-runtime';
|
||||||
import type { BeadIssue } from '../../lib/types';
|
import type { BeadIssue } from '../../lib/types';
|
||||||
import { cn } from '../../lib/utils';
|
import { cn } from '../../lib/utils';
|
||||||
import { useUrlState, type ViewType } from '../../hooks/use-url-state';
|
import { useUrlState, type LeftSidebarMode, type ViewType } from '../../hooks/use-url-state';
|
||||||
|
import { OrchestratorPanel } from './orchestrator-panel';
|
||||||
|
|
||||||
export type LeftPanelStatusFilter = 'all' | 'ready' | 'in_progress' | 'blocked' | 'deferred' | 'done';
|
export type LeftPanelStatusFilter = 'all' | 'ready' | 'in_progress' | 'blocked' | 'deferred' | 'done';
|
||||||
export type LeftPanelPriorityFilter = 'all' | 'P0' | 'P1' | 'P2' | 'P3' | 'P4';
|
export type LeftPanelPriorityFilter = 'all' | 'P0' | 'P1' | 'P2' | 'P3' | 'P4';
|
||||||
|
|
@ -27,9 +29,14 @@ export interface LeftPanelProps {
|
||||||
filters: LeftPanelFilters;
|
filters: LeftPanelFilters;
|
||||||
onFiltersChange: (filters: LeftPanelFilters) => void;
|
onFiltersChange: (filters: LeftPanelFilters) => void;
|
||||||
onAssignMode?: (epicId: string) => void;
|
onAssignMode?: (epicId: string) => void;
|
||||||
|
sidebarMode?: LeftSidebarMode;
|
||||||
|
onSidebarModeChange?: (mode: LeftSidebarMode) => void;
|
||||||
|
orchestrator?: RuntimeInstance;
|
||||||
|
orchestratorThread?: RuntimeConsoleEvent[];
|
||||||
|
projectRoot?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EpicEntry {
|
interface EpicEntry {
|
||||||
epic: BeadIssue;
|
epic: BeadIssue;
|
||||||
children: BeadIssue[];
|
children: BeadIssue[];
|
||||||
blockedCount: number;
|
blockedCount: number;
|
||||||
|
|
@ -38,34 +45,34 @@ interface EpicEntry {
|
||||||
deferredCount: number;
|
deferredCount: number;
|
||||||
doneCount: number;
|
doneCount: number;
|
||||||
agentBlockedCount: number;
|
agentBlockedCount: number;
|
||||||
latestTimestamp: string;
|
latestTimestamp: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shouldHideEpicEntry(params: {
|
export function shouldHideEpicEntry(params: {
|
||||||
epicStatus: BeadIssue['status'];
|
epicStatus: BeadIssue['status'];
|
||||||
matchedChildrenCount: number;
|
matchedChildrenCount: number;
|
||||||
totalChildrenCount: number;
|
totalChildrenCount: number;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
filters: LeftPanelFilters;
|
filters: LeftPanelFilters;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
const { epicStatus, matchedChildrenCount, totalChildrenCount, isSelected, filters } = params;
|
const { epicStatus, matchedChildrenCount, totalChildrenCount, isSelected, filters } = params;
|
||||||
const hasTaskFilters =
|
const hasTaskFilters =
|
||||||
filters.query.trim().length > 0 ||
|
filters.query.trim().length > 0 ||
|
||||||
filters.status !== 'all' ||
|
filters.status !== 'all' ||
|
||||||
filters.priority !== 'all' ||
|
filters.priority !== 'all' ||
|
||||||
filters.preset !== 'all';
|
filters.preset !== 'all';
|
||||||
const epicClosed = epicStatus === 'closed' || epicStatus === 'tombstone';
|
const epicClosed = epicStatus === 'closed' || epicStatus === 'tombstone';
|
||||||
const noVisibleChildren = matchedChildrenCount === 0 && totalChildrenCount > 0;
|
const noVisibleChildren = matchedChildrenCount === 0 && totalChildrenCount > 0;
|
||||||
const hiddenByTaskFilters = hasTaskFilters && noVisibleChildren;
|
const hiddenByTaskFilters = hasTaskFilters && noVisibleChildren;
|
||||||
const hiddenByHideClosed = filters.hideClosed && noVisibleChildren;
|
const hiddenByHideClosed = filters.hideClosed && noVisibleChildren;
|
||||||
const hiddenByEpicClosed = filters.hideClosed && epicClosed;
|
const hiddenByEpicClosed = filters.hideClosed && epicClosed;
|
||||||
|
|
||||||
if (hiddenByEpicClosed) {
|
if (hiddenByEpicClosed) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return !isSelected && (hiddenByTaskFilters || hiddenByHideClosed);
|
return !isSelected && (hiddenByTaskFilters || hiddenByHideClosed);
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapStatus(task: BeadIssue): LeftPanelStatusFilter {
|
function mapStatus(task: BeadIssue): LeftPanelStatusFilter {
|
||||||
if (task.status === 'open') return 'ready';
|
if (task.status === 'open') return 'ready';
|
||||||
|
|
@ -200,12 +207,24 @@ function isTaskMatch(task: BeadIssue, filters: LeftPanelFilters): boolean {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LeftPanel({ issues, selectedEpicId, onEpicSelect, onEpicEdit, filters, onFiltersChange, onAssignMode }: LeftPanelProps) {
|
export function LeftPanel({
|
||||||
|
issues,
|
||||||
|
selectedEpicId,
|
||||||
|
onEpicSelect,
|
||||||
|
onEpicEdit,
|
||||||
|
filters,
|
||||||
|
onFiltersChange,
|
||||||
|
onAssignMode,
|
||||||
|
sidebarMode = 'epics',
|
||||||
|
onSidebarModeChange,
|
||||||
|
orchestrator,
|
||||||
|
orchestratorThread = [],
|
||||||
|
}: LeftPanelProps) {
|
||||||
const { view, setView } = useUrlState();
|
const { view, setView } = useUrlState();
|
||||||
const entries = useMemo(() => buildEntries(issues), [issues]);
|
const entries = useMemo(() => buildEntries(issues), [issues]);
|
||||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
const views: Array<{ id: ViewType; label: string }> = [
|
const views: Array<{ id: ViewType; label: string }> = [
|
||||||
{ id: 'social', label: 'Social' },
|
{ id: 'social', label: 'Social' },
|
||||||
{ id: 'graph', label: 'Graph' },
|
{ id: 'graph', label: 'Graph' },
|
||||||
];
|
];
|
||||||
|
|
@ -316,11 +335,40 @@ export function LeftPanel({ issues, selectedEpicId, onEpicSelect, onEpicEdit, fi
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="mt-2 font-mono text-[10px] uppercase tracking-[0.16em] text-[var(--text-tertiary)]">Navigation / Epics</p>
|
<div className="mt-2 flex items-center gap-1 rounded-xl bg-[var(--surface-tertiary)] p-1 border border-[var(--border-subtle)]">
|
||||||
|
{([
|
||||||
|
{ id: 'epics', label: 'Epics' },
|
||||||
|
{ id: 'orchestrator', label: 'Orchestrator' },
|
||||||
|
] as Array<{ id: LeftSidebarMode; label: string }>).map((item) => {
|
||||||
|
const active = sidebarMode === item.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSidebarModeChange?.(item.id)}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 rounded-lg px-2 py-1.5 text-[10px] font-semibold uppercase tracking-[0.11em] transition-colors border',
|
||||||
|
active
|
||||||
|
? 'bg-[var(--accent-info)]/15 border-[var(--accent-info)]/40 text-[var(--accent-info)]'
|
||||||
|
: 'bg-[var(--surface-quaternary)] border-[var(--border-subtle)] text-[var(--text-tertiary)]',
|
||||||
|
)}
|
||||||
|
aria-pressed={active}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-2 font-mono text-[10px] uppercase tracking-[0.16em] text-[var(--text-tertiary)]">
|
||||||
|
{sidebarMode === 'orchestrator' ? 'Project Orchestrator' : 'Navigation / Epics'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto px-3 py-3 custom-scrollbar">
|
<div className="flex-1 overflow-y-auto px-3 py-3 custom-scrollbar">
|
||||||
{entries.map((entry) => {
|
{sidebarMode === 'orchestrator' ? (
|
||||||
|
orchestrator ? <OrchestratorPanel orchestrator={orchestrator} thread={orchestratorThread} /> : null
|
||||||
|
) : entries.map((entry) => {
|
||||||
const {
|
const {
|
||||||
epic,
|
epic,
|
||||||
children,
|
children,
|
||||||
|
|
@ -343,15 +391,15 @@ export function LeftPanel({ issues, selectedEpicId, onEpicSelect, onEpicEdit, fi
|
||||||
const laneColor = blockedCount > 0 ? 'var(--accent-danger)' : activeCount > 0 ? 'var(--accent-warning)' : 'var(--accent-success)';
|
const laneColor = blockedCount > 0 ? 'var(--accent-danger)' : activeCount > 0 ? 'var(--accent-warning)' : 'var(--accent-success)';
|
||||||
const rowBackground = rowTone(entry);
|
const rowBackground = rowTone(entry);
|
||||||
|
|
||||||
if (shouldHideEpicEntry({
|
if (shouldHideEpicEntry({
|
||||||
epicStatus: epic.status,
|
epicStatus: epic.status,
|
||||||
matchedChildrenCount: matchedChildren.length,
|
matchedChildrenCount: matchedChildren.length,
|
||||||
totalChildrenCount: total,
|
totalChildrenCount: total,
|
||||||
isSelected,
|
isSelected,
|
||||||
filters,
|
filters,
|
||||||
})) {
|
})) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={epic.id} className="mb-2">
|
<div key={epic.id} className="mb-2">
|
||||||
|
|
|
||||||
118
src/components/shared/orchestrator-panel.tsx
Normal file
118
src/components/shared/orchestrator-panel.tsx
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Send } from 'lucide-react';
|
||||||
|
import type { RuntimeInstance } from '../../lib/embedded-runtime';
|
||||||
|
import type { OrchestratorChatMessage } from '../../lib/orchestrator-chat';
|
||||||
|
|
||||||
|
export interface OrchestratorPanelProps {
|
||||||
|
orchestrator: RuntimeInstance;
|
||||||
|
thread: OrchestratorChatMessage[];
|
||||||
|
projectRoot?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OrchestratorPanel({ orchestrator, thread, projectRoot }: OrchestratorPanelProps) {
|
||||||
|
const [input, setInput] = useState('');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [optimisticMessages, setOptimisticMessages] = useState<OrchestratorChatMessage[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setOptimisticMessages((current) =>
|
||||||
|
current.filter((pending) => !thread.some((message) => message.role === 'user' && message.text === pending.text))
|
||||||
|
);
|
||||||
|
}, [thread]);
|
||||||
|
|
||||||
|
const visibleThread = useMemo(() => [...thread, ...optimisticMessages], [thread, optimisticMessages]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!input.trim() || submitting || !projectRoot) return;
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
const text = input.trim();
|
||||||
|
setInput('');
|
||||||
|
setOptimisticMessages((current) => [
|
||||||
|
...current,
|
||||||
|
{
|
||||||
|
id: `optimistic-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
role: 'user',
|
||||||
|
text,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch('/api/runtime/prompt', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ projectRoot, text })
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-0 flex-col" data-testid="orchestrator-panel">
|
||||||
|
<div className="border-b border-[var(--border-subtle)] px-4 py-3">
|
||||||
|
<p className="font-mono text-[10px] uppercase tracking-[0.14em] text-[var(--text-tertiary)]">Main Orchestrator</p>
|
||||||
|
<div className="mt-2 flex items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-[var(--text-primary)]">{orchestrator.label}</p>
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">Long-lived project control plane for Pi launches</p>
|
||||||
|
</div>
|
||||||
|
<span className="rounded-full border border-cyan-500/30 bg-cyan-500/10 px-2 py-1 text-[10px] uppercase tracking-[0.12em] text-cyan-200">
|
||||||
|
{orchestrator.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-3 custom-scrollbar">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{visibleThread.map((message) => (
|
||||||
|
<div
|
||||||
|
key={message.id}
|
||||||
|
className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={message.role === 'user'
|
||||||
|
? 'max-w-[85%] rounded-2xl rounded-br-md bg-cyan-500/15 px-3 py-2 text-sm text-cyan-50 border border-cyan-500/25'
|
||||||
|
: 'max-w-[85%] rounded-2xl rounded-bl-md bg-[var(--surface-quaternary)] px-3 py-2 text-sm text-[var(--text-primary)] border border-[var(--border-subtle)]'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className="whitespace-pre-wrap leading-relaxed">{message.text}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{visibleThread.length === 0 ? (
|
||||||
|
<p className="rounded-lg border border-dashed border-[var(--border-subtle)] bg-[var(--surface-quaternary)] px-3 py-4 text-sm text-[var(--text-tertiary)]">
|
||||||
|
No orchestrator messages yet.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{projectRoot && (
|
||||||
|
<div className="border-t border-[var(--border-subtle)] p-3">
|
||||||
|
<form onSubmit={handleSubmit} className="relative flex items-center">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
placeholder="Ask the orchestrator..."
|
||||||
|
className="w-full rounded-md border border-[var(--border-subtle)] bg-[var(--surface-tertiary)] px-3 py-2 pr-10 text-sm placeholder-[var(--text-tertiary)] focus:border-[var(--brand-primary)] focus:outline-none"
|
||||||
|
disabled={submitting}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!input.trim() || submitting}
|
||||||
|
className="absolute right-2 text-[var(--text-tertiary)] hover:text-[var(--text-primary)] disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Send size={16} />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
101
src/components/shared/runtime-console.tsx
Normal file
101
src/components/shared/runtime-console.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { ChevronDown, ChevronUp, TerminalSquare } from 'lucide-react';
|
||||||
|
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
import type { RuntimeConsoleEvent } from '../../lib/embedded-runtime';
|
||||||
|
|
||||||
|
export interface RuntimeConsoleProps {
|
||||||
|
events: RuntimeConsoleEvent[];
|
||||||
|
daemonStatus?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusTone(status?: RuntimeConsoleEvent['status']): string {
|
||||||
|
if (status === 'failed' || status === 'blocked') return 'text-red-300';
|
||||||
|
if (status === 'completed') return 'text-emerald-300';
|
||||||
|
if (status === 'planning' || status === 'launching') return 'text-amber-300';
|
||||||
|
return 'text-cyan-200';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWorkerEvent(event: RuntimeConsoleEvent): boolean {
|
||||||
|
return (
|
||||||
|
event.kind === 'worker.spawned' ||
|
||||||
|
event.kind === 'worker.updated' ||
|
||||||
|
event.kind === 'worker.completed' ||
|
||||||
|
event.kind === 'worker.failed'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(timestamp: string): string {
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
return Number.isNaN(date.getTime()) ? timestamp : date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RuntimeConsole({ events, daemonStatus }: RuntimeConsoleProps) {
|
||||||
|
const [isMinimized, setIsMinimized] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="border-t border-[var(--border-strong)] bg-[var(--surface-elevated)]"
|
||||||
|
data-testid="runtime-console"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between border-b border-[var(--border-subtle)] px-4 py-2">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="font-mono text-[10px] uppercase tracking-[0.14em] text-[var(--text-tertiary)]">Runtime Console</p>
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">Live orchestrator and worker telemetry</p>
|
||||||
|
</div>
|
||||||
|
<div className="inline-flex items-center gap-2 rounded-full border border-[var(--border-subtle)] bg-[var(--surface-tertiary)] px-2.5 py-1 text-[10px] uppercase tracking-[0.12em] text-[var(--text-tertiary)]">
|
||||||
|
<TerminalSquare className="h-3.5 w-3.5" aria-hidden="true" />
|
||||||
|
{daemonStatus ? `daemon ${daemonStatus} · ` : ''}{events.length} event{events.length === 1 ? '' : 's'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsMinimized(!isMinimized)}
|
||||||
|
className="rounded p-1 text-[var(--text-tertiary)] transition-colors hover:bg-[var(--alpha-white-low)] hover:text-[var(--text-primary)]"
|
||||||
|
title={isMinimized ? 'Expand console' : 'Minimize console'}
|
||||||
|
aria-label={isMinimized ? 'Expand console' : 'Minimize console'}
|
||||||
|
>
|
||||||
|
{isMinimized ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isMinimized && (
|
||||||
|
<div className="grid max-h-44 gap-2 overflow-y-auto px-4 py-3 custom-scrollbar">
|
||||||
|
{events.map((event) => (
|
||||||
|
<article
|
||||||
|
key={event.id}
|
||||||
|
className="rounded-lg border border-[var(--border-subtle)] bg-[var(--surface-quaternary)] px-3 py-2"
|
||||||
|
>
|
||||||
|
<div className="mb-1 flex items-center justify-between gap-2">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
{isWorkerEvent(event) && (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-indigo-500/10 px-1.5 py-0.5 text-[9px] font-mono uppercase tracking-wider text-indigo-300" title="Worker Agent Event">
|
||||||
|
Worker
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className={cn('font-mono text-[10px] uppercase tracking-[0.12em]', statusTone(event.status))}>
|
||||||
|
{event.kind}
|
||||||
|
</span>
|
||||||
|
{event.actorLabel ? (
|
||||||
|
<span className="truncate text-[11px] text-[var(--text-tertiary)]">{event.actorLabel}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 font-mono text-[10px] text-[var(--text-tertiary)]">{formatTimestamp(event.timestamp)}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium text-[var(--text-primary)]">{event.title}</p>
|
||||||
|
<p className="mt-1 text-xs leading-relaxed text-[var(--text-secondary)]">{event.detail}</p>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
{events.length === 0 ? (
|
||||||
|
<p className="rounded-lg border border-dashed border-[var(--border-subtle)] bg-[var(--surface-quaternary)] px-3 py-4 text-sm text-[var(--text-tertiary)]">
|
||||||
|
No runtime events yet.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -3,27 +3,30 @@
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import type { BeadIssue } from '../../lib/types';
|
import type { BeadIssue } from '../../lib/types';
|
||||||
import type { ProjectScopeOption } from '../../lib/project-scope';
|
import type { ProjectScopeOption } from '../../lib/project-scope';
|
||||||
|
import { buildLaunchRequest, createLaunchConsoleEvents, createOrchestratorInstance, type RuntimeConsoleEvent, type RuntimeStatus } from '../../lib/embedded-runtime';
|
||||||
import { TopBar } from './top-bar';
|
import { TopBar } from './top-bar';
|
||||||
import { LeftPanel, type LeftPanelFilters } from './left-panel';
|
import { LeftPanel, type LeftPanelFilters } from './left-panel-new';
|
||||||
import { RightPanel } from './right-panel';
|
import { RightPanel } from './right-panel';
|
||||||
import { MobileNav } from './mobile-nav';
|
import { MobileNav } from './mobile-nav';
|
||||||
import { ThreadDrawer } from './thread-drawer';
|
import { ThreadDrawer } from './thread-drawer';
|
||||||
import { ResizeHandle } from './resize-handle';
|
import { ResizeHandle } from './resize-handle';
|
||||||
import { useUrlState } from '../../hooks/use-url-state';
|
import { RuntimeConsole } from './runtime-console';
|
||||||
|
import { useUrlState, type LeftSidebarMode } from '../../hooks/use-url-state';
|
||||||
import { usePanelResize } from '../../hooks/use-panel-resize';
|
import { usePanelResize } from '../../hooks/use-panel-resize';
|
||||||
import { SmartDag } from '../graph/smart-dag';
|
import { SmartDag } from '../graph/smart-dag';
|
||||||
import { SocialPage } from '../social/social-page';
|
import { SocialPage } from '../social/social-page';
|
||||||
import { buildSocialCards } from '../../lib/social-cards';
|
import { buildSocialCards } from '../../lib/social-cards';
|
||||||
import { ContextualRightPanel } from '../activity/contextual-right-panel';
|
import { ContextualRightPanel } from '../activity/contextual-right-panel';
|
||||||
import { AssignmentPanel } from '../graph/assignment-panel';
|
import { AssignmentPanel } from '../graph/assignment-panel';
|
||||||
import { TelemetryStrip } from './telemetry-strip';
|
import { TelemetryStrip } from './telemetry-strip';
|
||||||
import { useSwarmList } from '../../hooks/use-swarm-list';
|
import { useSwarmList } from '../../hooks/use-swarm-list';
|
||||||
import { useBeadsSubscription } from '../../hooks/use-beads-subscription';
|
import { useBeadsSubscription } from '../../hooks/use-beads-subscription';
|
||||||
import { useBdHealth } from '../../hooks/use-bd-health';
|
import { useBdHealth } from '../../hooks/use-bd-health';
|
||||||
import { BlockedTriageModal } from './blocked-triage-modal';
|
import { BlockedTriageModal } from './blocked-triage-modal';
|
||||||
import { deriveBlockedIds } from '../../lib/kanban';
|
import { deriveBlockedIds } from '../../lib/kanban';
|
||||||
|
import { projectOrchestratorChat } from '../../lib/orchestrator-chat';
|
||||||
|
|
||||||
export interface UnifiedShellProps {
|
export interface UnifiedShellProps {
|
||||||
issues: BeadIssue[];
|
issues: BeadIssue[];
|
||||||
|
|
@ -33,13 +36,27 @@ export interface UnifiedShellProps {
|
||||||
projectScopeMode: 'single' | 'aggregate';
|
projectScopeMode: 'single' | 'aggregate';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mergeUniqueRuntimeEvents(existing: RuntimeConsoleEvent[], incoming: RuntimeConsoleEvent[]): RuntimeConsoleEvent[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const merged: RuntimeConsoleEvent[] = [];
|
||||||
|
|
||||||
|
for (const event of [...incoming, ...existing]) {
|
||||||
|
if (seen.has(event.id)) continue;
|
||||||
|
seen.add(event.id);
|
||||||
|
merged.push(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged.slice(0, 40);
|
||||||
|
}
|
||||||
|
|
||||||
export function UnifiedShell({
|
export function UnifiedShell({
|
||||||
issues: initialIssues,
|
issues: initialIssues,
|
||||||
projectRoot,
|
projectRoot,
|
||||||
projectScopeOptions,
|
projectScopeOptions,
|
||||||
}: UnifiedShellProps) {
|
}: UnifiedShellProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { view, taskId, setTaskId, swarmId, graphTab, panel, drawer, setDrawer, epicId, setEpicId, blockedOnly } = useUrlState();
|
const { view, taskId, setTaskId, swarmId, graphTab, drawer, setDrawer, epicId, setEpicId, blockedOnly } = useUrlState();
|
||||||
|
const [leftSidebarMode, setLeftSidebarMode] = useState<LeftSidebarMode>('epics');
|
||||||
|
|
||||||
// Subscribe to SSE for real-time updates on ALL views
|
// Subscribe to SSE for real-time updates on ALL views
|
||||||
const { issues } = useBeadsSubscription(initialIssues, projectRoot);
|
const { issues } = useBeadsSubscription(initialIssues, projectRoot);
|
||||||
|
|
@ -66,29 +83,32 @@ export function UnifiedShell({
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const [customRightPanel, setCustomRightPanel] = useState<React.ReactNode | null>(null);
|
const [customRightPanel, setCustomRightPanel] = useState<React.ReactNode | null>(null);
|
||||||
|
const [orchestrator, setOrchestrator] = useState(() => createOrchestratorInstance(projectRoot));
|
||||||
|
const [runtimeEvents, setRuntimeEvents] = useState<RuntimeConsoleEvent[]>([]);
|
||||||
|
const [daemonLifecycle, setDaemonLifecycle] = useState<{ status: RuntimeStatus | 'stopped' | 'starting' | 'stopping' | 'failed' } | null>(null);
|
||||||
|
|
||||||
// Assign mode state for graph view
|
// Assign mode state for graph view
|
||||||
const [assignMode, setAssignMode] = useState(false);
|
const [assignMode, setAssignMode] = useState(false);
|
||||||
const [selectedAssignIssue, setSelectedAssignIssue] = useState<BeadIssue | null>(null);
|
const [selectedAssignIssue, setSelectedAssignIssue] = useState<BeadIssue | null>(null);
|
||||||
|
|
||||||
// Remember last non-telemetry state for minimize button
|
// Remember last non-telemetry state for minimize button
|
||||||
const [lastTaskId, setLastTaskId] = useState<string | null>(null);
|
const [lastTaskId, setLastTaskId] = useState<string | null>(null);
|
||||||
const [lastAssignMode, setLastAssignMode] = useState(false);
|
const [lastAssignMode, setLastAssignMode] = useState(false);
|
||||||
|
|
||||||
// Blocked triage modal state
|
|
||||||
const [blockedTriageOpen, setBlockedTriageOpen] = useState(false);
|
|
||||||
const handleOpenBlockedTriage = useCallback(() => setBlockedTriageOpen(true), []);
|
|
||||||
const handleCloseBlockedTriage = useCallback(() => setBlockedTriageOpen(false), []);
|
|
||||||
|
|
||||||
const socialCards = useMemo(() => buildSocialCards(issues), [issues]);
|
// Blocked triage modal state
|
||||||
const blockedIds = useMemo(() => deriveBlockedIds(issues), [issues]);
|
const [blockedTriageOpen, setBlockedTriageOpen] = useState(false);
|
||||||
const blockedCount = useMemo(() => {
|
const handleOpenBlockedTriage = useCallback(() => setBlockedTriageOpen(true), []);
|
||||||
return issues.filter(i => i.status === 'blocked' || blockedIds.has(i.id)).length;
|
const handleCloseBlockedTriage = useCallback(() => setBlockedTriageOpen(false), []);
|
||||||
}, [issues, blockedIds]);
|
|
||||||
const { swarms: swarmCards } = useSwarmList(projectRoot);
|
const socialCards = useMemo(() => buildSocialCards(issues), [issues]);
|
||||||
const bdHealth = useBdHealth(projectRoot);
|
const blockedIds = useMemo(() => deriveBlockedIds(issues), [issues]);
|
||||||
|
const blockedCount = useMemo(() => {
|
||||||
const selectedSocialCard = taskId ? socialCards.find(c => c.id === taskId) : null;
|
return issues.filter(i => i.status === 'blocked' || blockedIds.has(i.id)).length;
|
||||||
|
}, [issues, blockedIds]);
|
||||||
|
const { swarms: swarmCards } = useSwarmList(projectRoot);
|
||||||
|
const bdHealth = useBdHealth(projectRoot);
|
||||||
|
|
||||||
|
const selectedSocialCard = taskId ? socialCards.find(c => c.id === taskId) : null;
|
||||||
const selectedSwarmCard = swarmId ? swarmCards.find(c => c.swarmId === swarmId) : null;
|
const selectedSwarmCard = swarmId ? swarmCards.find(c => c.swarmId === swarmId) : null;
|
||||||
const selectedIssue = taskId ? issues.find((issue) => issue.id === taskId) ?? null : null;
|
const selectedIssue = taskId ? issues.find((issue) => issue.id === taskId) ?? null : null;
|
||||||
|
|
||||||
|
|
@ -131,6 +151,116 @@ export function UnifiedShell({
|
||||||
setAssignMode(true);
|
setAssignMode(true);
|
||||||
}, [setTaskId]);
|
}, [setTaskId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function bootstrapRuntime() {
|
||||||
|
try {
|
||||||
|
const [statusResponse, orchestratorResponse, eventsResponse] = await Promise.all([
|
||||||
|
fetch('/api/runtime/status'),
|
||||||
|
fetch('/api/runtime/orchestrator', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ projectRoot }),
|
||||||
|
}),
|
||||||
|
fetch(`/api/runtime/events?projectRoot=${encodeURIComponent(projectRoot)}`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const statusPayload = await statusResponse.json().catch(() => null);
|
||||||
|
const orchestratorPayload = await orchestratorResponse.json().catch(() => null);
|
||||||
|
const eventsPayload = await eventsResponse.json().catch(() => null);
|
||||||
|
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusResponse.ok && statusPayload?.lifecycle) {
|
||||||
|
setDaemonLifecycle(statusPayload.lifecycle);
|
||||||
|
}
|
||||||
|
if (orchestratorResponse.ok && orchestratorPayload?.ok && orchestratorPayload.data) {
|
||||||
|
setOrchestrator(orchestratorPayload.data);
|
||||||
|
}
|
||||||
|
if (orchestratorPayload?.lifecycle) {
|
||||||
|
setDaemonLifecycle(orchestratorPayload.lifecycle);
|
||||||
|
}
|
||||||
|
if (eventsResponse.ok && eventsPayload?.ok && Array.isArray(eventsPayload.data)) {
|
||||||
|
setRuntimeEvents((current) => mergeUniqueRuntimeEvents(current, eventsPayload.data));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Runtime bootstrap is best-effort during early integration.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void bootstrapRuntime();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [projectRoot]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// daemon lifecycle and runtime events should come from the daemon stream, not local shell ownership
|
||||||
|
const source = new EventSource(`/api/runtime/stream?projectRoot=${encodeURIComponent(projectRoot)}`);
|
||||||
|
const onRuntime = (event: MessageEvent) => {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(event.data) as RuntimeConsoleEvent;
|
||||||
|
setRuntimeEvents((current) => mergeUniqueRuntimeEvents(current, [payload]));
|
||||||
|
} catch {
|
||||||
|
// Ignore malformed runtime frames.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
source.addEventListener('runtime', onRuntime as EventListener);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
source.removeEventListener('runtime', onRuntime as EventListener);
|
||||||
|
source.close();
|
||||||
|
};
|
||||||
|
}, [projectRoot]);
|
||||||
|
|
||||||
|
const handleAskOrchestrator = useCallback(async (issueId: string) => {
|
||||||
|
const issue = issues.find((entry) => entry.id === issueId);
|
||||||
|
if (!issue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const optimisticRequest = buildLaunchRequest({
|
||||||
|
issue,
|
||||||
|
origin: 'social',
|
||||||
|
projectRoot,
|
||||||
|
swarmId,
|
||||||
|
});
|
||||||
|
const optimisticEvents = createLaunchConsoleEvents(optimisticRequest);
|
||||||
|
setLeftSidebarMode('orchestrator');
|
||||||
|
setRuntimeEvents((current) => mergeUniqueRuntimeEvents(current, optimisticEvents));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/runtime/launch', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
projectRoot,
|
||||||
|
taskId: issueId,
|
||||||
|
origin: 'social',
|
||||||
|
swarmId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const payload = await response.json().catch(() => null);
|
||||||
|
if (response.ok && payload?.ok) {
|
||||||
|
if (payload.lifecycle) {
|
||||||
|
setDaemonLifecycle(payload.lifecycle);
|
||||||
|
}
|
||||||
|
if (payload.data?.orchestrator) {
|
||||||
|
setOrchestrator(payload.data.orchestrator);
|
||||||
|
}
|
||||||
|
if (Array.isArray(payload.data?.events)) {
|
||||||
|
setRuntimeEvents((current) => mergeUniqueRuntimeEvents(current, payload.data.events));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Keep optimistic console events visible; bridge hardening comes in later phases.
|
||||||
|
}
|
||||||
|
}, [issues, projectRoot, setLeftSidebarMode, swarmId]);
|
||||||
|
|
||||||
// Minimize: restore last clicked thing (task or assign mode)
|
// Minimize: restore last clicked thing (task or assign mode)
|
||||||
const handleMinimize = useCallback(() => {
|
const handleMinimize = useCallback(() => {
|
||||||
if (lastTaskId) {
|
if (lastTaskId) {
|
||||||
|
|
@ -156,23 +286,23 @@ export function UnifiedShell({
|
||||||
|
|
||||||
// Chat Mode Logic: If a card is selected (drawer='open'), we show Chat popup
|
// Chat Mode Logic: If a card is selected (drawer='open'), we show Chat popup
|
||||||
const isChatOpen = drawer === 'open' && (!!taskId || !!swarmId || !!epicId);
|
const isChatOpen = drawer === 'open' && (!!taskId || !!swarmId || !!epicId);
|
||||||
const selectedEpic = epicId ? issues.find((issue) => issue.id === epicId && issue.issue_type === 'epic') ?? null : null;
|
const selectedEpic = epicId ? issues.find((issue) => issue.id === epicId && issue.issue_type === 'epic') ?? null : null;
|
||||||
const drawerTitle = selectedSocialCard?.title || selectedSwarmCard?.title || selectedEpic?.title || '';
|
const drawerTitle = selectedSocialCard?.title || selectedSwarmCard?.title || selectedEpic?.title || '';
|
||||||
const drawerId = taskId || swarmId || epicId || '';
|
const drawerId = taskId || swarmId || epicId || '';
|
||||||
const selectedItem = selectedEpic ?? selectedIssue;
|
const selectedItem = selectedEpic ?? selectedIssue;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!filters.hideClosed || !epicId) {
|
if (!filters.hideClosed || !epicId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const epic = issues.find((issue) => issue.id === epicId && issue.issue_type === 'epic');
|
const epic = issues.find((issue) => issue.id === epicId && issue.issue_type === 'epic');
|
||||||
if (!epic) {
|
if (!epic) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (epic.status === 'closed' || epic.status === 'tombstone') {
|
if (epic.status === 'closed' || epic.status === 'tombstone') {
|
||||||
setEpicId(null);
|
setEpicId(null);
|
||||||
}
|
}
|
||||||
}, [filters.hideClosed, epicId, issues, setEpicId]);
|
}, [filters.hideClosed, epicId, issues, setEpicId]);
|
||||||
|
|
||||||
// Panel resize hook
|
// Panel resize hook
|
||||||
const { leftWidth, rightWidth, handleLeftResize, handleRightResize } = usePanelResize();
|
const { leftWidth, rightWidth, handleLeftResize, handleRightResize } = usePanelResize();
|
||||||
|
|
@ -214,6 +344,7 @@ export function UnifiedShell({
|
||||||
projectRoot={projectRoot}
|
projectRoot={projectRoot}
|
||||||
swarmId={swarmId ?? undefined}
|
swarmId={swarmId ?? undefined}
|
||||||
onRocketClick={handleSocialRocket}
|
onRocketClick={handleSocialRocket}
|
||||||
|
onAskOrchestrator={handleAskOrchestrator}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -262,16 +393,16 @@ export function UnifiedShell({
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-screen bg-[var(--surface-backdrop)]" data-testid="unified-shell">
|
<div className="flex flex-col h-screen bg-[var(--surface-backdrop)]" data-testid="unified-shell">
|
||||||
{/* TOP BAR: 3rem fixed */}
|
{/* TOP BAR: 3rem fixed */}
|
||||||
<TopBar
|
<TopBar
|
||||||
totalTasks={issues.filter(i => i.issue_type !== 'epic').length}
|
totalTasks={issues.filter(i => i.issue_type !== 'epic').length}
|
||||||
criticalAlerts={blockedCount}
|
criticalAlerts={blockedCount}
|
||||||
busyCount={issues.filter(i => i.status === 'in_progress').length}
|
busyCount={issues.filter(i => i.status === 'in_progress').length}
|
||||||
idleCount={0}
|
idleCount={0}
|
||||||
actor={actor}
|
actor={actor}
|
||||||
onActorChange={handleActorChange}
|
onActorChange={handleActorChange}
|
||||||
onLaunchSwarm={() => { setTaskId(null); setAssignMode(true); }}
|
onLaunchSwarm={() => { setTaskId(null); setAssignMode(true); }}
|
||||||
onOpenBlockedTriage={handleOpenBlockedTriage}
|
onOpenBlockedTriage={handleOpenBlockedTriage}
|
||||||
/>
|
/>
|
||||||
{!bdHealth.loading && !bdHealth.healthy ? (
|
{!bdHealth.loading && !bdHealth.healthy ? (
|
||||||
<div className="border-b border-amber-500/35 bg-amber-500/12 px-4 py-2 text-xs text-amber-100">
|
<div className="border-b border-amber-500/35 bg-amber-500/12 px-4 py-2 text-xs text-amber-100">
|
||||||
<span className="font-semibold">BD setup issue:</span> {bdHealth.message}
|
<span className="font-semibold">BD setup issue:</span> {bdHealth.message}
|
||||||
|
|
@ -293,6 +424,11 @@ export function UnifiedShell({
|
||||||
filters={filters}
|
filters={filters}
|
||||||
onFiltersChange={setFilters}
|
onFiltersChange={setFilters}
|
||||||
onAssignMode={(epicId) => { setEpicId(epicId); setTaskId(null); setAssignMode(true); }}
|
onAssignMode={(epicId) => { setEpicId(epicId); setTaskId(null); setAssignMode(true); }}
|
||||||
|
sidebarMode={leftSidebarMode}
|
||||||
|
onSidebarModeChange={setLeftSidebarMode}
|
||||||
|
orchestrator={orchestrator}
|
||||||
|
orchestratorThread={projectOrchestratorChat(runtimeEvents)}
|
||||||
|
projectRoot={projectRoot}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -344,20 +480,22 @@ export function UnifiedShell({
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* MOBILE NAV: Bottom tab bar */}
|
<RuntimeConsole events={runtimeEvents} daemonStatus={daemonLifecycle?.status ?? null} />
|
||||||
<MobileNav />
|
|
||||||
|
{/* MOBILE NAV: Bottom tab bar */}
|
||||||
{/* BLOCKED TRIAGE MODAL */}
|
<MobileNav />
|
||||||
<BlockedTriageModal
|
|
||||||
isOpen={blockedTriageOpen}
|
{/* BLOCKED TRIAGE MODAL */}
|
||||||
onClose={handleCloseBlockedTriage}
|
<BlockedTriageModal
|
||||||
issues={issues}
|
isOpen={blockedTriageOpen}
|
||||||
projectRoot={projectRoot}
|
onClose={handleCloseBlockedTriage}
|
||||||
onSelectTask={(taskId) => {
|
issues={issues}
|
||||||
setTaskId(taskId);
|
projectRoot={projectRoot}
|
||||||
handleCloseBlockedTriage();
|
onSelectTask={(taskId) => {
|
||||||
}}
|
setTaskId(taskId);
|
||||||
/>
|
handleCloseBlockedTriage();
|
||||||
</div>
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,36 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type { KeyboardEvent, MouseEventHandler } from 'react';
|
import type { KeyboardEvent, MouseEventHandler } from 'react';
|
||||||
import { Clock3, GitBranch, Link2, MessageCircle, MessageSquare, Rocket, UserPlus } from 'lucide-react';
|
import { Clock3, GitBranch, Link2, MessageCircle, MessageSquare, UserPlus } from 'lucide-react';
|
||||||
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
import type { SocialCard as SocialCardData, AgentStatus } from '../../lib/social-cards';
|
import type { SocialCard as SocialCardData, AgentStatus } from '../../lib/social-cards';
|
||||||
import { AgentAvatar } from '../shared/agent-avatar';
|
import { AgentAvatar } from '../shared/agent-avatar';
|
||||||
import { useArchetypePicker } from '../../hooks/use-archetype-picker';
|
import { AgentActionRow } from '../agents';
|
||||||
import type { AgentArchetype } from '../../lib/types-swarm';
|
import { useArchetypePicker } from '../../hooks/use-archetype-picker';
|
||||||
|
import type { AgentArchetype } from '../../lib/types-swarm';
|
||||||
|
|
||||||
interface SocialCardProps {
|
interface SocialCardProps {
|
||||||
data: SocialCardData;
|
data: SocialCardData;
|
||||||
className?: string;
|
className?: string;
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
onClick?: MouseEventHandler<HTMLDivElement>;
|
onClick?: MouseEventHandler<HTMLDivElement>;
|
||||||
onJumpToGraph?: (id: string) => void;
|
onJumpToGraph?: (id: string) => void;
|
||||||
onJumpToActivity?: (id: string) => void;
|
onJumpToActivity?: (id: string) => void;
|
||||||
onOpenThread?: () => void;
|
onOpenThread?: () => void;
|
||||||
description?: string;
|
description?: string;
|
||||||
updatedLabel?: string;
|
updatedLabel?: string;
|
||||||
dependencyCount?: number;
|
dependencyCount?: number;
|
||||||
commentCount?: number;
|
commentCount?: number;
|
||||||
unreadCount?: number;
|
unreadCount?: number;
|
||||||
blockedByDetails?: Array<{ id: string; title: string; epic?: string }>;
|
blockedByDetails?: Array<{ id: string; title: string; epic?: string }>;
|
||||||
unblocksDetails?: Array<{ id: string; title: string; epic?: string }>;
|
unblocksDetails?: Array<{ id: string; title: string; epic?: string }>;
|
||||||
archetypes?: AgentArchetype[];
|
archetypes?: AgentArchetype[];
|
||||||
swarmId?: string;
|
projectRoot?: string;
|
||||||
|
swarmId?: string;
|
||||||
onLaunchSwarm?: () => void;
|
onLaunchSwarm?: () => void;
|
||||||
|
onAskOrchestrator?: () => void;
|
||||||
agentUnreadByName?: Record<string, number>;
|
agentUnreadByName?: Record<string, number>;
|
||||||
agentMessagesByName?: Record<string, Array<{
|
agentMessagesByName?: Record<string, Array<{
|
||||||
message_id: string;
|
message_id: string;
|
||||||
|
|
@ -41,82 +44,82 @@ interface SocialCardProps {
|
||||||
agentReservationsByName?: Record<string, string | undefined>;
|
agentReservationsByName?: Record<string, string | undefined>;
|
||||||
onAckMessage?: (agent: string, messageId: string) => Promise<void> | void;
|
onAckMessage?: (agent: string, messageId: string) => Promise<void> | void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCardKeyDown(event: KeyboardEvent<HTMLDivElement>, onClick?: MouseEventHandler<HTMLDivElement>) {
|
function handleCardKeyDown(event: KeyboardEvent<HTMLDivElement>, onClick?: MouseEventHandler<HTMLDivElement>) {
|
||||||
if (!onClick) return;
|
if (!onClick) return;
|
||||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
onClick(event as unknown as Parameters<MouseEventHandler<HTMLDivElement>>[0]);
|
onClick(event as unknown as Parameters<MouseEventHandler<HTMLDivElement>>[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusVisual(status: SocialCardData['status']) {
|
function statusVisual(status: SocialCardData['status']) {
|
||||||
if (status === 'blocked') {
|
if (status === 'blocked') {
|
||||||
return {
|
return {
|
||||||
border: 'var(--accent-danger)',
|
border: 'var(--accent-danger)',
|
||||||
badgeBg: 'var(--status-blocked)',
|
badgeBg: 'var(--status-blocked)',
|
||||||
badgeText: '#ffd5df',
|
badgeText: '#ffd5df',
|
||||||
chipText: 'Blocked',
|
chipText: 'Blocked',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === 'in_progress') {
|
if (status === 'in_progress') {
|
||||||
return {
|
return {
|
||||||
border: 'var(--accent-warning)',
|
border: 'var(--accent-warning)',
|
||||||
badgeBg: 'var(--status-in-progress)',
|
badgeBg: 'var(--status-in-progress)',
|
||||||
badgeText: '#ffe5c7',
|
badgeText: '#ffe5c7',
|
||||||
chipText: 'Active',
|
chipText: 'Active',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === 'ready') {
|
if (status === 'ready') {
|
||||||
return {
|
return {
|
||||||
border: 'var(--accent-success)',
|
border: 'var(--accent-success)',
|
||||||
badgeBg: 'var(--status-ready)',
|
badgeBg: 'var(--status-ready)',
|
||||||
badgeText: '#d6ffe7',
|
badgeText: '#d6ffe7',
|
||||||
chipText: 'Ready',
|
chipText: 'Ready',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
border: 'var(--border-default)',
|
border: 'var(--border-default)',
|
||||||
badgeBg: 'var(--status-closed)',
|
badgeBg: 'var(--status-closed)',
|
||||||
badgeText: 'var(--text-tertiary)',
|
badgeText: 'var(--text-tertiary)',
|
||||||
chipText: 'Closed',
|
chipText: 'Closed',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function dependencyPanel(
|
function dependencyPanel(
|
||||||
title: string,
|
title: string,
|
||||||
color: string,
|
color: string,
|
||||||
details: Array<{ id: string; title: string; epic?: string }>,
|
details: Array<{ id: string; title: string; epic?: string }>,
|
||||||
) {
|
) {
|
||||||
if (details.length === 0) return null;
|
if (details.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border border-[var(--border-subtle)] bg-[var(--surface-tertiary)] px-2.5 py-2">
|
<div className="rounded-md border border-[var(--border-subtle)] bg-[var(--surface-tertiary)] px-2.5 py-2">
|
||||||
<p className="mb-1 font-mono text-[10px] uppercase tracking-[0.12em]" style={{ color }}>
|
<p className="mb-1 font-mono text-[10px] uppercase tracking-[0.12em]" style={{ color }}>
|
||||||
{title}
|
{title}
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{details.slice(0, 1).map((item) => (
|
{details.slice(0, 1).map((item) => (
|
||||||
<div
|
<div
|
||||||
key={`${title}-${item.id}`}
|
key={`${title}-${item.id}`}
|
||||||
className="rounded border border-[var(--border-subtle)] bg-[var(--surface-quaternary)] px-2 py-1.5"
|
className="rounded border border-[var(--border-subtle)] bg-[var(--surface-quaternary)] px-2 py-1.5"
|
||||||
>
|
>
|
||||||
<div className="mb-0.5 flex items-center gap-1">
|
<div className="mb-0.5 flex items-center gap-1">
|
||||||
<span className="h-1.5 w-1.5 rounded-full bg-[var(--accent-info)]" />
|
<span className="h-1.5 w-1.5 rounded-full bg-[var(--accent-info)]" />
|
||||||
<span className="font-mono text-[10px] text-[var(--text-tertiary)]">{item.id}</span>
|
<span className="font-mono text-[10px] text-[var(--text-tertiary)]">{item.id}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="line-clamp-1 text-xs text-[var(--text-primary)]">{item.title}</p>
|
<p className="line-clamp-1 text-xs text-[var(--text-primary)]">{item.title}</p>
|
||||||
{item.epic ? (
|
{item.epic ? (
|
||||||
<p className="line-clamp-1 text-[10px] text-[var(--accent-info)]">↳ {item.epic}</p>
|
<p className="line-clamp-1 text-[10px] text-[var(--accent-info)]">↳ {item.epic}</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{details.length > 1 ? <p className="mt-1 text-[11px] text-[var(--text-tertiary)]">+{details.length - 1} more</p> : null}
|
{details.length > 1 ? <p className="mt-1 text-[11px] text-[var(--text-tertiary)]">+{details.length - 1} more</p> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function categoryBadgeClass(category: 'HANDOFF' | 'BLOCKED' | 'DECISION' | 'INFO'): string {
|
function categoryBadgeClass(category: 'HANDOFF' | 'BLOCKED' | 'DECISION' | 'INFO'): string {
|
||||||
|
|
@ -127,23 +130,24 @@ function categoryBadgeClass(category: 'HANDOFF' | 'BLOCKED' | 'DECISION' | 'INFO
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SocialCard({
|
export function SocialCard({
|
||||||
data,
|
data,
|
||||||
className,
|
className,
|
||||||
selected = false,
|
selected = false,
|
||||||
onClick,
|
onClick,
|
||||||
onJumpToGraph,
|
onJumpToGraph,
|
||||||
onJumpToActivity,
|
onJumpToActivity,
|
||||||
onOpenThread,
|
description,
|
||||||
description,
|
updatedLabel = 'just now',
|
||||||
updatedLabel = 'just now',
|
dependencyCount,
|
||||||
dependencyCount,
|
commentCount,
|
||||||
commentCount,
|
unreadCount = 0,
|
||||||
unreadCount = 0,
|
blockedByDetails = [],
|
||||||
blockedByDetails = [],
|
unblocksDetails = [],
|
||||||
unblocksDetails = [],
|
|
||||||
archetypes = [],
|
archetypes = [],
|
||||||
|
projectRoot,
|
||||||
swarmId,
|
swarmId,
|
||||||
onLaunchSwarm,
|
onLaunchSwarm,
|
||||||
|
onAskOrchestrator,
|
||||||
agentUnreadByName = {},
|
agentUnreadByName = {},
|
||||||
agentMessagesByName = {},
|
agentMessagesByName = {},
|
||||||
agentReservationsByName = {},
|
agentReservationsByName = {},
|
||||||
|
|
@ -155,52 +159,52 @@ export function SocialCard({
|
||||||
const isSwarmHighlighted = swarmId && data.id.includes(swarmId);
|
const isSwarmHighlighted = swarmId && data.id.includes(swarmId);
|
||||||
const [expandedAgent, setExpandedAgent] = useState<string | null>(null);
|
const [expandedAgent, setExpandedAgent] = useState<string | null>(null);
|
||||||
const [ackingMessageId, setAckingMessageId] = useState<string | null>(null);
|
const [ackingMessageId, setAckingMessageId] = useState<string | null>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
onKeyDown={(event) => handleCardKeyDown(event, onClick)}
|
onKeyDown={(event) => handleCardKeyDown(event, onClick)}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
aria-label={`Open ${data.title}`}
|
aria-label={`Open ${data.title}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
'group relative flex min-h-[290px] cursor-pointer flex-col rounded-[14px] border px-3.5 py-3 text-left transition-all duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-info)]',
|
'group relative flex min-h-[290px] cursor-pointer flex-col rounded-[14px] border px-3.5 py-3 text-left transition-all duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-info)]',
|
||||||
isSwarmHighlighted && 'ring-2 ring-blue-500',
|
isSwarmHighlighted && 'ring-2 ring-blue-500',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--surface-quaternary)',
|
background: 'var(--surface-quaternary)',
|
||||||
borderColor: selected ? status.border : 'var(--border-default)',
|
borderColor: selected ? status.border : 'var(--border-default)',
|
||||||
boxShadow: selected
|
boxShadow: selected
|
||||||
? `0 0 0 2px ${status.border}, 0 20px 40px -20px rgba(0,0,0,0.6)`
|
? `0 0 0 2px ${status.border}, 0 20px 40px -20px rgba(0,0,0,0.6)`
|
||||||
: '0 4px 12px -6px rgba(0,0,0,0.4)',
|
: '0 4px 12px -6px rgba(0,0,0,0.4)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="mb-2 flex items-center justify-between gap-2">
|
<div className="mb-2 flex items-center justify-between gap-2">
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<Badge className="rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.1em]" style={{ backgroundColor: status.badgeBg, color: status.badgeText }}>
|
<Badge className="rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.1em]" style={{ backgroundColor: status.badgeBg, color: status.badgeText }}>
|
||||||
{status.chipText}
|
{status.chipText}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span className="font-mono text-[11px] text-[var(--accent-info)]">{data.priority}</span>
|
<span className="font-mono text-[11px] text-[var(--accent-info)]">{data.priority}</span>
|
||||||
<span className="truncate font-mono text-[11px] text-[var(--text-tertiary)]">{data.id}</span>
|
<span className="truncate font-mono text-[11px] text-[var(--text-tertiary)]">{data.id}</span>
|
||||||
{unreadCount > 0 ? (
|
{unreadCount > 0 ? (
|
||||||
<span className="inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-[var(--accent-danger)] px-1 text-[10px] font-semibold text-[var(--text-inverse)]">
|
<span className="inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-[var(--accent-danger)] px-1 text-[10px] font-semibold text-[var(--text-inverse)]">
|
||||||
{unreadCount}
|
{unreadCount}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 className="line-clamp-2 text-[27px] font-semibold leading-[1.13] tracking-[-0.01em] text-[var(--text-primary)]">{data.title}</h3>
|
<h3 className="line-clamp-2 text-[27px] font-semibold leading-[1.13] tracking-[-0.01em] text-[var(--text-primary)]">{data.title}</h3>
|
||||||
<p className="mt-1.5 line-clamp-3 min-h-[56px] text-[13px] leading-relaxed text-[var(--text-tertiary)]">
|
<p className="mt-1.5 line-clamp-3 min-h-[56px] text-[13px] leading-relaxed text-[var(--text-tertiary)]">
|
||||||
{description || 'No summary provided yet.'}
|
{description || 'No summary provided yet.'}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-2 flex flex-col gap-2">
|
<div className="mt-2 flex flex-col gap-2">
|
||||||
{dependencyPanel('Blocked By', 'var(--accent-danger)', blockedByDetails)}
|
{dependencyPanel('Blocked By', 'var(--accent-danger)', blockedByDetails)}
|
||||||
{dependencyPanel('Unblocks', 'var(--accent-success)', unblocksDetails)}
|
{dependencyPanel('Unblocks', 'var(--accent-success)', unblocksDetails)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-2 flex items-center gap-2">
|
<div className="mt-2 flex items-center gap-2">
|
||||||
{data.agents.slice(0, 3).map((agent) => {
|
{data.agents.slice(0, 3).map((agent) => {
|
||||||
const unreadCount = agentUnreadByName[agent.name] ?? 0;
|
const unreadCount = agentUnreadByName[agent.name] ?? 0;
|
||||||
|
|
@ -286,87 +290,98 @@ export function SocialCard({
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{showAssign && (
|
{showAssign && (
|
||||||
<div className="mt-2 flex gap-2 items-center overflow-hidden" onClick={(e) => e.stopPropagation()}>
|
<div className="mt-2 flex gap-2 items-center overflow-hidden" onClick={(e) => e.stopPropagation()}>
|
||||||
<select
|
<select
|
||||||
value={selectedArchetype ?? ''}
|
value={selectedArchetype ?? ''}
|
||||||
onChange={(e) => setSelectedArchetype(e.target.value || null)}
|
onChange={(e) => setSelectedArchetype(e.target.value || null)}
|
||||||
className="min-w-0 flex-1 text-xs border border-[var(--border-subtle)] rounded-md px-2 py-1.5 bg-[var(--surface-input)] text-[var(--text-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--accent-info)]"
|
className="min-w-0 flex-1 text-xs border border-[var(--border-subtle)] rounded-md px-2 py-1.5 bg-[var(--surface-input)] text-[var(--text-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--accent-info)]"
|
||||||
>
|
>
|
||||||
<option value="" disabled>Select agent role...</option>
|
<option value="" disabled>Select agent role...</option>
|
||||||
{archetypes.map((a) => (
|
{archetypes.map((a) => (
|
||||||
<option key={a.id} value={a.id}>{a.name}</option>
|
<option key={a.id} value={a.id}>{a.name}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<button
|
<button
|
||||||
onClick={async (e) => {
|
onClick={async (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
await handleAssign(data.id);
|
await handleAssign(data.id);
|
||||||
}}
|
}}
|
||||||
disabled={!selectedArchetype || isAssigning || assignSuccess}
|
disabled={!selectedArchetype || isAssigning || assignSuccess}
|
||||||
className={`flex-shrink-0 px-2.5 py-1.5 text-xs font-semibold rounded-md transition-colors disabled:opacity-50 flex items-center gap-1 ${assignSuccess ? 'bg-[var(--accent-success)] text-white' : 'bg-[var(--accent-info)] text-white hover:bg-[var(--accent-info)]/90'}`}
|
className={`flex-shrink-0 px-2.5 py-1.5 text-xs font-semibold rounded-md transition-colors disabled:opacity-50 flex items-center gap-1 ${assignSuccess ? 'bg-[var(--accent-success)] text-white' : 'bg-[var(--accent-info)] text-white hover:bg-[var(--accent-info)]/90'}`}
|
||||||
>
|
>
|
||||||
<UserPlus className="w-3 h-3" />
|
<UserPlus className="w-3 h-3" />
|
||||||
{isAssigning ? '...' : assignSuccess ? '✓' : 'Assign'}
|
{isAssigning ? '...' : assignSuccess ? '✓' : 'Assign'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-auto border-t border-[var(--border-subtle)] pt-1.5">
|
<div className="mt-auto border-t border-[var(--border-subtle)] pt-1.5">
|
||||||
<div className="mb-1.5 flex items-center justify-between text-xs text-[var(--text-tertiary)]">
|
<div className="mb-1.5 flex items-center justify-between text-xs text-[var(--text-tertiary)]">
|
||||||
<span className="inline-flex items-center gap-1"><Clock3 className="h-3.5 w-3.5" aria-hidden="true" />{updatedLabel}</span>
|
<span className="inline-flex items-center gap-1"><Clock3 className="h-3.5 w-3.5" aria-hidden="true" />{updatedLabel}</span>
|
||||||
<span className="font-mono text-[11px] text-[var(--accent-success)]">stage active</span>
|
<span className="font-mono text-[11px] text-[var(--accent-success)]">stage active</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3 text-xs text-[var(--text-tertiary)]">
|
<div className="flex items-center gap-3 text-xs text-[var(--text-tertiary)]">
|
||||||
<span className="inline-flex items-center gap-1"><Link2 className="h-3.5 w-3.5" aria-hidden="true" />{dependencyCount ?? data.blocks.length + data.unblocks.length}</span>
|
<span className="inline-flex items-center gap-1"><Link2 className="h-3.5 w-3.5" aria-hidden="true" />{dependencyCount ?? data.blocks.length + data.unblocks.length}</span>
|
||||||
<span className="inline-flex items-center gap-1"><MessageCircle className="h-3.5 w-3.5" aria-hidden="true" />{commentCount ?? 0}</span>
|
<span className="inline-flex items-center gap-1"><MessageCircle className="h-3.5 w-3.5" aria-hidden="true" />{commentCount ?? 0}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
onJumpToGraph?.(data.id);
|
onJumpToGraph?.(data.id);
|
||||||
}}
|
}}
|
||||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md border border-[var(--border-subtle)] bg-[var(--surface-tertiary)] text-[var(--accent-info)] transition-colors hover:bg-[var(--alpha-white-low)]"
|
className="inline-flex h-7 w-7 items-center justify-center rounded-md border border-[var(--border-subtle)] bg-[var(--surface-tertiary)] text-[var(--accent-info)] transition-colors hover:bg-[var(--alpha-white-low)]"
|
||||||
aria-label="View dependency graph"
|
aria-label="View dependency graph"
|
||||||
title="View dependency graph"
|
title="View dependency graph"
|
||||||
>
|
>
|
||||||
<GitBranch className="h-3.5 w-3.5" aria-hidden="true" />
|
<GitBranch className="h-3.5 w-3.5" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
onJumpToActivity?.(data.id);
|
onJumpToActivity?.(data.id);
|
||||||
}}
|
}}
|
||||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md border border-[var(--border-subtle)] bg-[var(--surface-tertiary)] text-[var(--accent-success)] transition-colors hover:bg-[var(--alpha-white-low)]"
|
className="inline-flex h-7 w-7 items-center justify-center rounded-md border border-[var(--border-subtle)] bg-[var(--surface-tertiary)] text-[var(--accent-success)] transition-colors hover:bg-[var(--alpha-white-low)]"
|
||||||
aria-label="View details"
|
aria-label="View details"
|
||||||
title="View details"
|
title="View details"
|
||||||
>
|
>
|
||||||
<MessageSquare className="h-3.5 w-3.5" aria-hidden="true" />
|
<MessageSquare className="h-3.5 w-3.5" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
{onLaunchSwarm ? (
|
{onAskOrchestrator ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
onLaunchSwarm();
|
onAskOrchestrator();
|
||||||
}}
|
}}
|
||||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md border border-emerald-500/20 bg-emerald-500/10 text-emerald-400 transition-colors hover:bg-emerald-500/20"
|
className="inline-flex items-center gap-1 rounded-md border border-cyan-500/20 bg-cyan-500/10 px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.1em] text-cyan-200 transition-colors hover:bg-cyan-500/20"
|
||||||
aria-label="Launch Swarm"
|
aria-label="Ask orchestrator"
|
||||||
title="Launch Swarm"
|
title="Ask Orchestrator"
|
||||||
>
|
>
|
||||||
<Rocket className="h-3.5 w-3.5" aria-hidden="true" />
|
<MessageSquare className="h-3.5 w-3.5" aria-hidden="true" />
|
||||||
</button>
|
Ask
|
||||||
) : null}
|
</button>
|
||||||
</div>
|
) : null}
|
||||||
</div>
|
{projectRoot && archetypes.length > 0 ? (
|
||||||
</div>
|
<AgentActionRow
|
||||||
</div>
|
beadId={data.id}
|
||||||
);
|
beadStatus={data.status}
|
||||||
}
|
agents={archetypes}
|
||||||
|
projectRoot={projectRoot}
|
||||||
|
currentAgentTypeId={data.agentTypeId}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,22 +2,23 @@
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
import type { BeadIssue } from '../../lib/types';
|
import type { BeadIssue } from '../../lib/types';
|
||||||
import type { ProjectScopeOption } from '../../lib/project-scope';
|
import type { ProjectScopeOption } from '../../lib/project-scope';
|
||||||
import { buildSocialCards } from '../../lib/social-cards';
|
import { buildSocialCards } from '../../lib/social-cards';
|
||||||
import { SocialCard } from './social-card';
|
import { SocialCard } from './social-card';
|
||||||
import { useArchetypes } from '../../hooks/use-archetypes';
|
import { useArchetypes } from '../../hooks/use-archetypes';
|
||||||
|
|
||||||
interface SocialPageProps {
|
interface SocialPageProps {
|
||||||
issues: BeadIssue[];
|
issues: BeadIssue[];
|
||||||
selectedId?: string;
|
selectedId?: string;
|
||||||
onSelect: (id: string) => void;
|
onSelect: (id: string) => void;
|
||||||
projectScopeOptions?: ProjectScopeOption[];
|
projectScopeOptions?: ProjectScopeOption[];
|
||||||
blockedOnly?: boolean;
|
blockedOnly?: boolean;
|
||||||
projectRoot: string;
|
projectRoot: string;
|
||||||
swarmId?: string;
|
swarmId?: string;
|
||||||
onRocketClick?: () => void;
|
onRocketClick?: () => void;
|
||||||
|
onAskOrchestrator?: (issueId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CoordMessage {
|
interface CoordMessage {
|
||||||
|
|
@ -30,137 +31,138 @@ interface CoordMessage {
|
||||||
state: 'unread' | 'read' | 'acked';
|
state: 'unread' | 'read' | 'acked';
|
||||||
requires_ack: boolean;
|
requires_ack: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type SectionKey = 'ready' | 'in_progress' | 'blocked' | 'deferred' | 'done';
|
type SectionKey = 'ready' | 'in_progress' | 'blocked' | 'deferred' | 'done';
|
||||||
|
|
||||||
const SECTION_LABEL: Record<SectionKey, string> = {
|
const SECTION_LABEL: Record<SectionKey, string> = {
|
||||||
ready: 'Ready',
|
ready: 'Ready',
|
||||||
in_progress: 'In Progress',
|
in_progress: 'In Progress',
|
||||||
blocked: 'Blocked',
|
blocked: 'Blocked',
|
||||||
deferred: 'Deferred',
|
deferred: 'Deferred',
|
||||||
done: 'Done',
|
done: 'Done',
|
||||||
};
|
};
|
||||||
|
|
||||||
const SECTION_COLOR: Record<SectionKey, string> = {
|
const SECTION_COLOR: Record<SectionKey, string> = {
|
||||||
ready: 'var(--ui-accent-ready)',
|
ready: 'var(--ui-accent-ready)',
|
||||||
in_progress: 'var(--ui-accent-warning)',
|
in_progress: 'var(--ui-accent-warning)',
|
||||||
blocked: 'var(--ui-accent-blocked)',
|
blocked: 'var(--ui-accent-blocked)',
|
||||||
deferred: 'var(--ui-accent-info)',
|
deferred: 'var(--ui-accent-info)',
|
||||||
done: 'var(--ui-text-muted)',
|
done: 'var(--ui-text-muted)',
|
||||||
};
|
};
|
||||||
|
|
||||||
function bucketForStatus(status: string): SectionKey {
|
function bucketForStatus(status: string): SectionKey {
|
||||||
if (status === 'ready') return 'ready';
|
if (status === 'ready') return 'ready';
|
||||||
if (status === 'in_progress') return 'in_progress';
|
if (status === 'in_progress') return 'in_progress';
|
||||||
if (status === 'blocked') return 'blocked';
|
if (status === 'blocked') return 'blocked';
|
||||||
if (status === 'closed') return 'done';
|
if (status === 'closed') return 'done';
|
||||||
return 'deferred';
|
return 'deferred';
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatRelative(timestamp: string): string {
|
function formatRelative(timestamp: string): string {
|
||||||
const then = new Date(timestamp);
|
const then = new Date(timestamp);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const diffMins = Math.floor((now.getTime() - then.getTime()) / 60000);
|
const diffMins = Math.floor((now.getTime() - then.getTime()) / 60000);
|
||||||
|
|
||||||
if (diffMins < 1) return 'just now';
|
if (diffMins < 1) return 'just now';
|
||||||
if (diffMins < 60) return `${diffMins}m ago`;
|
if (diffMins < 60) return `${diffMins}m ago`;
|
||||||
|
|
||||||
const diffHours = Math.floor(diffMins / 60);
|
const diffHours = Math.floor(diffMins / 60);
|
||||||
if (diffHours < 24) return `${diffHours}h ago`;
|
if (diffHours < 24) return `${diffHours}h ago`;
|
||||||
|
|
||||||
const diffDays = Math.floor(diffHours / 24);
|
const diffDays = Math.floor(diffHours / 24);
|
||||||
return `${diffDays}d ago`;
|
return `${diffDays}d ago`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SocialPage({
|
export function SocialPage({
|
||||||
issues,
|
issues,
|
||||||
selectedId,
|
selectedId,
|
||||||
onSelect,
|
onSelect,
|
||||||
projectScopeOptions = [],
|
projectScopeOptions = [],
|
||||||
blockedOnly = false,
|
blockedOnly = false,
|
||||||
projectRoot,
|
projectRoot,
|
||||||
swarmId,
|
swarmId,
|
||||||
onRocketClick,
|
onRocketClick,
|
||||||
}: SocialPageProps) {
|
onAskOrchestrator,
|
||||||
const router = useRouter();
|
}: SocialPageProps) {
|
||||||
const searchParams = useSearchParams();
|
const router = useRouter();
|
||||||
const cards = useMemo(() => buildSocialCards(issues), [issues]);
|
const searchParams = useSearchParams();
|
||||||
const { archetypes } = useArchetypes(projectRoot);
|
const cards = useMemo(() => buildSocialCards(issues), [issues]);
|
||||||
|
const { archetypes } = useArchetypes(projectRoot);
|
||||||
const navigateWithParams = (updates: Record<string, string | null>) => {
|
|
||||||
const next = new URLSearchParams(searchParams.toString());
|
const navigateWithParams = (updates: Record<string, string | null>) => {
|
||||||
for (const [key, value] of Object.entries(updates)) {
|
const next = new URLSearchParams(searchParams.toString());
|
||||||
if (!value) next.delete(key);
|
for (const [key, value] of Object.entries(updates)) {
|
||||||
else next.set(key, value);
|
if (!value) next.delete(key);
|
||||||
}
|
else next.set(key, value);
|
||||||
const query = next.toString();
|
}
|
||||||
router.push(query ? `/?${query}` : '/', { scroll: false });
|
const query = next.toString();
|
||||||
};
|
router.push(query ? `/?${query}` : '/', { scroll: false });
|
||||||
|
};
|
||||||
const issueById = useMemo(() => {
|
|
||||||
const map = new Map<string, BeadIssue>();
|
const issueById = useMemo(() => {
|
||||||
for (const issue of issues) map.set(issue.id, issue);
|
const map = new Map<string, BeadIssue>();
|
||||||
return map;
|
for (const issue of issues) map.set(issue.id, issue);
|
||||||
}, [issues]);
|
return map;
|
||||||
const epicTitleById = useMemo(() => {
|
}, [issues]);
|
||||||
const map = new Map<string, string>();
|
const epicTitleById = useMemo(() => {
|
||||||
for (const issue of issues) {
|
const map = new Map<string, string>();
|
||||||
if (issue.issue_type === 'epic') {
|
for (const issue of issues) {
|
||||||
map.set(issue.id, issue.title);
|
if (issue.issue_type === 'epic') {
|
||||||
}
|
map.set(issue.id, issue.title);
|
||||||
}
|
}
|
||||||
return map;
|
}
|
||||||
}, [issues]);
|
return map;
|
||||||
|
}, [issues]);
|
||||||
const toDependencyDetails = (ids: string[]) =>
|
|
||||||
ids.map((id) => {
|
const toDependencyDetails = (ids: string[]) =>
|
||||||
const depIssue = issueById.get(id);
|
ids.map((id) => {
|
||||||
const parentEpicId = depIssue?.dependencies.find((dep) => dep.type === 'parent')?.target;
|
const depIssue = issueById.get(id);
|
||||||
return {
|
const parentEpicId = depIssue?.dependencies.find((dep) => dep.type === 'parent')?.target;
|
||||||
id,
|
return {
|
||||||
title: depIssue?.title ?? id,
|
id,
|
||||||
epic: parentEpicId ? epicTitleById.get(parentEpicId) : undefined,
|
title: depIssue?.title ?? id,
|
||||||
};
|
epic: parentEpicId ? epicTitleById.get(parentEpicId) : undefined,
|
||||||
});
|
};
|
||||||
|
});
|
||||||
const orderedCards = useMemo(
|
|
||||||
() => [...cards].sort((a, b) => b.lastActivity.getTime() - a.lastActivity.getTime()),
|
const orderedCards = useMemo(
|
||||||
[cards],
|
() => [...cards].sort((a, b) => b.lastActivity.getTime() - a.lastActivity.getTime()),
|
||||||
);
|
[cards],
|
||||||
|
);
|
||||||
const visibleCards = useMemo(
|
|
||||||
() => (blockedOnly ? orderedCards.filter((card) => card.status === 'blocked') : orderedCards),
|
const visibleCards = useMemo(
|
||||||
[blockedOnly, orderedCards],
|
() => (blockedOnly ? orderedCards.filter((card) => card.status === 'blocked') : orderedCards),
|
||||||
);
|
[blockedOnly, orderedCards],
|
||||||
|
);
|
||||||
const grouped = useMemo(() => {
|
|
||||||
const map: Record<SectionKey, typeof visibleCards> = {
|
const grouped = useMemo(() => {
|
||||||
ready: [],
|
const map: Record<SectionKey, typeof visibleCards> = {
|
||||||
in_progress: [],
|
ready: [],
|
||||||
blocked: [],
|
in_progress: [],
|
||||||
deferred: [],
|
blocked: [],
|
||||||
done: [],
|
deferred: [],
|
||||||
};
|
done: [],
|
||||||
|
};
|
||||||
for (const card of visibleCards) {
|
|
||||||
map[bucketForStatus(card.status)].push(card);
|
for (const card of visibleCards) {
|
||||||
}
|
map[bucketForStatus(card.status)].push(card);
|
||||||
|
}
|
||||||
return map;
|
|
||||||
}, [visibleCards]);
|
return map;
|
||||||
|
}, [visibleCards]);
|
||||||
const [expandedSections, setExpandedSections] = useState<Record<SectionKey, boolean>>({
|
const [expandedSections, setExpandedSections] = useState<Record<SectionKey, boolean>>({
|
||||||
ready: false,
|
ready: false,
|
||||||
in_progress: false,
|
in_progress: false,
|
||||||
blocked: false,
|
blocked: false,
|
||||||
deferred: false,
|
deferred: false,
|
||||||
done: false,
|
done: false,
|
||||||
});
|
});
|
||||||
const [collapsedSections, setCollapsedSections] = useState<Record<SectionKey, boolean>>({
|
const [collapsedSections, setCollapsedSections] = useState<Record<SectionKey, boolean>>({
|
||||||
ready: false,
|
ready: false,
|
||||||
in_progress: false,
|
in_progress: false,
|
||||||
blocked: false,
|
blocked: false,
|
||||||
deferred: true,
|
deferred: true,
|
||||||
done: true,
|
done: true,
|
||||||
});
|
});
|
||||||
const [agentMessagesByName, setAgentMessagesByName] = useState<Record<string, CoordMessage[]>>({});
|
const [agentMessagesByName, setAgentMessagesByName] = useState<Record<string, CoordMessage[]>>({});
|
||||||
const [agentUnreadByName, setAgentUnreadByName] = useState<Record<string, number>>({});
|
const [agentUnreadByName, setAgentUnreadByName] = useState<Record<string, number>>({});
|
||||||
|
|
@ -243,100 +245,102 @@ export function SocialPage({
|
||||||
});
|
});
|
||||||
await refreshCoordination();
|
await refreshCoordination();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full overflow-y-auto bg-[var(--ui-bg-app)] custom-scrollbar">
|
<div className="h-full overflow-y-auto bg-[var(--ui-bg-app)] custom-scrollbar">
|
||||||
<div className="mx-auto w-full max-w-[1280px] px-4 pb-8 pt-4 xl:px-6">
|
<div className="mx-auto w-full max-w-[1280px] px-4 pb-8 pt-4 xl:px-6">
|
||||||
<div className="mb-4 flex items-center justify-between gap-3 border-b border-[var(--ui-border-soft)] pb-3">
|
<div className="mb-4 flex items-center justify-between gap-3 border-b border-[var(--ui-border-soft)] pb-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-mono text-[10px] uppercase tracking-[0.14em] text-[var(--ui-text-muted)]">Social Stream</p>
|
<p className="font-mono text-[10px] uppercase tracking-[0.14em] text-[var(--ui-text-muted)]">Social Stream</p>
|
||||||
<p className="text-sm text-[var(--ui-text-primary)]">Task Activity Command Feed</p>
|
<p className="text-sm text-[var(--ui-text-primary)]">Task Activity Command Feed</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-[11px]">
|
<div className="flex items-center gap-2 text-[11px]">
|
||||||
<span className="rounded-full border border-[var(--ui-border-soft)] bg-[var(--ui-bg-panel)] px-2 py-1 text-[var(--ui-text-muted)]">
|
<span className="rounded-full border border-[var(--ui-border-soft)] bg-[var(--ui-bg-panel)] px-2 py-1 text-[var(--ui-text-muted)]">
|
||||||
{projectScopeOptions.length} scopes
|
{projectScopeOptions.length} scopes
|
||||||
</span>
|
</span>
|
||||||
<span className="rounded-full border border-[var(--ui-border-soft)] bg-[var(--ui-bg-panel)] px-2 py-1 text-[var(--ui-text-muted)]">
|
<span className="rounded-full border border-[var(--ui-border-soft)] bg-[var(--ui-bg-panel)] px-2 py-1 text-[var(--ui-text-muted)]">
|
||||||
{visibleCards.length} tasks
|
{visibleCards.length} tasks
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="space-y-6">
|
<section className="space-y-6">
|
||||||
{(Object.keys(SECTION_LABEL) as SectionKey[]).map((key) => {
|
{(Object.keys(SECTION_LABEL) as SectionKey[]).map((key) => {
|
||||||
const cardsForSection = grouped[key];
|
const cardsForSection = grouped[key];
|
||||||
return (
|
return (
|
||||||
<div key={key}>
|
<div key={key}>
|
||||||
<div className="mb-3 flex items-center gap-2 border-b border-[var(--ui-border-soft)] pb-2">
|
<div className="mb-3 flex items-center gap-2 border-b border-[var(--ui-border-soft)] pb-2">
|
||||||
<p className="font-mono text-xs uppercase tracking-[0.14em]" style={{ color: SECTION_COLOR[key] }}>
|
<p className="font-mono text-xs uppercase tracking-[0.14em]" style={{ color: SECTION_COLOR[key] }}>
|
||||||
{SECTION_LABEL[key]}
|
{SECTION_LABEL[key]}
|
||||||
</p>
|
</p>
|
||||||
<span className="inline-flex h-5 min-w-5 items-center justify-center rounded-full border border-[var(--ui-border-soft)] bg-[var(--ui-bg-panel)] px-1.5 text-[10px] text-[var(--ui-text-primary)]">
|
<span className="inline-flex h-5 min-w-5 items-center justify-center rounded-full border border-[var(--ui-border-soft)] bg-[var(--ui-bg-panel)] px-1.5 text-[10px] text-[var(--ui-text-primary)]">
|
||||||
{cardsForSection.length}
|
{cardsForSection.length}
|
||||||
</span>
|
</span>
|
||||||
{(key === 'deferred' || key === 'done') ? (
|
{(key === 'deferred' || key === 'done') ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setCollapsedSections((current) => ({ ...current, [key]: !current[key] }))
|
setCollapsedSections((current) => ({ ...current, [key]: !current[key] }))
|
||||||
}
|
}
|
||||||
className="ml-auto rounded border border-[var(--ui-border-soft)] bg-[var(--ui-bg-panel)] px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.1em] text-[var(--ui-text-muted)] transition-colors hover:text-[var(--ui-text-primary)]"
|
className="ml-auto rounded border border-[var(--ui-border-soft)] bg-[var(--ui-bg-panel)] px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.1em] text-[var(--ui-text-muted)] transition-colors hover:text-[var(--ui-text-primary)]"
|
||||||
>
|
>
|
||||||
{collapsedSections[key] ? 'Expand' : 'Minimize'}
|
{collapsedSections[key] ? 'Expand' : 'Minimize'}
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{collapsedSections[key] ? (
|
{collapsedSections[key] ? (
|
||||||
<p className="px-0.5 py-1 text-sm text-[var(--ui-text-muted)]">
|
<p className="px-0.5 py-1 text-sm text-[var(--ui-text-muted)]">
|
||||||
{cardsForSection.length === 0
|
{cardsForSection.length === 0
|
||||||
? `No tasks in ${SECTION_LABEL[key].toLowerCase()}.`
|
? `No tasks in ${SECTION_LABEL[key].toLowerCase()}.`
|
||||||
: `${cardsForSection.length} tasks hidden.`}
|
: `${cardsForSection.length} tasks hidden.`}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||||
{(expandedSections[key] ? cardsForSection : cardsForSection.slice(0, 3)).map((card) => {
|
{(expandedSections[key] ? cardsForSection : cardsForSection.slice(0, 3)).map((card) => {
|
||||||
const issue = issueById.get(card.id);
|
const issue = issueById.get(card.id);
|
||||||
const commentCount = typeof issue?.metadata?.commentCount === 'number' ? issue.metadata.commentCount : 0;
|
const commentCount = typeof issue?.metadata?.commentCount === 'number' ? issue.metadata.commentCount : 0;
|
||||||
const unreadCount = typeof issue?.metadata?.unreadCount === 'number' ? issue.metadata.unreadCount : 0;
|
const unreadCount = typeof issue?.metadata?.unreadCount === 'number' ? issue.metadata.unreadCount : 0;
|
||||||
const dependencyCount = issue?.dependencies.length ?? card.blocks.length + card.unblocks.length;
|
const dependencyCount = issue?.dependencies.length ?? card.blocks.length + card.unblocks.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SocialCard
|
<SocialCard
|
||||||
key={card.id}
|
key={card.id}
|
||||||
data={card}
|
data={card}
|
||||||
selected={selectedId === card.id}
|
selected={selectedId === card.id}
|
||||||
onClick={() => onSelect(card.id)}
|
onClick={() => onSelect(card.id)}
|
||||||
onJumpToGraph={(id) =>
|
onJumpToGraph={(id) =>
|
||||||
navigateWithParams({
|
navigateWithParams({
|
||||||
view: 'graph',
|
view: 'graph',
|
||||||
graphTab: 'flow',
|
graphTab: 'flow',
|
||||||
task: id,
|
task: id,
|
||||||
swarm: null,
|
swarm: null,
|
||||||
right: 'open',
|
right: 'open',
|
||||||
panel: 'open',
|
panel: 'open',
|
||||||
drawer: 'closed',
|
drawer: 'closed',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
onJumpToActivity={(id) =>
|
onJumpToActivity={(id) =>
|
||||||
navigateWithParams({
|
navigateWithParams({
|
||||||
task: id,
|
task: id,
|
||||||
right: 'open',
|
right: 'open',
|
||||||
panel: 'open',
|
panel: 'open',
|
||||||
drawer: 'closed',
|
drawer: 'closed',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
onOpenThread={() => onSelect(card.id)}
|
onOpenThread={() => onSelect(card.id)}
|
||||||
description={issue?.description ?? undefined}
|
description={issue?.description ?? undefined}
|
||||||
updatedLabel={issue ? formatRelative(issue.updated_at) : 'just now'}
|
updatedLabel={issue ? formatRelative(issue.updated_at) : 'just now'}
|
||||||
dependencyCount={dependencyCount}
|
dependencyCount={dependencyCount}
|
||||||
commentCount={commentCount}
|
commentCount={commentCount}
|
||||||
unreadCount={unreadCount}
|
unreadCount={unreadCount}
|
||||||
blockedByDetails={toDependencyDetails(card.unblocks)}
|
blockedByDetails={toDependencyDetails(card.unblocks)}
|
||||||
unblocksDetails={toDependencyDetails(card.blocks)}
|
unblocksDetails={toDependencyDetails(card.blocks)}
|
||||||
archetypes={archetypes}
|
archetypes={archetypes}
|
||||||
swarmId={swarmId}
|
projectRoot={projectRoot}
|
||||||
|
swarmId={swarmId}
|
||||||
onLaunchSwarm={onRocketClick}
|
onLaunchSwarm={onRocketClick}
|
||||||
|
onAskOrchestrator={() => onAskOrchestrator?.(card.id)}
|
||||||
agentUnreadByName={agentUnreadByName}
|
agentUnreadByName={agentUnreadByName}
|
||||||
agentMessagesByName={agentMessagesByName}
|
agentMessagesByName={agentMessagesByName}
|
||||||
agentReservationsByName={agentReservationsByName}
|
agentReservationsByName={agentReservationsByName}
|
||||||
|
|
@ -344,38 +348,38 @@ export function SocialPage({
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{cardsForSection.length === 0 ? (
|
{cardsForSection.length === 0 ? (
|
||||||
<p className="px-0.5 py-1 text-sm text-[var(--ui-text-muted)]">
|
<p className="px-0.5 py-1 text-sm text-[var(--ui-text-muted)]">
|
||||||
No tasks in this lane.
|
No tasks in this lane.
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!collapsedSections[key] && cardsForSection.length > 3 ? (
|
{!collapsedSections[key] && cardsForSection.length > 3 ? (
|
||||||
<div className="mt-2 flex justify-end">
|
<div className="mt-2 flex justify-end">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setExpandedSections((current) => ({ ...current, [key]: !current[key] }))
|
setExpandedSections((current) => ({ ...current, [key]: !current[key] }))
|
||||||
}
|
}
|
||||||
className="rounded-md border border-[var(--ui-border-soft)] bg-[var(--ui-bg-panel)] px-2.5 py-1.5 text-xs font-semibold uppercase tracking-[0.1em] text-[var(--ui-text-muted)] transition-colors hover:text-[var(--ui-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ui-accent-info)]"
|
className="rounded-md border border-[var(--ui-border-soft)] bg-[var(--ui-bg-panel)] px-2.5 py-1.5 text-xs font-semibold uppercase tracking-[0.1em] text-[var(--ui-text-muted)] transition-colors hover:text-[var(--ui-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ui-accent-info)]"
|
||||||
>
|
>
|
||||||
{expandedSections[key] ? 'Show Less' : `Show ${cardsForSection.length - 3} More`}
|
{expandedSections[key] ? 'Show Less' : `Show ${cardsForSection.length - 3} More`}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{visibleCards.length === 0 ? (
|
{visibleCards.length === 0 ? (
|
||||||
<p className="mt-5 px-0.5 py-1 text-sm text-[var(--ui-text-muted)]">
|
<p className="mt-5 px-0.5 py-1 text-sm text-[var(--ui-text-muted)]">
|
||||||
No blocked tasks right now.
|
No blocked tasks right now.
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
484
src/components/swarm/agent-inspector.tsx
Normal file
484
src/components/swarm/agent-inspector.tsx
Normal file
|
|
@ -0,0 +1,484 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { X, Save, ShieldAlert, Trash2, Plus, Copy, Palette, Smile } from 'lucide-react';
|
||||||
|
import type { AgentArchetype } from '../../lib/types-swarm';
|
||||||
|
|
||||||
|
const COLOR_PRESETS = [
|
||||||
|
'#3b82f6', '#2563eb', '#1d4ed8', '#0ea5e9', '#06b6d4',
|
||||||
|
'#10b981', '#059669', '#22c55e', '#84cc16', '#a3e635',
|
||||||
|
'#8b5cf6', '#7c3aed', '#a855f7', '#c084fc', '#e879f9',
|
||||||
|
'#ef4444', '#dc2626', '#f97316', '#fb923c', '#fbbf24',
|
||||||
|
'#ec4899', '#db2777', '#f472b6', '#f9a8d4', '#fda4af',
|
||||||
|
'#6366f1', '#64748b', '#78716c', '#57534e', '#1e293b',
|
||||||
|
];
|
||||||
|
|
||||||
|
const EMOJI_PRESETS = [
|
||||||
|
'🏗️', '⚙️', '🔍', '🧪', '🚀', '🤖', '👨💻', '👩💻', '🧙♂️', '🧙♀️',
|
||||||
|
'🔧', '📝', '🎯', '⚡', '🛡️', '📊', '🗂️', '💡', '🔮', '🧩',
|
||||||
|
'⭐', '🔥', '💎', '🚦', '🎪', '🎨', '🎭', '🃏', '👑', '🏆',
|
||||||
|
'🦅', '🐺', '🦁', '🐻', '🦊', '🐙', '🐝', '🦋', '🌿', '🌊',
|
||||||
|
];
|
||||||
|
|
||||||
|
const SUGGESTED_CAPABILITIES = [
|
||||||
|
'coding', 'testing', 'debugging', 'refactoring', 'documentation',
|
||||||
|
'code_review', 'system_design', 'architecture', 'planning', 'analysis',
|
||||||
|
'research', 'investigation', 'deployment', 'ci_cd', 'monitoring',
|
||||||
|
'security', 'performance', 'optimization', 'integration', 'migration',
|
||||||
|
'data_analysis', 'automation', 'scripting', 'api_design', 'database',
|
||||||
|
'frontend', 'backend', 'devops', 'qa', 'mentoring',
|
||||||
|
];
|
||||||
|
|
||||||
|
interface AgentInspectorProps {
|
||||||
|
archetype?: AgentArchetype;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (data: Partial<AgentArchetype>) => Promise<void>;
|
||||||
|
onDelete?: (id: string) => Promise<void>;
|
||||||
|
onClone?: (archetype: AgentArchetype) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentInspector({ archetype, onClose, onSave, onDelete, onClone }: AgentInspectorProps) {
|
||||||
|
const isNew = !archetype;
|
||||||
|
|
||||||
|
const [name, setName] = useState(archetype?.name || '');
|
||||||
|
const [description, setDescription] = useState(archetype?.description || '');
|
||||||
|
const [systemPrompt, setSystemPrompt] = useState(archetype?.systemPrompt || '');
|
||||||
|
const [capabilities, setCapabilities] = useState<string[]>(archetype?.capabilities || []);
|
||||||
|
const [color, setColor] = useState(archetype?.color || '#3b82f6');
|
||||||
|
const [icon, setIcon] = useState(archetype?.icon || '');
|
||||||
|
const [newCapability, setNewCapability] = useState('');
|
||||||
|
const [capabilityFilter, setCapabilityFilter] = useState('');
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const [isCloning, setIsCloning] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
||||||
|
const [showColorPicker, setShowColorPicker] = useState(false);
|
||||||
|
const [showCapabilityDropdown, setShowCapabilityDropdown] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (archetype) {
|
||||||
|
setName(archetype.name);
|
||||||
|
setDescription(archetype.description);
|
||||||
|
setSystemPrompt(archetype.systemPrompt);
|
||||||
|
setCapabilities(archetype.capabilities);
|
||||||
|
setColor(archetype.color);
|
||||||
|
setIcon(archetype.icon || '');
|
||||||
|
}
|
||||||
|
}, [archetype]);
|
||||||
|
|
||||||
|
const filteredSuggestions = useMemo(() => {
|
||||||
|
return SUGGESTED_CAPABILITIES.filter(
|
||||||
|
cap =>
|
||||||
|
cap.includes(capabilityFilter.toLowerCase()) &&
|
||||||
|
!capabilities.includes(cap)
|
||||||
|
).slice(0, 6);
|
||||||
|
}, [capabilityFilter, capabilities]);
|
||||||
|
|
||||||
|
const handleAddCapability = (cap?: string) => {
|
||||||
|
const toAdd = cap || newCapability.trim();
|
||||||
|
if (toAdd && !capabilities.includes(toAdd.toLowerCase())) {
|
||||||
|
setCapabilities([...capabilities, toAdd.toLowerCase()]);
|
||||||
|
setNewCapability('');
|
||||||
|
setCapabilityFilter('');
|
||||||
|
setShowCapabilityDropdown(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveCapability = (index: number) => {
|
||||||
|
setCapabilities(capabilities.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!name.trim() || !systemPrompt.trim()) {
|
||||||
|
setError('Name and System Prompt are required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onSave({
|
||||||
|
id: archetype?.id,
|
||||||
|
name: name.trim(),
|
||||||
|
description: description.trim(),
|
||||||
|
systemPrompt: systemPrompt.trim(),
|
||||||
|
capabilities,
|
||||||
|
color,
|
||||||
|
icon: icon || undefined,
|
||||||
|
isBuiltIn: archetype?.isBuiltIn
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to save');
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!archetype || !onDelete) return;
|
||||||
|
|
||||||
|
if (!confirm(`Delete archetype "${archetype.name}"? This cannot be undone.`)) return;
|
||||||
|
|
||||||
|
setIsDeleting(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onDelete(archetype.id);
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to delete');
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClone = async () => {
|
||||||
|
if (!archetype || !onClone) return;
|
||||||
|
|
||||||
|
setIsCloning(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onClone(archetype);
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to clone');
|
||||||
|
} finally {
|
||||||
|
setIsCloning(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayChar = icon || name.charAt(0) || '?';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4 animate-in fade-in duration-200">
|
||||||
|
<div className="flex flex-col h-[90vh] w-full max-w-3xl overflow-hidden rounded-xl border border-[var(--ui-border-soft)] bg-[#0f1824] shadow-2xl animate-in zoom-in-95 duration-200">
|
||||||
|
<div className="flex items-center justify-between border-b border-[var(--ui-border-soft)] px-5 py-4 bg-[#14202e]">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div
|
||||||
|
className="h-12 w-12 rounded-xl flex items-center justify-center text-xl font-bold border-2 transition-all duration-200"
|
||||||
|
style={{
|
||||||
|
backgroundColor: `${color}20`,
|
||||||
|
color: color,
|
||||||
|
borderColor: `${color}50`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{displayChar}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-[var(--ui-text-primary)] leading-tight">
|
||||||
|
{isNew ? 'New Archetype' : name || 'Edit Archetype'}
|
||||||
|
</h2>
|
||||||
|
{!isNew && (
|
||||||
|
<p className="font-mono uppercase tracking-wider text-[10px] text-[var(--ui-text-muted)] mt-0.5">{archetype.id}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="rounded-full p-2 text-[var(--ui-text-muted)] hover:bg-white/5 hover:text-white transition-colors">
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mx-5 mt-4 p-3 bg-rose-500/10 border border-rose-500/20 rounded-lg text-rose-400 text-sm flex items-center gap-2">
|
||||||
|
<ShieldAlert className="w-4 h-4 flex-shrink-0" />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-5">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[var(--ui-text-secondary)] mb-1.5">Name *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-lg bg-[var(--ui-bg-soft)] border border-[var(--ui-border-soft)] text-[var(--ui-text-primary)] placeholder:text-[var(--ui-text-muted)] focus:outline-none focus:ring-2 focus:ring-blue-500/50"
|
||||||
|
placeholder="e.g., Code Reviewer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[var(--ui-text-secondary)] mb-1.5">Description</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-lg bg-[var(--ui-bg-soft)] border border-[var(--ui-border-soft)] text-[var(--ui-text-primary)] placeholder:text-[var(--ui-text-muted)] focus:outline-none focus:ring-2 focus:ring-blue-500/50"
|
||||||
|
placeholder="Brief description"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[var(--ui-text-secondary)] mb-1.5">System Prompt *</label>
|
||||||
|
<textarea
|
||||||
|
value={systemPrompt}
|
||||||
|
onChange={(e) => setSystemPrompt(e.target.value)}
|
||||||
|
rows={6}
|
||||||
|
className="w-full px-3 py-2 rounded-lg bg-[var(--ui-bg-soft)] border border-[var(--ui-border-soft)] text-[var(--ui-text-primary)] placeholder:text-[var(--ui-text-muted)] focus:outline-none focus:ring-2 focus:ring-blue-500/50 font-mono text-sm resize-none"
|
||||||
|
placeholder="You are a helpful assistant that..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="flex items-center gap-2 text-sm font-medium text-[var(--ui-text-secondary)] mb-1.5">
|
||||||
|
<Palette className="w-4 h-4" />
|
||||||
|
Color
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<div
|
||||||
|
className="h-8 w-8 rounded-lg border-2 border-white/20"
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={color}
|
||||||
|
onChange={(e) => setColor(e.target.value)}
|
||||||
|
className="flex-1 px-3 py-1.5 rounded-lg bg-[var(--ui-bg-soft)] border border-[var(--ui-border-soft)] text-[var(--ui-text-primary)] font-mono text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/50"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowColorPicker(!showColorPicker)}
|
||||||
|
className="px-3 py-1.5 rounded-lg bg-[var(--ui-bg-soft)] border border-[var(--ui-border-soft)] text-[var(--ui-text-secondary)] hover:bg-white/5 text-sm"
|
||||||
|
>
|
||||||
|
{showColorPicker ? 'Hide' : 'Pick'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{showColorPicker && (
|
||||||
|
<div className="grid grid-cols-10 gap-1.5 p-2 bg-[var(--ui-bg-soft)] rounded-lg border border-[var(--ui-border-soft)]">
|
||||||
|
{COLOR_PRESETS.map((presetColor) => (
|
||||||
|
<button
|
||||||
|
key={presetColor}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setColor(presetColor);
|
||||||
|
setShowColorPicker(false);
|
||||||
|
}}
|
||||||
|
className={`h-6 w-6 rounded-md border-2 transition-all hover:scale-110 ${color === presetColor ? 'border-white ring-2 ring-white/30' : 'border-transparent'}`}
|
||||||
|
style={{ backgroundColor: presetColor }}
|
||||||
|
title={presetColor}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="flex items-center gap-2 text-sm font-medium text-[var(--ui-text-secondary)] mb-1.5">
|
||||||
|
<Smile className="w-4 h-4" />
|
||||||
|
Icon / Emoji
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<div
|
||||||
|
className="h-8 w-8 rounded-lg flex items-center justify-center text-lg border border-[var(--ui-border-soft)] bg-[var(--ui-bg-soft)]"
|
||||||
|
style={{ color }}
|
||||||
|
>
|
||||||
|
{icon || '?'}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={icon}
|
||||||
|
onChange={(e) => setIcon(e.target.value)}
|
||||||
|
className="flex-1 px-3 py-1.5 rounded-lg bg-[var(--ui-bg-soft)] border border-[var(--ui-border-soft)] text-[var(--ui-text-primary)] text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/50"
|
||||||
|
placeholder="Emoji or leave empty"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowEmojiPicker(!showEmojiPicker)}
|
||||||
|
className="px-3 py-1.5 rounded-lg bg-[var(--ui-bg-soft)] border border-[var(--ui-border-soft)] text-[var(--ui-text-secondary)] hover:bg-white/5 text-sm"
|
||||||
|
>
|
||||||
|
{showEmojiPicker ? 'Hide' : 'Pick'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{showEmojiPicker && (
|
||||||
|
<div className="grid grid-cols-10 gap-1.5 p-2 bg-[var(--ui-bg-soft)] rounded-lg border border-[var(--ui-border-soft)]">
|
||||||
|
{EMOJI_PRESETS.map((emoji) => (
|
||||||
|
<button
|
||||||
|
key={emoji}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setIcon(emoji);
|
||||||
|
setShowEmojiPicker(false);
|
||||||
|
}}
|
||||||
|
className={`h-6 w-6 rounded-md flex items-center justify-center text-base transition-all hover:scale-110 hover:bg-white/10 ${icon === emoji ? 'bg-white/20 ring-2 ring-white/30' : ''}`}
|
||||||
|
>
|
||||||
|
{emoji}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[var(--ui-text-secondary)] mb-1.5">Capabilities</label>
|
||||||
|
<div className="flex flex-wrap gap-2 mb-2">
|
||||||
|
{capabilities.map((cap, index) => (
|
||||||
|
<span
|
||||||
|
key={cap}
|
||||||
|
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-[var(--ui-bg-soft)] border border-[var(--ui-border-soft)] text-[var(--ui-text-secondary)]"
|
||||||
|
>
|
||||||
|
{cap}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRemoveCapability(index)}
|
||||||
|
className="text-[var(--ui-text-muted)] hover:text-rose-400 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newCapability}
|
||||||
|
onChange={(e) => {
|
||||||
|
setNewCapability(e.target.value);
|
||||||
|
setCapabilityFilter(e.target.value);
|
||||||
|
setShowCapabilityDropdown(true);
|
||||||
|
}}
|
||||||
|
onFocus={() => setShowCapabilityDropdown(true)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleAddCapability();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="flex-1 px-3 py-2 rounded-lg bg-[var(--ui-bg-soft)] border border-[var(--ui-border-soft)] text-[var(--ui-text-primary)] placeholder:text-[var(--ui-text-muted)] focus:outline-none focus:ring-2 focus:ring-blue-500/50 text-sm"
|
||||||
|
placeholder="Add capability..."
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleAddCapability()}
|
||||||
|
disabled={!newCapability.trim()}
|
||||||
|
className="px-3 py-2 rounded-lg bg-blue-600 text-white hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{showCapabilityDropdown && filteredSuggestions.length > 0 && (
|
||||||
|
<div className="absolute z-10 mt-1 w-full bg-[var(--ui-bg-soft)] border border-[var(--ui-border-soft)] rounded-lg shadow-lg overflow-hidden">
|
||||||
|
{filteredSuggestions.map((suggestion) => (
|
||||||
|
<button
|
||||||
|
key={suggestion}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleAddCapability(suggestion)}
|
||||||
|
className="w-full px-3 py-2 text-left text-sm text-[var(--ui-text-primary)] hover:bg-white/5 transition-colors"
|
||||||
|
>
|
||||||
|
{suggestion}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-[var(--ui-border-soft)] pt-4">
|
||||||
|
<h3 className="text-sm font-medium text-[var(--ui-text-secondary)] mb-3">Live Preview</h3>
|
||||||
|
<div className="p-4 rounded-xl bg-[var(--ui-bg-soft)] border border-[var(--ui-border-soft)]">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<div
|
||||||
|
className="h-10 w-10 rounded-xl flex items-center justify-center text-lg font-bold border-2"
|
||||||
|
style={{
|
||||||
|
backgroundColor: `${color}20`,
|
||||||
|
color: color,
|
||||||
|
borderColor: `${color}50`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{displayChar}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold text-[var(--ui-text-primary)]">
|
||||||
|
{name || 'Archetype Name'}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-[var(--ui-text-muted)]">
|
||||||
|
{description || 'No description'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{capabilities.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{capabilities.slice(0, 5).map((cap) => (
|
||||||
|
<span
|
||||||
|
key={cap}
|
||||||
|
className="px-2 py-0.5 rounded-full text-[10px] font-medium"
|
||||||
|
style={{
|
||||||
|
backgroundColor: `${color}20`,
|
||||||
|
color: color
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cap}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{capabilities.length > 5 && (
|
||||||
|
<span className="px-2 py-0.5 rounded-full text-[10px] font-medium text-[var(--ui-text-muted)]">
|
||||||
|
+{capabilities.length - 5} more
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{systemPrompt && (
|
||||||
|
<div className="mt-3 p-2 rounded-lg bg-black/20 text-xs text-[var(--ui-text-muted)] font-mono line-clamp-2">
|
||||||
|
{systemPrompt}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between border-t border-[var(--ui-border-soft)] px-5 py-4 bg-[#14202e]">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{!isNew && onDelete && (
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={isDeleting || archetype?.isBuiltIn}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 rounded-lg text-rose-400 hover:bg-rose-500/10 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!isNew && onClone && (
|
||||||
|
<button
|
||||||
|
onClick={handleClone}
|
||||||
|
disabled={isCloning}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 rounded-lg text-[var(--ui-text-secondary)] hover:bg-white/5 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
<Copy className="w-4 h-4" />
|
||||||
|
{isCloning ? 'Cloning...' : 'Clone'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{archetype?.isBuiltIn && (
|
||||||
|
<span className="text-xs text-amber-400 bg-amber-500/10 px-2 py-1 rounded">
|
||||||
|
Built-in archetype
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 rounded-lg text-[var(--ui-text-secondary)] hover:bg-white/5 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isSaving || !name.trim() || !systemPrompt.trim()}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-blue-600 text-white hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
<Save className="w-4 h-4" />
|
||||||
|
{isSaving ? 'Saving...' : (isNew ? 'Create' : 'Save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
131
src/components/swarm/agent-picker.tsx
Normal file
131
src/components/swarm/agent-picker.tsx
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { X, Blocks, Check, Pencil, Plus } from 'lucide-react';
|
||||||
|
import type { AgentArchetype } from '../../lib/types-swarm';
|
||||||
|
import { getArchetypeDisplayChar } from '../../lib/utils';
|
||||||
|
|
||||||
|
interface AgentPickerProps {
|
||||||
|
archetypes: AgentArchetype[];
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSelect: (archetype: AgentArchetype) => void;
|
||||||
|
onEdit: (archetypeId: string) => void;
|
||||||
|
onCreateNew: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentPicker({
|
||||||
|
archetypes,
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onSelect,
|
||||||
|
onEdit,
|
||||||
|
onCreateNew
|
||||||
|
}: AgentPickerProps) {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelect = (archetype: AgentArchetype) => {
|
||||||
|
onSelect(archetype);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4 animate-in fade-in duration-200"
|
||||||
|
onClick={handleBackdropClick}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col w-full max-w-[800px] max-h-[85vh] overflow-hidden rounded-xl border border-[var(--ui-border-soft)] bg-[#0f1824] shadow-2xl animate-in zoom-in-95 duration-200">
|
||||||
|
<div className="flex items-center justify-between border-b border-[var(--ui-border-soft)] px-5 py-4 bg-[#14202e]">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Blocks className="w-5 h-5 text-[var(--ui-text-secondary)]" />
|
||||||
|
<h2 className="text-lg font-bold text-[var(--ui-text-primary)]">
|
||||||
|
Select Archetype
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-full p-2 text-[var(--ui-text-muted)] hover:bg-white/5 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto px-4 py-4">
|
||||||
|
{archetypes.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-[var(--ui-text-muted)]">
|
||||||
|
<Blocks className="w-12 h-12 mb-3 opacity-50" />
|
||||||
|
<p className="text-sm">No archetypes available</p>
|
||||||
|
<p className="text-xs mt-1">Create one to get started</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{archetypes.map((archetype) => {
|
||||||
|
const displayChar = getArchetypeDisplayChar(archetype);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={archetype.id}
|
||||||
|
className="group relative flex flex-col p-4 rounded-xl bg-[var(--ui-bg-soft)] border border-[var(--ui-border-soft)] hover:border-[var(--ui-border)] hover:bg-[#111f2b] transition-all duration-200"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3 mb-2">
|
||||||
|
<div
|
||||||
|
className="h-10 w-10 rounded-xl flex items-center justify-center text-lg font-bold border-2 flex-shrink-0"
|
||||||
|
style={{
|
||||||
|
backgroundColor: `${archetype.color}20`,
|
||||||
|
color: archetype.color,
|
||||||
|
borderColor: `${archetype.color}50`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{displayChar}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="font-semibold text-[var(--ui-text-primary)] text-sm truncate">
|
||||||
|
{archetype.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-[var(--ui-text-muted)] line-clamp-4 mt-0.5">
|
||||||
|
{archetype.description || 'No description'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 mt-auto pt-2 border-t border-[var(--ui-border-soft)]">
|
||||||
|
<button
|
||||||
|
onClick={() => handleSelect(archetype)}
|
||||||
|
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg bg-blue-600 text-white text-xs font-medium hover:bg-blue-500 transition-colors"
|
||||||
|
>
|
||||||
|
<Check className="w-3.5 h-3.5" />
|
||||||
|
Select
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onEdit(archetype.id)}
|
||||||
|
className="p-1.5 rounded-lg text-[var(--ui-text-muted)] hover:text-[var(--ui-text-primary)] hover:bg-white/5 transition-colors"
|
||||||
|
title="Edit archetype"
|
||||||
|
>
|
||||||
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-[var(--ui-border-soft)] px-5 py-4 bg-[#14202e]">
|
||||||
|
<button
|
||||||
|
onClick={onCreateNew}
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg bg-[var(--ui-bg-soft)] border border-[var(--ui-border-soft)] text-[var(--ui-text-secondary)] hover:bg-[#111f2b] hover:text-[var(--ui-text-primary)] hover:border-[var(--ui-border)] transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
Create New Archetype
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -8,7 +8,7 @@ import { cn, getArchetypeDisplayChar, getTemplateDisplayChar, getTemplateColor }
|
||||||
import type { BeadIssue } from '../../lib/types';
|
import type { BeadIssue } from '../../lib/types';
|
||||||
import { useArchetypes } from '../../hooks/use-archetypes';
|
import { useArchetypes } from '../../hooks/use-archetypes';
|
||||||
import { useTemplates } from '../../hooks/use-templates';
|
import { useTemplates } from '../../hooks/use-templates';
|
||||||
import { ArchetypeInspector } from './archetype-inspector';
|
import { AgentInspector } from './agent-inspector';
|
||||||
import { TemplateInspector } from './template-inspector';
|
import { TemplateInspector } from './template-inspector';
|
||||||
|
|
||||||
export function SwarmWorkspace({ selectedMissionId, issues = [], projectRoot }: { selectedMissionId?: string, issues?: BeadIssue[], projectRoot: string }) {
|
export function SwarmWorkspace({ selectedMissionId, issues = [], projectRoot }: { selectedMissionId?: string, issues?: BeadIssue[], projectRoot: string }) {
|
||||||
|
|
@ -276,13 +276,13 @@ export function SwarmWorkspace({ selectedMissionId, issues = [], projectRoot }:
|
||||||
<div className="text-[10px] uppercase font-bold text-[var(--ui-text-muted)] tracking-wider mb-2">Team Composition</div>
|
<div className="text-[10px] uppercase font-bold text-[var(--ui-text-muted)] tracking-wider mb-2">Team Composition</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{tpl.team.map((member, idx) => {
|
{tpl.team.map((member, idx) => {
|
||||||
const arch = archetypes.find(a => a.id === member.archetypeId);
|
const arch = archetypes.find(a => a.id === member.agentTypeId);
|
||||||
return (
|
return (
|
||||||
<div key={idx} className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-[#0f1824] border border-[var(--ui-border-soft)]">
|
<div key={idx} className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-[#0f1824] border border-[var(--ui-border-soft)]">
|
||||||
<div className="h-4 w-4 rounded text-[9px] flex items-center justify-center font-bold" style={{ backgroundColor: `${arch?.color || '#888'}20`, color: arch?.color || '#888' }}>
|
<div className="h-4 w-4 rounded text-[9px] flex items-center justify-center font-bold" style={{ backgroundColor: `${arch?.color || '#888'}20`, color: arch?.color || '#888' }}>
|
||||||
{arch ? getArchetypeDisplayChar(arch) : '?'}
|
{arch ? getArchetypeDisplayChar(arch) : '?'}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[11px] text-[var(--ui-text-primary)] font-medium">{member.count}x {arch?.name || member.archetypeId}</span>
|
<span className="text-[11px] text-[var(--ui-text-primary)] font-medium">{member.count}x {arch?.name || member.agentTypeId}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
@ -350,7 +350,7 @@ export function SwarmWorkspace({ selectedMissionId, issues = [], projectRoot }:
|
||||||
|
|
||||||
{/* Popups */}
|
{/* Popups */}
|
||||||
{inspectingArchetypeId !== null && (
|
{inspectingArchetypeId !== null && (
|
||||||
<ArchetypeInspector
|
<AgentInspector
|
||||||
archetype={archetypes.find(a => a.id === inspectingArchetypeId)}
|
archetype={archetypes.find(a => a.id === inspectingArchetypeId)}
|
||||||
onClose={() => setInspectingArchetypeId(null)}
|
onClose={() => setInspectingArchetypeId(null)}
|
||||||
onSave={saveArchetype}
|
onSave={saveArchetype}
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,10 @@ export function TemplateInspector({ template, archetypes, onClose, onSave, onDel
|
||||||
|
|
||||||
const [name, setName] = useState(template?.name || '');
|
const [name, setName] = useState(template?.name || '');
|
||||||
const [description, setDescription] = useState(template?.description || '');
|
const [description, setDescription] = useState(template?.description || '');
|
||||||
const [team, setTeam] = useState<{ archetypeId: string; count: number }[]>(template?.team || []);
|
// Use agentTypeId internally, normalize from template.team
|
||||||
|
const [team, setTeam] = useState<{ agentTypeId: string; count: number }[]>(
|
||||||
|
template?.team?.map(m => ({ agentTypeId: m.agentTypeId, count: m.count })) || []
|
||||||
|
);
|
||||||
const [protoFormula, setProtoFormula] = useState(template?.protoFormula || '');
|
const [protoFormula, setProtoFormula] = useState(template?.protoFormula || '');
|
||||||
const [color, setColor] = useState(template?.color || '#f59e0b');
|
const [color, setColor] = useState(template?.color || '#f59e0b');
|
||||||
const [icon, setIcon] = useState(template?.icon || '');
|
const [icon, setIcon] = useState(template?.icon || '');
|
||||||
|
|
@ -49,26 +52,26 @@ export function TemplateInspector({ template, archetypes, onClose, onSave, onDel
|
||||||
if (template) {
|
if (template) {
|
||||||
setName(template.name);
|
setName(template.name);
|
||||||
setDescription(template.description);
|
setDescription(template.description);
|
||||||
setTeam(template.team);
|
setTeam(template.team?.map(m => ({ agentTypeId: m.agentTypeId, count: m.count })) || []);
|
||||||
setProtoFormula(template.protoFormula || '');
|
setProtoFormula(template.protoFormula || '');
|
||||||
setColor(template.color || '#f59e0b');
|
setColor(template.color || '#f59e0b');
|
||||||
setIcon(template.icon || '');
|
setIcon(template.icon || '');
|
||||||
}
|
}
|
||||||
}, [template]);
|
}, [template]);
|
||||||
|
|
||||||
const updateTeamMember = (index: number, field: 'archetypeId' | 'count', value: string | number) => {
|
const updateTeamMember = (index: number, field: 'agentTypeId' | 'count', value: string | number) => {
|
||||||
const newTeam = [...team];
|
const newTeam = [...team];
|
||||||
if (field === 'count') {
|
if (field === 'count') {
|
||||||
newTeam[index] = { ...newTeam[index], count: Math.max(1, Number(value)) };
|
newTeam[index] = { ...newTeam[index], count: Math.max(1, Number(value)) };
|
||||||
} else {
|
} else {
|
||||||
newTeam[index] = { ...newTeam[index], archetypeId: value as string };
|
newTeam[index] = { ...newTeam[index], agentTypeId: value as string };
|
||||||
}
|
}
|
||||||
setTeam(newTeam);
|
setTeam(newTeam);
|
||||||
};
|
};
|
||||||
|
|
||||||
const addTeamMember = () => {
|
const addTeamMember = () => {
|
||||||
const firstAvailableArchetype = archetypes[0]?.id || '';
|
const firstAvailableAgentType = archetypes[0]?.id || '';
|
||||||
setTeam([...team, { archetypeId: firstAvailableArchetype, count: 1 }]);
|
setTeam([...team, { agentTypeId: firstAvailableAgentType, count: 1 }]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeTeamMember = (index: number) => {
|
const removeTeamMember = (index: number) => {
|
||||||
|
|
@ -328,8 +331,8 @@ export function TemplateInspector({ template, archetypes, onClose, onSave, onDel
|
||||||
{team.map((member, index) => (
|
{team.map((member, index) => (
|
||||||
<div key={index} className="flex items-center gap-2">
|
<div key={index} className="flex items-center gap-2">
|
||||||
<select
|
<select
|
||||||
value={member.archetypeId}
|
value={member.agentTypeId}
|
||||||
onChange={(e) => updateTeamMember(index, 'archetypeId', e.target.value)}
|
onChange={(e) => updateTeamMember(index, 'agentTypeId', e.target.value)}
|
||||||
className="flex-1 bg-[#0a111a] border border-[var(--ui-border-soft)] rounded-md px-3 py-2 text-sm text-[var(--ui-text-primary)] focus:outline-none focus:border-[var(--ui-accent-info)]"
|
className="flex-1 bg-[#0a111a] border border-[var(--ui-border-soft)] rounded-md px-3 py-2 text-sm text-[var(--ui-text-primary)] focus:outline-none focus:border-[var(--ui-accent-info)]"
|
||||||
>
|
>
|
||||||
{archetypes.map(a => (
|
{archetypes.map(a => (
|
||||||
|
|
@ -403,11 +406,11 @@ export function TemplateInspector({ template, archetypes, onClose, onSave, onDel
|
||||||
key={idx}
|
key={idx}
|
||||||
className="px-2 py-0.5 rounded-full text-[10px] font-medium"
|
className="px-2 py-0.5 rounded-full text-[10px] font-medium"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: `${getArchetypeColor(member.archetypeId)}20`,
|
backgroundColor: `${getArchetypeColor(member.agentTypeId)}20`,
|
||||||
color: getArchetypeColor(member.archetypeId)
|
color: getArchetypeColor(member.agentTypeId)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{getArchetypeName(member.archetypeId)} ×{member.count}
|
{getArchetypeName(member.agentTypeId)} ×{member.count}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,13 @@
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useSearchParams, useRouter } from 'next/navigation';
|
import { useSearchParams, useRouter } from 'next/navigation';
|
||||||
|
|
||||||
export type ViewType = 'social' | 'graph' | 'activity';
|
export type ViewType = 'social' | 'graph';
|
||||||
export type PanelState = 'open' | 'closed';
|
export type PanelState = 'open' | 'closed';
|
||||||
export type DrawerState = 'open' | 'closed';
|
export type DrawerState = 'open' | 'closed';
|
||||||
export type GraphTabType = 'flow' | 'overview';
|
export type GraphTabType = 'flow' | 'overview';
|
||||||
|
export type LeftSidebarMode = 'epics' | 'orchestrator';
|
||||||
|
export type LeftPanelStatusFilter = 'all' | 'ready' | 'in_progress' | 'blocked' | 'deferred' | 'done';export type LeftPanelPriorityFilter = 'all' | 'P0' | 'P1' | 'P2' | 'P3' | 'P4';export type LeftPanelPresetFilter = 'all' | 'active' | 'blocked_agents';export interface LeftPanelFilters { status: LeftPanelStatusFilter; priority: LeftPanelPriorityFilter; preset: LeftPanelPresetFilter; hideClosed: boolean;
|
||||||
|
query: string;}
|
||||||
|
|
||||||
export interface UrlState {
|
export interface UrlState {
|
||||||
view: ViewType;
|
view: ViewType;
|
||||||
|
|
@ -22,6 +25,9 @@ export interface UrlState {
|
||||||
leftPanel: PanelState;
|
leftPanel: PanelState;
|
||||||
setLeftPanel: (state: PanelState) => void;
|
setLeftPanel: (state: PanelState) => void;
|
||||||
toggleLeftPanel: () => void;
|
toggleLeftPanel: () => void;
|
||||||
|
leftSidebarMode: LeftSidebarMode;
|
||||||
|
setLeftSidebarMode: (mode: LeftSidebarMode) => void;
|
||||||
|
toggleLeftSidebarMode: () => void;
|
||||||
rightPanel: PanelState;
|
rightPanel: PanelState;
|
||||||
setRightPanel: (state: PanelState) => void;
|
setRightPanel: (state: PanelState) => void;
|
||||||
toggleRightPanel: () => void;
|
toggleRightPanel: () => void;
|
||||||
|
|
@ -42,11 +48,13 @@ const DEFAULT_LEFT_PANEL: PanelState = 'open';
|
||||||
const DEFAULT_RIGHT_PANEL: PanelState = 'open';
|
const DEFAULT_RIGHT_PANEL: PanelState = 'open';
|
||||||
const DEFAULT_DRAWER: DrawerState = 'closed';
|
const DEFAULT_DRAWER: DrawerState = 'closed';
|
||||||
const DEFAULT_GRAPH_TAB: GraphTabType = 'overview';
|
const DEFAULT_GRAPH_TAB: GraphTabType = 'overview';
|
||||||
|
const DEFAULT_LEFT_SIDEBAR_MODE: LeftSidebarMode = 'epics';
|
||||||
|
|
||||||
const VALID_VIEWS: ViewType[] = ['social', 'graph', 'activity'];
|
const VALID_VIEWS: ViewType[] = ['social', 'graph'];
|
||||||
const VALID_PANELS: PanelState[] = ['open', 'closed'];
|
const VALID_PANELS: PanelState[] = ['open', 'closed'];
|
||||||
const VALID_DRAWERS: DrawerState[] = ['open', 'closed'];
|
const VALID_DRAWERS: DrawerState[] = ['open', 'closed'];
|
||||||
const VALID_GRAPH_TABS: GraphTabType[] = ['flow', 'overview'];
|
const VALID_GRAPH_TABS: GraphTabType[] = ['flow', 'overview'];
|
||||||
|
const VALID_LEFT_SIDEBAR_MODES: LeftSidebarMode[] = ['epics', 'orchestrator'];
|
||||||
|
|
||||||
const PANEL_STORAGE_KEYS = {
|
const PANEL_STORAGE_KEYS = {
|
||||||
left: 'bb.ui.leftPanel',
|
left: 'bb.ui.leftPanel',
|
||||||
|
|
@ -78,6 +86,13 @@ function isBlockedEnabled(value: string | null): boolean {
|
||||||
return value === '1' || value === 'true';
|
return value === '1' || value === 'true';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseLeftSidebarMode(value: string | null): LeftSidebarMode {
|
||||||
|
if (!value || !VALID_LEFT_SIDEBAR_MODES.includes(value as LeftSidebarMode)) {
|
||||||
|
return DEFAULT_LEFT_SIDEBAR_MODE;
|
||||||
|
}
|
||||||
|
return value as LeftSidebarMode;
|
||||||
|
}
|
||||||
|
|
||||||
export function parseUrlState(
|
export function parseUrlState(
|
||||||
searchParams: URLSearchParams,
|
searchParams: URLSearchParams,
|
||||||
defaults: PanelDefaults = {
|
defaults: PanelDefaults = {
|
||||||
|
|
@ -91,6 +106,7 @@ export function parseUrlState(
|
||||||
agentId: string | null;
|
agentId: string | null;
|
||||||
epicId: string | null;
|
epicId: string | null;
|
||||||
leftPanel: PanelState;
|
leftPanel: PanelState;
|
||||||
|
leftSidebarMode: LeftSidebarMode;
|
||||||
rightPanel: PanelState;
|
rightPanel: PanelState;
|
||||||
blockedOnly: boolean;
|
blockedOnly: boolean;
|
||||||
panel: PanelState;
|
panel: PanelState;
|
||||||
|
|
@ -114,6 +130,7 @@ export function parseUrlState(
|
||||||
const leftPanel = leftPanelFromUrl ?? defaults.leftPanel;
|
const leftPanel = leftPanelFromUrl ?? defaults.leftPanel;
|
||||||
const rightPanel = rightPanelFromUrl ?? legacyPanel ?? defaults.rightPanel;
|
const rightPanel = rightPanelFromUrl ?? legacyPanel ?? defaults.rightPanel;
|
||||||
const panel = rightPanel;
|
const panel = rightPanel;
|
||||||
|
const leftSidebarMode = parseLeftSidebarMode(searchParams.get('leftMode'));
|
||||||
|
|
||||||
const blockedOnly = isBlockedEnabled(searchParams.get('blocked'));
|
const blockedOnly = isBlockedEnabled(searchParams.get('blocked'));
|
||||||
|
|
||||||
|
|
@ -127,7 +144,7 @@ export function parseUrlState(
|
||||||
? (graphTabParam as GraphTabType)
|
? (graphTabParam as GraphTabType)
|
||||||
: DEFAULT_GRAPH_TAB;
|
: DEFAULT_GRAPH_TAB;
|
||||||
|
|
||||||
return { view, taskId, swarmId, agentId, epicId, leftPanel, rightPanel, blockedOnly, panel, drawer, graphTab };
|
return { view, taskId, swarmId, agentId, epicId, leftPanel, leftSidebarMode, rightPanel, blockedOnly, panel, drawer, graphTab };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildUrlParams(
|
export function buildUrlParams(
|
||||||
|
|
@ -190,6 +207,14 @@ export function useUrlState(): UrlState {
|
||||||
setLeftPanel(state.leftPanel === 'open' ? 'closed' : 'open');
|
setLeftPanel(state.leftPanel === 'open' ? 'closed' : 'open');
|
||||||
}, [setLeftPanel, state.leftPanel]);
|
}, [setLeftPanel, state.leftPanel]);
|
||||||
|
|
||||||
|
const setLeftSidebarMode = useCallback((mode: LeftSidebarMode) => {
|
||||||
|
updateUrl({ leftMode: mode });
|
||||||
|
}, [updateUrl]);
|
||||||
|
|
||||||
|
const toggleLeftSidebarMode = useCallback(() => {
|
||||||
|
setLeftSidebarMode(state.leftSidebarMode === 'epics' ? 'orchestrator' : 'epics');
|
||||||
|
}, [setLeftSidebarMode, state.leftSidebarMode]);
|
||||||
|
|
||||||
const setRightPanel = useCallback((next: PanelState) => {
|
const setRightPanel = useCallback((next: PanelState) => {
|
||||||
// Keep legacy `panel` in sync while migrating to explicit `right`.
|
// Keep legacy `panel` in sync while migrating to explicit `right`.
|
||||||
updateUrl({ right: next, panel: next });
|
updateUrl({ right: next, panel: next });
|
||||||
|
|
@ -259,6 +284,9 @@ export function useUrlState(): UrlState {
|
||||||
leftPanel: state.leftPanel,
|
leftPanel: state.leftPanel,
|
||||||
setLeftPanel,
|
setLeftPanel,
|
||||||
toggleLeftPanel,
|
toggleLeftPanel,
|
||||||
|
leftSidebarMode: state.leftSidebarMode,
|
||||||
|
setLeftSidebarMode,
|
||||||
|
toggleLeftSidebarMode,
|
||||||
rightPanel: state.rightPanel,
|
rightPanel: state.rightPanel,
|
||||||
setRightPanel,
|
setRightPanel,
|
||||||
toggleRightPanel,
|
toggleRightPanel,
|
||||||
|
|
|
||||||
72
src/lib/agent-instance.ts
Normal file
72
src/lib/agent-instance.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
/**
|
||||||
|
* Agent Instance Model
|
||||||
|
*
|
||||||
|
* An Agent Instance is a running copy of an Agent Type.
|
||||||
|
* When spawned, it gets a numbered instance (e.g., "Engineer 01", "Engineer 02").
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface AgentInstance {
|
||||||
|
/** Unique instance ID (e.g., "engineer-01-abc123") */
|
||||||
|
id: string;
|
||||||
|
/** What kind of agent this is (e.g., "engineer", "architect") */
|
||||||
|
agentTypeId: string;
|
||||||
|
/** Display name for UI (e.g., "Engineer 01") */
|
||||||
|
displayName: string;
|
||||||
|
/** Current status of this instance */
|
||||||
|
status: 'spawning' | 'working' | 'idle' | 'completed' | 'failed';
|
||||||
|
/** The bead/task this agent is working on */
|
||||||
|
currentBeadId?: string;
|
||||||
|
/** When this instance was spawned */
|
||||||
|
startedAt: string;
|
||||||
|
/** When this instance completed/failed */
|
||||||
|
completedAt?: string;
|
||||||
|
/** Result summary for completed agents */
|
||||||
|
result?: string;
|
||||||
|
/** Error message for failed agents */
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentStatus {
|
||||||
|
/** Total number of active agents */
|
||||||
|
totalActive: number;
|
||||||
|
/** Count by agent type { "engineer": 2, "architect": 1 } */
|
||||||
|
byType: Record<string, number>;
|
||||||
|
/** All active instances */
|
||||||
|
instances: AgentInstance[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a unique agent instance ID.
|
||||||
|
* Format: {agentTypeId}-{number}-{random}
|
||||||
|
*/
|
||||||
|
export function generateAgentInstanceId(agentTypeId: string, instanceNumber: number): string {
|
||||||
|
const suffix = String(instanceNumber).padStart(2, '0');
|
||||||
|
const random = Math.random().toString(36).slice(2, 8);
|
||||||
|
return `${agentTypeId}-${suffix}-${random}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get display name for an agent instance.
|
||||||
|
* Format: "{AgentTypeName} {number}" (e.g., "Engineer 01")
|
||||||
|
*/
|
||||||
|
export function getAgentDisplayName(agentTypeName: string, instanceNumber: number): string {
|
||||||
|
const num = String(instanceNumber).padStart(2, '0');
|
||||||
|
return `${agentTypeName} ${num}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse an instance ID to extract its components.
|
||||||
|
*/
|
||||||
|
export function parseAgentInstanceId(instanceId: string): {
|
||||||
|
agentTypeId: string;
|
||||||
|
instanceNumber: number;
|
||||||
|
random: string;
|
||||||
|
} | null {
|
||||||
|
const match = instanceId.match(/^([a-z-]+)-(\d{2})-([a-z0-9]+)$/);
|
||||||
|
if (!match) return null;
|
||||||
|
return {
|
||||||
|
agentTypeId: match[1],
|
||||||
|
instanceNumber: parseInt(match[2], 10),
|
||||||
|
random: match[3],
|
||||||
|
};
|
||||||
|
}
|
||||||
93
src/lib/agent-persistence.ts
Normal file
93
src/lib/agent-persistence.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
/**
|
||||||
|
* Agent Instance Persistence
|
||||||
|
*
|
||||||
|
* Persists agent instances to disk so they survive app restarts.
|
||||||
|
* Uses .beads/agents.jsonl for storage.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'fs/promises';
|
||||||
|
import path from 'path';
|
||||||
|
import type { AgentInstance } from './agent-instance';
|
||||||
|
|
||||||
|
const AGENTS_FILE = (projectRoot: string) => path.join(projectRoot, '.beads', 'agents.jsonl');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load all agent instances from disk.
|
||||||
|
*/
|
||||||
|
export async function loadAgentInstances(projectRoot: string): Promise<AgentInstance[]> {
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(AGENTS_FILE(projectRoot), 'utf-8');
|
||||||
|
return content
|
||||||
|
.trim()
|
||||||
|
.split('\n')
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(line => JSON.parse(line));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save a new agent instance to disk.
|
||||||
|
*/
|
||||||
|
export async function saveAgentInstance(projectRoot: string, instance: AgentInstance): Promise<void> {
|
||||||
|
const agentsPath = AGENTS_FILE(projectRoot);
|
||||||
|
await fs.mkdir(path.dirname(agentsPath), { recursive: true });
|
||||||
|
|
||||||
|
const line = JSON.stringify(instance) + '\n';
|
||||||
|
await fs.appendFile(agentsPath, line, 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing agent instance in place.
|
||||||
|
*/
|
||||||
|
export async function updateAgentInstance(projectRoot: string, instance: AgentInstance): Promise<void> {
|
||||||
|
const agentsPath = AGENTS_FILE(projectRoot);
|
||||||
|
const instances = await loadAgentInstances(projectRoot);
|
||||||
|
|
||||||
|
const idx = instances.findIndex(i => i.id === instance.id);
|
||||||
|
if (idx >= 0) {
|
||||||
|
instances[idx] = instance;
|
||||||
|
await fs.writeFile(
|
||||||
|
agentsPath,
|
||||||
|
instances.map(i => JSON.stringify(i)).join('\n') + '\n',
|
||||||
|
'utf-8'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get only active instances (spawning, working, idle).
|
||||||
|
*/
|
||||||
|
export async function getActiveInstances(projectRoot: string): Promise<AgentInstance[]> {
|
||||||
|
const all = await loadAgentInstances(projectRoot);
|
||||||
|
return all.filter(i =>
|
||||||
|
i.status === 'spawning' ||
|
||||||
|
i.status === 'working' ||
|
||||||
|
i.status === 'idle'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get recent completed/failed instances for history.
|
||||||
|
*/
|
||||||
|
export async function getRecentInstances(projectRoot: string, limit = 20): Promise<AgentInstance[]> {
|
||||||
|
const all = await loadAgentInstances(projectRoot);
|
||||||
|
return all
|
||||||
|
.filter(i => i.status === 'completed' || i.status === 'failed')
|
||||||
|
.sort((a, b) =>
|
||||||
|
new Date(b.completedAt || 0).getTime() - new Date(a.completedAt || 0).getTime()
|
||||||
|
)
|
||||||
|
.slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all agent instances (for testing/reset).
|
||||||
|
*/
|
||||||
|
export async function clearAgentInstances(projectRoot: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await fs.unlink(AGENTS_FILE(projectRoot));
|
||||||
|
} catch {
|
||||||
|
// File doesn't exist, that's fine
|
||||||
|
}
|
||||||
|
}
|
||||||
41
src/lib/agent-workspace.ts
Normal file
41
src/lib/agent-workspace.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { listProjects } from './registry';
|
||||||
|
import { resolveProjectScope } from './project-scope';
|
||||||
|
|
||||||
|
export interface ResolveAgentWorkspaceOptions {
|
||||||
|
currentProjectRoot?: string;
|
||||||
|
requestedProjectKey?: string | null;
|
||||||
|
requestedProjectRoot?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedAgentWorkspace {
|
||||||
|
root: string;
|
||||||
|
key: string;
|
||||||
|
source: 'explicit-root' | 'scope-selection';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveAgentWorkspace(options: ResolveAgentWorkspaceOptions = {}): Promise<ResolvedAgentWorkspace> {
|
||||||
|
const currentProjectRoot = options.currentProjectRoot ?? process.cwd();
|
||||||
|
|
||||||
|
if (options.requestedProjectRoot && options.requestedProjectRoot.trim()) {
|
||||||
|
const root = options.requestedProjectRoot.trim();
|
||||||
|
return {
|
||||||
|
root,
|
||||||
|
key: root.toLowerCase(),
|
||||||
|
source: 'explicit-root',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const registryProjects = await listProjects();
|
||||||
|
const scope = resolveProjectScope({
|
||||||
|
currentProjectRoot,
|
||||||
|
registryProjects,
|
||||||
|
requestedProjectKey: options.requestedProjectKey ?? null,
|
||||||
|
requestedMode: 'single',
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
root: process.platform === 'win32' ? scope.selected.root : scope.selected.displayPath,
|
||||||
|
key: scope.selected.key,
|
||||||
|
source: 'scope-selection',
|
||||||
|
};
|
||||||
|
}
|
||||||
219
src/lib/bb-daemon.ts
Normal file
219
src/lib/bb-daemon.ts
Normal file
|
|
@ -0,0 +1,219 @@
|
||||||
|
import { embeddedPiDaemon, type HostDaemonStatus } from './embedded-daemon';
|
||||||
|
import type { LaunchSurface, RuntimeConsoleEvent, RuntimeInstance } from './embedded-runtime';
|
||||||
|
import { createPiDaemonAdapter, type PiDaemonAdapter } from './pi-daemon-adapter';
|
||||||
|
import { detectPiRuntimeStrategy, type PiRuntimeResolution } from './pi-runtime-detection';
|
||||||
|
import type { BeadIssue } from './types';
|
||||||
|
|
||||||
|
export type BbDaemonLifecycleStatus = 'stopped' | 'starting' | 'running' | 'stopping' | 'failed';
|
||||||
|
|
||||||
|
export interface BbDaemonLifecycle {
|
||||||
|
status: BbDaemonLifecycleStatus;
|
||||||
|
startedAt: string | null;
|
||||||
|
stoppedAt: string | null;
|
||||||
|
lastError: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuntimeEventSubscriptionOptions {
|
||||||
|
projectRoot?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BbDaemonStatus extends HostDaemonStatus {
|
||||||
|
lifecycle: BbDaemonLifecycle;
|
||||||
|
piRuntime: PiRuntimeResolution | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BbDaemon {
|
||||||
|
start(): Promise<BbDaemonLifecycle>;
|
||||||
|
stop(): Promise<BbDaemonLifecycle>;
|
||||||
|
ensureRunning(): Promise<BbDaemonLifecycle>;
|
||||||
|
getLifecycle(): BbDaemonLifecycle;
|
||||||
|
getStatus(): BbDaemonStatus;
|
||||||
|
getPiRuntime(): PiRuntimeResolution | null;
|
||||||
|
ensureProject(projectRoot: string): { projectRoot: string; orchestratorId: string };
|
||||||
|
ensureOrchestrator(projectRoot: string): Promise<RuntimeInstance>;
|
||||||
|
listEvents(projectRoot: string): RuntimeConsoleEvent[];
|
||||||
|
subscribeRuntimeEvents(listener: (event: RuntimeConsoleEvent) => void, options?: RuntimeEventSubscriptionOptions): () => void;
|
||||||
|
launchFromIssue(params: {
|
||||||
|
projectRoot: string;
|
||||||
|
issue: BeadIssue;
|
||||||
|
origin: LaunchSurface;
|
||||||
|
swarmId?: string | null;
|
||||||
|
}): Promise<{ orchestrator: RuntimeInstance; events: RuntimeConsoleEvent[] }>;
|
||||||
|
prompt(projectRoot: string, text: string): Promise<void>;
|
||||||
|
resetForTests(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createInitialLifecycle(): BbDaemonLifecycle {
|
||||||
|
return {
|
||||||
|
status: 'stopped',
|
||||||
|
startedAt: null,
|
||||||
|
stoppedAt: null,
|
||||||
|
lastError: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RuntimeEventSubscriber {
|
||||||
|
projectRoot?: string;
|
||||||
|
listener: (event: RuntimeConsoleEvent) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
class InProcessBbDaemon implements BbDaemon {
|
||||||
|
private lifecycle: BbDaemonLifecycle = createInitialLifecycle();
|
||||||
|
private readonly adapter: PiDaemonAdapter;
|
||||||
|
private readonly subscribers = new Map<number, RuntimeEventSubscriber>();
|
||||||
|
private nextSubscriberId = 1;
|
||||||
|
private piRuntime: PiRuntimeResolution | null = null;
|
||||||
|
|
||||||
|
constructor(adapter: PiDaemonAdapter = createPiDaemonAdapter()) {
|
||||||
|
this.adapter = adapter;
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<BbDaemonLifecycle> {
|
||||||
|
if (this.lifecycle.status === 'running') {
|
||||||
|
return this.getLifecycle();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lifecycle = {
|
||||||
|
...this.lifecycle,
|
||||||
|
status: 'starting',
|
||||||
|
lastError: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.piRuntime = await detectPiRuntimeStrategy();
|
||||||
|
|
||||||
|
this.lifecycle = {
|
||||||
|
status: 'running',
|
||||||
|
startedAt: this.lifecycle.startedAt ?? new Date().toISOString(),
|
||||||
|
stoppedAt: null,
|
||||||
|
lastError: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.getLifecycle();
|
||||||
|
}
|
||||||
|
|
||||||
|
async ensureRunning(): Promise<BbDaemonLifecycle> {
|
||||||
|
return this.lifecycle.status === 'running' ? this.getLifecycle() : this.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<BbDaemonLifecycle> {
|
||||||
|
if (this.lifecycle.status === 'stopped') {
|
||||||
|
return this.getLifecycle();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lifecycle = {
|
||||||
|
...this.lifecycle,
|
||||||
|
status: 'stopping',
|
||||||
|
};
|
||||||
|
|
||||||
|
this.lifecycle = {
|
||||||
|
status: 'stopped',
|
||||||
|
startedAt: this.lifecycle.startedAt,
|
||||||
|
stoppedAt: new Date().toISOString(),
|
||||||
|
lastError: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.getLifecycle();
|
||||||
|
}
|
||||||
|
|
||||||
|
getLifecycle(): BbDaemonLifecycle {
|
||||||
|
return { ...this.lifecycle };
|
||||||
|
}
|
||||||
|
|
||||||
|
getPiRuntime(): PiRuntimeResolution | null {
|
||||||
|
return this.piRuntime;
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatus(): BbDaemonStatus {
|
||||||
|
return {
|
||||||
|
...embeddedPiDaemon.getStatus(),
|
||||||
|
lifecycle: this.getLifecycle(),
|
||||||
|
piRuntime: this.getPiRuntime(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureProject(projectRoot: string): { projectRoot: string; orchestratorId: string } {
|
||||||
|
const state = embeddedPiDaemon.ensureProject(projectRoot);
|
||||||
|
return {
|
||||||
|
projectRoot: state.projectRoot,
|
||||||
|
orchestratorId: state.orchestrator.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async ensureOrchestrator(projectRoot: string): Promise<RuntimeInstance> {
|
||||||
|
await this.ensureRunning();
|
||||||
|
const binding = await this.adapter.ensureProjectOrchestrator(projectRoot);
|
||||||
|
return binding.runtime;
|
||||||
|
}
|
||||||
|
|
||||||
|
listEvents(projectRoot: string): RuntimeConsoleEvent[] {
|
||||||
|
return this.adapter.listEvents(projectRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribeRuntimeEvents(listener: (event: RuntimeConsoleEvent) => void, options: RuntimeEventSubscriptionOptions = {}): () => void {
|
||||||
|
const id = this.nextSubscriberId;
|
||||||
|
this.nextSubscriberId += 1;
|
||||||
|
this.subscribers.set(id, {
|
||||||
|
projectRoot: options.projectRoot,
|
||||||
|
listener,
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
this.subscribers.delete(id);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitRuntimeEvents(projectRoot: string, events: RuntimeConsoleEvent[]) {
|
||||||
|
for (const event of events) {
|
||||||
|
for (const subscriber of this.subscribers.values()) {
|
||||||
|
if (!subscriber.projectRoot || subscriber.projectRoot === projectRoot) {
|
||||||
|
subscriber.listener(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async launchFromIssue(params: {
|
||||||
|
projectRoot: string;
|
||||||
|
issue: BeadIssue;
|
||||||
|
origin: LaunchSurface;
|
||||||
|
swarmId?: string | null;
|
||||||
|
}): Promise<{ orchestrator: RuntimeInstance; events: RuntimeConsoleEvent[] }> {
|
||||||
|
await this.ensureRunning();
|
||||||
|
const result = await this.adapter.launchFromIssue(params);
|
||||||
|
this.emitRuntimeEvents(params.projectRoot, result.events);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async prompt(projectRoot: string, text: string): Promise<void> {
|
||||||
|
await this.ensureRunning();
|
||||||
|
|
||||||
|
// Fire-and-forget the adapter prompt - adapter stores user message immediately,
|
||||||
|
// SDK subscription handles real-time events, SSE poller picks up from embeddedPiDaemon
|
||||||
|
if (typeof this.adapter.prompt === 'function') {
|
||||||
|
this.adapter.prompt(projectRoot, text).catch((e) => {
|
||||||
|
console.error('[BbDaemon] Adapter prompt error:', e);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resetForTests(): void {
|
||||||
|
this.lifecycle = createInitialLifecycle();
|
||||||
|
this.piRuntime = null;
|
||||||
|
embeddedPiDaemon.resetForTests();
|
||||||
|
this.subscribers.clear();
|
||||||
|
this.nextSubscriberId = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBbDaemon(adapter?: PiDaemonAdapter): BbDaemon {
|
||||||
|
return new InProcessBbDaemon(adapter);
|
||||||
|
}
|
||||||
|
|
||||||
|
const globalRegistry = globalThis as typeof globalThis & {
|
||||||
|
__beadboardBbDaemon?: BbDaemon;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const bbDaemon = globalRegistry.__beadboardBbDaemon ?? createBbDaemon();
|
||||||
|
if (!globalRegistry.__beadboardBbDaemon) {
|
||||||
|
globalRegistry.__beadboardBbDaemon = bbDaemon;
|
||||||
|
}
|
||||||
194
src/lib/bb-pi-bootstrap.ts
Normal file
194
src/lib/bb-pi-bootstrap.ts
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { getManagedPiPaths } from './pi-runtime-detection';
|
||||||
|
|
||||||
|
export interface BootstrapManagedPiResult {
|
||||||
|
managedRoot: string;
|
||||||
|
sdkPath: string;
|
||||||
|
agentDir: string;
|
||||||
|
created: boolean;
|
||||||
|
alreadyInstalled: boolean;
|
||||||
|
installedPackages: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BootstrapManagedPiOptions {
|
||||||
|
version?: string;
|
||||||
|
home?: string;
|
||||||
|
output?: { write(chunk: string): void };
|
||||||
|
execFile?: (
|
||||||
|
file: string,
|
||||||
|
args: string[],
|
||||||
|
options: { cwd: string; env: NodeJS.ProcessEnv },
|
||||||
|
) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getManagedShellPath(): string | null {
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
return process.env.ComSpec ?? 'C:\\Windows\\System32\\cmd.exe';
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = ['/bin/sh', '/usr/bin/sh', '/bin/bash', '/usr/bin/bash'];
|
||||||
|
return candidates.find((candidate) => require('node:fs').existsSync(candidate)) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureManagedPiSettings(agentDir: string): Promise<void> {
|
||||||
|
const settingsPath = path.join(agentDir, 'settings.json');
|
||||||
|
let settings: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existing = await fs.readFile(settingsPath, 'utf8');
|
||||||
|
settings = JSON.parse(existing) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
settings = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const shellPath = getManagedShellPath();
|
||||||
|
const nextSettings = {
|
||||||
|
defaultProvider: settings.defaultProvider ?? null,
|
||||||
|
defaultModel: settings.defaultModel ?? null,
|
||||||
|
...(shellPath ? { shellPath } : {}),
|
||||||
|
...settings,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (shellPath) {
|
||||||
|
nextSettings.shellPath = shellPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.mkdir(agentDir, { recursive: true });
|
||||||
|
await fs.writeFile(settingsPath, JSON.stringify(nextSettings, null, 2) + '\n', 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
const AGENTS_MD_CONTENT = `# BeadBoard Orchestrator
|
||||||
|
|
||||||
|
You are the BeadBoard Orchestrator, the central embedded intelligence of the BeadBoard project management and agent coordination system.
|
||||||
|
|
||||||
|
## Your Role
|
||||||
|
You are not a generic coding assistant. You are a headless, autonomous daemon responsible for coordinating work across the repository.
|
||||||
|
|
||||||
|
1. **Dolt Data Awareness**: You read and understand the project's task topology via Dolt (BeadBoard's versioned SQL backend).
|
||||||
|
2. **Mailbox Management**: You read, route, and respond to agent coordination messages (HANDOFF, BLOCKED, INFO).
|
||||||
|
3. **Session Presence**: You broadcast your status and presence state so the BeadBoard frontend can render what you are doing in real-time.
|
||||||
|
4. **Worker Dispatch**: You evaluate mission templates, select archetypes, and dispatch worker sub-agents to complete specific tasks.
|
||||||
|
|
||||||
|
## Operating Constraints
|
||||||
|
- You operate in a headless environment. You must NEVER prompt for human CLI input.
|
||||||
|
- You must always query the Dolt backend before making assumptions about task state.
|
||||||
|
- You must always acknowledge (ACK) messages in the BLOCKED or HANDOFF categories.
|
||||||
|
`;
|
||||||
|
|
||||||
|
export async function ensureManagedPiAgentsMd(agentDir: string): Promise<void> {
|
||||||
|
const agentsPath = path.join(agentDir, 'AGENTS.md');
|
||||||
|
await fs.mkdir(agentDir, { recursive: true });
|
||||||
|
await fs.writeFile(agentsPath, AGENTS_MD_CONTENT, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pathExists(target: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await fs.access(target);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readDependencyVersions(): Promise<{ pi: string; minimatch: string }> {
|
||||||
|
// Use process.cwd() rather than import.meta.url: in Next.js webpack context,
|
||||||
|
// import.meta.url is a webpack:// URL, not a file:// URL, so fileURLToPath()
|
||||||
|
// would throw a cross-realm TypeError. process.cwd() reliably resolves to the
|
||||||
|
// project root in both dev and production Next.js server environments.
|
||||||
|
const packageJsonPath = path.join(process.cwd(), 'package.json');
|
||||||
|
const raw = await fs.readFile(packageJsonPath, 'utf8');
|
||||||
|
const pkg = JSON.parse(raw) as {
|
||||||
|
dependencies?: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
pi: pkg.dependencies?.['@mariozechner/pi-coding-agent'] ?? '^0.30.2',
|
||||||
|
minimatch: pkg.dependencies?.minimatch ?? '^10.2.4',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function defaultExecFile(file: string, args: string[], options: { cwd: string; env: NodeJS.ProcessEnv }): Promise<void> {
|
||||||
|
const { execFile } = await import('node:child_process');
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
execFile(file, args, options, (error) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function npmCommand(): string {
|
||||||
|
return process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bootstrapManagedPi(options: BootstrapManagedPiOptions = {}): Promise<BootstrapManagedPiResult> {
|
||||||
|
const version = options.version ?? '0.1.0';
|
||||||
|
const home = options.home ?? os.homedir();
|
||||||
|
const output = options.output;
|
||||||
|
const execFile = options.execFile ?? defaultExecFile;
|
||||||
|
const managed = getManagedPiPaths(version, home);
|
||||||
|
|
||||||
|
if (await pathExists(managed.sdkPath)) {
|
||||||
|
await ensureManagedPiSettings(managed.agentDir);
|
||||||
|
await ensureManagedPiAgentsMd(managed.agentDir);
|
||||||
|
return {
|
||||||
|
managedRoot: managed.managedRoot,
|
||||||
|
sdkPath: managed.sdkPath,
|
||||||
|
agentDir: managed.agentDir,
|
||||||
|
created: false,
|
||||||
|
alreadyInstalled: true,
|
||||||
|
installedPackages: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const versions = await readDependencyVersions();
|
||||||
|
|
||||||
|
await fs.mkdir(managed.managedRoot, { recursive: true });
|
||||||
|
await fs.mkdir(managed.agentDir, { recursive: true });
|
||||||
|
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(managed.managedRoot, 'package.json'),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
name: 'bb-managed-pi-runtime',
|
||||||
|
private: true,
|
||||||
|
type: 'module',
|
||||||
|
dependencies: {
|
||||||
|
'@mariozechner/pi-coding-agent': versions.pi,
|
||||||
|
minimatch: versions.minimatch,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
) + '\n',
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
|
await ensureManagedPiSettings(managed.agentDir);
|
||||||
|
await ensureManagedPiAgentsMd(managed.agentDir);
|
||||||
|
|
||||||
|
output?.write(`[bb bootstrap] Installing BeadBoard agent runtime at ${managed.managedRoot}\n`);
|
||||||
|
await execFile(npmCommand(), ['install', '--no-package-lock', '--no-fund', '--no-audit'], {
|
||||||
|
cwd: managed.managedRoot,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
PATH: process.env.PATH ?? '',
|
||||||
|
HOME: process.env.HOME ?? home,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
output?.write('[bb bootstrap] Agent runtime installed.\n');
|
||||||
|
|
||||||
|
return {
|
||||||
|
managedRoot: managed.managedRoot,
|
||||||
|
sdkPath: managed.sdkPath,
|
||||||
|
agentDir: managed.agentDir,
|
||||||
|
created: true,
|
||||||
|
alreadyInstalled: false,
|
||||||
|
installedPackages: ['@mariozechner/pi-coding-agent', 'minimatch'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -35,14 +35,42 @@ async function readDoltMetadata(projectRoot: string): Promise<DoltMetadata> {
|
||||||
throw new DoltConnectionError(`Invalid JSON in ${metadataPath}`, err);
|
throw new DoltConnectionError(`Invalid JSON in ${metadataPath}`, err);
|
||||||
}
|
}
|
||||||
|
|
||||||
const port = parsed.dolt_server_port;
|
|
||||||
const database = parsed.dolt_database;
|
const database = parsed.dolt_database;
|
||||||
if (typeof port !== 'number' || typeof database !== 'string') {
|
if (typeof database !== 'string') {
|
||||||
throw new DoltConnectionError(
|
throw new DoltConnectionError(
|
||||||
`${metadataPath} is missing required fields: dolt_server_port (number) and dolt_database (string)`
|
`${metadataPath} is missing required field: dolt_database (string)`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Try port file first (preferred by bd), fall back to metadata.json
|
||||||
|
let port: number;
|
||||||
|
try {
|
||||||
|
const portPath = path.join(projectRoot, '.beads', 'dolt-server.port');
|
||||||
|
const portRaw = await fs.readFile(portPath, 'utf-8');
|
||||||
|
const portNum = parseInt(portRaw.trim(), 10);
|
||||||
|
if (!isNaN(portNum) && portNum > 0) {
|
||||||
|
port = portNum;
|
||||||
|
} else {
|
||||||
|
// Fall back to metadata.json port
|
||||||
|
const metadataPort = parsed.dolt_server_port;
|
||||||
|
if (typeof metadataPort !== 'number') {
|
||||||
|
throw new DoltConnectionError(
|
||||||
|
`${metadataPath} is missing valid port and .beads/dolt-server.port is missing or invalid`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
port = metadataPort;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall back to metadata.json port
|
||||||
|
const metadataPort = parsed.dolt_server_port;
|
||||||
|
if (typeof metadataPort !== 'number') {
|
||||||
|
throw new DoltConnectionError(
|
||||||
|
`${metadataPath} is missing required field: dolt_server_port (number) and .beads/dolt-server.port is missing`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
port = metadataPort;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
dolt_server_port: port,
|
dolt_server_port: port,
|
||||||
dolt_database: database,
|
dolt_database: database,
|
||||||
|
|
|
||||||
156
src/lib/embedded-daemon.ts
Normal file
156
src/lib/embedded-daemon.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
import { buildLaunchRequest, createLaunchConsoleEvents, createOrchestratorInstance, type LaunchSurface, type RuntimeConsoleEvent, type RuntimeInstance } from './embedded-runtime';
|
||||||
|
import type { BeadIssue } from './types';
|
||||||
|
|
||||||
|
export interface ProjectRuntimeState {
|
||||||
|
projectRoot: string;
|
||||||
|
orchestrator: RuntimeInstance;
|
||||||
|
events: RuntimeConsoleEvent[];
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HostDaemonStatus {
|
||||||
|
ok: true;
|
||||||
|
daemon: {
|
||||||
|
backend: 'pi';
|
||||||
|
status: 'online';
|
||||||
|
projectCount: number;
|
||||||
|
};
|
||||||
|
projects: Array<{
|
||||||
|
projectRoot: string;
|
||||||
|
orchestratorId: string;
|
||||||
|
orchestratorStatus: RuntimeInstance['status'];
|
||||||
|
eventCount: number;
|
||||||
|
updatedAt: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class EmbeddedPiDaemon {
|
||||||
|
private readonly projects = new Map<string, ProjectRuntimeState>();
|
||||||
|
private readonly orchestratorBooted = new Set<string>();
|
||||||
|
|
||||||
|
ensureProject(projectRoot: string): ProjectRuntimeState {
|
||||||
|
const existing = this.projects.get(projectRoot);
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const orchestrator = createOrchestratorInstance(projectRoot);
|
||||||
|
const state: ProjectRuntimeState = {
|
||||||
|
projectRoot,
|
||||||
|
orchestrator,
|
||||||
|
events: [],
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
this.projects.set(projectRoot, state);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureOrchestrator(projectRoot: string): RuntimeInstance {
|
||||||
|
const state = this.ensureProject(projectRoot);
|
||||||
|
const projectId = state.orchestrator.projectId;
|
||||||
|
|
||||||
|
// Only add boot event once per project (track via Set)
|
||||||
|
if (!this.orchestratorBooted.has(projectId)) {
|
||||||
|
state.events.unshift({
|
||||||
|
id: `${projectId}:boot:${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
projectId,
|
||||||
|
kind: 'launch.started',
|
||||||
|
title: 'Host daemon attached project orchestrator',
|
||||||
|
detail: 'BeadBoard host bridge registered project orchestrator.',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
status: 'idle',
|
||||||
|
actorLabel: state.orchestrator.label,
|
||||||
|
});
|
||||||
|
state.updatedAt = new Date().toISOString();
|
||||||
|
this.orchestratorBooted.add(projectId);
|
||||||
|
}
|
||||||
|
return state.orchestrator;
|
||||||
|
}
|
||||||
|
|
||||||
|
launchFromIssue(params: {
|
||||||
|
projectRoot: string;
|
||||||
|
issue: BeadIssue;
|
||||||
|
origin: LaunchSurface;
|
||||||
|
swarmId?: string | null;
|
||||||
|
}): { orchestrator: RuntimeInstance; events: RuntimeConsoleEvent[] } {
|
||||||
|
const state = this.ensureProject(params.projectRoot);
|
||||||
|
state.orchestrator.status = 'planning';
|
||||||
|
|
||||||
|
const request = buildLaunchRequest({
|
||||||
|
issue: params.issue,
|
||||||
|
origin: params.origin,
|
||||||
|
projectRoot: params.projectRoot,
|
||||||
|
swarmId: params.swarmId ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const launchEvents = createLaunchConsoleEvents(request);
|
||||||
|
state.events.unshift(...launchEvents);
|
||||||
|
state.updatedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
return {
|
||||||
|
orchestrator: state.orchestrator,
|
||||||
|
events: launchEvents,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
listEvents(projectRoot: string): RuntimeConsoleEvent[] {
|
||||||
|
return [...(this.projects.get(projectRoot)?.events ?? [])];
|
||||||
|
}
|
||||||
|
|
||||||
|
appendEvent(projectRoot: string, event: Omit<RuntimeConsoleEvent, 'id' | 'timestamp' | 'projectId'>): void {
|
||||||
|
const state = this.ensureProject(projectRoot);
|
||||||
|
const fullEvent: RuntimeConsoleEvent = {
|
||||||
|
...event,
|
||||||
|
id: `evt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
projectId: state.orchestrator.projectId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
state.events.unshift(fullEvent);
|
||||||
|
state.updatedAt = new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
appendWorkerEvent(projectRoot: string, workerId: string, event: {
|
||||||
|
kind: 'worker.spawned' | 'worker.updated' | 'worker.completed' | 'worker.failed';
|
||||||
|
title: string;
|
||||||
|
detail: string;
|
||||||
|
status?: RuntimeConsoleEvent['status'];
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}): void {
|
||||||
|
this.appendEvent(projectRoot, {
|
||||||
|
...event,
|
||||||
|
metadata: { workerId, ...(event.metadata || {}) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatus(): HostDaemonStatus {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
daemon: {
|
||||||
|
backend: 'pi',
|
||||||
|
status: 'online',
|
||||||
|
projectCount: this.projects.size,
|
||||||
|
},
|
||||||
|
projects: [...this.projects.values()].map((state) => ({
|
||||||
|
projectRoot: state.projectRoot,
|
||||||
|
orchestratorId: state.orchestrator.id,
|
||||||
|
orchestratorStatus: state.orchestrator.status,
|
||||||
|
eventCount: state.events.length,
|
||||||
|
updatedAt: state.updatedAt,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
resetForTests(): void {
|
||||||
|
this.projects.clear();
|
||||||
|
this.orchestratorBooted.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const globalRegistry = globalThis as typeof globalThis & {
|
||||||
|
__beadboardEmbeddedPiDaemon?: EmbeddedPiDaemon;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const embeddedPiDaemon = globalRegistry.__beadboardEmbeddedPiDaemon ?? new EmbeddedPiDaemon();
|
||||||
|
if (!globalRegistry.__beadboardEmbeddedPiDaemon) {
|
||||||
|
globalRegistry.__beadboardEmbeddedPiDaemon = embeddedPiDaemon;
|
||||||
|
}
|
||||||
238
src/lib/embedded-runtime.ts
Normal file
238
src/lib/embedded-runtime.ts
Normal file
|
|
@ -0,0 +1,238 @@
|
||||||
|
import type { BeadIssue } from './types';
|
||||||
|
|
||||||
|
export type RuntimeBackendId = 'pi';
|
||||||
|
export type AgentInstanceKind = 'orchestrator' | 'worker';
|
||||||
|
export type LaunchSurface = 'social' | 'graph' | 'swarm' | 'sessions' | 'activity' | 'task';
|
||||||
|
export type RuntimeEventKind =
|
||||||
|
| 'orchestrator.message'
|
||||||
|
| 'launch.requested'
|
||||||
|
| 'launch.planned'
|
||||||
|
| 'launch.started'
|
||||||
|
| 'worker.spawned'
|
||||||
|
| 'worker.updated'
|
||||||
|
| 'worker.completed'
|
||||||
|
| 'worker.failed'
|
||||||
|
| 'deviation.proposed'
|
||||||
|
| 'deviation.approved'
|
||||||
|
| 'deviation.rejected';
|
||||||
|
|
||||||
|
export type RuntimeStatus =
|
||||||
|
| 'idle'
|
||||||
|
| 'planning'
|
||||||
|
| 'launching'
|
||||||
|
| 'working'
|
||||||
|
| 'waiting'
|
||||||
|
| 'blocked'
|
||||||
|
| 'completed'
|
||||||
|
| 'failed'
|
||||||
|
| 'stale';
|
||||||
|
|
||||||
|
export type TemplateDeviationSeverity = 'minor' | 'major';
|
||||||
|
|
||||||
|
export interface AgentTypeDefinition {
|
||||||
|
id: string;
|
||||||
|
archetypeId: string;
|
||||||
|
label: string;
|
||||||
|
backend: RuntimeBackendId;
|
||||||
|
defaultModel?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuntimeInstance {
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
|
backend: RuntimeBackendId;
|
||||||
|
kind: AgentInstanceKind;
|
||||||
|
agentTypeId: string;
|
||||||
|
label: string;
|
||||||
|
status: RuntimeStatus;
|
||||||
|
taskId: string | null;
|
||||||
|
epicId: string | null;
|
||||||
|
swarmId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LaunchRequest {
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
|
backend: RuntimeBackendId;
|
||||||
|
origin: LaunchSurface;
|
||||||
|
taskId: string;
|
||||||
|
epicId: string | null;
|
||||||
|
swarmId: string | null;
|
||||||
|
templateId: string | null;
|
||||||
|
requestedAgentTypeId: string | null;
|
||||||
|
contextSummary: string;
|
||||||
|
issueTitle: string;
|
||||||
|
dependencyIds: string[];
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TemplateDeviationRecord {
|
||||||
|
id: string;
|
||||||
|
launchRequestId: string;
|
||||||
|
severity: TemplateDeviationSeverity;
|
||||||
|
summary: string;
|
||||||
|
reason: string;
|
||||||
|
requiresApproval: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuntimeConsoleEvent {
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
|
kind: RuntimeEventKind;
|
||||||
|
title: string;
|
||||||
|
detail: string;
|
||||||
|
timestamp: string;
|
||||||
|
status?: RuntimeStatus;
|
||||||
|
actorLabel?: string;
|
||||||
|
taskId?: string | null;
|
||||||
|
swarmId?: string | null;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stableProjectId(projectRoot: string): string {
|
||||||
|
return projectRoot
|
||||||
|
.replace(/^[A-Za-z]:/, '')
|
||||||
|
.replaceAll('\\', '/')
|
||||||
|
.split('/')
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('-')
|
||||||
|
.replace(/[^a-zA-Z0-9-]/g, '-')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.toLowerCase() || 'root';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProjectRuntimeId(projectRoot: string): string {
|
||||||
|
// Client-safe path normalization (removes trailing slashes)
|
||||||
|
const normalizedRoot = projectRoot.replace(/[/\\]+$/, '') || projectRoot;
|
||||||
|
return stableProjectId(normalizedRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOrchestratorAgentType(): AgentTypeDefinition {
|
||||||
|
return {
|
||||||
|
id: 'pi-orchestrator',
|
||||||
|
archetypeId: 'orchestrator',
|
||||||
|
label: 'Project Orchestrator',
|
||||||
|
backend: 'pi',
|
||||||
|
defaultModel: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createOrchestratorInstance(projectRoot: string): RuntimeInstance {
|
||||||
|
const projectId = getProjectRuntimeId(projectRoot);
|
||||||
|
return {
|
||||||
|
id: `${projectId}:orchestrator`,
|
||||||
|
projectId,
|
||||||
|
backend: 'pi',
|
||||||
|
kind: 'orchestrator',
|
||||||
|
agentTypeId: getOrchestratorAgentType().id,
|
||||||
|
label: 'Main Orchestrator',
|
||||||
|
status: 'idle',
|
||||||
|
taskId: null,
|
||||||
|
epicId: null,
|
||||||
|
swarmId: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildLaunchRequest(params: {
|
||||||
|
issue: BeadIssue;
|
||||||
|
origin: LaunchSurface;
|
||||||
|
projectRoot: string;
|
||||||
|
swarmId?: string | null;
|
||||||
|
requestedAgentTypeId?: string | null;
|
||||||
|
}): LaunchRequest {
|
||||||
|
const { issue, origin, projectRoot, swarmId = null, requestedAgentTypeId = null } = params;
|
||||||
|
const projectId = getProjectRuntimeId(projectRoot);
|
||||||
|
const epicId = issue.dependencies.find((dep) => dep.type === 'parent')?.target ?? null;
|
||||||
|
const dependencyIds = issue.dependencies
|
||||||
|
.filter((dep) => dep.type !== 'parent')
|
||||||
|
.map((dep) => dep.target)
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `${projectId}:${origin}:${issue.id}`,
|
||||||
|
projectId,
|
||||||
|
backend: 'pi',
|
||||||
|
origin,
|
||||||
|
taskId: issue.id,
|
||||||
|
epicId,
|
||||||
|
swarmId,
|
||||||
|
templateId: issue.templateId ?? null,
|
||||||
|
requestedAgentTypeId,
|
||||||
|
contextSummary: `Launch ${issue.id} from ${origin} with ${dependencyIds.length} dependency link(s).`,
|
||||||
|
issueTitle: issue.title,
|
||||||
|
dependencyIds,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDeviationRecord(params: {
|
||||||
|
launchRequest: LaunchRequest;
|
||||||
|
severity: TemplateDeviationSeverity;
|
||||||
|
summary: string;
|
||||||
|
reason: string;
|
||||||
|
}): TemplateDeviationRecord {
|
||||||
|
const { launchRequest, severity, summary, reason } = params;
|
||||||
|
return {
|
||||||
|
id: `${launchRequest.id}:deviation`,
|
||||||
|
launchRequestId: launchRequest.id,
|
||||||
|
severity,
|
||||||
|
summary,
|
||||||
|
reason,
|
||||||
|
requiresApproval: severity === 'major',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRuntimeConsoleEvent(params: {
|
||||||
|
projectId: string;
|
||||||
|
kind: RuntimeEventKind;
|
||||||
|
title: string;
|
||||||
|
detail: string;
|
||||||
|
status?: RuntimeStatus;
|
||||||
|
actorLabel?: string;
|
||||||
|
taskId?: string | null;
|
||||||
|
swarmId?: string | null;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}): RuntimeConsoleEvent {
|
||||||
|
const { projectId, kind, title, detail, status, actorLabel, taskId = null, swarmId = null, metadata } = params;
|
||||||
|
return {
|
||||||
|
id: `${projectId}:${kind}:${taskId ?? 'global'}:${Date.now()}`,
|
||||||
|
projectId,
|
||||||
|
kind,
|
||||||
|
title,
|
||||||
|
detail,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
status,
|
||||||
|
actorLabel,
|
||||||
|
taskId,
|
||||||
|
swarmId,
|
||||||
|
metadata,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createLaunchConsoleEvents(request: LaunchRequest): RuntimeConsoleEvent[] {
|
||||||
|
return [
|
||||||
|
createRuntimeConsoleEvent({
|
||||||
|
projectId: request.projectId,
|
||||||
|
kind: 'launch.requested',
|
||||||
|
title: `Launch requested for ${request.taskId}`,
|
||||||
|
detail: `${request.issueTitle} queued from ${request.origin}.`,
|
||||||
|
status: 'planning',
|
||||||
|
actorLabel: 'Main Orchestrator',
|
||||||
|
taskId: request.taskId,
|
||||||
|
swarmId: request.swarmId,
|
||||||
|
metadata: { templateId: request.templateId, requestedAgentTypeId: request.requestedAgentTypeId },
|
||||||
|
}),
|
||||||
|
createRuntimeConsoleEvent({
|
||||||
|
projectId: request.projectId,
|
||||||
|
kind: 'orchestrator.message',
|
||||||
|
title: 'Orchestrator reviewing launch context',
|
||||||
|
detail: request.contextSummary,
|
||||||
|
status: 'planning',
|
||||||
|
actorLabel: 'Main Orchestrator',
|
||||||
|
taskId: request.taskId,
|
||||||
|
swarmId: request.swarmId,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
159
src/lib/orchestrator-chat.ts
Normal file
159
src/lib/orchestrator-chat.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
import type { RuntimeConsoleEvent } from './embedded-runtime';
|
||||||
|
|
||||||
|
export interface OrchestratorChatMessage {
|
||||||
|
id: string;
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
text: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function projectOrchestratorChat(events: RuntimeConsoleEvent[]): OrchestratorChatMessage[] {
|
||||||
|
const ordered = [...events]
|
||||||
|
.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
|
||||||
|
|
||||||
|
const messages: OrchestratorChatMessage[] = [];
|
||||||
|
let currentAssistantIndex: number | null = null;
|
||||||
|
|
||||||
|
for (const event of ordered) {
|
||||||
|
if (event.actorLabel === 'human' && event.detail.trim()) {
|
||||||
|
messages.push({
|
||||||
|
id: `chat-${event.id}`,
|
||||||
|
role: 'user',
|
||||||
|
text: event.detail,
|
||||||
|
timestamp: event.timestamp,
|
||||||
|
});
|
||||||
|
currentAssistantIndex = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.title === 'Orchestrator Responding') {
|
||||||
|
if (currentAssistantIndex === null) {
|
||||||
|
messages.push({
|
||||||
|
id: `chat-${event.id}`,
|
||||||
|
role: 'assistant',
|
||||||
|
text: '…',
|
||||||
|
timestamp: event.timestamp,
|
||||||
|
});
|
||||||
|
currentAssistantIndex = messages.length - 1;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.title === 'Orchestrator Thinking' && event.detail.trim()) {
|
||||||
|
if (currentAssistantIndex === null) {
|
||||||
|
messages.push({
|
||||||
|
id: `chat-${event.id}`,
|
||||||
|
role: 'assistant',
|
||||||
|
text: event.detail,
|
||||||
|
timestamp: event.timestamp,
|
||||||
|
});
|
||||||
|
currentAssistantIndex = messages.length - 1;
|
||||||
|
} else {
|
||||||
|
const last = messages[currentAssistantIndex];
|
||||||
|
messages[currentAssistantIndex] = {
|
||||||
|
...last,
|
||||||
|
text: `${last.text === '…' ? '' : last.text}${event.detail}`,
|
||||||
|
timestamp: event.timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.title === 'Orchestrator Reply' && event.detail.trim()) {
|
||||||
|
if (currentAssistantIndex !== null && messages[currentAssistantIndex]?.role === 'assistant') {
|
||||||
|
const last = messages[currentAssistantIndex];
|
||||||
|
const nextText = event.detail;
|
||||||
|
const mergedText = nextText.startsWith(last.text) || nextText.length >= last.text.length
|
||||||
|
? nextText
|
||||||
|
: `${last.text === '…' ? '' : last.text}${nextText}`;
|
||||||
|
messages[currentAssistantIndex] = {
|
||||||
|
...last,
|
||||||
|
text: mergedText,
|
||||||
|
timestamp: event.timestamp,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
messages.push({
|
||||||
|
id: `chat-${event.id}`,
|
||||||
|
role: 'assistant',
|
||||||
|
text: event.detail,
|
||||||
|
timestamp: event.timestamp,
|
||||||
|
});
|
||||||
|
currentAssistantIndex = messages.length - 1;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.title === 'Session Error') {
|
||||||
|
currentAssistantIndex = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Worker events - when worker completes, orchestrator should report it
|
||||||
|
if (event.kind === 'worker.spawned') {
|
||||||
|
if (currentAssistantIndex === null) {
|
||||||
|
messages.push({
|
||||||
|
id: `chat-${event.id}`,
|
||||||
|
role: 'assistant',
|
||||||
|
text: `Spawning worker${event.metadata?.taskId ? ` for task ${event.metadata.taskId}` : ''}...`,
|
||||||
|
timestamp: event.timestamp,
|
||||||
|
});
|
||||||
|
currentAssistantIndex = messages.length - 1;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.kind === 'worker.updated') {
|
||||||
|
if (currentAssistantIndex !== null && messages[currentAssistantIndex]?.role === 'assistant') {
|
||||||
|
const last = messages[currentAssistantIndex];
|
||||||
|
messages[currentAssistantIndex] = {
|
||||||
|
...last,
|
||||||
|
text: `${last.text === '…' ? '' : last.text}Worker is now working${event.metadata?.taskId ? ` on task ${event.metadata.taskId}` : ''}.`,
|
||||||
|
timestamp: event.timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.kind === 'worker.completed') {
|
||||||
|
if (currentAssistantIndex === null) {
|
||||||
|
messages.push({
|
||||||
|
id: `chat-${event.id}`,
|
||||||
|
role: 'assistant',
|
||||||
|
text: `Worker${event.metadata?.workerId ? ` ${event.metadata.workerId}` : ''} completed${event.metadata?.taskId ? ` task ${event.metadata.taskId}` : ''}.`,
|
||||||
|
timestamp: event.timestamp,
|
||||||
|
});
|
||||||
|
currentAssistantIndex = messages.length - 1;
|
||||||
|
} else if (currentAssistantIndex !== null && messages[currentAssistantIndex]?.role === 'assistant') {
|
||||||
|
const last = messages[currentAssistantIndex];
|
||||||
|
messages[currentAssistantIndex] = {
|
||||||
|
...last,
|
||||||
|
text: `${last.text === '…' ? '' : last.text}Worker${event.metadata?.workerId ? ` ${event.metadata.workerId}` : ''} completed${event.metadata?.taskId ? ` task ${event.metadata.taskId}` : ''}.`,
|
||||||
|
timestamp: event.timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.kind === 'worker.failed') {
|
||||||
|
if (currentAssistantIndex === null) {
|
||||||
|
messages.push({
|
||||||
|
id: `chat-${event.id}`,
|
||||||
|
role: 'assistant',
|
||||||
|
text: `Worker${event.metadata?.workerId ? ` ${event.metadata.workerId}` : ''} failed${event.metadata?.taskId ? ` task ${event.metadata.taskId}` : ''}${event.detail ? `: ${event.detail}` : ''}.`,
|
||||||
|
timestamp: event.timestamp,
|
||||||
|
});
|
||||||
|
currentAssistantIndex = messages.length - 1;
|
||||||
|
} else if (currentAssistantIndex !== null && messages[currentAssistantIndex]?.role === 'assistant') {
|
||||||
|
const last = messages[currentAssistantIndex];
|
||||||
|
messages[currentAssistantIndex] = {
|
||||||
|
...last,
|
||||||
|
text: `${last.text === '…' ? '' : last.text}Worker${event.metadata?.workerId ? ` ${event.metadata.workerId}` : ''} failed${event.metadata?.taskId ? ` task ${event.metadata.taskId}` : ''}${event.detail ? `: ${event.detail}` : ''}.`,
|
||||||
|
timestamp: event.timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
276
src/lib/pi-daemon-adapter.ts
Normal file
276
src/lib/pi-daemon-adapter.ts
Normal file
|
|
@ -0,0 +1,276 @@
|
||||||
|
import { embeddedPiDaemon } from './embedded-daemon';
|
||||||
|
import type { LaunchSurface, RuntimeConsoleEvent, RuntimeInstance } from './embedded-runtime';
|
||||||
|
import type { BeadIssue } from './types';
|
||||||
|
import { detectPiRuntimeStrategy } from './pi-runtime-detection';
|
||||||
|
import { ensureManagedPiSettings, bootstrapManagedPi } from './bb-pi-bootstrap';
|
||||||
|
import { buildBeadBoardSystemPrompt } from '../tui/system-prompt';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { createDoltReadTool } from '../tui/tools/bb-dolt-read';
|
||||||
|
import { createMailboxTools } from '../tui/tools/bb-mailbox';
|
||||||
|
import { createPresenceTools } from '../tui/tools/bb-presence';
|
||||||
|
import { createDeviationTool } from '../tui/tools/bb-deviation';
|
||||||
|
import { createSpawnWorkerTool } from '../tui/tools/bb-spawn-worker';
|
||||||
|
import { createSpawnTemplateTool } from '../tui/tools/bb-spawn-template';
|
||||||
|
import { createWorkerStatusTool } from '../tui/tools/bb-worker-status';
|
||||||
|
import { createAssignAgentTool } from '../tui/tools/bb-assign-agent';
|
||||||
|
import { createListAgentsTool } from '../tui/tools/bb-list-agents';
|
||||||
|
import { createCreateAgentTool } from '../tui/tools/bb-create-agent';
|
||||||
|
import { createUpdateAgentTool } from '../tui/tools/bb-update-agent';
|
||||||
|
import { createDeleteAgentTool } from '../tui/tools/bb-delete-agent';
|
||||||
|
import { createListTemplatesTool } from '../tui/tools/bb-list-templates';
|
||||||
|
import { createCreateTemplateTool } from '../tui/tools/bb-create-template';
|
||||||
|
import { createUpdateTemplateTool } from '../tui/tools/bb-update-template';
|
||||||
|
import { createDeleteTemplateTool } from '../tui/tools/bb-delete-template';
|
||||||
|
import { createBeadCrudTools } from '../tui/tools/bb-bead-crud';
|
||||||
|
import { createWorkerResultsTool } from '../tui/tools/bb-worker-results';
|
||||||
|
|
||||||
|
export interface PiDaemonBinding {
|
||||||
|
id: string;
|
||||||
|
backend: 'pi';
|
||||||
|
kind: RuntimeInstance['kind'];
|
||||||
|
projectRoot: string;
|
||||||
|
attachMode: 'in-process' | 'host-daemon';
|
||||||
|
launchTarget: 'embedded-pi-daemon';
|
||||||
|
runtime: RuntimeInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PiDaemonAdapter {
|
||||||
|
ensureProjectOrchestrator(projectRoot: string): Promise<PiDaemonBinding>;
|
||||||
|
listEvents(projectRoot: string): RuntimeConsoleEvent[];
|
||||||
|
launchFromIssue(params: {
|
||||||
|
projectRoot: string;
|
||||||
|
issue: BeadIssue;
|
||||||
|
origin: LaunchSurface;
|
||||||
|
swarmId?: string | null;
|
||||||
|
}): Promise<{ orchestrator: RuntimeInstance; events: RuntimeConsoleEvent[] }>;
|
||||||
|
prompt?(projectRoot: string, text: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
class InProcessPiDaemonAdapter implements PiDaemonAdapter {
|
||||||
|
private activeSessions = new Map<string, any>(); // Map<projectRoot, AgentSession>
|
||||||
|
private recentEventKeys = new Set<string>(); // Deduplicate events within same second
|
||||||
|
|
||||||
|
private async getOrCreateSession(projectRoot: string): Promise<any> {
|
||||||
|
if (this.activeSessions.has(projectRoot)) {
|
||||||
|
return this.activeSessions.get(projectRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolution = await detectPiRuntimeStrategy();
|
||||||
|
|
||||||
|
// Auto-bootstrap if Pi not installed
|
||||||
|
if (!resolution.sdkPath || resolution.installState === 'bootstrap-required') {
|
||||||
|
console.log('[Agent] SDK not found, auto-bootstrapping...');
|
||||||
|
const bootstrapResult = await bootstrapManagedPi();
|
||||||
|
console.log('[Agent] Bootstrap complete:', bootstrapResult.managedRoot);
|
||||||
|
|
||||||
|
// Re-detect after bootstrap
|
||||||
|
resolution = await detectPiRuntimeStrategy();
|
||||||
|
if (!resolution.sdkPath) {
|
||||||
|
throw new Error('Auto-bootstrap completed but SDK still not available. Check npm install logs.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const managedAgentDir = resolution.agentDir;
|
||||||
|
await ensureManagedPiSettings(managedAgentDir);
|
||||||
|
process.env.PI_CODING_AGENT_DIR = managedAgentDir;
|
||||||
|
|
||||||
|
// Dynamically load the PI SDK
|
||||||
|
const { pathToFileURL } = await import('node:url');
|
||||||
|
const sdk = await import(/* webpackIgnore: true */ pathToFileURL(resolution.sdkPath).href);
|
||||||
|
|
||||||
|
const authStorage = new sdk.AuthStorage(path.join(managedAgentDir, 'auth.json'));
|
||||||
|
const modelRegistry = new sdk.ModelRegistry(authStorage, path.join(managedAgentDir, 'models.json'));
|
||||||
|
const settingsManager = sdk.SettingsManager.create(projectRoot, managedAgentDir);
|
||||||
|
const sessionManager = sdk.SessionManager.create(projectRoot);
|
||||||
|
|
||||||
|
const dynamicPrompt = await buildBeadBoardSystemPrompt(projectRoot, `You are a headless orchestrator for the BeadBoard system.`);
|
||||||
|
|
||||||
|
const res = await sdk.createAgentSession({
|
||||||
|
cwd: projectRoot,
|
||||||
|
agentDir: managedAgentDir,
|
||||||
|
authStorage,
|
||||||
|
modelRegistry,
|
||||||
|
settingsManager,
|
||||||
|
sessionManager,
|
||||||
|
systemPrompt: dynamicPrompt,
|
||||||
|
tools: [
|
||||||
|
sdk.createReadTool(projectRoot),
|
||||||
|
sdk.createBashTool(projectRoot),
|
||||||
|
sdk.createEditTool(projectRoot),
|
||||||
|
sdk.createWriteTool(projectRoot),
|
||||||
|
],
|
||||||
|
hooks: [],
|
||||||
|
skills: [],
|
||||||
|
contextFiles: [],
|
||||||
|
slashCommands: [],
|
||||||
|
customTools: [
|
||||||
|
{ tool: createDoltReadTool(projectRoot) },
|
||||||
|
{ tool: createDeviationTool(projectRoot) },
|
||||||
|
{ tool: createSpawnWorkerTool(projectRoot) },
|
||||||
|
{ tool: createSpawnTemplateTool(projectRoot) },
|
||||||
|
{ tool: createWorkerStatusTool(projectRoot) },
|
||||||
|
{ tool: createAssignAgentTool(projectRoot) },
|
||||||
|
// Agent CRUD tools
|
||||||
|
{ tool: createListAgentsTool(projectRoot) },
|
||||||
|
{ tool: createCreateAgentTool(projectRoot) },
|
||||||
|
{ tool: createUpdateAgentTool(projectRoot) },
|
||||||
|
{ tool: createDeleteAgentTool(projectRoot) },
|
||||||
|
// Template CRUD tools
|
||||||
|
{ tool: createListTemplatesTool(projectRoot) },
|
||||||
|
{ tool: createCreateTemplateTool(projectRoot) },
|
||||||
|
{ tool: createUpdateTemplateTool(projectRoot) },
|
||||||
|
{ tool: createDeleteTemplateTool(projectRoot) },
|
||||||
|
// Bead CRUD tools
|
||||||
|
...createBeadCrudTools(projectRoot).map((tool) => ({ tool: tool as any })),
|
||||||
|
// Worker results tool
|
||||||
|
{ tool: createWorkerResultsTool(projectRoot) },
|
||||||
|
...createMailboxTools().map((tool) => ({ tool: tool as any })),
|
||||||
|
...createPresenceTools().map((tool) => ({ tool: tool as any })),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const session = res.session;
|
||||||
|
|
||||||
|
// Helper: deduplicate and emit events
|
||||||
|
const emitEvent = (kind: RuntimeConsoleEvent['kind'], title: string, detail: string, status?: RuntimeConsoleEvent['status']) => {
|
||||||
|
const normalizedDetail = detail.trim();
|
||||||
|
const eventKey = `${kind}:${title}:${status || 'none'}:${normalizedDetail}`;
|
||||||
|
if (this.recentEventKeys.has(eventKey)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.recentEventKeys.add(eventKey);
|
||||||
|
setTimeout(() => this.recentEventKeys.delete(eventKey), 1000);
|
||||||
|
|
||||||
|
embeddedPiDaemon.appendEvent(projectRoot, {
|
||||||
|
kind,
|
||||||
|
title,
|
||||||
|
detail,
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
session.subscribe((event: any) => {
|
||||||
|
console.log('[Pi SDK Event]', event.type, event);
|
||||||
|
|
||||||
|
// Map PI SDK events to BeadBoard runtime console events
|
||||||
|
if (event.type === 'message_start' && event.message.role === 'assistant') {
|
||||||
|
emitEvent('orchestrator.message', 'Orchestrator Responding', 'Processing request...', 'working');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === 'tool_execution_start') {
|
||||||
|
emitEvent('orchestrator.message', `Tool: ${event.toolName}`, `Executing ${event.toolName}...`, 'working');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === 'tool_execution_end') {
|
||||||
|
emitEvent('orchestrator.message', `Tool Complete: ${event.toolName}`, `Finished ${event.toolName}`, 'completed');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === 'message_update') {
|
||||||
|
const ame = event.assistantMessageEvent;
|
||||||
|
if (ame.type === 'error') {
|
||||||
|
emitEvent('orchestrator.message', 'Error', ame.error.errorMessage, 'failed');
|
||||||
|
} else if (ame.type === 'thinking_delta') {
|
||||||
|
const delta = ame.delta || '';
|
||||||
|
if (delta) {
|
||||||
|
emitEvent('orchestrator.message', 'Orchestrator Thinking', delta, 'working');
|
||||||
|
}
|
||||||
|
} else if (ame.type === 'text_delta') {
|
||||||
|
const delta = ame.delta || '';
|
||||||
|
if (delta) {
|
||||||
|
emitEvent('orchestrator.message', 'Orchestrator Reply', delta, 'completed');
|
||||||
|
}
|
||||||
|
} else if (ame.type === 'text_done') {
|
||||||
|
const text = ame.text || '';
|
||||||
|
if (text) {
|
||||||
|
emitEvent('orchestrator.message', 'Orchestrator Reply', text, 'completed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === 'agent_end') {
|
||||||
|
const lastMsg = event.messages?.[event.messages.length - 1];
|
||||||
|
if (lastMsg?.role === 'assistant') {
|
||||||
|
if (lastMsg.stopReason === 'error' && lastMsg.errorMessage) {
|
||||||
|
emitEvent('orchestrator.message', 'Execution Failed', lastMsg.errorMessage, 'failed');
|
||||||
|
} else {
|
||||||
|
const txt = lastMsg.content?.filter((c: any) => c.type === 'text').map((c: any) => c.text).join('\n') || 'Completed.';
|
||||||
|
emitEvent('orchestrator.message', 'Orchestrator Reply', txt.substring(0, 500), 'completed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.activeSessions.set(projectRoot, session);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
async ensureProjectOrchestrator(projectRoot: string): Promise<PiDaemonBinding> {
|
||||||
|
const runtime = embeddedPiDaemon.ensureOrchestrator(projectRoot);
|
||||||
|
// eager initialize the session if we can
|
||||||
|
this.getOrCreateSession(projectRoot).catch(() => {});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: runtime.id,
|
||||||
|
backend: 'pi',
|
||||||
|
kind: runtime.kind,
|
||||||
|
projectRoot,
|
||||||
|
attachMode: 'in-process',
|
||||||
|
launchTarget: 'embedded-pi-daemon',
|
||||||
|
runtime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
listEvents(projectRoot: string): RuntimeConsoleEvent[] {
|
||||||
|
return embeddedPiDaemon.listEvents(projectRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
async launchFromIssue(params: {
|
||||||
|
projectRoot: string;
|
||||||
|
issue: BeadIssue;
|
||||||
|
origin: LaunchSurface;
|
||||||
|
swarmId?: string | null;
|
||||||
|
}): Promise<{ orchestrator: RuntimeInstance; events: RuntimeConsoleEvent[] }> {
|
||||||
|
const result = embeddedPiDaemon.launchFromIssue(params);
|
||||||
|
// Send it to the orchestrator as a prompt
|
||||||
|
const text = `I am launching a task from the UI.\n\nTask: ${params.issue.title}\nID: ${params.issue.id}\n\nPlease read the current state of the project using your tools and proceed with the necessary steps to orchestrate this task.`;
|
||||||
|
this.prompt(params.projectRoot, text).catch(() => {});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async prompt(projectRoot: string, text: string): Promise<void> {
|
||||||
|
console.log('[Pi Daemon] Prompt called for projectRoot:', projectRoot, 'text:', text);
|
||||||
|
|
||||||
|
// Emit user message immediately so UI shows it
|
||||||
|
embeddedPiDaemon.appendEvent(projectRoot, {
|
||||||
|
kind: 'orchestrator.message',
|
||||||
|
title: 'User Prompt',
|
||||||
|
detail: text,
|
||||||
|
actorLabel: 'human',
|
||||||
|
status: 'idle',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fire-and-forget the session prompt - SDK subscription handles real-time event emission
|
||||||
|
this.getOrCreateSession(projectRoot)
|
||||||
|
.then((session) => {
|
||||||
|
console.log('[Pi Daemon] Session obtained, calling session.prompt()');
|
||||||
|
return session.prompt(text);
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
console.log('[Pi Daemon] Session prompt completed');
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.error('[Pi Daemon] Session error:', e);
|
||||||
|
embeddedPiDaemon.appendEvent(projectRoot, {
|
||||||
|
kind: 'orchestrator.message',
|
||||||
|
title: 'Session Error',
|
||||||
|
detail: e instanceof Error ? e.message : String(e),
|
||||||
|
status: 'failed',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPiDaemonAdapter(): PiDaemonAdapter {
|
||||||
|
return new InProcessPiDaemonAdapter();
|
||||||
|
}
|
||||||
103
src/lib/pi-runtime-detection.ts
Normal file
103
src/lib/pi-runtime-detection.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { getRuntimePaths } from './runtime-manager';
|
||||||
|
|
||||||
|
export type PiRuntimeMode = 'linked-pi' | 'bb-managed-pi';
|
||||||
|
export type PiInstallState = 'ready' | 'bootstrap-required';
|
||||||
|
|
||||||
|
export interface PiRuntimeResolution {
|
||||||
|
mode: PiRuntimeMode;
|
||||||
|
installState: PiInstallState;
|
||||||
|
sdkPath: string | null;
|
||||||
|
authPath: string | null;
|
||||||
|
agentDir: string;
|
||||||
|
version: string;
|
||||||
|
managedRoot: string;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pathExists(target: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await fs.access(target);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getManagedPiPaths(version: string, home: string = os.homedir()) {
|
||||||
|
const runtime = getRuntimePaths(home, version);
|
||||||
|
const managedRoot = path.join(runtime.runtimeRoot, 'pi');
|
||||||
|
return {
|
||||||
|
managedRoot,
|
||||||
|
sdkPath: path.join(managedRoot, 'node_modules', '@mariozechner', 'pi-coding-agent', 'dist', 'index.js'),
|
||||||
|
agentDir: path.join(managedRoot, 'agent'),
|
||||||
|
authPath: path.join(managedRoot, 'agent', 'auth.json'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function detectPiRuntimeStrategy(params: {
|
||||||
|
cwd?: string;
|
||||||
|
version?: string;
|
||||||
|
home?: string;
|
||||||
|
globalPiRoot?: string;
|
||||||
|
allowLinkedPi?: boolean;
|
||||||
|
} = {}): Promise<PiRuntimeResolution> {
|
||||||
|
const cwd = params.cwd ?? process.cwd();
|
||||||
|
const version = params.version ?? '0.1.0';
|
||||||
|
const home = params.home ?? os.homedir();
|
||||||
|
const globalPiRoot = params.globalPiRoot ?? '/home/clawdbot/npm-global/lib/node_modules/@mariozechner/pi-coding-agent';
|
||||||
|
const allowLinkedPi = params.allowLinkedPi ?? false;
|
||||||
|
|
||||||
|
const localSdkPath = path.join(cwd, 'node_modules', '@mariozechner', 'pi-coding-agent', 'dist', 'index.js');
|
||||||
|
const globalSdkPath = path.join(globalPiRoot, 'dist', 'index.js');
|
||||||
|
const linkedAuthPath = path.join(home, '.pi', 'agent', 'auth.json');
|
||||||
|
const linkedAgentDir = path.join(home, '.pi', 'agent');
|
||||||
|
|
||||||
|
const managed = getManagedPiPaths(version, home);
|
||||||
|
|
||||||
|
if (allowLinkedPi && await pathExists(localSdkPath)) {
|
||||||
|
return {
|
||||||
|
mode: 'linked-pi',
|
||||||
|
installState: 'ready',
|
||||||
|
sdkPath: localSdkPath,
|
||||||
|
authPath: (await pathExists(linkedAuthPath)) ? linkedAuthPath : null,
|
||||||
|
agentDir: linkedAgentDir,
|
||||||
|
version,
|
||||||
|
managedRoot: managed.managedRoot,
|
||||||
|
reason: 'Using project-local Pi SDK and linked ~/.pi/agent state when available.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allowLinkedPi && await pathExists(globalSdkPath)) {
|
||||||
|
return {
|
||||||
|
mode: 'linked-pi',
|
||||||
|
installState: 'ready',
|
||||||
|
sdkPath: globalSdkPath,
|
||||||
|
authPath: (await pathExists(linkedAuthPath)) ? linkedAuthPath : null,
|
||||||
|
agentDir: linkedAgentDir,
|
||||||
|
version,
|
||||||
|
managedRoot: managed.managedRoot,
|
||||||
|
reason: 'Using globally installed Pi SDK and linked ~/.pi/agent state when available.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const managedSdkExists = await pathExists(managed.sdkPath);
|
||||||
|
const managedAuthExists = await pathExists(managed.authPath);
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode: 'bb-managed-pi',
|
||||||
|
installState: managedSdkExists ? 'ready' : 'bootstrap-required',
|
||||||
|
sdkPath: managedSdkExists ? managed.sdkPath : null,
|
||||||
|
authPath: managedAuthExists ? managed.authPath : null,
|
||||||
|
agentDir: managed.agentDir,
|
||||||
|
version,
|
||||||
|
managedRoot: managed.managedRoot,
|
||||||
|
reason: managedSdkExists
|
||||||
|
? 'Using BeadBoard-managed Pi runtime.'
|
||||||
|
: allowLinkedPi
|
||||||
|
? 'No linked Pi installation found. BeadBoard-managed Pi bootstrap is required.'
|
||||||
|
: 'BeadBoard-managed Pi bootstrap is required.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import fs from 'fs/promises';
|
import fs from 'fs/promises';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { AgentArchetype, SwarmTemplate } from '../types-swarm';
|
import { AgentType, AgentArchetype, SwarmTemplate } from '../types-swarm';
|
||||||
|
|
||||||
const ARCHE_DIR = path.join(process.cwd(), '.beads', 'archetypes');
|
const AGENT_DIR = path.join(process.cwd(), '.beads', 'archetypes');
|
||||||
const TEMPLATE_DIR = path.join(process.cwd(), '.beads', 'templates');
|
const TEMPLATE_DIR = path.join(process.cwd(), '.beads', 'templates');
|
||||||
|
|
||||||
export function slugify(name: string): string {
|
export function slugify(name: string): string {
|
||||||
|
|
@ -15,7 +15,7 @@ export function slugify(name: string): string {
|
||||||
.replace(/^-|-$/g, '');
|
.replace(/^-|-$/g, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SaveArchetypeInput = Partial<AgentArchetype> & {
|
export type SaveAgentTypeInput = Partial<AgentType> & {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
systemPrompt: string;
|
systemPrompt: string;
|
||||||
|
|
@ -23,8 +23,11 @@ export type SaveArchetypeInput = Partial<AgentArchetype> & {
|
||||||
color: string;
|
color: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function saveArchetype(input: SaveArchetypeInput): Promise<AgentArchetype> {
|
/** @deprecated Use SaveAgentTypeInput instead */
|
||||||
await fs.mkdir(ARCHE_DIR, { recursive: true });
|
export type SaveArchetypeInput = SaveAgentTypeInput;
|
||||||
|
|
||||||
|
export async function saveAgentType(input: SaveAgentTypeInput): Promise<AgentType> {
|
||||||
|
await fs.mkdir(AGENT_DIR, { recursive: true });
|
||||||
|
|
||||||
const id = input.id || slugify(input.name);
|
const id = input.id || slugify(input.name);
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
|
@ -33,7 +36,7 @@ export async function saveArchetype(input: SaveArchetypeInput): Promise<AgentArc
|
||||||
let createdAt = input.createdAt || now;
|
let createdAt = input.createdAt || now;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const existingContent = await fs.readFile(path.join(ARCHE_DIR, `${id}.json`), 'utf-8');
|
const existingContent = await fs.readFile(path.join(AGENT_DIR, `${id}.json`), 'utf-8');
|
||||||
const existing = JSON.parse(existingContent);
|
const existing = JSON.parse(existingContent);
|
||||||
if (existing.isBuiltIn) {
|
if (existing.isBuiltIn) {
|
||||||
isBuiltIn = true; // Protect built-in status
|
isBuiltIn = true; // Protect built-in status
|
||||||
|
|
@ -45,127 +48,246 @@ export async function saveArchetype(input: SaveArchetypeInput): Promise<AgentArc
|
||||||
// File doesn't exist, which is fine
|
// File doesn't exist, which is fine
|
||||||
}
|
}
|
||||||
|
|
||||||
const archetype: AgentArchetype = {
|
const agentType: AgentType = {
|
||||||
id,
|
id,
|
||||||
name: input.name,
|
name: input.name,
|
||||||
description: input.description,
|
description: input.description,
|
||||||
systemPrompt: input.systemPrompt,
|
systemPrompt: input.systemPrompt,
|
||||||
capabilities: input.capabilities,
|
capabilities: input.capabilities,
|
||||||
color: input.color,
|
color: input.color,
|
||||||
|
icon: input.icon,
|
||||||
createdAt,
|
createdAt,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
isBuiltIn
|
isBuiltIn
|
||||||
};
|
};
|
||||||
|
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
path.join(ARCHE_DIR, `${id}.json`),
|
path.join(AGENT_DIR, `${id}.json`),
|
||||||
JSON.stringify(archetype, null, 2)
|
JSON.stringify(agentType, null, 2)
|
||||||
);
|
);
|
||||||
|
|
||||||
return archetype;
|
return agentType;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteArchetype(id: string): Promise<void> {
|
/** @deprecated Use saveAgentType instead */
|
||||||
const filePath = path.join(ARCHE_DIR, `${id}.json`);
|
export const saveArchetype = saveAgentType;
|
||||||
|
|
||||||
let archetype: AgentArchetype;
|
export async function deleteAgentType(id: string): Promise<void> {
|
||||||
|
const filePath = path.join(AGENT_DIR, `${id}.json`);
|
||||||
|
|
||||||
|
let agentType: AgentType;
|
||||||
try {
|
try {
|
||||||
const content = await fs.readFile(filePath, 'utf-8');
|
const content = await fs.readFile(filePath, 'utf-8');
|
||||||
archetype = JSON.parse(content);
|
agentType = JSON.parse(content);
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error(`Archetype not found: ${id}`);
|
throw new Error(`Agent type not found: ${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (archetype.isBuiltIn) {
|
if (agentType.isBuiltIn) {
|
||||||
throw new Error(`Cannot delete built-in archetype: ${id}`);
|
throw new Error(`Cannot delete built-in agent type: ${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await fs.unlink(filePath);
|
await fs.unlink(filePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
const SEED_ARCHETYPES: AgentArchetype[] = [
|
/** @deprecated Use deleteAgentType instead */
|
||||||
|
export const deleteArchetype = deleteAgentType;
|
||||||
|
|
||||||
|
const SEED_AGENTS: AgentType[] = [
|
||||||
{
|
{
|
||||||
id: 'architect',
|
id: 'architect',
|
||||||
name: 'System Architect',
|
name: 'System Architect',
|
||||||
description: 'Designs complex system structures and writes detailed implementation plans.',
|
description: 'Designs system structures, decomposes work into actionable tasks, and makes technical decisions.',
|
||||||
systemPrompt: 'You are a staff-level software architect focused on high-level system design.',
|
systemPrompt: 'You are a staff-level software architect focused on high-level system design. You create clear, actionable plans that other agents can execute.',
|
||||||
capabilities: ['planning', 'design_docs', 'arch_review'],
|
capabilities: ['system_design', 'work_decomposition', 'technical_decisions', 'risk_assessment', 'documentation'],
|
||||||
color: '#3b82f6',
|
color: '#3b82f6',
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
isBuiltIn: true
|
isBuiltIn: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'coder',
|
id: 'engineer',
|
||||||
name: 'Implementation Engineer',
|
name: 'Implementation Engineer',
|
||||||
description: 'Translates plans into precise, type-safe, and tested code.',
|
description: 'Translates plans into precise, type-safe, and tested code. Focuses on clean implementation and maintainability.',
|
||||||
systemPrompt: 'You are a senior software engineer focused on execution and clean code.',
|
systemPrompt: 'You are a senior software engineer focused on turning designs and plans into production-quality code. You implement features, fix bugs, and write tests.',
|
||||||
capabilities: ['coding', 'refactoring', 'testing'],
|
capabilities: ['coding', 'refactoring', 'testing', 'debugging', 'documentation'],
|
||||||
color: '#10b981',
|
color: '#10b981',
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
isBuiltIn: true
|
isBuiltIn: true
|
||||||
}
|
},
|
||||||
];
|
|
||||||
|
|
||||||
export async function getArchetypes(): Promise<AgentArchetype[]> {
|
|
||||||
try {
|
|
||||||
await fs.mkdir(ARCHE_DIR, { recursive: true });
|
|
||||||
const files = await fs.readdir(ARCHE_DIR);
|
|
||||||
|
|
||||||
if (files.filter(f => f.endsWith('.json')).length === 0) {
|
|
||||||
// Seed defaults
|
|
||||||
for (const arch of SEED_ARCHETYPES) {
|
|
||||||
await fs.writeFile(path.join(ARCHE_DIR, `${arch.id}.json`), JSON.stringify(arch, null, 2));
|
|
||||||
}
|
|
||||||
return SEED_ARCHETYPES;
|
|
||||||
}
|
|
||||||
|
|
||||||
const archetypes: AgentArchetype[] = [];
|
|
||||||
for (const file of files) {
|
|
||||||
if (!file.endsWith('.json')) continue;
|
|
||||||
try {
|
|
||||||
const content = await fs.readFile(path.join(ARCHE_DIR, file), 'utf-8');
|
|
||||||
const parsed = JSON.parse(content);
|
|
||||||
archetypes.push({
|
|
||||||
...parsed,
|
|
||||||
id: file.replace('.json', '')
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Failed to parse archetype file: ${file}`, err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return archetypes;
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error in getArchetypes:', e);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const SEED_TEMPLATES: SwarmTemplate[] = [
|
|
||||||
{
|
{
|
||||||
id: 'standard-app',
|
id: 'reviewer',
|
||||||
name: 'Standard Application Swarm',
|
name: 'Code Reviewer',
|
||||||
description: 'A balanced team of an Architect and two Coders for standard feature development.',
|
description: 'Conducts rigorous technical code reviews with focus on correctness, performance, maintainability, and test quality.',
|
||||||
team: [
|
systemPrompt: 'You are a senior systems engineer conducting rigorous technical code reviews. Your analysis prioritizes technical correctness, performance, maintainability, and simplicity. Be direct about problems and constructive with solutions. You do NOT modify files.',
|
||||||
{ archetypeId: 'architect', count: 1 },
|
capabilities: ['code_review', 'quality_gates', 'test_evaluation', 'security_review', 'performance_analysis'],
|
||||||
{ archetypeId: 'coder', count: 2 }
|
color: '#f59e0b',
|
||||||
],
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
isBuiltIn: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tester',
|
||||||
|
name: 'Test Engineer',
|
||||||
|
description: 'Designs and implements comprehensive test suites, discovers edge cases, and ensures code correctness through rigorous verification.',
|
||||||
|
systemPrompt: 'You are a senior test engineer focused on ensuring code correctness through comprehensive test design and implementation. You think adversarially about code, always looking for ways it could fail.',
|
||||||
|
capabilities: ['test_design', 'test_implementation', 'edge_case_discovery', 'coverage_analysis', 'quality_assurance'],
|
||||||
|
color: '#8b5cf6',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
isBuiltIn: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'investigator',
|
||||||
|
name: 'Investigator',
|
||||||
|
description: 'Debugs complex issues, performs root cause analysis, and researches unknowns to unblock development.',
|
||||||
|
systemPrompt: 'You are a senior engineer specializing in debugging, root cause analysis, and technical research. You excel at unraveling complex problems. You do NOT modify files unless implementing a confirmed fix.',
|
||||||
|
capabilities: ['debugging', 'root_cause_analysis', 'research', 'documentation', 'problem_solving'],
|
||||||
|
color: '#ef4444',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
isBuiltIn: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'shipper',
|
||||||
|
name: 'Shipper',
|
||||||
|
description: 'Manages CI/CD pipelines, deployments, and release processes. Ensures safe and reliable software delivery.',
|
||||||
|
systemPrompt: 'You are a senior DevOps/release engineer focused on safe, reliable software delivery. You manage CI/CD pipelines, deployment processes, and release coordination.',
|
||||||
|
capabilities: ['ci_cd', 'deployment', 'release_management', 'monitoring', 'incident_response'],
|
||||||
|
color: '#06b6d4',
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
isBuiltIn: true
|
isBuiltIn: true
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
export async function getTemplates(): Promise<SwarmTemplate[]> {
|
/** @deprecated Use SEED_AGENTS instead */
|
||||||
|
const SEED_ARCHETYPES = SEED_AGENTS;
|
||||||
|
|
||||||
|
export async function getAgentTypes(projectRoot: string = process.cwd()): Promise<AgentType[]> {
|
||||||
|
const agentDir = path.join(projectRoot, '.beads', 'archetypes');
|
||||||
try {
|
try {
|
||||||
await fs.mkdir(TEMPLATE_DIR, { recursive: true });
|
await fs.mkdir(agentDir, { recursive: true });
|
||||||
const files = await fs.readdir(TEMPLATE_DIR);
|
const files = await fs.readdir(agentDir);
|
||||||
|
|
||||||
|
if (files.filter(f => f.endsWith('.json')).length === 0) {
|
||||||
|
// Seed defaults
|
||||||
|
for (const agent of SEED_AGENTS) {
|
||||||
|
await fs.writeFile(path.join(agentDir, `${agent.id}.json`), JSON.stringify(agent, null, 2));
|
||||||
|
}
|
||||||
|
return SEED_AGENTS;
|
||||||
|
}
|
||||||
|
|
||||||
|
const agentTypes: AgentType[] = [];
|
||||||
|
for (const file of files) {
|
||||||
|
if (!file.endsWith('.json')) continue;
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(path.join(agentDir, file), 'utf-8');
|
||||||
|
const parsed = JSON.parse(content);
|
||||||
|
agentTypes.push({
|
||||||
|
...parsed,
|
||||||
|
id: file.replace('.json', '')
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to parse agent type file: ${file}`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return agentTypes;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error in getAgentTypes:', e);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use getAgentTypes instead */
|
||||||
|
export const getArchetypes = getAgentTypes;
|
||||||
|
|
||||||
|
const SEED_TEMPLATES: SwarmTemplate[] = [
|
||||||
|
{
|
||||||
|
id: 'standard-app',
|
||||||
|
name: 'Standard Application',
|
||||||
|
description: 'Classic balanced team for routine application development. One Architect for design, two Engineers for implementation.',
|
||||||
|
team: [{ agentTypeId: 'architect', count: 1 }, { agentTypeId: 'engineer', count: 2 }],
|
||||||
|
color: '#f59e0b', icon: '📦',
|
||||||
|
createdAt: '2026-02-21T03:22:04.089Z', updatedAt: '2026-02-25T00:00:00.000Z', isBuiltIn: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'feature-dev',
|
||||||
|
name: 'Feature Development',
|
||||||
|
description: 'Balanced team for implementing new features. Architect plans, Engineers build, Reviewer ensures quality, Tester verifies behavior.',
|
||||||
|
team: [{ agentTypeId: 'architect', count: 1 }, { agentTypeId: 'engineer', count: 2 }, { agentTypeId: 'reviewer', count: 1 }, { agentTypeId: 'tester', count: 1 }],
|
||||||
|
color: '#3b82f6', icon: '✨',
|
||||||
|
createdAt: '2026-02-25T00:00:00.000Z', updatedAt: '2026-02-25T00:00:00.000Z', isBuiltIn: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bug-fix',
|
||||||
|
name: 'Bug Fix Squad',
|
||||||
|
description: 'Focused team for debugging and fixing issues. Investigator finds root cause, Engineer implements fix, Tester verifies resolution.',
|
||||||
|
team: [{ agentTypeId: 'investigator', count: 1 }, { agentTypeId: 'engineer', count: 1 }, { agentTypeId: 'tester', count: 1 }],
|
||||||
|
color: '#ef4444', icon: '🐛',
|
||||||
|
createdAt: '2026-02-25T00:00:00.000Z', updatedAt: '2026-02-25T00:00:00.000Z', isBuiltIn: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'code-review',
|
||||||
|
name: 'Code Review',
|
||||||
|
description: 'Lightweight team for reviewing and improving existing code. Reviewer analyzes, Engineer makes improvements.',
|
||||||
|
team: [{ agentTypeId: 'reviewer', count: 1 }, { agentTypeId: 'engineer', count: 1 }],
|
||||||
|
color: '#f59e0b', icon: '👁️',
|
||||||
|
createdAt: '2026-02-25T00:00:00.000Z', updatedAt: '2026-02-25T00:00:00.000Z', isBuiltIn: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'greenfield',
|
||||||
|
name: 'Greenfield Project',
|
||||||
|
description: 'Full team for starting new projects from scratch.',
|
||||||
|
team: [{ agentTypeId: 'architect', count: 1 }, { agentTypeId: 'engineer', count: 3 }, { agentTypeId: 'tester', count: 1 }, { agentTypeId: 'shipper', count: 1 }],
|
||||||
|
color: '#10b981', icon: '🌱',
|
||||||
|
createdAt: '2026-02-25T00:00:00.000Z', updatedAt: '2026-02-25T00:00:00.000Z', isBuiltIn: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'investigation',
|
||||||
|
name: 'Investigation Team',
|
||||||
|
description: 'Specialized team for research and analysis.',
|
||||||
|
team: [{ agentTypeId: 'investigator', count: 1 }, { agentTypeId: 'tester', count: 1 }],
|
||||||
|
color: '#8b5cf6', icon: '🔍',
|
||||||
|
createdAt: '2026-02-25T00:00:00.000Z', updatedAt: '2026-02-25T00:00:00.000Z', isBuiltIn: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'refactor',
|
||||||
|
name: 'Refactoring Team',
|
||||||
|
description: 'Team for improving existing code without changing behavior.',
|
||||||
|
team: [{ agentTypeId: 'architect', count: 1 }, { agentTypeId: 'engineer', count: 2 }, { agentTypeId: 'tester', count: 1 }],
|
||||||
|
color: '#64748b', icon: '🔧',
|
||||||
|
createdAt: '2026-02-25T00:00:00.000Z', updatedAt: '2026-02-25T00:00:00.000Z', isBuiltIn: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'release',
|
||||||
|
name: 'Release Team',
|
||||||
|
description: 'Team focused on safe deployments.',
|
||||||
|
team: [{ agentTypeId: 'tester', count: 1 }, { agentTypeId: 'reviewer', count: 1 }, { agentTypeId: 'shipper', count: 1 }],
|
||||||
|
color: '#06b6d4', icon: '🚀',
|
||||||
|
createdAt: '2026-02-25T00:00:00.000Z', updatedAt: '2026-02-25T00:00:00.000Z', isBuiltIn: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'full-squad',
|
||||||
|
name: 'Full Development Squad',
|
||||||
|
description: 'Complete team for complex projects requiring all capabilities.',
|
||||||
|
team: [{ agentTypeId: 'architect', count: 1 }, { agentTypeId: 'engineer', count: 2 }, { agentTypeId: 'reviewer', count: 1 }, { agentTypeId: 'tester', count: 1 }, { agentTypeId: 'investigator', count: 1 }, { agentTypeId: 'shipper', count: 1 }],
|
||||||
|
color: '#ec4899', icon: '🎯',
|
||||||
|
createdAt: '2026-02-25T00:00:00.000Z', updatedAt: '2026-02-25T00:00:00.000Z', isBuiltIn: true
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function getTemplates(projectRoot: string = process.cwd()): Promise<SwarmTemplate[]> {
|
||||||
|
const templateDir = path.join(projectRoot, '.beads', 'templates');
|
||||||
|
try {
|
||||||
|
await fs.mkdir(templateDir, { recursive: true });
|
||||||
|
const files = await fs.readdir(templateDir);
|
||||||
|
|
||||||
if (files.filter(f => f.endsWith('.json')).length === 0) {
|
if (files.filter(f => f.endsWith('.json')).length === 0) {
|
||||||
for (const tpl of SEED_TEMPLATES) {
|
for (const tpl of SEED_TEMPLATES) {
|
||||||
await fs.writeFile(path.join(TEMPLATE_DIR, `${tpl.id}.json`), JSON.stringify(tpl, null, 2));
|
await fs.writeFile(path.join(templateDir, `${tpl.id}.json`), JSON.stringify(tpl, null, 2));
|
||||||
}
|
}
|
||||||
return SEED_TEMPLATES;
|
return SEED_TEMPLATES;
|
||||||
}
|
}
|
||||||
|
|
@ -174,8 +296,17 @@ export async function getTemplates(): Promise<SwarmTemplate[]> {
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
if (!file.endsWith('.json')) continue;
|
if (!file.endsWith('.json')) continue;
|
||||||
try {
|
try {
|
||||||
const content = await fs.readFile(path.join(TEMPLATE_DIR, file), 'utf-8');
|
const content = await fs.readFile(path.join(templateDir, file), 'utf-8');
|
||||||
const parsed = JSON.parse(content);
|
const parsed = JSON.parse(content);
|
||||||
|
|
||||||
|
// Normalize legacy archetypeId → agentTypeId
|
||||||
|
if (parsed.team && Array.isArray(parsed.team)) {
|
||||||
|
parsed.team = parsed.team.map((member: any) => ({
|
||||||
|
agentTypeId: member.agentTypeId || member.archetypeId,
|
||||||
|
count: member.count,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
templates.push({
|
templates.push({
|
||||||
...parsed,
|
...parsed,
|
||||||
id: file.replace('.json', '')
|
id: file.replace('.json', '')
|
||||||
|
|
@ -192,21 +323,35 @@ export async function getTemplates(): Promise<SwarmTemplate[]> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SaveTemplateInput = Partial<SwarmTemplate> & {
|
export type SaveTemplateInput = {
|
||||||
|
id?: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
team: { archetypeId: string; count: number }[];
|
/** Team composition. Accepts both agentTypeId and archetypeId (for backward compat) */
|
||||||
|
team: { agentTypeId?: string; archetypeId?: string; count: number }[];
|
||||||
|
protoFormula?: string;
|
||||||
|
color?: string;
|
||||||
|
icon?: string;
|
||||||
|
isBuiltIn?: boolean;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function saveTemplate(input: SaveTemplateInput): Promise<SwarmTemplate> {
|
export async function saveTemplate(input: SaveTemplateInput): Promise<SwarmTemplate> {
|
||||||
await fs.mkdir(TEMPLATE_DIR, { recursive: true });
|
await fs.mkdir(TEMPLATE_DIR, { recursive: true });
|
||||||
|
|
||||||
const archetypes = await getArchetypes();
|
const agentTypes = await getAgentTypes();
|
||||||
const validArchetypeIds = new Set(archetypes.map(a => a.id));
|
const validAgentTypeIds = new Set(agentTypes.map(a => a.id));
|
||||||
|
|
||||||
for (const member of input.team) {
|
// Normalize team: support both agentTypeId and archetypeId
|
||||||
if (!validArchetypeIds.has(member.archetypeId)) {
|
const normalizedTeam = input.team.map(member => ({
|
||||||
throw new Error(`Invalid archetype ID in team: ${member.archetypeId}`);
|
agentTypeId: member.agentTypeId || member.archetypeId || '',
|
||||||
|
count: member.count,
|
||||||
|
}));
|
||||||
|
|
||||||
|
for (const member of normalizedTeam) {
|
||||||
|
if (!validAgentTypeIds.has(member.agentTypeId)) {
|
||||||
|
throw new Error(`Invalid agent type ID in team: ${member.agentTypeId}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -233,8 +378,10 @@ export async function saveTemplate(input: SaveTemplateInput): Promise<SwarmTempl
|
||||||
id,
|
id,
|
||||||
name: input.name,
|
name: input.name,
|
||||||
description: input.description,
|
description: input.description,
|
||||||
team: input.team,
|
team: normalizedTeam,
|
||||||
protoFormula: input.protoFormula,
|
protoFormula: input.protoFormula,
|
||||||
|
color: input.color,
|
||||||
|
icon: input.icon,
|
||||||
createdAt,
|
createdAt,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
isBuiltIn
|
isBuiltIn
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ export interface SocialCard {
|
||||||
agents: AgentInfo[];
|
agents: AgentInfo[];
|
||||||
lastActivity: Date;
|
lastActivity: Date;
|
||||||
priority: SocialCardPriority;
|
priority: SocialCardPriority;
|
||||||
|
agentTypeId?: string; // Assigned agent type for spawn button
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapStatus(status: BeadIssue['status']): SocialCardStatus {
|
function mapStatus(status: BeadIssue['status']): SocialCardStatus {
|
||||||
|
|
@ -63,6 +64,12 @@ function extractAgentName(bead: BeadIssue): string | null {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractAgentTypeId(labels: string[] | undefined): string | undefined {
|
||||||
|
if (!labels) return undefined;
|
||||||
|
const agentLabel = labels.find(l => l.startsWith('agent:'));
|
||||||
|
return agentLabel ? agentLabel.replace('agent:', '') : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function extractAgents(bead: BeadIssue): AgentInfo[] {
|
function extractAgents(bead: BeadIssue): AgentInfo[] {
|
||||||
const agents: AgentInfo[] = [];
|
const agents: AgentInfo[] = [];
|
||||||
if (bead.assignee) {
|
if (bead.assignee) {
|
||||||
|
|
@ -141,6 +148,7 @@ export function buildSocialCards(beads: BeadIssue[]): SocialCard[] {
|
||||||
agents: extractAgents(bead),
|
agents: extractAgents(bead),
|
||||||
lastActivity: new Date(bead.updated_at),
|
lastActivity: new Date(bead.updated_at),
|
||||||
priority: mapPriority(bead.priority),
|
priority: mapPriority(bead.priority),
|
||||||
|
agentTypeId: bead.agentTypeId || extractAgentTypeId(bead.labels),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
export interface AgentArchetype {
|
export interface AgentType {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
|
@ -12,11 +12,15 @@ export interface AgentArchetype {
|
||||||
isBuiltIn: boolean;
|
isBuiltIn: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use AgentType instead. Kept for backward compatibility. */
|
||||||
|
export type AgentArchetype = AgentType;
|
||||||
|
|
||||||
export interface SwarmTemplate {
|
export interface SwarmTemplate {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
team: { archetypeId: string; count: number }[];
|
/** Team composition. Use agentTypeId (archetypeId is deprecated but still supported for backward compat) */
|
||||||
|
team: { agentTypeId: string; count: number }[];
|
||||||
protoFormula?: string;
|
protoFormula?: string;
|
||||||
/** Color for template display. Defaults to amber if not set. */
|
/** Color for template display. Defaults to amber if not set. */
|
||||||
color?: string;
|
color?: string;
|
||||||
|
|
@ -26,3 +30,17 @@ export interface SwarmTemplate {
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
isBuiltIn: boolean;
|
isBuiltIn: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @deprecated Internal type for backward compatibility when reading old template files */
|
||||||
|
export interface LegacySwarmTemplate {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
team: { archetypeId: string; count: number }[];
|
||||||
|
protoFormula?: string;
|
||||||
|
color?: string;
|
||||||
|
icon?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
isBuiltIn: boolean;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,9 +39,9 @@ export interface BeadIssue {
|
||||||
status: BeadStatus;
|
status: BeadStatus;
|
||||||
priority: number;
|
priority: number;
|
||||||
issue_type: BeadIssueType;
|
issue_type: BeadIssueType;
|
||||||
assignee: string | null;
|
assignee: string | null;
|
||||||
templateId: string | null;
|
templateId: string | null;
|
||||||
owner: string | null;
|
owner: string | null;
|
||||||
labels: string[];
|
labels: string[];
|
||||||
dependencies: BeadDependency[];
|
dependencies: BeadDependency[];
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
|
@ -52,9 +52,13 @@ export interface BeadIssue {
|
||||||
created_by: string | null;
|
created_by: string | null;
|
||||||
due_at: string | null;
|
due_at: string | null;
|
||||||
estimated_minutes: number | null;
|
estimated_minutes: number | null;
|
||||||
external_ref: string | null;
|
external_ref: string | null;
|
||||||
comments_count?: number;
|
comments_count?: number;
|
||||||
metadata: Record<string, unknown>;
|
/** Which agent type should work on this bead */
|
||||||
|
agentTypeId?: string;
|
||||||
|
/** Which specific agent instance is assigned (if running) */
|
||||||
|
agentInstanceId?: string;
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ParseableBeadIssue extends Partial<BeadIssue> {
|
export interface ParseableBeadIssue extends Partial<BeadIssue> {
|
||||||
|
|
|
||||||
505
src/lib/worker-session-manager.ts
Normal file
505
src/lib/worker-session-manager.ts
Normal file
|
|
@ -0,0 +1,505 @@
|
||||||
|
import { detectPiRuntimeStrategy } from './pi-runtime-detection';
|
||||||
|
import { ensureManagedPiSettings } from './bb-pi-bootstrap';
|
||||||
|
import { embeddedPiDaemon } from './embedded-daemon';
|
||||||
|
import { getAgentTypes } from './server/beads-fs';
|
||||||
|
import type { AgentType } from './types-swarm';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
export type WorkerStatus = 'spawning' | 'working' | 'completed' | 'failed';
|
||||||
|
|
||||||
|
export interface WorkerSession {
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
|
projectRoot: string;
|
||||||
|
taskId: string;
|
||||||
|
beadId?: string; // Bead ID the worker is assigned to
|
||||||
|
status: WorkerStatus;
|
||||||
|
session: any; // Pi SDK session
|
||||||
|
createdAt: string;
|
||||||
|
completedAt: string | null;
|
||||||
|
result: string | null;
|
||||||
|
error: string | null;
|
||||||
|
/** @deprecated Use agentTypeId instead */
|
||||||
|
archetypeId?: string;
|
||||||
|
/** Agent type ID (e.g., "engineer", "architect") */
|
||||||
|
agentTypeId?: string;
|
||||||
|
/** Unique instance ID (e.g., "engineer-01-abc123") */
|
||||||
|
agentInstanceId?: string;
|
||||||
|
/** Display name for UI (e.g., "Engineer 01") */
|
||||||
|
displayName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map capabilities to tool access.
|
||||||
|
* Full access: coding, implementation, testing
|
||||||
|
* Read-only: planning, design_docs, review, arch_review, research, all others
|
||||||
|
*/
|
||||||
|
export function getToolsForCapabilities(capabilities: string[]): {
|
||||||
|
allowEdit: boolean;
|
||||||
|
allowWrite: boolean;
|
||||||
|
allowBash: boolean;
|
||||||
|
} {
|
||||||
|
// Full tool access: agent types that write/implement code
|
||||||
|
const fullAccessCapabilities = [
|
||||||
|
'coding', 'implementation', 'testing',
|
||||||
|
'test_design', 'test_implementation', // tester
|
||||||
|
'refactoring', 'debugging', // engineer
|
||||||
|
'ci_cd', 'deployment', // shipper
|
||||||
|
];
|
||||||
|
const hasFullAccess = capabilities.some(c => fullAccessCapabilities.includes(c));
|
||||||
|
|
||||||
|
if (hasFullAccess) {
|
||||||
|
return { allowEdit: true, allowWrite: true, allowBash: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read-only: architect, reviewer, investigator
|
||||||
|
return { allowEdit: false, allowWrite: false, allowBash: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a unique agent instance ID.
|
||||||
|
* Format: {agentTypeId}-{number}-{random}
|
||||||
|
*/
|
||||||
|
function generateAgentInstanceId(agentTypeId: string, instanceNumber: number): string {
|
||||||
|
const suffix = String(instanceNumber).padStart(2, '0');
|
||||||
|
const random = Math.random().toString(36).slice(2, 8);
|
||||||
|
return `${agentTypeId}-${suffix}-${random}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get display name for an agent instance.
|
||||||
|
* Format: "{AgentTypeName} {number}" (e.g., "Engineer 01")
|
||||||
|
*/
|
||||||
|
function getAgentDisplayName(agentTypeName: string, instanceNumber: number): string {
|
||||||
|
const num = String(instanceNumber).padStart(2, '0');
|
||||||
|
return `${agentTypeName} ${num}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpawnWorkerParams {
|
||||||
|
projectRoot: string;
|
||||||
|
taskId: string;
|
||||||
|
taskContext: string;
|
||||||
|
/** @deprecated Use agentType instead */
|
||||||
|
archetype?: string;
|
||||||
|
/** Agent type ID to spawn (e.g., "engineer", "architect") */
|
||||||
|
agentType?: string;
|
||||||
|
/** Bead ID for the worker to claim and work on */
|
||||||
|
beadId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class WorkerSessionManagerImpl {
|
||||||
|
private workers = new Map<string, WorkerSession>();
|
||||||
|
private nextWorkerId = 1;
|
||||||
|
|
||||||
|
async spawnWorker(params: SpawnWorkerParams): Promise<WorkerSession> {
|
||||||
|
// Support both old and new param names
|
||||||
|
const agentTypeId = params.agentType || params.archetype;
|
||||||
|
const { projectRoot, taskId, taskContext, beadId } = params;
|
||||||
|
|
||||||
|
// Generate worker ID
|
||||||
|
const workerId = `worker-${Date.now()}-${this.nextWorkerId++}`;
|
||||||
|
|
||||||
|
// Get project ID for events
|
||||||
|
const projectId = projectRoot
|
||||||
|
.replace(/^[A-Za-z]:/, '')
|
||||||
|
.replaceAll('\\', '/')
|
||||||
|
.split('/')
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('-')
|
||||||
|
.replace(/[^a-zA-Z0-9-]/g, '-')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.toLowerCase() || 'root';
|
||||||
|
|
||||||
|
// Load agent type to get name for display
|
||||||
|
let agentTypeName = agentTypeId || 'Agent';
|
||||||
|
let agentType: AgentType | undefined;
|
||||||
|
if (agentTypeId) {
|
||||||
|
try {
|
||||||
|
const agentTypes = await getAgentTypes(projectRoot);
|
||||||
|
agentType = agentTypes.find(a => a.id === agentTypeId);
|
||||||
|
if (agentType) {
|
||||||
|
agentTypeName = agentType.name;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[WorkerSessionManager] Failed to load agent types:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate instance number for this agent type
|
||||||
|
const existingOfType = [...this.workers.values()]
|
||||||
|
.filter(w => w.agentTypeId === agentTypeId)
|
||||||
|
.length;
|
||||||
|
const instanceNumber = existingOfType + 1;
|
||||||
|
|
||||||
|
// Generate instance ID and display name
|
||||||
|
const agentInstanceId = agentTypeId
|
||||||
|
? generateAgentInstanceId(agentTypeId, instanceNumber)
|
||||||
|
: undefined;
|
||||||
|
const displayName = agentTypeId
|
||||||
|
? getAgentDisplayName(agentTypeName, instanceNumber)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// Create initial worker record
|
||||||
|
const worker: WorkerSession = {
|
||||||
|
id: workerId,
|
||||||
|
projectId,
|
||||||
|
projectRoot,
|
||||||
|
taskId,
|
||||||
|
beadId,
|
||||||
|
status: 'spawning',
|
||||||
|
session: null,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
completedAt: null,
|
||||||
|
result: null,
|
||||||
|
error: null,
|
||||||
|
archetypeId: agentTypeId, // backward compat
|
||||||
|
agentTypeId,
|
||||||
|
agentInstanceId,
|
||||||
|
displayName,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.workers.set(workerId, worker);
|
||||||
|
|
||||||
|
// Emit spawning event with agent instance info
|
||||||
|
embeddedPiDaemon.appendWorkerEvent(projectRoot, workerId, {
|
||||||
|
kind: 'worker.spawned',
|
||||||
|
title: displayName ? `${displayName} spawned` : `Worker spawned for ${taskId}`,
|
||||||
|
detail: `Agent starting. Type: ${agentTypeId || 'default'}`,
|
||||||
|
status: 'working',
|
||||||
|
metadata: {
|
||||||
|
workerId,
|
||||||
|
agentInstanceId,
|
||||||
|
agentTypeId,
|
||||||
|
displayName,
|
||||||
|
taskId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Spawn worker session asynchronously
|
||||||
|
this.createWorkerSession(worker, taskContext, agentType, beadId).catch((error) => {
|
||||||
|
console.error(`[WorkerSessionManager] Failed to create worker session:`, error);
|
||||||
|
worker.status = 'failed';
|
||||||
|
worker.error = error instanceof Error ? error.message : String(error);
|
||||||
|
worker.completedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
embeddedPiDaemon.appendEvent(projectRoot, {
|
||||||
|
kind: 'worker.failed',
|
||||||
|
title: displayName ? `${displayName} failed` : `Worker ${workerId} failed`,
|
||||||
|
detail: worker.error,
|
||||||
|
status: 'failed',
|
||||||
|
metadata: { workerId, agentInstanceId, taskId },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return worker;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createWorkerSession(
|
||||||
|
worker: WorkerSession,
|
||||||
|
taskContext: string,
|
||||||
|
agentType?: AgentType,
|
||||||
|
beadId?: string
|
||||||
|
): Promise<void> {
|
||||||
|
const { projectRoot, taskId, id: workerId, displayName } = worker;
|
||||||
|
const agentTypeId = worker.agentTypeId;
|
||||||
|
|
||||||
|
// Resolve Pi SDK
|
||||||
|
const resolution = await detectPiRuntimeStrategy();
|
||||||
|
if (!resolution.sdkPath || resolution.installState === 'bootstrap-required') {
|
||||||
|
throw new Error('Pi SDK not available. Run bootstrap first.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const managedAgentDir = resolution.agentDir;
|
||||||
|
await ensureManagedPiSettings(managedAgentDir);
|
||||||
|
process.env.PI_CODING_AGENT_DIR = managedAgentDir;
|
||||||
|
|
||||||
|
// Dynamically load Pi SDK
|
||||||
|
const { pathToFileURL } = await import('node:url');
|
||||||
|
const sdk = await import(/* webpackIgnore: true */ pathToFileURL(resolution.sdkPath).href);
|
||||||
|
|
||||||
|
const authStorage = new sdk.AuthStorage(path.join(managedAgentDir, 'auth.json'));
|
||||||
|
const modelRegistry = new sdk.ModelRegistry(authStorage, path.join(managedAgentDir, 'models.json'));
|
||||||
|
const settingsManager = sdk.SettingsManager.create(projectRoot, managedAgentDir);
|
||||||
|
|
||||||
|
// Create unique session directory for worker
|
||||||
|
const workerSessionDir = path.join(managedAgentDir, 'worker-sessions', workerId);
|
||||||
|
const sessionManager = sdk.SessionManager.create(workerSessionDir);
|
||||||
|
|
||||||
|
const capabilities = agentType?.capabilities ?? [];
|
||||||
|
const toolAccess = getToolsForCapabilities(capabilities);
|
||||||
|
|
||||||
|
// Build worker-specific system prompt with agent type
|
||||||
|
const systemPrompt = this.buildWorkerPrompt(taskId, taskContext, agentType, beadId, displayName);
|
||||||
|
|
||||||
|
// Import worker-safe tools (no spawn tool for workers)
|
||||||
|
const { createDoltReadTool } = await import('../tui/tools/bb-dolt-read');
|
||||||
|
const { createMailboxTools } = await import('../tui/tools/bb-mailbox');
|
||||||
|
const { createPresenceTools } = await import('../tui/tools/bb-presence');
|
||||||
|
const { createBeadCrudTools } = await import('../tui/tools/bb-bead-crud');
|
||||||
|
|
||||||
|
// Build tools based on agent type capabilities
|
||||||
|
const tools = [sdk.createReadTool(projectRoot)];
|
||||||
|
|
||||||
|
if (toolAccess.allowBash) {
|
||||||
|
tools.push(sdk.createBashTool(projectRoot));
|
||||||
|
}
|
||||||
|
if (toolAccess.allowEdit) {
|
||||||
|
tools.push(sdk.createEditTool(projectRoot));
|
||||||
|
}
|
||||||
|
if (toolAccess.allowWrite) {
|
||||||
|
tools.push(sdk.createWriteTool(projectRoot));
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await sdk.createAgentSession({
|
||||||
|
cwd: projectRoot,
|
||||||
|
agentDir: managedAgentDir,
|
||||||
|
authStorage,
|
||||||
|
modelRegistry,
|
||||||
|
settingsManager,
|
||||||
|
sessionManager,
|
||||||
|
systemPrompt,
|
||||||
|
tools,
|
||||||
|
hooks: [],
|
||||||
|
skills: [],
|
||||||
|
contextFiles: [],
|
||||||
|
slashCommands: [],
|
||||||
|
customTools: [
|
||||||
|
{ tool: createDoltReadTool(projectRoot) },
|
||||||
|
...createMailboxTools().map((tool) => ({ tool: tool as any })),
|
||||||
|
...createPresenceTools().map((tool) => ({ tool: tool as any })),
|
||||||
|
...createBeadCrudTools(projectRoot).map((tool) => ({ tool: tool as any })),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const session = res.session;
|
||||||
|
worker.session = session;
|
||||||
|
worker.status = 'working';
|
||||||
|
|
||||||
|
// Subscribe to worker events
|
||||||
|
session.subscribe((event: any) => {
|
||||||
|
this.handleWorkerEvent(worker, event);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Emit working event
|
||||||
|
embeddedPiDaemon.appendEvent(projectRoot, {
|
||||||
|
kind: 'worker.updated',
|
||||||
|
title: displayName ? `${displayName} started` : `Worker ${workerId} started`,
|
||||||
|
detail: `Agent is now executing task ${taskId}`,
|
||||||
|
status: 'working',
|
||||||
|
metadata: { workerId, agentInstanceId: worker.agentInstanceId, taskId },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send the task as initial prompt
|
||||||
|
try {
|
||||||
|
await session.prompt(taskContext);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[WorkerSessionManager] Worker prompt failed:`, error);
|
||||||
|
worker.status = 'failed';
|
||||||
|
worker.error = error instanceof Error ? error.message : String(error);
|
||||||
|
worker.completedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
embeddedPiDaemon.appendWorkerEvent(projectRoot, workerId, {
|
||||||
|
kind: 'worker.failed',
|
||||||
|
title: displayName ? `${displayName} failed` : `Worker ${workerId} failed`,
|
||||||
|
detail: worker.error,
|
||||||
|
status: 'failed',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildWorkerPrompt(
|
||||||
|
taskId: string,
|
||||||
|
taskContext: string,
|
||||||
|
agentType?: AgentType,
|
||||||
|
beadId?: string,
|
||||||
|
displayName?: string
|
||||||
|
): string {
|
||||||
|
const agentSection = agentType
|
||||||
|
? `## Your Role
|
||||||
|
|
||||||
|
${agentType.systemPrompt}
|
||||||
|
|
||||||
|
`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const beadWorkflow = beadId ? `
|
||||||
|
## IMPORTANT: Bead Workflow
|
||||||
|
|
||||||
|
You are working on bead: ${beadId}
|
||||||
|
Your display name: ${displayName || 'Worker'}
|
||||||
|
|
||||||
|
**You MUST follow this workflow:**
|
||||||
|
|
||||||
|
1. **Claim the bead** (first thing you do):
|
||||||
|
\`\`\`
|
||||||
|
bb_update(id="${beadId}", status="in_progress", assignee="${displayName || 'Worker'}")
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
2. **Update progress** (add notes as you work):
|
||||||
|
\`\`\`
|
||||||
|
bb_update(id="${beadId}", notes="Found the issue in auth.ts...")
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
3. **When blocked**, update status:
|
||||||
|
\`\`\`
|
||||||
|
bb_update(id="${beadId}", status="blocked", notes="Waiting for API key from infra team")
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
4. **When complete**, close the bead:
|
||||||
|
\`\`\`
|
||||||
|
bb_close(id="${beadId}", reason="Fixed by updating auth.ts line 42. Tests passing.")
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
**Never skip this workflow.** The bead tracks your work for coordination.
|
||||||
|
|
||||||
|
` : '';
|
||||||
|
|
||||||
|
return `You are a worker agent for BeadBoard. Your job is to execute a specific task.
|
||||||
|
|
||||||
|
Task ID: ${taskId}
|
||||||
|
${beadId ? `Bead ID: ${beadId}` : ''}
|
||||||
|
|
||||||
|
Task Context:
|
||||||
|
${taskContext}
|
||||||
|
|
||||||
|
${agentSection}${beadWorkflow}## Instructions
|
||||||
|
|
||||||
|
- Focus ONLY on this specific task
|
||||||
|
- Report progress using the bb_update tool on your bead
|
||||||
|
- When complete, close the bead with a clear summary
|
||||||
|
- If you encounter blockers, set the bead status to "blocked" and explain what is blocking you
|
||||||
|
- You CANNOT spawn more workers - execute this task yourself
|
||||||
|
- Be thorough but efficient
|
||||||
|
- If you need to read project files, use bb_dolt_read
|
||||||
|
- If you need to send messages to other agents, use bb_mailbox_send`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleWorkerEvent(worker: WorkerSession, event: any): void {
|
||||||
|
const { projectRoot, taskId, id: workerId, displayName } = worker;
|
||||||
|
|
||||||
|
// Track completion
|
||||||
|
if (event.type === 'agent_end') {
|
||||||
|
const lastMsg = event.messages?.[event.messages.length - 1];
|
||||||
|
if (lastMsg?.role === 'assistant') {
|
||||||
|
worker.status = 'completed';
|
||||||
|
worker.completedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
if (lastMsg.stopReason === 'error' && lastMsg.errorMessage) {
|
||||||
|
worker.status = 'failed';
|
||||||
|
worker.error = lastMsg.errorMessage;
|
||||||
|
|
||||||
|
embeddedPiDaemon.appendWorkerEvent(projectRoot, workerId, {
|
||||||
|
kind: 'worker.failed',
|
||||||
|
title: displayName ? `${displayName} failed` : `Worker ${workerId} failed`,
|
||||||
|
detail: worker.error || 'Unknown error',
|
||||||
|
status: 'failed',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Extract result text
|
||||||
|
const text = lastMsg.content
|
||||||
|
?.filter((c: any) => c.type === 'text')
|
||||||
|
.map((c: any) => c.text)
|
||||||
|
.join('\n') || 'Completed';
|
||||||
|
worker.result = text.substring(0, 1000);
|
||||||
|
|
||||||
|
embeddedPiDaemon.appendWorkerEvent(projectRoot, workerId, {
|
||||||
|
kind: 'worker.completed',
|
||||||
|
title: displayName ? `${displayName} completed` : `Worker ${workerId} completed`,
|
||||||
|
detail: (worker.result || 'Completed').substring(0, 200),
|
||||||
|
status: 'completed',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getWorker(workerId: string): WorkerSession | undefined {
|
||||||
|
return this.workers.get(workerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
listWorkers(projectRoot: string): WorkerSession[] {
|
||||||
|
return [...this.workers.values()].filter((w) => w.projectRoot === projectRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
getAllWorkers(): WorkerSession[] {
|
||||||
|
return [...this.workers.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
async terminateWorker(workerId: string): Promise<void> {
|
||||||
|
const worker = this.workers.get(workerId);
|
||||||
|
if (!worker) return;
|
||||||
|
|
||||||
|
if (worker.session && typeof worker.session.stop === 'function') {
|
||||||
|
try {
|
||||||
|
await worker.session.stop();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[WorkerSessionManager] Error stopping worker session:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
worker.status = 'failed';
|
||||||
|
worker.error = 'Terminated by user';
|
||||||
|
worker.completedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
embeddedPiDaemon.appendEvent(worker.projectRoot, {
|
||||||
|
kind: 'worker.failed',
|
||||||
|
title: worker.displayName ? `${worker.displayName} terminated` : `Worker ${workerId} terminated`,
|
||||||
|
detail: 'Worker was manually terminated',
|
||||||
|
status: 'failed',
|
||||||
|
metadata: { workerId, agentInstanceId: worker.agentInstanceId, taskId: worker.taskId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async waitForWorker(workerId: string, timeoutMs = 300000): Promise<string> {
|
||||||
|
const worker = this.workers.get(workerId);
|
||||||
|
if (!worker) {
|
||||||
|
throw new Error(`Worker ${workerId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const checkInterval = setInterval(() => {
|
||||||
|
const w = this.workers.get(workerId);
|
||||||
|
if (!w) {
|
||||||
|
clearInterval(checkInterval);
|
||||||
|
reject(new Error(`Worker ${workerId} not found`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (w.status === 'completed') {
|
||||||
|
clearInterval(checkInterval);
|
||||||
|
resolve(w.result || 'Completed with no result');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (w.status === 'failed') {
|
||||||
|
clearInterval(checkInterval);
|
||||||
|
reject(new Error(w.error || 'Worker failed'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Date.now() - startTime > timeoutMs) {
|
||||||
|
clearInterval(checkInterval);
|
||||||
|
reject(new Error('Worker timeout'));
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// For testing
|
||||||
|
reset(): void {
|
||||||
|
this.workers.clear();
|
||||||
|
this.nextWorkerId = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton
|
||||||
|
const globalRegistry = globalThis as typeof globalThis & {
|
||||||
|
__beadboardWorkerSessionManager?: WorkerSessionManagerImpl;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const workerSessionManager = globalRegistry.__beadboardWorkerSessionManager ?? new WorkerSessionManagerImpl();
|
||||||
|
if (!globalRegistry.__beadboardWorkerSessionManager) {
|
||||||
|
globalRegistry.__beadboardWorkerSessionManager = workerSessionManager;
|
||||||
|
}
|
||||||
483
src/tui/bb-agent-tui.ts
Normal file
483
src/tui/bb-agent-tui.ts
Normal file
|
|
@ -0,0 +1,483 @@
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import readline from 'node:readline';
|
||||||
|
import { pathToFileURL, fileURLToPath } from 'node:url';
|
||||||
|
import { resolveAgentWorkspace } from '../lib/agent-workspace';
|
||||||
|
import { bootstrapManagedPi, ensureManagedPiSettings } from '../lib/bb-pi-bootstrap';
|
||||||
|
import { detectPiRuntimeStrategy } from '../lib/pi-runtime-detection';
|
||||||
|
import { createDoltReadTool } from './tools/bb-dolt-read';
|
||||||
|
import { createMailboxTools } from './tools/bb-mailbox';
|
||||||
|
import { createPresenceTools } from './tools/bb-presence';
|
||||||
|
import { createDeviationTool } from './tools/bb-deviation';
|
||||||
|
import { buildBeadBoardSystemPrompt } from './system-prompt';
|
||||||
|
|
||||||
|
export interface BbAgentTuiOptions {
|
||||||
|
cwd?: string;
|
||||||
|
agentDir?: string;
|
||||||
|
projectKey?: string | null;
|
||||||
|
projectRoot?: string | null;
|
||||||
|
input?: NodeJS.ReadableStream;
|
||||||
|
output?: NodeJS.WritableStream;
|
||||||
|
initialMessage?: string;
|
||||||
|
testMode?: boolean;
|
||||||
|
debug?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type BbManagedSession = {
|
||||||
|
prompt: (text: string) => Promise<void>;
|
||||||
|
dispose: () => void;
|
||||||
|
subscribe: (listener: (event: any) => void) => () => void;
|
||||||
|
model?: { id?: string; provider?: string } | null;
|
||||||
|
modelRegistry?: {
|
||||||
|
getAll?: () => Array<{ id: string; provider: string }>;
|
||||||
|
getAvailable?: () => Promise<Array<{ id: string; provider: string }>>;
|
||||||
|
find?: (provider: string, modelId: string) => { id: string; provider: string } | null;
|
||||||
|
authStorage?: {
|
||||||
|
login?: (provider: string, callbacks: {
|
||||||
|
onAuth: (payload: { url: string; instructions?: string }) => void;
|
||||||
|
onPrompt: (payload: { message: string }) => Promise<string | null>;
|
||||||
|
onProgress?: (message: string) => void;
|
||||||
|
}) => Promise<void>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
settingsManager?: {
|
||||||
|
setDefaultModelAndProvider?: (provider: string, modelId: string) => void;
|
||||||
|
};
|
||||||
|
setModel?: (model: { id: string; provider: string }) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderBanner(): string {
|
||||||
|
return [
|
||||||
|
'╔══════════════════════════════════════════════════════════════╗',
|
||||||
|
'║ BeadBoard Pi Runtime TUI ║',
|
||||||
|
'╠══════════════════════════════════════════════════════════════╣',
|
||||||
|
'║ Talk directly to the BeadBoard agent runtime. ║',
|
||||||
|
'║ Commands: /exit, /quit, /help ║',
|
||||||
|
'╚══════════════════════════════════════════════════════════════╝',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPiSdk(): Promise<Record<string, any> & { resolution: Awaited<ReturnType<typeof detectPiRuntimeStrategy>> }> {
|
||||||
|
const resolution = await detectPiRuntimeStrategy();
|
||||||
|
|
||||||
|
if (resolution.installState === 'bootstrap-required' || !resolution.sdkPath) {
|
||||||
|
return {
|
||||||
|
createAgentSession: async () => {
|
||||||
|
throw new Error(`Agent runtime required at ${resolution.managedRoot}`);
|
||||||
|
},
|
||||||
|
resolution,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = [resolution.sdkPath].filter((candidate): candidate is string => Boolean(candidate));
|
||||||
|
|
||||||
|
const errors: string[] = [];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
try {
|
||||||
|
const mod = await import(pathToFileURL(candidate).href);
|
||||||
|
if (typeof mod.createAgentSession === 'function') {
|
||||||
|
return { ...mod, resolution };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
errors.push(error instanceof Error ? error.message : String(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unable to load Pi SDK createAgentSession() for ${resolution.mode} (${resolution.installState}). ${resolution.reason} ${errors.join(' | ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderBbAgentTuiBanner(): string {
|
||||||
|
return renderBanner();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDefaultShellPath(): string | null {
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
return process.env.ComSpec ?? 'C:\\Windows\\System32\\cmd.exe';
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = ['/bin/sh', '/usr/bin/sh', '/bin/bash', '/usr/bin/bash'];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (fs.existsSync(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSafePath(existingPath: string | undefined, shellPath: string | null): string {
|
||||||
|
const entries = new Set<string>();
|
||||||
|
const delimiter = path.delimiter;
|
||||||
|
|
||||||
|
for (const entry of String(existingPath ?? '').split(delimiter)) {
|
||||||
|
if (entry.trim()) {
|
||||||
|
entries.add(entry.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const home = process.env.HOME ?? os.homedir();
|
||||||
|
const preferredEntries = process.platform === 'win32'
|
||||||
|
? [
|
||||||
|
path.dirname(shellPath ?? process.env.ComSpec ?? 'C:\\Windows\\System32\\cmd.exe'),
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
path.join(home, '.local', 'bin'),
|
||||||
|
shellPath ? path.dirname(shellPath) : '',
|
||||||
|
'/usr/local/bin',
|
||||||
|
'/usr/bin',
|
||||||
|
'/bin',
|
||||||
|
'/usr/sbin',
|
||||||
|
'/sbin',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const entry of preferredEntries) {
|
||||||
|
if (entry) {
|
||||||
|
entries.add(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(entries).join(delimiter);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureSafeShellEnvironment(env: NodeJS.ProcessEnv = process.env): { path: string; shell: string | null } {
|
||||||
|
const shellPath = getDefaultShellPath();
|
||||||
|
const safePath = buildSafePath(env.PATH ?? env.Path, shellPath);
|
||||||
|
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
env.PATH = safePath;
|
||||||
|
env.Path = safePath;
|
||||||
|
if (shellPath && !env.ComSpec) {
|
||||||
|
env.ComSpec = shellPath;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
env.PATH = safePath;
|
||||||
|
if (shellPath && !env.SHELL) {
|
||||||
|
env.SHELL = shellPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { path: safePath, shell: shellPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
function rewriteManagedPiError(message: string, agentDir: string): string {
|
||||||
|
return message.replace(/\/home\/clawdbot\/\.pi\/agent\/models\.json/g, path.join(agentDir, 'models.json'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatModelChoices(session: BbManagedSession): string {
|
||||||
|
const available = session.modelRegistry?.getAll?.() ?? [];
|
||||||
|
if (available.length === 0) {
|
||||||
|
return 'No models are registered yet.';
|
||||||
|
}
|
||||||
|
return available.map((model) => `- ${model.provider}/${model.id}`).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLoginCommand(session: BbManagedSession, text: string, output: NodeJS.WritableStream, rl: readline.Interface): Promise<void> {
|
||||||
|
const provider = text.split(/\s+/)[1];
|
||||||
|
const authStorage = session.modelRegistry?.authStorage;
|
||||||
|
const supportedProviders = ['anthropic', 'github-copilot', 'google-gemini-cli', 'google-antigravity'];
|
||||||
|
|
||||||
|
if (!provider) {
|
||||||
|
output.write(`Login requires a provider. Try one of:\n- ${supportedProviders.join('\n- ')}\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!supportedProviders.includes(provider)) {
|
||||||
|
output.write(`Unsupported login provider: ${provider}\nSupported: ${supportedProviders.join(', ')}\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!authStorage?.login) {
|
||||||
|
output.write('Managed Pi auth storage is unavailable.\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await authStorage.login(provider, {
|
||||||
|
onAuth: ({ url, instructions }) => {
|
||||||
|
output.write(`Open this URL to continue login for ${provider}:\n${url}\n`);
|
||||||
|
if (instructions) {
|
||||||
|
output.write(`${instructions}\n`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onPrompt: ({ message }) => new Promise((resolve) => {
|
||||||
|
rl.question(`${message} `, (answer) => resolve(answer ?? ''));
|
||||||
|
}),
|
||||||
|
onProgress: (message) => {
|
||||||
|
output.write(`${message}\n`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const availableModels = await session.modelRegistry?.getAvailable?.() ?? [];
|
||||||
|
if (!session.model && availableModels.length > 0 && session.setModel) {
|
||||||
|
await session.setModel(availableModels[0]);
|
||||||
|
session.settingsManager?.setDefaultModelAndProvider?.(availableModels[0].provider, availableModels[0].id);
|
||||||
|
output.write(`Selected model: ${availableModels[0].provider}/${availableModels[0].id}\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleModelCommand(session: BbManagedSession, text: string, output: NodeJS.WritableStream): Promise<void> {
|
||||||
|
const requested = text.split(/\s+/)[1];
|
||||||
|
if (!requested) {
|
||||||
|
output.write(`Available models:\n${formatModelChoices(session)}\nUsage: /model <provider/model>\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [provider, ...modelParts] = requested.split('/');
|
||||||
|
const modelId = modelParts.join('/');
|
||||||
|
if (!provider || !modelId) {
|
||||||
|
output.write('Usage: /model <provider/model>\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const model = session.modelRegistry?.find?.(provider, modelId);
|
||||||
|
if (!model || !session.setModel) {
|
||||||
|
output.write(`Model not found: ${requested}\nAvailable models:\n${formatModelChoices(session)}\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await session.setModel(model);
|
||||||
|
session.settingsManager?.setDefaultModelAndProvider?.(model.provider, model.id);
|
||||||
|
output.write(`Selected model: ${model.provider}/${model.id}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runBbAgentTui(options: BbAgentTuiOptions = {}): Promise<void> {
|
||||||
|
const input = options.input ?? process.stdin;
|
||||||
|
const output = options.output ?? process.stdout;
|
||||||
|
const debug = options.debug ?? process.env.BB_TUI_DEBUG === '1';
|
||||||
|
const logDebug = (message: string) => {
|
||||||
|
if (debug) {
|
||||||
|
output.write(`[bb tui debug] ${message}\n`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
output.write(`${renderBanner()}\n`);
|
||||||
|
|
||||||
|
const shellEnv = ensureSafeShellEnvironment();
|
||||||
|
logDebug(`shell env ready: shell=${shellEnv.shell ?? 'unknown'} path=${shellEnv.path}`);
|
||||||
|
|
||||||
|
if (options.testMode) {
|
||||||
|
output.write('[bb tui test mode]\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspace = await resolveAgentWorkspace({
|
||||||
|
currentProjectRoot: options.cwd ?? process.cwd(),
|
||||||
|
requestedProjectKey: options.projectKey ?? null,
|
||||||
|
requestedProjectRoot: options.projectRoot ?? null,
|
||||||
|
});
|
||||||
|
logDebug(`workspace: ${workspace.root} (${workspace.source})`);
|
||||||
|
|
||||||
|
let resolution = await detectPiRuntimeStrategy();
|
||||||
|
if (resolution.installState === 'bootstrap-required') {
|
||||||
|
output.write(`[bb tui] Installing agent runtime at ${resolution.managedRoot}\n`);
|
||||||
|
await bootstrapManagedPi({ output });
|
||||||
|
resolution = await detectPiRuntimeStrategy();
|
||||||
|
}
|
||||||
|
|
||||||
|
const managedAgentDir = options.agentDir ?? resolution.agentDir;
|
||||||
|
await ensureManagedPiSettings(managedAgentDir);
|
||||||
|
process.env.PI_CODING_AGENT_DIR = managedAgentDir;
|
||||||
|
logDebug(`managed agent dir: ${managedAgentDir}`);
|
||||||
|
|
||||||
|
logDebug('loading Pi SDK');
|
||||||
|
const sdk = await loadPiSdk();
|
||||||
|
const {
|
||||||
|
createAgentSession,
|
||||||
|
createReadTool,
|
||||||
|
createBashTool,
|
||||||
|
createEditTool,
|
||||||
|
createWriteTool,
|
||||||
|
AuthStorage,
|
||||||
|
ModelRegistry,
|
||||||
|
SessionManager,
|
||||||
|
SettingsManager,
|
||||||
|
loadSkillsFromDir,
|
||||||
|
} = sdk;
|
||||||
|
logDebug(`pi runtime mode: ${resolution.mode} (${resolution.installState})`);
|
||||||
|
if (resolution.installState === 'bootstrap-required') {
|
||||||
|
output.write(`[bb tui error] Agent runtime required at ${resolution.managedRoot}\n`);
|
||||||
|
output.write('[bb tui error] Automatic bb-pi bootstrap failed.\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logDebug('creating Pi session');
|
||||||
|
|
||||||
|
let session: any;
|
||||||
|
let managedSession: BbManagedSession;
|
||||||
|
let currentWorkspaceRoot = workspace.root;
|
||||||
|
|
||||||
|
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||||
|
const driverSkills = loadSkillsFromDir ? loadSkillsFromDir({
|
||||||
|
dir: path.join(rootDir, 'skills'),
|
||||||
|
source: 'beadboard',
|
||||||
|
}).skills : [];
|
||||||
|
|
||||||
|
async function startSession(root: string) {
|
||||||
|
if (session) {
|
||||||
|
session.dispose();
|
||||||
|
}
|
||||||
|
currentWorkspaceRoot = root;
|
||||||
|
const authStorage = new AuthStorage(path.join(managedAgentDir, 'auth.json'));
|
||||||
|
const modelRegistry = new ModelRegistry(authStorage, path.join(managedAgentDir, 'models.json'));
|
||||||
|
const settingsManager = SettingsManager.create(root, managedAgentDir);
|
||||||
|
const sessionManager = SessionManager.create(root);
|
||||||
|
|
||||||
|
// Evaluate the system prompt once before creating the session since the SDK does not await the function
|
||||||
|
const dynamicPrompt = await buildBeadBoardSystemPrompt(root, `You are a headless orchestrator for the BeadBoard system.`);
|
||||||
|
|
||||||
|
const res = await createAgentSession({
|
||||||
|
cwd: root,
|
||||||
|
agentDir: managedAgentDir,
|
||||||
|
authStorage,
|
||||||
|
modelRegistry,
|
||||||
|
settingsManager,
|
||||||
|
sessionManager,
|
||||||
|
systemPrompt: dynamicPrompt,
|
||||||
|
tools: [
|
||||||
|
createReadTool(root),
|
||||||
|
createBashTool(root),
|
||||||
|
createEditTool(root),
|
||||||
|
createWriteTool(root),
|
||||||
|
],
|
||||||
|
hooks: [],
|
||||||
|
skills: driverSkills,
|
||||||
|
contextFiles: [],
|
||||||
|
slashCommands: [],
|
||||||
|
customTools: [
|
||||||
|
{ tool: createDoltReadTool(root) },
|
||||||
|
{ tool: createDeviationTool(root) },
|
||||||
|
...createMailboxTools().map((tool) => ({ tool })),
|
||||||
|
...createPresenceTools().map((tool) => ({ tool })),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
session = res.session;
|
||||||
|
managedSession = session as BbManagedSession;
|
||||||
|
|
||||||
|
session.subscribe((event: any) => {
|
||||||
|
if (event.type === 'message_start') {
|
||||||
|
logDebug(`message_start:${event.message.role}`);
|
||||||
|
}
|
||||||
|
if (event.type === 'tool_execution_start') {
|
||||||
|
logDebug(`tool_start:${event.toolName}`);
|
||||||
|
output.write(`\n> Running tool: ${event.toolName}...\n`);
|
||||||
|
}
|
||||||
|
if (event.type === 'tool_execution_end') {
|
||||||
|
logDebug(`tool_end:${event.toolName}:${event.isError ? 'error' : 'ok'}`);
|
||||||
|
if (event.isError) {
|
||||||
|
output.write(`> Tool error: ${event.toolName}\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (event.type === 'message_update') {
|
||||||
|
const ame = event.assistantMessageEvent;
|
||||||
|
if (ame.type === 'text_delta') {
|
||||||
|
output.write(ame.delta);
|
||||||
|
} else if (ame.type === 'thinking_delta') {
|
||||||
|
output.write(`\x1b[90m${ame.delta}\x1b[0m`); // dim text for thinking
|
||||||
|
} else if (ame.type === 'error') {
|
||||||
|
output.write(`\n\x1b[31m[Agent Error] ${ame.error.errorMessage}\x1b[0m\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (event.type === 'agent_end') {
|
||||||
|
logDebug('agent_end');
|
||||||
|
|
||||||
|
// Safety check in case the error wasn't streamed via message_update
|
||||||
|
const lastMsg = event.messages?.[event.messages.length - 1];
|
||||||
|
if (lastMsg?.role === 'assistant' && lastMsg.stopReason === 'error' && lastMsg.errorMessage) {
|
||||||
|
output.write(`\n\x1b[31m[Agent Error] ${lastMsg.errorMessage}\x1b[0m\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
output.write('\n\n');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await startSession(workspace.root);
|
||||||
|
logDebug(`session ready (model: ${session.model?.id ?? 'unknown'})`);
|
||||||
|
|
||||||
|
const rl = readline.createInterface({
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
prompt: 'bb> ',
|
||||||
|
});
|
||||||
|
|
||||||
|
const close = async () => {
|
||||||
|
rl.close();
|
||||||
|
session.dispose();
|
||||||
|
};
|
||||||
|
|
||||||
|
rl.on('line', async (line) => {
|
||||||
|
const text = line.trim();
|
||||||
|
if (!text) {
|
||||||
|
rl.prompt();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (text === '/exit' || text === '/quit') {
|
||||||
|
await close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (text === '/help') {
|
||||||
|
output.write('Enter a prompt to talk to BeadBoard Pi. Use /exit to quit.\n');
|
||||||
|
output.write('Commands: /help, /exit, /quit, /login <provider>, /model <provider/model>, /workspace <path>\n');
|
||||||
|
rl.prompt();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (text === '/workspace' || text.startsWith('/workspace ')) {
|
||||||
|
const newWorkspace = text.slice(10).trim();
|
||||||
|
if (!newWorkspace) {
|
||||||
|
output.write(`Current workspace: ${currentWorkspaceRoot}\nUsage: /workspace <path>\n`);
|
||||||
|
} else {
|
||||||
|
const resolvedPath = path.resolve(currentWorkspaceRoot, newWorkspace);
|
||||||
|
if (!fs.existsSync(resolvedPath)) {
|
||||||
|
output.write(`Workspace path does not exist: ${resolvedPath}\n`);
|
||||||
|
} else {
|
||||||
|
await startSession(resolvedPath);
|
||||||
|
output.write(`Workspace switched to: ${resolvedPath}\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rl.prompt();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (text === '/login' || text.startsWith('/login ')) {
|
||||||
|
await handleLoginCommand(managedSession, text, output, rl);
|
||||||
|
rl.prompt();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (text === '/model' || text.startsWith('/model ')) {
|
||||||
|
await handleModelCommand(managedSession, text, output);
|
||||||
|
rl.prompt();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logDebug(`prompt:start:${text}`);
|
||||||
|
await session.prompt(text);
|
||||||
|
logDebug('prompt:resolved');
|
||||||
|
} catch (error) {
|
||||||
|
const rawMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
const message = rewriteManagedPiError(rawMessage, managedAgentDir);
|
||||||
|
output.write(`\n[bb tui error] ${message}\n`);
|
||||||
|
logDebug(`prompt:error:${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
rl.prompt();
|
||||||
|
});
|
||||||
|
|
||||||
|
rl.on('close', () => {
|
||||||
|
session.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (options.initialMessage) {
|
||||||
|
try {
|
||||||
|
logDebug(`initialPrompt:start:${options.initialMessage}`);
|
||||||
|
await session.prompt(options.initialMessage);
|
||||||
|
logDebug('initialPrompt:resolved');
|
||||||
|
} catch (error) {
|
||||||
|
const rawMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
const message = rewriteManagedPiError(rawMessage, managedAgentDir);
|
||||||
|
output.write(`\n[bb tui error] ${message}\n`);
|
||||||
|
logDebug(`initialPrompt:error:${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rl.prompt();
|
||||||
|
}
|
||||||
63
src/tui/bb-daemon-tui.ts
Normal file
63
src/tui/bb-daemon-tui.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
import { bbDaemon, type BbDaemonLifecycleStatus } from '../lib/bb-daemon';
|
||||||
|
|
||||||
|
export interface BbDaemonTuiProjectSummary {
|
||||||
|
projectRoot: string;
|
||||||
|
orchestratorStatus: string;
|
||||||
|
eventCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BbDaemonTuiSnapshot {
|
||||||
|
daemonStatus: BbDaemonLifecycleStatus | string;
|
||||||
|
projects: BbDaemonTuiProjectSummary[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortenProjectRoot(projectRoot: string): string {
|
||||||
|
const parts = projectRoot.replaceAll('\\', '/').split('/').filter(Boolean);
|
||||||
|
return parts.slice(-2).join('/') || projectRoot;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderDaemonTuiSnapshot(snapshot: BbDaemonTuiSnapshot): string[] {
|
||||||
|
const header = [
|
||||||
|
'╔══════════════════════════════════════════════════════════════╗',
|
||||||
|
'║ BeadBoard Daemon TUI ║',
|
||||||
|
'╠══════════════════════════════════════════════════════════════╣',
|
||||||
|
`║ Daemon Status : ${String(snapshot.daemonStatus).padEnd(44)}║`,
|
||||||
|
`║ Projects : ${String(snapshot.projects.length).padEnd(44)}║`,
|
||||||
|
'╠══════════════════════════════════════════════════════════════╣',
|
||||||
|
];
|
||||||
|
|
||||||
|
const projectLines = snapshot.projects.length
|
||||||
|
? snapshot.projects.flatMap((project) => {
|
||||||
|
const label = shortenProjectRoot(project.projectRoot);
|
||||||
|
return [
|
||||||
|
`║ Project : ${label.padEnd(44)}║`,
|
||||||
|
`║ Orchestrator : ${project.orchestratorStatus.padEnd(44)}║`,
|
||||||
|
`║ Runtime Events: ${String(project.eventCount).padEnd(44)}║`,
|
||||||
|
'╟──────────────────────────────────────────────────────────────╢',
|
||||||
|
];
|
||||||
|
})
|
||||||
|
: ['║ No registered daemon projects yet. ║', '╟──────────────────────────────────────────────────────────────╢'];
|
||||||
|
|
||||||
|
const footer = [
|
||||||
|
'║ Commands: bb daemon start | bb daemon status | bb daemon tui║',
|
||||||
|
'╚══════════════════════════════════════════════════════════════╝',
|
||||||
|
];
|
||||||
|
|
||||||
|
return [...header, ...projectLines, ...footer];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderDaemonTuiFromRuntime(): string[] {
|
||||||
|
const status = bbDaemon.getStatus();
|
||||||
|
return renderDaemonTuiSnapshot({
|
||||||
|
daemonStatus: status.lifecycle.status,
|
||||||
|
projects: status.projects.map((project) => ({
|
||||||
|
projectRoot: project.projectRoot,
|
||||||
|
orchestratorStatus: project.orchestratorStatus,
|
||||||
|
eventCount: project.eventCount,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderDaemonTuiText(): string {
|
||||||
|
return renderDaemonTuiFromRuntime().join('\n');
|
||||||
|
}
|
||||||
222
src/tui/system-prompt.ts
Normal file
222
src/tui/system-prompt.ts
Normal file
|
|
@ -0,0 +1,222 @@
|
||||||
|
import { readIssuesFromDisk } from '../lib/read-issues';
|
||||||
|
import { getAgentTypes, getTemplates } from '../lib/server/beads-fs';
|
||||||
|
|
||||||
|
export async function buildBeadBoardSystemPrompt(workspaceRoot: string, defaultPrompt: string): Promise<string> {
|
||||||
|
let issuesContext = '';
|
||||||
|
try {
|
||||||
|
const allIssues = await readIssuesFromDisk({ projectRoot: workspaceRoot });
|
||||||
|
// Keep context somewhat tight by excluding closed/tombstoned issues for the initial state overview
|
||||||
|
const activeIssues = allIssues.filter((i) => !['closed', 'tombstone'].includes(i.status));
|
||||||
|
|
||||||
|
const compactIssues = activeIssues.map(i => ({
|
||||||
|
id: i.id,
|
||||||
|
title: i.title,
|
||||||
|
status: i.status,
|
||||||
|
assignee: i.assignee || 'unassigned',
|
||||||
|
}));
|
||||||
|
|
||||||
|
issuesContext = JSON.stringify(compactIssues, null, 2);
|
||||||
|
} catch (error) {
|
||||||
|
issuesContext = `Failed to read tasks (Dolt may not be running). Error: ${error instanceof Error ? error.message : String(error)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let agentTypesContext = '';
|
||||||
|
try {
|
||||||
|
const agentTypes = await getAgentTypes(workspaceRoot);
|
||||||
|
const compactAgentTypes = agentTypes.map(a => ({
|
||||||
|
id: a.id,
|
||||||
|
name: a.name,
|
||||||
|
description: a.description,
|
||||||
|
capabilities: a.capabilities,
|
||||||
|
}));
|
||||||
|
agentTypesContext = JSON.stringify(compactAgentTypes, null, 2);
|
||||||
|
} catch (error) {
|
||||||
|
agentTypesContext = `Failed to read agent types: ${error instanceof Error ? error.message : String(error)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let templatesContext = '';
|
||||||
|
try {
|
||||||
|
const templates = await getTemplates(workspaceRoot);
|
||||||
|
const compactTemplates = templates.map(t => ({
|
||||||
|
id: t.id,
|
||||||
|
name: t.name,
|
||||||
|
description: t.description,
|
||||||
|
team: t.team,
|
||||||
|
}));
|
||||||
|
templatesContext = JSON.stringify(compactTemplates, null, 2);
|
||||||
|
} catch (error) {
|
||||||
|
templatesContext = `Failed to read templates: ${error instanceof Error ? error.message : String(error)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${defaultPrompt}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Agent System
|
||||||
|
|
||||||
|
You can spawn agent workers to accomplish tasks in parallel. Agents are typed workers with specific capabilities:
|
||||||
|
|
||||||
|
- **architect**: System design, work decomposition, technical decisions (read-only, does not modify code)
|
||||||
|
- **engineer**: Implementation, coding, testing, debugging (full code access)
|
||||||
|
- **reviewer**: Code review, quality analysis (read-only, does not modify code)
|
||||||
|
- **tester**: Test design and implementation (full code access)
|
||||||
|
- **investigator**: Debugging, root cause analysis (read-only unless implementing a confirmed fix)
|
||||||
|
- **shipper**: Deployment, CI/CD, release management (full code access)
|
||||||
|
|
||||||
|
## Spawning Agents
|
||||||
|
|
||||||
|
Use \`bb_spawn_worker\` to spawn an agent for a specific task. You can spawn multiple agents in parallel.
|
||||||
|
|
||||||
|
## Agent Instances
|
||||||
|
|
||||||
|
When you spawn an agent, it gets a numbered instance (e.g., "Engineer 01", "Engineer 02"). The right panel shows all active agent instances with their status.
|
||||||
|
|
||||||
|
## Templates
|
||||||
|
|
||||||
|
Templates are named compositions of agents:
|
||||||
|
- **feature-dev**: architect + 2x engineer + reviewer + tester
|
||||||
|
- **bug-fix**: investigator + engineer + tester
|
||||||
|
- **greenfield**: architect + 3x engineer + tester + shipper
|
||||||
|
|
||||||
|
Use templates for large efforts (epics). For small tasks, spawn individual agents directly.
|
||||||
|
|
||||||
|
## Task Scope Decision Tree
|
||||||
|
|
||||||
|
Before spawning agents, assess task scope:
|
||||||
|
|
||||||
|
### Small Task (Single Agent)
|
||||||
|
- Bug fix with known cause
|
||||||
|
- Single file change
|
||||||
|
- Quick refactor
|
||||||
|
- Single test addition
|
||||||
|
|
||||||
|
**Action:** Spawn 1 agent directly. Example: \`bb_spawn_worker\` with \`agentType: "engineer"\`
|
||||||
|
|
||||||
|
### Medium Task (2-3 Agents)
|
||||||
|
- Feature with clear design
|
||||||
|
- Bug investigation + fix
|
||||||
|
- Code review + fixes
|
||||||
|
|
||||||
|
**Action:** Spawn 2-3 agents based on template or custom composition.
|
||||||
|
|
||||||
|
### Large Task (Use Template)
|
||||||
|
- New feature from scratch
|
||||||
|
- System redesign
|
||||||
|
- Multi-component changes
|
||||||
|
|
||||||
|
**Action:** Use \`bb_spawn_template\` with appropriate template:
|
||||||
|
- \`feature-dev\` for new features
|
||||||
|
- \`bug-fix\` for debugging issues
|
||||||
|
- \`greenfield\` for new projects
|
||||||
|
- \`full-squad\` for complex multi-domain work
|
||||||
|
|
||||||
|
### Epic Creation
|
||||||
|
If the task requires multiple beads with dependencies:
|
||||||
|
1. Use \`bb_create_epic\` to create the epic
|
||||||
|
2. Use \`bb_create\` to create child beads
|
||||||
|
3. Assign agent types to each bead with \`bb_assign_agent\`
|
||||||
|
4. Spawn agents to work on unblocked beads
|
||||||
|
|
||||||
|
**Example Flow:**
|
||||||
|
\`\`\`
|
||||||
|
User: "Build user authentication system"
|
||||||
|
|
||||||
|
Orchestrator:
|
||||||
|
1. "This is a large task. Creating epic 'User Authentication'."
|
||||||
|
2. Creates epic + decomposes into beads:
|
||||||
|
- BEAD-001: Design auth schema [architect]
|
||||||
|
- BEAD-002: Implement JWT service [engineer]
|
||||||
|
- BEAD-003: Implement refresh tokens [engineer]
|
||||||
|
- BEAD-004: Write auth tests [tester]
|
||||||
|
- BEAD-005: Review implementation [reviewer]
|
||||||
|
3. Spawns Architect 01 to start on BEAD-001
|
||||||
|
4. When BEAD-001 done, spawns Engineer 01 and 02 for BEAD-002 and BEAD-003
|
||||||
|
5. And so on...
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Worker Coordination Workflow
|
||||||
|
|
||||||
|
When you spawn workers, you coordinate their work asynchronously. **Spawning is non-blocking** - workers run in the background while you can continue the conversation.
|
||||||
|
|
||||||
|
### Spawning Workers
|
||||||
|
|
||||||
|
\`\`\`
|
||||||
|
User: "Spawn 3 engineers to work on the API"
|
||||||
|
You: [call bb_spawn_worker or bb_spawn_team]
|
||||||
|
"Spawned Engineer-01, Engineer-02, Engineer-03. They're working in parallel."
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### Checking Progress
|
||||||
|
|
||||||
|
Users can ask about progress at any time:
|
||||||
|
|
||||||
|
\`\`\`
|
||||||
|
User: "What's the status?"
|
||||||
|
You: [call bb_worker_status]
|
||||||
|
"Engineer-01 is working on auth.ts, Engineer-02 completed their task, Engineer-03 is still working."
|
||||||
|
|
||||||
|
User: "Show me Engineer-02's results"
|
||||||
|
You: [call bb_worker_results, then read the actual files]
|
||||||
|
"Engineer-02 found that the auth module needs refactoring. Looking at auth.ts now..."
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### Getting Results
|
||||||
|
|
||||||
|
**CRITICAL: Verify work by reading actual files, not just result summaries.**
|
||||||
|
|
||||||
|
1. Call \`bb_worker_results\` to get bead summaries
|
||||||
|
2. Read the actual files that workers touched
|
||||||
|
3. Synthesize an informed response
|
||||||
|
|
||||||
|
\`\`\`
|
||||||
|
User: "Any updates?"
|
||||||
|
You:
|
||||||
|
1. [call bb_worker_status] - see who completed
|
||||||
|
2. [call bb_worker_results] - get bead summaries
|
||||||
|
3. [read auth.ts, user-service.ts] - verify actual changes
|
||||||
|
4. Respond with synthesis:
|
||||||
|
|
||||||
|
"Engineer-01 and Engineer-03 have completed:
|
||||||
|
|
||||||
|
- **Engineer-01**: Refactored auth.ts to use JWT tokens (lines 42-78 modified)
|
||||||
|
- **Engineer-03**: Added refresh token logic to user-service.ts
|
||||||
|
|
||||||
|
The changes look solid. Ready to proceed with testing?"
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### Why Read Files?
|
||||||
|
|
||||||
|
The bead summary is high-level. Reading files lets you:
|
||||||
|
- Verify the implementation matches intent
|
||||||
|
- Understand technical details for follow-up work
|
||||||
|
- Provide informed synthesis to the user
|
||||||
|
- Catch issues the worker might have missed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Current Workspace State
|
||||||
|
|
||||||
|
You are currently orchestrating the project at:
|
||||||
|
${workspaceRoot}
|
||||||
|
|
||||||
|
## Available Agent Types
|
||||||
|
\`\`\`json
|
||||||
|
${agentTypesContext}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## Available Mission Templates
|
||||||
|
\`\`\`json
|
||||||
|
${templatesContext}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## Active Project Tasks (via Dolt)
|
||||||
|
\`\`\`json
|
||||||
|
${issuesContext}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
You should follow templates by default but can recommend sensible deviations if the active task graph requires it (e.g., if a needed agent type is missing or if concurrency needs demand more workers).
|
||||||
|
Always use your provided tools to read the latest state, manage the mailbox, and update your presence.
|
||||||
|
`;
|
||||||
|
}
|
||||||
45
src/tui/tools/bb-assign-agent.ts
Normal file
45
src/tui/tools/bb-assign-agent.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { Type } from '@sinclair/typebox';
|
||||||
|
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
||||||
|
|
||||||
|
export function createAssignAgentTool(projectRoot: string): ToolDefinition {
|
||||||
|
return {
|
||||||
|
name: 'bb_assign_agent',
|
||||||
|
label: 'Assign Agent to Bead',
|
||||||
|
description: 'Assign an agent type to a bead. This signals which kind of agent should work on this task. Does NOT spawn the agent - use bb_spawn_worker for that.',
|
||||||
|
parameters: Type.Object({
|
||||||
|
bead_id: Type.String({ description: 'Bead ID to assign agent type to' }),
|
||||||
|
agent_type: Type.String({ description: 'Agent type ID (e.g., "engineer", "architect", "reviewer", "tester", "investigator", "shipper")' }),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId: string, params: unknown): Promise<any> {
|
||||||
|
const { bead_id, agent_type } = params as { bead_id: string; agent_type: string };
|
||||||
|
|
||||||
|
// Validate agent type
|
||||||
|
const validAgentTypes = ['architect', 'engineer', 'reviewer', 'tester', 'investigator', 'shipper'];
|
||||||
|
if (!validAgentTypes.includes(agent_type)) {
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: 'text',
|
||||||
|
text: `Error: Invalid agent type "${agent_type}". Valid options: ${validAgentTypes.join(', ')}`
|
||||||
|
}],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: In a full implementation, this would update the bead in the database
|
||||||
|
// For now, we just return success and the orchestrator should remember this assignment
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: 'text',
|
||||||
|
text: `Assigned agent type "${agent_type}" to bead ${bead_id}.
|
||||||
|
|
||||||
|
When ready to work on this bead, spawn an agent with:
|
||||||
|
\`\`\`
|
||||||
|
bb_spawn_worker(task_id: "${bead_id}", agent_type: "${agent_type}", task_context: "...")
|
||||||
|
\`\`\``,
|
||||||
|
}],
|
||||||
|
details: { bead_id, agent_type },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
318
src/tui/tools/bb-bead-crud.ts
Normal file
318
src/tui/tools/bb-bead-crud.ts
Normal file
|
|
@ -0,0 +1,318 @@
|
||||||
|
import { Type } from '@sinclair/typebox';
|
||||||
|
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
||||||
|
import { execFileSync } from 'child_process';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bead CRUD tools that wrap the `bd` CLI.
|
||||||
|
* These allow the orchestrator and workers to create, update, and close beads.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function createBeadCrudTools(projectRoot: string): ToolDefinition[] {
|
||||||
|
return [
|
||||||
|
// bb_create - Create a new bead
|
||||||
|
{
|
||||||
|
name: 'bb_create',
|
||||||
|
label: 'Create Bead',
|
||||||
|
description: `Create a new bead (task/epic) for tracking work. Returns the bead ID.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- Create a task: bb_create(title="Fix login bug", description="Users can't log in with SSO", type="task")
|
||||||
|
- Create an epic: bb_create(title="User Profile Feature", type="epic", priority=1)
|
||||||
|
- With labels: bb_create(title="Security audit", labels=["security", "urgent"])`,
|
||||||
|
parameters: Type.Object({
|
||||||
|
title: Type.String({ description: 'Bead title (concise summary)' }),
|
||||||
|
description: Type.Optional(Type.String({ description: 'Detailed description of the work' })),
|
||||||
|
type: Type.Optional(Type.String({
|
||||||
|
description: 'Bead type: task (default), epic, wisp, note',
|
||||||
|
default: 'task'
|
||||||
|
})),
|
||||||
|
priority: Type.Optional(Type.Number({
|
||||||
|
description: 'Priority: 0 (urgent), 1 (high), 2 (medium), 3 (low). Default: 2'
|
||||||
|
})),
|
||||||
|
labels: Type.Optional(Type.Array(Type.String(), {
|
||||||
|
description: 'Labels to apply (e.g., ["bug", "frontend"])'
|
||||||
|
})),
|
||||||
|
parent: Type.Optional(Type.String({
|
||||||
|
description: 'Parent bead ID if this is a child task'
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params: unknown): Promise<any> {
|
||||||
|
const { title, description, type, priority, labels, parent } = params as {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
type?: string;
|
||||||
|
priority?: number;
|
||||||
|
labels?: string[];
|
||||||
|
parent?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!title?.trim()) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: 'Title is required to create a bead.' }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const args = ['create', title];
|
||||||
|
|
||||||
|
if (description) {
|
||||||
|
args.push('--description', description);
|
||||||
|
}
|
||||||
|
if (type) {
|
||||||
|
args.push('--type', type);
|
||||||
|
}
|
||||||
|
if (priority !== undefined) {
|
||||||
|
args.push('--priority', String(priority));
|
||||||
|
}
|
||||||
|
if (labels && labels.length > 0) {
|
||||||
|
args.push('--label', labels.join(','));
|
||||||
|
}
|
||||||
|
if (parent) {
|
||||||
|
args.push('--parent', parent);
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = execFileSync('bd', args, {
|
||||||
|
cwd: projectRoot,
|
||||||
|
encoding: 'utf-8',
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// bd create outputs the bead ID
|
||||||
|
const beadId = output.trim().split('\n').pop()?.trim();
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: 'text',
|
||||||
|
text: `✓ Created bead: ${beadId}
|
||||||
|
|
||||||
|
Title: ${title}
|
||||||
|
Type: ${type || 'task'}
|
||||||
|
Priority: ${priority ?? 2}
|
||||||
|
|
||||||
|
Use bb_update to add details or assign an agent. Use bb_spawn_worker to start work on this bead.`
|
||||||
|
}],
|
||||||
|
details: { beadId, title, type: type || 'task' },
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: `Failed to create bead: ${message}` }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// bb_update - Update a bead
|
||||||
|
{
|
||||||
|
name: 'bb_update',
|
||||||
|
label: 'Update Bead',
|
||||||
|
description: `Update a bead's fields: status, assignee, notes, etc.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- Claim a bead: bb_update(id="bead-001", status="in_progress", assignee="Engineer 01")
|
||||||
|
- Add notes: bb_update(id="bead-001", notes="Found the bug in auth.ts line 42")
|
||||||
|
- Mark blocked: bb_update(id="bead-001", status="blocked", notes="Waiting for API key")`,
|
||||||
|
parameters: Type.Object({
|
||||||
|
id: Type.String({ description: 'Bead ID to update' }),
|
||||||
|
status: Type.Optional(Type.String({
|
||||||
|
description: 'New status: open, in_progress, blocked, review, done, closed'
|
||||||
|
})),
|
||||||
|
assignee: Type.Optional(Type.String({
|
||||||
|
description: 'Agent assigned to this bead (e.g., "Engineer 01")'
|
||||||
|
})),
|
||||||
|
notes: Type.Optional(Type.String({
|
||||||
|
description: 'Notes to append to the bead (use for progress updates)'
|
||||||
|
})),
|
||||||
|
priority: Type.Optional(Type.Number({ description: 'Update priority' })),
|
||||||
|
title: Type.Optional(Type.String({ description: 'Update title' })),
|
||||||
|
description: Type.Optional(Type.String({ description: 'Update description' })),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params: unknown): Promise<any> {
|
||||||
|
const { id, status, assignee, notes, priority, title, description } = params as {
|
||||||
|
id: string;
|
||||||
|
status?: string;
|
||||||
|
assignee?: string;
|
||||||
|
notes?: string;
|
||||||
|
priority?: number;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: 'Bead ID is required.' }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const args = ['update', id];
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
args.push('--status', status);
|
||||||
|
}
|
||||||
|
if (assignee) {
|
||||||
|
args.push('--assignee', assignee);
|
||||||
|
}
|
||||||
|
if (notes) {
|
||||||
|
args.push('--notes', notes);
|
||||||
|
}
|
||||||
|
if (priority !== undefined) {
|
||||||
|
args.push('--priority', String(priority));
|
||||||
|
}
|
||||||
|
if (title) {
|
||||||
|
args.push('--title', title);
|
||||||
|
}
|
||||||
|
if (description) {
|
||||||
|
args.push('--description', description);
|
||||||
|
}
|
||||||
|
|
||||||
|
execFileSync('bd', args, {
|
||||||
|
cwd: projectRoot,
|
||||||
|
encoding: 'utf-8',
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const changes = [status && `status=${status}`, assignee && `assignee=${assignee}`, notes && 'notes added']
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ');
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: 'text',
|
||||||
|
text: `✓ Updated ${id}: ${changes || 'no changes'}`
|
||||||
|
}],
|
||||||
|
details: { beadId: id, status, assignee },
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: `Failed to update bead: ${message}` }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// bb_close - Close a bead
|
||||||
|
{
|
||||||
|
name: 'bb_close',
|
||||||
|
label: 'Close Bead',
|
||||||
|
description: `Close a bead as completed or with a reason.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- Complete: bb_close(id="bead-001", reason="Fixed the login bug by updating auth.ts")
|
||||||
|
- Wontfix: bb_close(id="bead-002", reason="Not reproducible")`,
|
||||||
|
parameters: Type.Object({
|
||||||
|
id: Type.String({ description: 'Bead ID to close' }),
|
||||||
|
reason: Type.String({ description: 'Reason for closing (what was done or why wontfix)' }),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params: unknown): Promise<any> {
|
||||||
|
const { id, reason } = params as { id: string; reason: string };
|
||||||
|
|
||||||
|
if (!id || !reason) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: 'Both id and reason are required.' }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
execFileSync('bd', ['close', id, '--reason', reason], {
|
||||||
|
cwd: projectRoot,
|
||||||
|
encoding: 'utf-8',
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: 'text',
|
||||||
|
text: `✓ Closed ${id}: ${reason}`
|
||||||
|
}],
|
||||||
|
details: { beadId: id, reason },
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: `Failed to close bead: ${message}` }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// bb_show - Show bead details
|
||||||
|
{
|
||||||
|
name: 'bb_show',
|
||||||
|
label: 'Show Bead',
|
||||||
|
description: 'Get details about a specific bead including status, assignee, notes, and dependencies.',
|
||||||
|
parameters: Type.Object({
|
||||||
|
id: Type.String({ description: 'Bead ID to show' }),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params: unknown): Promise<any> {
|
||||||
|
const { id } = params as { id: string };
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: 'Bead ID is required.' }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const output = execFileSync('bd', ['show', id], {
|
||||||
|
cwd: projectRoot,
|
||||||
|
encoding: 'utf-8',
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: output }],
|
||||||
|
details: { beadId: id },
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: `Failed to show bead: ${message}` }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// bb_ready - List beads ready for work
|
||||||
|
{
|
||||||
|
name: 'bb_ready',
|
||||||
|
label: 'List Ready Beads',
|
||||||
|
description: 'List beads that are ready to be worked on (open status, no blockers).',
|
||||||
|
parameters: Type.Object({}),
|
||||||
|
async execute(_toolCallId, _params: unknown): Promise<any> {
|
||||||
|
try {
|
||||||
|
const output = execFileSync('bd', ['ready'], {
|
||||||
|
cwd: projectRoot,
|
||||||
|
encoding: 'utf-8',
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!output.trim()) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: 'No beads ready for work.' }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: `Ready beads:\n${output}` }],
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: `Failed to list ready beads: ${message}` }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
88
src/tui/tools/bb-create-agent.ts
Normal file
88
src/tui/tools/bb-create-agent.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
import { Type } from '@sinclair/typebox';
|
||||||
|
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
||||||
|
import { saveAgentType } from '../../lib/server/beads-fs';
|
||||||
|
|
||||||
|
export function createCreateAgentTool(projectRoot: string): ToolDefinition {
|
||||||
|
return {
|
||||||
|
name: 'bb_create_agent',
|
||||||
|
label: 'Create Agent Type',
|
||||||
|
description: 'Create a new agent type. Requires name, description, systemPrompt, and capabilities. The systemPrompt will be injected into workers spawned with this agent type.',
|
||||||
|
parameters: Type.Object({
|
||||||
|
name: Type.String({ description: 'Display name for the agent type (e.g., "Code Reviewer")' }),
|
||||||
|
description: Type.String({ description: 'What this agent type does (e.g., "Reviews code for quality and bugs")' }),
|
||||||
|
systemPrompt: Type.String({ description: 'System prompt injected into workers with this agent type. Define their focus and behavior.' }),
|
||||||
|
capabilities: Type.Array(Type.String(), { description: 'List of capabilities. Options: coding, implementation, planning, design_docs, review, arch_review, testing, research, debugging, ci_cd' }),
|
||||||
|
color: Type.Optional(Type.String({ description: 'Hex color for display (e.g., "#3b82f6"). Default: blue' })),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params: any) {
|
||||||
|
try {
|
||||||
|
const { name, description, systemPrompt, capabilities, color } = params;
|
||||||
|
|
||||||
|
// Validate required params
|
||||||
|
if (!name || typeof name !== 'string') {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: 'Error: name is required and must be a string.' }],
|
||||||
|
isError: true,
|
||||||
|
details: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!description || typeof description !== 'string') {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: 'Error: description is required.' }],
|
||||||
|
isError: true,
|
||||||
|
details: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!systemPrompt || typeof systemPrompt !== 'string') {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: 'Error: systemPrompt is required.' }],
|
||||||
|
isError: true,
|
||||||
|
details: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(capabilities)) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: 'Error: capabilities must be an array of strings.' }],
|
||||||
|
isError: true,
|
||||||
|
details: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const agentType = await saveAgentType({
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
systemPrompt,
|
||||||
|
capabilities,
|
||||||
|
color: color || '#3b82f6',
|
||||||
|
isBuiltIn: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: 'text',
|
||||||
|
text: `Agent type created successfully!
|
||||||
|
|
||||||
|
ID: ${agentType.id}
|
||||||
|
Name: ${agentType.name}
|
||||||
|
Description: ${agentType.description}
|
||||||
|
Capabilities: ${agentType.capabilities.join(', ')}
|
||||||
|
Color: ${agentType.color}
|
||||||
|
|
||||||
|
Workers spawned with this agent type will receive the custom system prompt and tool access based on capabilities.`,
|
||||||
|
}],
|
||||||
|
details: { agentType },
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: `Failed to create agent type: ${message}` }],
|
||||||
|
isError: true,
|
||||||
|
details: { error: message },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
88
src/tui/tools/bb-create-archetype.ts
Normal file
88
src/tui/tools/bb-create-archetype.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
import { Type } from '@sinclair/typebox';
|
||||||
|
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
||||||
|
import { saveArchetype } from '../../lib/server/beads-fs';
|
||||||
|
|
||||||
|
export function createCreateArchetypeTool(projectRoot: string): ToolDefinition {
|
||||||
|
return {
|
||||||
|
name: 'bb_create_archetype',
|
||||||
|
label: 'Create Archetype',
|
||||||
|
description: 'Create a new archetype. Requires name, description, systemPrompt, and capabilities. The systemPrompt will be injected into workers spawned with this archetype.',
|
||||||
|
parameters: Type.Object({
|
||||||
|
name: Type.String({ description: 'Display name for the archetype (e.g., "Code Reviewer")' }),
|
||||||
|
description: Type.String({ description: 'What this archetype does (e.g., "Reviews code for quality and bugs")' }),
|
||||||
|
systemPrompt: Type.String({ description: 'System prompt injected into workers with this archetype. Define their focus and behavior.' }),
|
||||||
|
capabilities: Type.Array(Type.String(), { description: 'List of capabilities. Options: coding, implementation, planning, design_docs, review, arch_review, testing, research' }),
|
||||||
|
color: Type.Optional(Type.String({ description: 'Hex color for display (e.g., "#3b82f6"). Default: blue' })),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params: any) {
|
||||||
|
try {
|
||||||
|
const { name, description, systemPrompt, capabilities, color } = params;
|
||||||
|
|
||||||
|
// Validate required params
|
||||||
|
if (!name || typeof name !== 'string') {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: 'Error: name is required and must be a string.' }],
|
||||||
|
isError: true,
|
||||||
|
details: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!description || typeof description !== 'string') {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: 'Error: description is required.' }],
|
||||||
|
isError: true,
|
||||||
|
details: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!systemPrompt || typeof systemPrompt !== 'string') {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: 'Error: systemPrompt is required.' }],
|
||||||
|
isError: true,
|
||||||
|
details: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(capabilities)) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: 'Error: capabilities must be an array of strings.' }],
|
||||||
|
isError: true,
|
||||||
|
details: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const archetype = await saveArchetype({
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
systemPrompt,
|
||||||
|
capabilities,
|
||||||
|
color: color || '#3b82f6',
|
||||||
|
isBuiltIn: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: 'text',
|
||||||
|
text: `Archetype created successfully!
|
||||||
|
|
||||||
|
ID: ${archetype.id}
|
||||||
|
Name: ${archetype.name}
|
||||||
|
Description: ${archetype.description}
|
||||||
|
Capabilities: ${archetype.capabilities.join(', ')}
|
||||||
|
Color: ${archetype.color}
|
||||||
|
|
||||||
|
Workers spawned with this archetype will receive the custom system prompt and tool access based on capabilities.`,
|
||||||
|
}],
|
||||||
|
details: { archetype },
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: `Failed to create archetype: ${message}` }],
|
||||||
|
isError: true,
|
||||||
|
details: { error: message },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
106
src/tui/tools/bb-create-template.ts
Normal file
106
src/tui/tools/bb-create-template.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
import { Type } from '@sinclair/typebox';
|
||||||
|
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
||||||
|
import { saveTemplate, getArchetypes } from '../../lib/server/beads-fs';
|
||||||
|
|
||||||
|
const TeamMemberSchema = Type.Object({
|
||||||
|
archetypeId: Type.String({ description: 'Archetype ID for this team member type' }),
|
||||||
|
count: Type.Number({ description: 'Number of workers with this archetype (minimum 1)' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export function createCreateTemplateTool(projectRoot: string): ToolDefinition {
|
||||||
|
return {
|
||||||
|
name: 'bb_create_template',
|
||||||
|
label: 'Create Swarm Template',
|
||||||
|
description: 'Create a new swarm template. Defines team composition by specifying which archetypes and how many workers of each.',
|
||||||
|
parameters: Type.Object({
|
||||||
|
name: Type.String({ description: 'Display name for the template (e.g., "Full Stack Team")' }),
|
||||||
|
description: Type.String({ description: 'What this template is for (e.g., "General feature development with review")' }),
|
||||||
|
team: Type.Array(TeamMemberSchema, { description: 'Team composition. Each entry specifies an archetype ID and worker count.' }),
|
||||||
|
color: Type.Optional(Type.String({ description: 'Hex color for display. Default: amber' })),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params: any) {
|
||||||
|
try {
|
||||||
|
const { name, description, team, color } = params;
|
||||||
|
|
||||||
|
// Validate required params
|
||||||
|
if (!name || typeof name !== 'string') {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: 'Error: name is required.' }],
|
||||||
|
isError: true,
|
||||||
|
details: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!description || typeof description !== 'string') {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: 'Error: description is required.' }],
|
||||||
|
isError: true,
|
||||||
|
details: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(team) || team.length === 0) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: 'Error: team must be a non-empty array.' }],
|
||||||
|
isError: true,
|
||||||
|
details: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate archetype IDs exist
|
||||||
|
const archetypes = await getArchetypes();
|
||||||
|
const validIds = new Set(archetypes.map(a => a.id));
|
||||||
|
|
||||||
|
for (const member of team) {
|
||||||
|
if (!validIds.has(member.archetypeId)) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: `Error: Unknown archetype "${member.archetypeId}". Valid archetypes: ${Array.from(validIds).join(', ')}` }],
|
||||||
|
isError: true,
|
||||||
|
details: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (member.count < 1) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: `Error: count must be at least 1 for archetype "${member.archetypeId}".` }],
|
||||||
|
isError: true,
|
||||||
|
details: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const template = await saveTemplate({
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
team,
|
||||||
|
color: color || '#f59e0b',
|
||||||
|
isBuiltIn: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const teamDesc = team.map(m => `${m.count}x ${m.archetypeId}`).join(', ');
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: 'text',
|
||||||
|
text: `Swarm template created successfully!
|
||||||
|
|
||||||
|
ID: ${template.id}
|
||||||
|
Name: ${template.name}
|
||||||
|
Description: ${template.description}
|
||||||
|
Team: ${teamDesc}
|
||||||
|
Color: ${template.color}
|
||||||
|
|
||||||
|
Use this template when spawning swarms for coordinated multi-agent work.`,
|
||||||
|
}],
|
||||||
|
details: { template },
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: `Failed to create template: ${message}` }],
|
||||||
|
isError: true,
|
||||||
|
details: { error: message },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
56
src/tui/tools/bb-delete-agent.ts
Normal file
56
src/tui/tools/bb-delete-agent.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import { Type } from '@sinclair/typebox';
|
||||||
|
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
||||||
|
import { deleteAgentType, getAgentTypes } from '../../lib/server/beads-fs';
|
||||||
|
|
||||||
|
export function createDeleteAgentTool(projectRoot: string): ToolDefinition {
|
||||||
|
return {
|
||||||
|
name: 'bb_delete_agent',
|
||||||
|
label: 'Delete Agent Type',
|
||||||
|
description: 'Delete an agent type. Built-in agent types cannot be deleted.',
|
||||||
|
parameters: Type.Object({
|
||||||
|
id: Type.String({ description: 'Agent type ID to delete' }),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params: any) {
|
||||||
|
try {
|
||||||
|
const { id } = params;
|
||||||
|
|
||||||
|
if (!id || typeof id !== 'string') {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: 'Error: id is required.' }],
|
||||||
|
isError: true,
|
||||||
|
details: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if agent type exists
|
||||||
|
const agentTypes = await getAgentTypes(projectRoot);
|
||||||
|
const existing = agentTypes.find(a => a.id === id);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: `Error: Agent type "${id}" not found.` }],
|
||||||
|
isError: true,
|
||||||
|
details: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteAgentType(id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: 'text',
|
||||||
|
text: `Agent type "${existing.name}" (${id}) deleted successfully.`,
|
||||||
|
}],
|
||||||
|
details: { deletedId: id },
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: `Failed to delete agent type: ${message}` }],
|
||||||
|
isError: true,
|
||||||
|
details: { error: message },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
64
src/tui/tools/bb-delete-archetype.ts
Normal file
64
src/tui/tools/bb-delete-archetype.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { Type } from '@sinclair/typebox';
|
||||||
|
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
||||||
|
import { deleteArchetype, getArchetypes } from '../../lib/server/beads-fs';
|
||||||
|
|
||||||
|
export function createDeleteArchetypeTool(projectRoot: string): ToolDefinition {
|
||||||
|
return {
|
||||||
|
name: 'bb_delete_archetype',
|
||||||
|
label: 'Delete Archetype',
|
||||||
|
description: 'Delete an archetype. Cannot delete built-in archetypes. Workers currently using this archetype will continue but new workers cannot be spawned with it.',
|
||||||
|
parameters: Type.Object({
|
||||||
|
id: Type.String({ description: 'Archetype ID to delete' }),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params: any) {
|
||||||
|
try {
|
||||||
|
const { id } = params;
|
||||||
|
|
||||||
|
if (!id || typeof id !== 'string') {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: 'Error: id is required.' }],
|
||||||
|
isError: true,
|
||||||
|
details: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it exists first for better error message
|
||||||
|
const archetypes = await getArchetypes();
|
||||||
|
const existing = archetypes.find(a => a.id === id);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: `Error: Archetype "${id}" not found.` }],
|
||||||
|
isError: true,
|
||||||
|
details: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.isBuiltIn) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: `Error: Cannot delete built-in archetype "${id}". Built-in archetypes are protected.` }],
|
||||||
|
isError: true,
|
||||||
|
details: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteArchetype(id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: 'text',
|
||||||
|
text: `Archetype "${id}" deleted successfully.`,
|
||||||
|
}],
|
||||||
|
details: { deletedId: id },
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: `Failed to delete archetype: ${message}` }],
|
||||||
|
isError: true,
|
||||||
|
details: { error: message },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
64
src/tui/tools/bb-delete-template.ts
Normal file
64
src/tui/tools/bb-delete-template.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { Type } from '@sinclair/typebox';
|
||||||
|
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
||||||
|
import { deleteTemplate, getTemplates } from '../../lib/server/beads-fs';
|
||||||
|
|
||||||
|
export function createDeleteTemplateTool(projectRoot: string): ToolDefinition {
|
||||||
|
return {
|
||||||
|
name: 'bb_delete_template',
|
||||||
|
label: 'Delete Swarm Template',
|
||||||
|
description: 'Delete a swarm template. Cannot delete built-in templates.',
|
||||||
|
parameters: Type.Object({
|
||||||
|
id: Type.String({ description: 'Template ID to delete' }),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params: any) {
|
||||||
|
try {
|
||||||
|
const { id } = params;
|
||||||
|
|
||||||
|
if (!id || typeof id !== 'string') {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: 'Error: id is required.' }],
|
||||||
|
isError: true,
|
||||||
|
details: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it exists first
|
||||||
|
const templates = await getTemplates();
|
||||||
|
const existing = templates.find(t => t.id === id);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: `Error: Template "${id}" not found.` }],
|
||||||
|
isError: true,
|
||||||
|
details: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.isBuiltIn) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: `Error: Cannot delete built-in template "${id}". Built-in templates are protected.` }],
|
||||||
|
isError: true,
|
||||||
|
details: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteTemplate(id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: 'text',
|
||||||
|
text: `Swarm template "${id}" deleted successfully.`,
|
||||||
|
}],
|
||||||
|
details: { deletedId: id },
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: `Failed to delete template: ${message}` }],
|
||||||
|
isError: true,
|
||||||
|
details: { error: message },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
39
src/tui/tools/bb-deviation.ts
Normal file
39
src/tui/tools/bb-deviation.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { Type } from '@sinclair/typebox';
|
||||||
|
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
||||||
|
import { embeddedPiDaemon } from '../../lib/embedded-daemon';
|
||||||
|
|
||||||
|
export function createDeviationTool(projectRoot: string): ToolDefinition {
|
||||||
|
return {
|
||||||
|
name: 'bb_record_deviation',
|
||||||
|
label: 'Record Template Deviation',
|
||||||
|
description: 'Log when and why you are deviating from a standard mission template (e.g., adding an extra worker, skipping an archetype, changing the flow).',
|
||||||
|
parameters: Type.Object({
|
||||||
|
reason: Type.String({ description: 'Why the deviation was necessary' }),
|
||||||
|
deviation_type: Type.String({ description: 'What kind of deviation (e.g., "extra_worker", "missing_archetype", "custom_flow")' }),
|
||||||
|
details: Type.Optional(Type.String({ description: 'Additional context about the deviation' })),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params: any) {
|
||||||
|
try {
|
||||||
|
// We use the existing daemon event system to record this
|
||||||
|
embeddedPiDaemon.appendEvent(projectRoot, {
|
||||||
|
kind: 'deviation.proposed',
|
||||||
|
title: `Deviation [${params.deviation_type}]`,
|
||||||
|
detail: params.reason,
|
||||||
|
status: 'idle',
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: 'Deviation recorded successfully.' }],
|
||||||
|
details: {},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: `Failed to record deviation: ${message}` }],
|
||||||
|
isError: true,
|
||||||
|
details: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
50
src/tui/tools/bb-dolt-read.ts
Normal file
50
src/tui/tools/bb-dolt-read.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { Type } from '@sinclair/typebox';
|
||||||
|
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
||||||
|
import { readIssuesFromDisk } from '../../lib/read-issues';
|
||||||
|
|
||||||
|
export function createDoltReadTool(projectRoot: string): ToolDefinition {
|
||||||
|
return {
|
||||||
|
name: 'bb_dolt_read_issues',
|
||||||
|
label: 'Read Project Tasks',
|
||||||
|
description: 'Read the current project tasks (BeadBoard issues) from the Dolt backend. Always use this to check task state before acting.',
|
||||||
|
parameters: Type.Object({
|
||||||
|
limit: Type.Optional(Type.Number({ description: 'Max number of tasks to return' })),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params: any) {
|
||||||
|
try {
|
||||||
|
const issues = await readIssuesFromDisk({ projectRoot });
|
||||||
|
const sliced = params.limit ? issues.slice(0, params.limit) : issues;
|
||||||
|
|
||||||
|
// Strip out some heavy metadata to keep context clean
|
||||||
|
const compactIssues = sliced.map((i) => ({
|
||||||
|
id: i.id,
|
||||||
|
title: i.title,
|
||||||
|
status: i.status,
|
||||||
|
priority: i.priority,
|
||||||
|
issue_type: i.issue_type,
|
||||||
|
assignee: i.assignee,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(compactIssues, null, 2),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
details: {
|
||||||
|
total: issues.length,
|
||||||
|
returned: compactIssues.length,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: `Failed to read tasks: ${message}` }],
|
||||||
|
isError: true,
|
||||||
|
details: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
45
src/tui/tools/bb-list-agents.ts
Normal file
45
src/tui/tools/bb-list-agents.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { Type } from '@sinclair/typebox';
|
||||||
|
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
||||||
|
import { getAgentTypes } from '../../lib/server/beads-fs';
|
||||||
|
|
||||||
|
export function createListAgentsTool(projectRoot: string): ToolDefinition {
|
||||||
|
return {
|
||||||
|
name: 'bb_list_agents',
|
||||||
|
label: 'List Agent Types',
|
||||||
|
description: 'List all available agent types. Returns id, name, description, capabilities, and color for each. Agent types are the kinds of workers the orchestrator can spawn.',
|
||||||
|
parameters: Type.Object({}),
|
||||||
|
async execute() {
|
||||||
|
try {
|
||||||
|
const agentTypes = await getAgentTypes(projectRoot);
|
||||||
|
|
||||||
|
const summary = agentTypes.map(a =>
|
||||||
|
`- ${a.name} (${a.id}): ${a.description}\n Capabilities: ${a.capabilities.join(', ')}`
|
||||||
|
).join('\n\n');
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: 'text',
|
||||||
|
text: `Found ${agentTypes.length} agent types:\n\n${summary}`,
|
||||||
|
}],
|
||||||
|
details: {
|
||||||
|
agentTypes: agentTypes.map(a => ({
|
||||||
|
id: a.id,
|
||||||
|
name: a.name,
|
||||||
|
description: a.description,
|
||||||
|
capabilities: a.capabilities,
|
||||||
|
color: a.color,
|
||||||
|
isBuiltIn: a.isBuiltIn,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: `Failed to list agent types: ${message}` }],
|
||||||
|
isError: true,
|
||||||
|
details: { error: message },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
45
src/tui/tools/bb-list-archetypes.ts
Normal file
45
src/tui/tools/bb-list-archetypes.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { Type } from '@sinclair/typebox';
|
||||||
|
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
||||||
|
import { getArchetypes } from '../../lib/server/beads-fs';
|
||||||
|
|
||||||
|
export function createListArchetypesTool(projectRoot: string): ToolDefinition {
|
||||||
|
return {
|
||||||
|
name: 'bb_list_archetypes',
|
||||||
|
label: 'List Archetypes',
|
||||||
|
description: 'List all available archetypes. Returns id, name, description, capabilities, and color for each archetype.',
|
||||||
|
parameters: Type.Object({}),
|
||||||
|
async execute() {
|
||||||
|
try {
|
||||||
|
const archetypes = await getArchetypes();
|
||||||
|
|
||||||
|
const summary = archetypes.map(a =>
|
||||||
|
`- ${a.name} (${a.id}): ${a.description}\n Capabilities: ${a.capabilities.join(', ')}`
|
||||||
|
).join('\n\n');
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: 'text',
|
||||||
|
text: `Found ${archetypes.length} archetypes:\n\n${summary}`,
|
||||||
|
}],
|
||||||
|
details: {
|
||||||
|
archetypes: archetypes.map(a => ({
|
||||||
|
id: a.id,
|
||||||
|
name: a.name,
|
||||||
|
description: a.description,
|
||||||
|
capabilities: a.capabilities,
|
||||||
|
color: a.color,
|
||||||
|
isBuiltIn: a.isBuiltIn,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: `Failed to list archetypes: ${message}` }],
|
||||||
|
isError: true,
|
||||||
|
details: { error: message },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
45
src/tui/tools/bb-list-templates.ts
Normal file
45
src/tui/tools/bb-list-templates.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { Type } from '@sinclair/typebox';
|
||||||
|
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
||||||
|
import { getTemplates } from '../../lib/server/beads-fs';
|
||||||
|
|
||||||
|
export function createListTemplatesTool(projectRoot: string): ToolDefinition {
|
||||||
|
return {
|
||||||
|
name: 'bb_list_templates',
|
||||||
|
label: 'List Swarm Templates',
|
||||||
|
description: 'List all available swarm templates. Returns team composition showing which archetypes and how many workers of each.',
|
||||||
|
parameters: Type.Object({}),
|
||||||
|
async execute() {
|
||||||
|
try {
|
||||||
|
const templates = await getTemplates();
|
||||||
|
|
||||||
|
const summary = templates.map(t => {
|
||||||
|
const teamDesc = t.team.map(m => `${m.count}x ${m.agentTypeId}`).join(', ');
|
||||||
|
return `- ${t.name} (${t.id}): ${t.description}\n Team: ${teamDesc}`;
|
||||||
|
}).join('\n\n');
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: 'text',
|
||||||
|
text: `Found ${templates.length} swarm templates:\n\n${summary}`,
|
||||||
|
}],
|
||||||
|
details: {
|
||||||
|
templates: templates.map(t => ({
|
||||||
|
id: t.id,
|
||||||
|
name: t.name,
|
||||||
|
description: t.description,
|
||||||
|
team: t.team,
|
||||||
|
isBuiltIn: t.isBuiltIn,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: `Failed to list templates: ${message}` }],
|
||||||
|
isError: true,
|
||||||
|
details: { error: message },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
105
src/tui/tools/bb-mailbox.ts
Normal file
105
src/tui/tools/bb-mailbox.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
import { Type } from '@sinclair/typebox';
|
||||||
|
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
||||||
|
import { inboxAgentMessages, sendAgentMessage, ackAgentMessage } from '../../lib/agent-mail';
|
||||||
|
|
||||||
|
export function createMailboxTools(): ToolDefinition[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: 'bb_read_inbox',
|
||||||
|
label: 'Read Mailbox',
|
||||||
|
description: 'Read incoming coordination messages (HANDOFF, BLOCKED, INFO) for an agent.',
|
||||||
|
parameters: Type.Object({
|
||||||
|
agent: Type.String({ description: 'The agent ID to read messages for (usually yourself)' }),
|
||||||
|
state: Type.Optional(Type.String({ description: 'Filter by state: unread, read, acked' })),
|
||||||
|
limit: Type.Optional(Type.Number({ description: 'Max messages to return' })),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params: any) {
|
||||||
|
try {
|
||||||
|
const result = await inboxAgentMessages({
|
||||||
|
agent: params.agent,
|
||||||
|
state: params.state,
|
||||||
|
limit: params.limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
return { content: [{ type: 'text', text: `Failed: ${result.error?.message}` }], isError: true, details: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: JSON.stringify(result.data, null, 2) }],
|
||||||
|
details: {},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true, details: {} };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'bb_send_message',
|
||||||
|
label: 'Send Message',
|
||||||
|
description: 'Send a coordination message to another agent or broadcast. Use for HANDOFF or reporting BLOCKED states.',
|
||||||
|
parameters: Type.Object({
|
||||||
|
from: Type.String({ description: 'Your agent ID' }),
|
||||||
|
to: Type.String({ description: 'Recipient agent ID (or broadcast)' }),
|
||||||
|
bead: Type.String({ description: 'The task/bead ID this relates to' }),
|
||||||
|
category: Type.String({ description: 'One of: HANDOFF, BLOCKED, DECISION, INFO' }),
|
||||||
|
subject: Type.String({ description: 'Short summary' }),
|
||||||
|
body: Type.String({ description: 'Detailed message body' }),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params: any) {
|
||||||
|
try {
|
||||||
|
const result = await sendAgentMessage({
|
||||||
|
from: params.from,
|
||||||
|
to: params.to,
|
||||||
|
bead: params.bead,
|
||||||
|
category: params.category,
|
||||||
|
subject: params.subject,
|
||||||
|
body: params.body,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
return { content: [{ type: 'text', text: `Failed: ${result.error?.message}` }], isError: true, details: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: `Message sent successfully. ID: ${result.data?.message_id}` }],
|
||||||
|
details: {},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true, details: {} };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'bb_ack_message',
|
||||||
|
label: 'Acknowledge Message',
|
||||||
|
description: 'Acknowledge a required message (like a HANDOFF or BLOCKED notification) to signal you have received it.',
|
||||||
|
parameters: Type.Object({
|
||||||
|
agent: Type.String({ description: 'Your agent ID' }),
|
||||||
|
messageId: Type.String({ description: 'The ID of the message to acknowledge' }),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params: any) {
|
||||||
|
try {
|
||||||
|
const result = await ackAgentMessage({
|
||||||
|
agent: params.agent,
|
||||||
|
message: params.messageId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
return { content: [{ type: 'text', text: `Failed: ${result.error?.message}` }], isError: true, details: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: `Message ${params.messageId} acknowledged successfully.` }],
|
||||||
|
details: {},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true, details: {} };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
42
src/tui/tools/bb-presence.ts
Normal file
42
src/tui/tools/bb-presence.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { Type } from '@sinclair/typebox';
|
||||||
|
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
||||||
|
import { registerAgent, extendActivityLease } from '../../lib/agent-registry';
|
||||||
|
|
||||||
|
export function createPresenceTools(): ToolDefinition[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: 'bb_update_presence',
|
||||||
|
label: 'Update Presence',
|
||||||
|
description: 'Register or extend your presence lease in the BeadBoard agent registry so the frontend knows you are alive.',
|
||||||
|
parameters: Type.Object({
|
||||||
|
agent: Type.String({ description: 'Your agent ID (e.g., orchestrator)' }),
|
||||||
|
role: Type.String({ description: 'Your role (e.g., orchestrator, worker)' }),
|
||||||
|
display: Type.Optional(Type.String({ description: 'Friendly display name' })),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params: any) {
|
||||||
|
try {
|
||||||
|
// Attempt to register/update
|
||||||
|
const result = await registerAgent({
|
||||||
|
name: params.agent,
|
||||||
|
role: params.role,
|
||||||
|
display: params.display,
|
||||||
|
forceUpdate: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
// Fallback to just lease extension if register fails or acts weird
|
||||||
|
await extendActivityLease({ agent: params.agent });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: `Presence updated for ${params.agent}.` }],
|
||||||
|
details: {},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true, details: {} };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
167
src/tui/tools/bb-spawn-template.ts
Normal file
167
src/tui/tools/bb-spawn-template.ts
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
import { Type } from '@sinclair/typebox';
|
||||||
|
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
||||||
|
import { getTemplates, getAgentTypes } from '../../lib/server/beads-fs';
|
||||||
|
import { workerSessionManager } from '../../lib/worker-session-manager';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a task ID from natural language.
|
||||||
|
*/
|
||||||
|
function generateTaskId(description: string): string {
|
||||||
|
const slug = description
|
||||||
|
.toLowerCase()
|
||||||
|
.slice(0, 40)
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '');
|
||||||
|
|
||||||
|
const ts = Date.now().toString(36).slice(-4);
|
||||||
|
return `${slug}-${ts}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-select template based on task description keywords.
|
||||||
|
*/
|
||||||
|
function autoSelectTemplate(description: string): string {
|
||||||
|
const desc = description.toLowerCase();
|
||||||
|
|
||||||
|
if (desc.includes('bug') || desc.includes('fix') || desc.includes('error') || desc.includes('fail')) {
|
||||||
|
return 'bug-fix';
|
||||||
|
}
|
||||||
|
if (desc.includes('review') || desc.includes('audit')) {
|
||||||
|
return 'code-review';
|
||||||
|
}
|
||||||
|
if (desc.includes('investigate') || desc.includes('research') || desc.includes('analyze')) {
|
||||||
|
return 'investigation';
|
||||||
|
}
|
||||||
|
if (desc.includes('refactor') || desc.includes('clean') || desc.includes('improve')) {
|
||||||
|
return 'refactor';
|
||||||
|
}
|
||||||
|
if (desc.includes('deploy') || desc.includes('release') || desc.includes('ship')) {
|
||||||
|
return 'release';
|
||||||
|
}
|
||||||
|
if (desc.includes('new project') || desc.includes('from scratch') || desc.includes('greenfield')) {
|
||||||
|
return 'greenfield';
|
||||||
|
}
|
||||||
|
// Default for features and general work
|
||||||
|
return 'feature-dev';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSpawnTemplateTool(projectRoot: string): ToolDefinition {
|
||||||
|
return {
|
||||||
|
name: 'bb_spawn_team',
|
||||||
|
label: 'Spawn Agent Team',
|
||||||
|
description: `Spawn a team of agents using a template. Use for larger tasks that need multiple specialists.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- "Build a user authentication system"
|
||||||
|
- "Investigate and fix the payment processing bug"
|
||||||
|
- "Review and improve the API layer"
|
||||||
|
|
||||||
|
Available templates:
|
||||||
|
- feature-dev: Architect + 2 Engineers + Reviewer + Tester (new features)
|
||||||
|
- bug-fix: Investigator + Engineer + Tester (debugging)
|
||||||
|
- code-review: Reviewer + Engineer (code review)
|
||||||
|
- investigation: Investigator + Tester (research/analysis)
|
||||||
|
- refactor: Architect + 2 Engineers + Tester (code improvement)
|
||||||
|
- release: Tester + Reviewer + Shipper (deployment)
|
||||||
|
- greenfield: Full team for new projects
|
||||||
|
- full-squad: All agent types for complex work`,
|
||||||
|
parameters: Type.Object({
|
||||||
|
description: Type.String({
|
||||||
|
description: 'What you want the team to accomplish. Be specific about scope and goals.'
|
||||||
|
}),
|
||||||
|
template: Type.Optional(Type.String({
|
||||||
|
description: 'Template name. If not provided, one will be selected based on your description.'
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId: string, params: unknown): Promise<any> {
|
||||||
|
const { description, template } = params as {
|
||||||
|
description: string;
|
||||||
|
template?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!description || typeof description !== 'string') {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: 'Please describe what you want the team to accomplish.' }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-select template if not provided
|
||||||
|
const templateId = template || autoSelectTemplate(description);
|
||||||
|
|
||||||
|
// Load template
|
||||||
|
const templates = await getTemplates(projectRoot);
|
||||||
|
const selectedTemplate = templates.find(t => t.id === templateId);
|
||||||
|
|
||||||
|
if (!selectedTemplate) {
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: 'text',
|
||||||
|
text: `Template "${templateId}" not found. Available: ${templates.map(t => t.id).join(', ')}`
|
||||||
|
}],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load agent types
|
||||||
|
const agentTypes = await getAgentTypes(projectRoot);
|
||||||
|
const agentTypeMap = new Map(agentTypes.map(a => [a.id, a]));
|
||||||
|
|
||||||
|
// Generate task ID
|
||||||
|
const taskId = generateTaskId(description);
|
||||||
|
|
||||||
|
// Spawn workers for each team member
|
||||||
|
const spawned: Array<{ id: string; displayName?: string; agentTypeId: string }> = [];
|
||||||
|
const spawnErrors: string[] = [];
|
||||||
|
|
||||||
|
for (const member of selectedTemplate.team) {
|
||||||
|
const agentType = agentTypeMap.get(member.agentTypeId);
|
||||||
|
if (!agentType) {
|
||||||
|
spawnErrors.push(`Unknown agent type: ${member.agentTypeId}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < member.count; i++) {
|
||||||
|
try {
|
||||||
|
const worker = await workerSessionManager.spawnWorker({
|
||||||
|
projectRoot,
|
||||||
|
taskId: `${taskId}-${member.agentTypeId}-${i + 1}`,
|
||||||
|
taskContext: description,
|
||||||
|
agentType: member.agentTypeId,
|
||||||
|
});
|
||||||
|
spawned.push({
|
||||||
|
id: worker.id,
|
||||||
|
displayName: worker.displayName,
|
||||||
|
agentTypeId: member.agentTypeId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
spawnErrors.push(`Failed to spawn ${member.agentTypeId}: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build result
|
||||||
|
const teamDesc = selectedTemplate.team.map(m => `${m.count}×${m.agentTypeId}`).join(' + ');
|
||||||
|
const names = spawned.map(s => s.displayName).filter(Boolean).join(', ');
|
||||||
|
|
||||||
|
let message = `✓ Spawned ${spawned.length} agents using "${selectedTemplate.name}"\n\n`;
|
||||||
|
message += `Team: ${teamDesc}\n`;
|
||||||
|
message += `Active: ${names}\n\n`;
|
||||||
|
message += `Goal: "${description.slice(0, 80)}${description.length > 80 ? '...' : ''}"`;
|
||||||
|
|
||||||
|
if (spawnErrors.length > 0) {
|
||||||
|
message += `\n\nErrors:\n${spawnErrors.map(e => `- ${e}`).join('\n')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: message }],
|
||||||
|
details: {
|
||||||
|
template: { id: selectedTemplate.id, name: selectedTemplate.name },
|
||||||
|
taskId,
|
||||||
|
spawned: spawned.map(s => ({ id: s.id, displayName: s.displayName })),
|
||||||
|
errors: spawnErrors.length > 0 ? spawnErrors : undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
168
src/tui/tools/bb-spawn-worker.ts
Normal file
168
src/tui/tools/bb-spawn-worker.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
import { Type } from '@sinclair/typebox';
|
||||||
|
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
||||||
|
import { workerSessionManager } from '../../lib/worker-session-manager';
|
||||||
|
import { embeddedPiDaemon } from '../../lib/embedded-daemon';
|
||||||
|
import { execFileSync } from 'child_process';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a task ID from natural language description.
|
||||||
|
*/
|
||||||
|
function generateTaskId(description: string): string {
|
||||||
|
const slug = description
|
||||||
|
.toLowerCase()
|
||||||
|
.slice(0, 40)
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '');
|
||||||
|
|
||||||
|
const ts = Date.now().toString(36).slice(-4);
|
||||||
|
return `${slug}-${ts}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a bead for the worker to work on.
|
||||||
|
* Returns the bead ID.
|
||||||
|
*/
|
||||||
|
function createBeadForTask(projectRoot: string, title: string, description: string): string {
|
||||||
|
const output = execFileSync('bd', [
|
||||||
|
'create',
|
||||||
|
title,
|
||||||
|
'--description', description,
|
||||||
|
'--type', 'task',
|
||||||
|
'--priority', '2',
|
||||||
|
], {
|
||||||
|
cwd: projectRoot,
|
||||||
|
encoding: 'utf-8',
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// bd create outputs the bead ID on the last line
|
||||||
|
const beadId = output.trim().split('\n').pop()?.trim();
|
||||||
|
if (!beadId) {
|
||||||
|
throw new Error('Failed to parse bead ID from bd create output');
|
||||||
|
}
|
||||||
|
return beadId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSpawnWorkerTool(projectRoot: string): ToolDefinition {
|
||||||
|
return {
|
||||||
|
name: 'bb_spawn_worker',
|
||||||
|
label: 'Spawn Worker Agent',
|
||||||
|
description: `Spawn an agent to work on a task. Just describe what you want done in natural language.
|
||||||
|
|
||||||
|
IMPORTANT: Every worker MUST have a bead to track their work. If no bead_id is provided, one will be created automatically.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- "Review the authentication module for security issues"
|
||||||
|
- "Fix the failing test in user-service.test.ts"
|
||||||
|
- "Implement the password reset feature"
|
||||||
|
- "Debug why the payment webhook is failing"
|
||||||
|
|
||||||
|
The agent will:
|
||||||
|
1. Claim the bead (status: in_progress)
|
||||||
|
2. Work on the task
|
||||||
|
3. Update the bead with progress notes
|
||||||
|
4. Close the bead when done (with summary)
|
||||||
|
|
||||||
|
Check the right panel (Agent Status) to see active agents.`,
|
||||||
|
parameters: Type.Object({
|
||||||
|
description: Type.String({
|
||||||
|
description: 'What you want the agent to accomplish. Be specific about files, context, and expected outcome.'
|
||||||
|
}),
|
||||||
|
agent_type: Type.Optional(Type.String({
|
||||||
|
description: 'Agent type: architect (design), engineer (code), reviewer (review), tester (tests), investigator (debug), shipper (deploy). Default: engineer.'
|
||||||
|
})),
|
||||||
|
bead_id: Type.Optional(Type.String({
|
||||||
|
description: 'Optional: existing bead ID to assign this agent to. If not provided, a new bead will be created.'
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId: string, params: unknown): Promise<any> {
|
||||||
|
const { description, agent_type, bead_id: providedBeadId } = params as {
|
||||||
|
description: string;
|
||||||
|
agent_type?: string;
|
||||||
|
bead_id?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!description || typeof description !== 'string') {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: 'Please describe what you want the agent to do.' }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate agent type
|
||||||
|
const validAgentTypes = ['architect', 'engineer', 'reviewer', 'tester', 'investigator', 'shipper'];
|
||||||
|
if (agent_type && !validAgentTypes.includes(agent_type)) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: `Unknown agent type "${agent_type}". Options: ${validAgentTypes.join(', ')}` }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Step 1: Create a bead if one wasn't provided
|
||||||
|
let beadId = providedBeadId;
|
||||||
|
if (!beadId) {
|
||||||
|
// Use first 60 chars of description as title
|
||||||
|
const title = description.length > 60
|
||||||
|
? description.slice(0, 57) + '...'
|
||||||
|
: description;
|
||||||
|
|
||||||
|
beadId = createBeadForTask(projectRoot, title, description);
|
||||||
|
|
||||||
|
embeddedPiDaemon.appendEvent(projectRoot, {
|
||||||
|
kind: 'worker.spawned',
|
||||||
|
title: `Created bead: ${beadId}`,
|
||||||
|
detail: title,
|
||||||
|
status: 'launching',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Spawn the worker
|
||||||
|
const worker = await workerSessionManager.spawnWorker({
|
||||||
|
projectRoot,
|
||||||
|
taskId: beadId, // Use bead ID as task ID
|
||||||
|
taskContext: description,
|
||||||
|
agentType: agent_type,
|
||||||
|
beadId, // Pass bead ID to worker
|
||||||
|
});
|
||||||
|
|
||||||
|
const typeMsg = agent_type ? ` (${agent_type})` : '';
|
||||||
|
const beadMsg = providedBeadId
|
||||||
|
? `Working on existing bead: ${beadId}`
|
||||||
|
: `Created bead: ${beadId}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: 'text',
|
||||||
|
text: `✓ Spawned ${worker.displayName}${typeMsg}
|
||||||
|
|
||||||
|
${beadMsg}
|
||||||
|
Task: "${description.slice(0, 80)}${description.length > 80 ? '...' : ''}"
|
||||||
|
|
||||||
|
The agent will claim this bead, work on it, and close it when done. Watch the right panel for status.`
|
||||||
|
}],
|
||||||
|
details: {
|
||||||
|
workerId: worker.id,
|
||||||
|
displayName: worker.displayName,
|
||||||
|
beadId,
|
||||||
|
agentType: agent_type || 'engineer',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
|
||||||
|
embeddedPiDaemon.appendEvent(projectRoot, {
|
||||||
|
kind: 'worker.failed',
|
||||||
|
title: 'Spawn failed',
|
||||||
|
detail: message,
|
||||||
|
status: 'failed',
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: `Failed to spawn agent: ${message}` }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
74
src/tui/tools/bb-update-agent.ts
Normal file
74
src/tui/tools/bb-update-agent.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
import { Type } from '@sinclair/typebox';
|
||||||
|
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
||||||
|
import { saveAgentType, getAgentTypes } from '../../lib/server/beads-fs';
|
||||||
|
|
||||||
|
export function createUpdateAgentTool(projectRoot: string): ToolDefinition {
|
||||||
|
return {
|
||||||
|
name: 'bb_update_agent',
|
||||||
|
label: 'Update Agent Type',
|
||||||
|
description: 'Update an existing agent type. Provide the ID and fields to update.',
|
||||||
|
parameters: Type.Object({
|
||||||
|
id: Type.String({ description: 'Agent type ID to update' }),
|
||||||
|
name: Type.Optional(Type.String({ description: 'New display name' })),
|
||||||
|
description: Type.Optional(Type.String({ description: 'New description' })),
|
||||||
|
systemPrompt: Type.Optional(Type.String({ description: 'New system prompt for workers' })),
|
||||||
|
capabilities: Type.Optional(Type.Array(Type.String(), { description: 'New capabilities list' })),
|
||||||
|
color: Type.Optional(Type.String({ description: 'New hex color' })),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params: any) {
|
||||||
|
try {
|
||||||
|
const { id, name, description, systemPrompt, capabilities, color } = params;
|
||||||
|
|
||||||
|
if (!id || typeof id !== 'string') {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: 'Error: id is required.' }],
|
||||||
|
isError: true,
|
||||||
|
details: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if agent type exists
|
||||||
|
const agentTypes = await getAgentTypes(projectRoot);
|
||||||
|
const existing = agentTypes.find(a => a.id === id);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: `Error: Agent type "${id}" not found.` }],
|
||||||
|
isError: true,
|
||||||
|
details: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge with existing values
|
||||||
|
const updated = await saveAgentType({
|
||||||
|
id,
|
||||||
|
name: name ?? existing.name,
|
||||||
|
description: description ?? existing.description,
|
||||||
|
systemPrompt: systemPrompt ?? existing.systemPrompt,
|
||||||
|
capabilities: capabilities ?? existing.capabilities,
|
||||||
|
color: color ?? existing.color,
|
||||||
|
isBuiltIn: existing.isBuiltIn,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: 'text',
|
||||||
|
text: `Agent type updated successfully!
|
||||||
|
|
||||||
|
ID: ${updated.id}
|
||||||
|
Name: ${updated.name}
|
||||||
|
Capabilities: ${updated.capabilities.join(', ')}`,
|
||||||
|
}],
|
||||||
|
details: { agentType: updated },
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: `Failed to update agent type: ${message}` }],
|
||||||
|
isError: true,
|
||||||
|
details: { error: message },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
74
src/tui/tools/bb-update-archetype.ts
Normal file
74
src/tui/tools/bb-update-archetype.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
import { Type } from '@sinclair/typebox';
|
||||||
|
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
||||||
|
import { saveArchetype, getArchetypes } from '../../lib/server/beads-fs';
|
||||||
|
|
||||||
|
export function createUpdateArchetypeTool(projectRoot: string): ToolDefinition {
|
||||||
|
return {
|
||||||
|
name: 'bb_update_archetype',
|
||||||
|
label: 'Update Archetype',
|
||||||
|
description: 'Update an existing archetype. Provide the ID and fields to update. Cannot modify built-in archetypes.',
|
||||||
|
parameters: Type.Object({
|
||||||
|
id: Type.String({ description: 'Archetype ID to update' }),
|
||||||
|
name: Type.Optional(Type.String({ description: 'New display name' })),
|
||||||
|
description: Type.Optional(Type.String({ description: 'New description' })),
|
||||||
|
systemPrompt: Type.Optional(Type.String({ description: 'New system prompt for workers' })),
|
||||||
|
capabilities: Type.Optional(Type.Array(Type.String(), { description: 'New capabilities list' })),
|
||||||
|
color: Type.Optional(Type.String({ description: 'New hex color' })),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params: any) {
|
||||||
|
try {
|
||||||
|
const { id, name, description, systemPrompt, capabilities, color } = params;
|
||||||
|
|
||||||
|
if (!id || typeof id !== 'string') {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: 'Error: id is required.' }],
|
||||||
|
isError: true,
|
||||||
|
details: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if archetype exists and get current values
|
||||||
|
const archetypes = await getArchetypes();
|
||||||
|
const existing = archetypes.find(a => a.id === id);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: `Error: Archetype "${id}" not found.` }],
|
||||||
|
isError: true,
|
||||||
|
details: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge with existing values
|
||||||
|
const updated = await saveArchetype({
|
||||||
|
id,
|
||||||
|
name: name ?? existing.name,
|
||||||
|
description: description ?? existing.description,
|
||||||
|
systemPrompt: systemPrompt ?? existing.systemPrompt,
|
||||||
|
capabilities: capabilities ?? existing.capabilities,
|
||||||
|
color: color ?? existing.color,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: 'text',
|
||||||
|
text: `Archetype updated successfully!
|
||||||
|
|
||||||
|
ID: ${updated.id}
|
||||||
|
Name: ${updated.name}
|
||||||
|
Description: ${updated.description}
|
||||||
|
Capabilities: ${updated.capabilities.join(', ')}`,
|
||||||
|
}],
|
||||||
|
details: { archetype: updated },
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: `Failed to update archetype: ${message}` }],
|
||||||
|
isError: true,
|
||||||
|
details: { error: message },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
96
src/tui/tools/bb-update-template.ts
Normal file
96
src/tui/tools/bb-update-template.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
import { Type } from '@sinclair/typebox';
|
||||||
|
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
||||||
|
import { saveTemplate, getTemplates, getAgentTypes } from '../../lib/server/beads-fs';
|
||||||
|
|
||||||
|
const TeamMemberSchema = Type.Object({
|
||||||
|
agentTypeId: Type.Optional(Type.String({ description: 'Agent type ID for this team member' })),
|
||||||
|
archetypeId: Type.Optional(Type.String({ description: 'DEPRECATED: Use agentTypeId instead' })),
|
||||||
|
count: Type.Number({ description: 'Number of workers of this type' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export function createUpdateTemplateTool(projectRoot: string): ToolDefinition {
|
||||||
|
return {
|
||||||
|
name: 'bb_update_template',
|
||||||
|
label: 'Update Swarm Template',
|
||||||
|
description: 'Update an existing swarm template. Provide the ID and fields to update.',
|
||||||
|
parameters: Type.Object({
|
||||||
|
id: Type.String({ description: 'Template ID to update' }),
|
||||||
|
name: Type.Optional(Type.String({ description: 'New display name' })),
|
||||||
|
description: Type.Optional(Type.String({ description: 'New description' })),
|
||||||
|
team: Type.Optional(Type.Array(TeamMemberSchema, { description: 'New team composition' })),
|
||||||
|
color: Type.Optional(Type.String({ description: 'New hex color' })),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params: any) {
|
||||||
|
try {
|
||||||
|
const { id, name, description, team, color } = params;
|
||||||
|
|
||||||
|
if (!id || typeof id !== 'string') {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: 'Error: id is required.' }],
|
||||||
|
isError: true,
|
||||||
|
details: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if template exists
|
||||||
|
const templates = await getTemplates();
|
||||||
|
const existing = templates.find(t => t.id === id);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: `Error: Template "${id}" not found.` }],
|
||||||
|
isError: true,
|
||||||
|
details: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate agent type IDs if team is being updated
|
||||||
|
if (team) {
|
||||||
|
const agentTypes = await getAgentTypes();
|
||||||
|
const validIds = new Set(agentTypes.map(a => a.id));
|
||||||
|
|
||||||
|
for (const member of team) {
|
||||||
|
const agentTypeId = member.agentTypeId || member.archetypeId;
|
||||||
|
if (!agentTypeId || !validIds.has(agentTypeId)) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: `Error: Unknown agent type "${agentTypeId}".` }],
|
||||||
|
isError: true,
|
||||||
|
details: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge with existing values
|
||||||
|
const updated = await saveTemplate({
|
||||||
|
id,
|
||||||
|
name: name ?? existing.name,
|
||||||
|
description: description ?? existing.description,
|
||||||
|
team: team ?? existing.team,
|
||||||
|
color: color ?? existing.color,
|
||||||
|
});
|
||||||
|
|
||||||
|
const teamDesc = updated.team.map(m => `${m.count}x ${m.agentTypeId}`).join(', ');
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: 'text',
|
||||||
|
text: `Swarm template updated successfully!
|
||||||
|
|
||||||
|
ID: ${updated.id}
|
||||||
|
Name: ${updated.name}
|
||||||
|
Team: ${teamDesc}`,
|
||||||
|
}],
|
||||||
|
details: { template: updated },
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: `Failed to update template: ${message}` }],
|
||||||
|
isError: true,
|
||||||
|
details: { error: message },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
116
src/tui/tools/bb-worker-results.ts
Normal file
116
src/tui/tools/bb-worker-results.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
import { Type } from '@sinclair/typebox';
|
||||||
|
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
||||||
|
import { workerSessionManager } from '../../lib/worker-session-manager';
|
||||||
|
import { execFileSync } from 'child_process';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get results from completed workers.
|
||||||
|
*
|
||||||
|
* For each completed worker, returns:
|
||||||
|
* - Worker metadata (display name, type, status)
|
||||||
|
* - Bead summary (from close reason)
|
||||||
|
* - Suggestion to read actual files for verification
|
||||||
|
*/
|
||||||
|
export function createWorkerResultsTool(projectRoot: string): ToolDefinition {
|
||||||
|
return {
|
||||||
|
name: 'bb_worker_results',
|
||||||
|
label: 'Get Worker Results',
|
||||||
|
description: `Get results from completed workers. Shows bead summary and suggests files to read.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
- bb_worker_results() - get all completed worker results
|
||||||
|
- bb_worker_results(worker_ids: ["worker-123", "worker-456"]) - specific workers
|
||||||
|
|
||||||
|
After getting results, READ THE ACTUAL FILES to verify and understand the work.
|
||||||
|
The bead summary is high-level; the files show the real implementation.`,
|
||||||
|
parameters: Type.Object({
|
||||||
|
worker_ids: Type.Optional(Type.Array(Type.String(), {
|
||||||
|
description: 'Optional: specific worker IDs to get results for. If not provided, returns all completed workers.'
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId: string, params: unknown): Promise<any> {
|
||||||
|
const { worker_ids } = params as { worker_ids?: string[] };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const workers = workerSessionManager.getAllWorkers();
|
||||||
|
|
||||||
|
// Filter to completed workers
|
||||||
|
let completedWorkers = workers.filter(w => w.status === 'completed' || w.status === 'failed');
|
||||||
|
|
||||||
|
// Filter to specific IDs if provided
|
||||||
|
if (worker_ids && worker_ids.length > 0) {
|
||||||
|
completedWorkers = completedWorkers.filter(w => worker_ids.includes(w.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (completedWorkers.length === 0) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: 'No completed workers found. Use bb_worker_status to see active workers.' }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build results by reading beads
|
||||||
|
const results: string[] = [];
|
||||||
|
|
||||||
|
for (const worker of completedWorkers) {
|
||||||
|
const statusIcon = worker.status === 'completed' ? '✓' : '✗';
|
||||||
|
const errorSection = worker.error ? `\n Error: ${worker.error}` : '';
|
||||||
|
|
||||||
|
// Try to read the bead for summary
|
||||||
|
let beadSummary = '';
|
||||||
|
if (worker.beadId) {
|
||||||
|
try {
|
||||||
|
const beadOutput = execFileSync('bd', ['show', worker.beadId], {
|
||||||
|
cwd: projectRoot,
|
||||||
|
encoding: 'utf-8',
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Extract close reason or notes
|
||||||
|
const lines = beadOutput.split('\n');
|
||||||
|
const closeReasonLine = lines.find(l => l.toLowerCase().includes('close') || l.toLowerCase().includes('reason'));
|
||||||
|
const notesLine = lines.find(l => l.toLowerCase().includes('notes'));
|
||||||
|
|
||||||
|
if (closeReasonLine) {
|
||||||
|
beadSummary = `\n Summary: ${closeReasonLine.split(':').slice(1).join(':').trim()}`;
|
||||||
|
} else if (notesLine) {
|
||||||
|
beadSummary = `\n Notes: ${notesLine.split(':').slice(1).join(':').trim()}`;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
beadSummary = `\n Bead: ${worker.beadId} (could not read details)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push(`${statusIcon} **${worker.displayName || worker.id}** (${worker.agentTypeId || 'default'})
|
||||||
|
Status: ${worker.status}
|
||||||
|
Bead: ${worker.beadId || 'none'}${beadSummary}${errorSection}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = `## Worker Results (${completedWorkers.length} completed)
|
||||||
|
|
||||||
|
${results.join('\n\n')}
|
||||||
|
|
||||||
|
---
|
||||||
|
**Next step:** Read the actual files the workers touched to verify and understand the implementation. Use the read tool on relevant files.`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: summary }],
|
||||||
|
details: {
|
||||||
|
workers: completedWorkers.map(w => ({
|
||||||
|
id: w.id,
|
||||||
|
displayName: w.displayName,
|
||||||
|
beadId: w.beadId,
|
||||||
|
status: w.status,
|
||||||
|
agentTypeId: w.agentTypeId,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: `Failed to get worker results: ${message}` }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
107
src/tui/tools/bb-worker-status.ts
Normal file
107
src/tui/tools/bb-worker-status.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
import { Type } from '@sinclair/typebox';
|
||||||
|
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
||||||
|
import { workerSessionManager } from '../../lib/worker-session-manager';
|
||||||
|
|
||||||
|
export function createWorkerStatusTool(projectRoot: string): ToolDefinition {
|
||||||
|
return {
|
||||||
|
name: 'bb_worker_status',
|
||||||
|
label: 'Check Worker Status',
|
||||||
|
description: 'Check the status of a spawned worker agent. Use this to see if a worker is still working, completed successfully, or failed.',
|
||||||
|
parameters: Type.Object({
|
||||||
|
worker_id: Type.String({ description: 'The ID of the worker to check (returned by bb_spawn_worker)' }),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params: any) {
|
||||||
|
try {
|
||||||
|
const { worker_id } = params;
|
||||||
|
|
||||||
|
console.log(`[bb_worker_status] Checking status for worker ${worker_id}`);
|
||||||
|
|
||||||
|
// Validate required param
|
||||||
|
if (!worker_id || typeof worker_id !== 'string') {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: 'Error: worker_id is required and must be a string.' }],
|
||||||
|
isError: true,
|
||||||
|
details: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get worker from manager
|
||||||
|
const worker = workerSessionManager.getWorker(worker_id);
|
||||||
|
|
||||||
|
if (!worker) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: `Worker ${worker_id} not found. It may not exist or has already been cleaned up.` }],
|
||||||
|
isError: true,
|
||||||
|
details: { workerId: worker_id },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build status report
|
||||||
|
const statusEmoji = {
|
||||||
|
spawning: '🔄',
|
||||||
|
working: '🔨',
|
||||||
|
completed: '✅',
|
||||||
|
failed: '❌',
|
||||||
|
};
|
||||||
|
|
||||||
|
let statusMessage = `${statusEmoji[worker.status]} ${worker.status.toUpperCase()}`;
|
||||||
|
|
||||||
|
let details = `Task: ${worker.taskId}
|
||||||
|
Created: ${worker.createdAt}
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (worker.completedAt) {
|
||||||
|
details += `Completed: ${worker.completedAt}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (worker.result) {
|
||||||
|
details += `
|
||||||
|
Result:
|
||||||
|
${worker.result}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (worker.error) {
|
||||||
|
details += `
|
||||||
|
Error: ${worker.error}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If working, add hint about waiting
|
||||||
|
if (worker.status === 'working') {
|
||||||
|
details += `
|
||||||
|
Worker is currently executing. Check again later for completion status.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If completed, offer to get more details
|
||||||
|
if (worker.status === 'completed') {
|
||||||
|
details += `
|
||||||
|
Use this worker's result in your orchestration decisions. The worker has reported back and is no longer active.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: statusMessage }],
|
||||||
|
details: {
|
||||||
|
workerId: worker.id,
|
||||||
|
taskId: worker.taskId,
|
||||||
|
status: worker.status,
|
||||||
|
createdAt: worker.createdAt,
|
||||||
|
completedAt: worker.completedAt,
|
||||||
|
hasResult: !!worker.result,
|
||||||
|
hasError: !!worker.error,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error(`[bb_worker_status] Error:`, message);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: `Failed to check worker status: ${message}` }],
|
||||||
|
isError: true,
|
||||||
|
details: { error: message },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
9
src/tui/tools/types.ts
Normal file
9
src/tui/tools/types.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
/**
|
||||||
|
* Re-export Pi SDK tool types for BeadBoard TUI tools.
|
||||||
|
*
|
||||||
|
* We use ToolDefinition from the Pi SDK, which requires:
|
||||||
|
* - name, label, description, parameters (TypeBox schema)
|
||||||
|
* - execute(toolCallId, params, signal, onUpdate, ctx) => Promise<AgentToolResult>
|
||||||
|
*/
|
||||||
|
export type { ToolDefinition as CustomAgentTool, AgentToolResult, ExtensionContext } from '@mariozechner/pi-coding-agent';
|
||||||
|
export { Type } from '@sinclair/typebox';
|
||||||
Loading…
Add table
Add a link
Reference in a new issue