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/
|
||||
.gemini/
|
||||
.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
|
||||
```
|
||||
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"
|
||||
},
|
||||
"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-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
|
|
@ -32,6 +35,7 @@
|
|||
"@remotion/google-fonts": "^4.0.422",
|
||||
"@remotion/tailwind": "^4.0.422",
|
||||
"@remotion/zod-types": "^4.0.422",
|
||||
"@sinclair/typebox": "^0.34.48",
|
||||
"@xyflow/react": "^12.10.0",
|
||||
"chokidar": "^5.0.0",
|
||||
"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 }) {
|
||||
return (
|
||||
<html lang="en" data-theme="aurora">
|
||||
<body className={notoSans.variable}>{children}</body>
|
||||
<html lang="en" data-theme="aurora" suppressHydrationWarning>
|
||||
<body className={notoSans.variable} suppressHydrationWarning>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,14 @@
|
|||
import { Suspense } from 'react';
|
||||
import { UnifiedShell } from '../components/shared/unified-shell';
|
||||
import { OnboardingWizard } from '../components/onboarding/onboarding-wizard';
|
||||
import { readIssuesForScope } from '../lib/aggregate-read';
|
||||
import { resolveProjectScope } from '../lib/project-scope';
|
||||
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';
|
||||
|
||||
|
|
@ -10,10 +16,45 @@ interface PageProps {
|
|||
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) {
|
||||
const params = (await searchParams) ?? {};
|
||||
const requestedProjectKey = typeof params.project === 'string' ? params.project : 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 scope = resolveProjectScope({
|
||||
currentProjectRoot: process.cwd(),
|
||||
|
|
@ -30,6 +71,22 @@ export default async function Page({ searchParams }: PageProps) {
|
|||
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 (
|
||||
<Suspense>
|
||||
<UnifiedShell
|
||||
|
|
|
|||
|
|
@ -25,6 +25,10 @@ import {
|
|||
statusAgentReservations,
|
||||
type ReservationCommandResponse,
|
||||
} 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 = {
|
||||
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 {
|
||||
const raw = (env.BB_RUNTIME_VERSION || env.npm_package_version || '0.1.0').trim();
|
||||
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);
|
||||
}
|
||||
|
||||
if (command === 'daemon') {
|
||||
const subArgs = commandIndex >= 0 ? args.slice(commandIndex + 1) : [];
|
||||
return runDaemonCli(subArgs, asJson);
|
||||
}
|
||||
|
||||
if (command === 'doctor') {
|
||||
return {
|
||||
ok: true,
|
||||
|
|
@ -343,7 +448,8 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|||
return {
|
||||
ok: true,
|
||||
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 open Open BeadBoard in browser',
|
||||
' 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/...)',
|
||||
'',
|
||||
'Management Commands:',
|
||||
|
|
@ -371,6 +478,24 @@ function renderHelpText(): string {
|
|||
async function main() {
|
||||
const argv = process.argv.slice(2);
|
||||
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);
|
||||
if (!asJson && result.command === 'help') {
|
||||
process.stdout.write(`${renderHelpText()}\n`);
|
||||
|
|
|
|||
|
|
@ -24,24 +24,24 @@ export type EventTone = {
|
|||
idClass: string;
|
||||
};
|
||||
|
||||
interface AgentRosterEntry {
|
||||
name: string;
|
||||
status: AgentStatus;
|
||||
lastSeen: string | null;
|
||||
beadId: string;
|
||||
}
|
||||
|
||||
interface CoordMessage {
|
||||
message_id: string;
|
||||
bead_id: string;
|
||||
from_agent: string;
|
||||
to_agent: string;
|
||||
category: 'HANDOFF' | 'BLOCKED' | 'DECISION' | 'INFO';
|
||||
subject: string;
|
||||
state: 'unread' | 'read' | 'acked';
|
||||
created_at: string;
|
||||
acked_at: string | null;
|
||||
}
|
||||
interface AgentRosterEntry {
|
||||
name: string;
|
||||
status: AgentStatus;
|
||||
lastSeen: string | null;
|
||||
beadId: string;
|
||||
}
|
||||
|
||||
interface CoordMessage {
|
||||
message_id: string;
|
||||
bead_id: string;
|
||||
from_agent: string;
|
||||
to_agent: string;
|
||||
category: 'HANDOFF' | 'BLOCKED' | 'DECISION' | 'INFO';
|
||||
subject: string;
|
||||
state: 'unread' | 'read' | 'acked';
|
||||
created_at: string;
|
||||
acked_at: string | null;
|
||||
}
|
||||
|
||||
interface ActivityPanelProps {
|
||||
issues: BeadIssue[];
|
||||
|
|
@ -51,6 +51,21 @@ interface ActivityPanelProps {
|
|||
|
||||
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
|
||||
function deriveAgentStatus(lastSeenAt: string | null): AgentStatus {
|
||||
if (!lastSeenAt) return 'dead';
|
||||
|
|
@ -152,23 +167,23 @@ function getAgentTone(status: AgentStatus): AgentTone {
|
|||
}
|
||||
|
||||
// reopened=blue, closed=amber, created/opened=green, others semantic
|
||||
export function getEventTone(kind: string): EventTone {
|
||||
const normalized = kind.toLowerCase();
|
||||
const byKind: Record<string, EventTone> = {
|
||||
coord_send: {
|
||||
label: 'Coord Send',
|
||||
labelClass: 'text-[#D4A574]',
|
||||
dotClass: 'bg-[#D4A574]',
|
||||
cardClass: 'bg-[var(--status-in-progress)]',
|
||||
idClass: 'text-[#DAB891]',
|
||||
},
|
||||
coord_ack: {
|
||||
label: 'Coord Ack',
|
||||
labelClass: 'text-[#7CB97A]',
|
||||
dotClass: 'bg-[#7CB97A]',
|
||||
cardClass: 'bg-[var(--status-ready)]',
|
||||
idClass: 'text-[#9ACB98]',
|
||||
},
|
||||
export function getEventTone(kind: string): EventTone {
|
||||
const normalized = kind.toLowerCase();
|
||||
const byKind: Record<string, EventTone> = {
|
||||
coord_send: {
|
||||
label: 'Coord Send',
|
||||
labelClass: 'text-[#D4A574]',
|
||||
dotClass: 'bg-[#D4A574]',
|
||||
cardClass: 'bg-[var(--status-in-progress)]',
|
||||
idClass: 'text-[#DAB891]',
|
||||
},
|
||||
coord_ack: {
|
||||
label: 'Coord Ack',
|
||||
labelClass: 'text-[#7CB97A]',
|
||||
dotClass: 'bg-[#7CB97A]',
|
||||
cardClass: 'bg-[var(--status-ready)]',
|
||||
idClass: 'text-[#9ACB98]',
|
||||
},
|
||||
created: {
|
||||
label: 'Created',
|
||||
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);
|
||||
}
|
||||
|
||||
export function ActivityPanel({ issues, collapsed = false, projectRoot }: ActivityPanelProps) {
|
||||
const [activities, setActivities] = useState<ActivityEvent[]>([]);
|
||||
const [coordActivities, setCoordActivities] = useState<ActivityEvent[]>([]);
|
||||
const [reservationByAgent, setReservationByAgent] = useState<Record<string, string | undefined>>({});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
export function ActivityPanel({ issues, collapsed = false, projectRoot }: ActivityPanelProps) {
|
||||
const [activities, setActivities] = useState<ActivityEvent[]>([]);
|
||||
const [coordActivities, setCoordActivities] = useState<ActivityEvent[]>([]);
|
||||
const [reservationByAgent, setReservationByAgent] = useState<Record<string, string | undefined>>({});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const agentRoster = useMemo(() => buildAgentRoster(issues), [issues]);
|
||||
|
||||
// Fetch activity history
|
||||
useEffect(() => {
|
||||
async function fetchActivity() {
|
||||
// Fetch activity history
|
||||
useEffect(() => {
|
||||
async function fetchActivity() {
|
||||
try {
|
||||
const response = await fetch('/api/activity');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setActivities(data.slice(0, 50)); // Limit to 50 events
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ActivityPanel] Failed to fetch activity:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchActivity();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCoordination = async () => {
|
||||
if (agentRoster.length === 0) {
|
||||
setCoordActivities([]);
|
||||
setReservationByAgent({});
|
||||
return;
|
||||
}
|
||||
|
||||
// Use batch endpoints to reduce API calls from 2N to 2
|
||||
const agentNames = agentRoster.map(a => a.name).join(',');
|
||||
|
||||
const [mailResponse, reservationsResponse] = await Promise.all([
|
||||
fetch(`/api/agents/mail/batch?agents=${encodeURIComponent(agentNames)}&limit=15`),
|
||||
fetch(`/api/agents/reservations/batch?agents=${encodeURIComponent(agentNames)}`),
|
||||
]);
|
||||
|
||||
const mailPayload = await mailResponse.json().catch(() => ({ ok: false, data: [] }));
|
||||
const reservationsPayload = await reservationsResponse.json().catch(() => ({ ok: false, data: [] }));
|
||||
|
||||
// Collect all messages from all agents
|
||||
const uniqueMessages = new Map<string, CoordMessage>();
|
||||
if (mailPayload.ok && mailPayload.data) {
|
||||
for (const entry of mailPayload.data) {
|
||||
for (const message of (entry.messages ?? [])) {
|
||||
uniqueMessages.set(message.message_id, message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mapped = [...uniqueMessages.values()]
|
||||
.map((message) => ({
|
||||
id: `coord-${message.message_id}`,
|
||||
kind: (message.state === 'acked' ? 'coord_ack' : 'coord_send') as ActivityEvent['kind'],
|
||||
beadId: message.bead_id,
|
||||
beadTitle: `${message.category}: ${message.subject}`,
|
||||
timestamp: message.state === 'acked' && message.acked_at ? message.acked_at : message.created_at,
|
||||
actor: message.state === 'acked' ? message.to_agent : message.from_agent,
|
||||
projectId: projectRoot,
|
||||
projectName: 'beadboard',
|
||||
payload: {},
|
||||
}))
|
||||
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
|
||||
.slice(0, 25);
|
||||
|
||||
// Build reservation map
|
||||
const reservationMap: Record<string, string | undefined> = {};
|
||||
if (reservationsPayload.ok && reservationsPayload.data) {
|
||||
for (const entry of reservationsPayload.data) {
|
||||
reservationMap[entry.agent] = entry.scope;
|
||||
}
|
||||
}
|
||||
|
||||
setCoordActivities(mapped);
|
||||
setReservationByAgent(reservationMap);
|
||||
};
|
||||
|
||||
void fetchCoordination();
|
||||
const timer = setInterval(() => {
|
||||
void fetchCoordination();
|
||||
}, 15000);
|
||||
return () => clearInterval(timer);
|
||||
}, [agentRoster, projectRoot]);
|
||||
setActivities((prev) => mergeUniqueActivities(prev, data));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ActivityPanel] Failed to fetch activity:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchActivity();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCoordination = async () => {
|
||||
if (agentRoster.length === 0) {
|
||||
setCoordActivities([]);
|
||||
setReservationByAgent({});
|
||||
return;
|
||||
}
|
||||
|
||||
// Use batch endpoints to reduce API calls from 2N to 2
|
||||
const agentNames = agentRoster.map(a => a.name).join(',');
|
||||
|
||||
const [mailResponse, reservationsResponse] = await Promise.all([
|
||||
fetch(`/api/agents/mail/batch?agents=${encodeURIComponent(agentNames)}&limit=15`),
|
||||
fetch(`/api/agents/reservations/batch?agents=${encodeURIComponent(agentNames)}`),
|
||||
]);
|
||||
|
||||
const mailPayload = await mailResponse.json().catch(() => ({ ok: false, data: [] }));
|
||||
const reservationsPayload = await reservationsResponse.json().catch(() => ({ ok: false, data: [] }));
|
||||
|
||||
// Collect all messages from all agents
|
||||
const uniqueMessages = new Map<string, CoordMessage>();
|
||||
if (mailPayload.ok && mailPayload.data) {
|
||||
for (const entry of mailPayload.data) {
|
||||
for (const message of (entry.messages ?? [])) {
|
||||
uniqueMessages.set(message.message_id, message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mapped = [...uniqueMessages.values()]
|
||||
.map((message) => ({
|
||||
id: `coord-${message.message_id}`,
|
||||
kind: (message.state === 'acked' ? 'coord_ack' : 'coord_send') as ActivityEvent['kind'],
|
||||
beadId: message.bead_id,
|
||||
beadTitle: `${message.category}: ${message.subject}`,
|
||||
timestamp: message.state === 'acked' && message.acked_at ? message.acked_at : message.created_at,
|
||||
actor: message.state === 'acked' ? message.to_agent : message.from_agent,
|
||||
projectId: projectRoot,
|
||||
projectName: 'beadboard',
|
||||
payload: {},
|
||||
}))
|
||||
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
|
||||
.slice(0, 25);
|
||||
|
||||
// Build reservation map
|
||||
const reservationMap: Record<string, string | undefined> = {};
|
||||
if (reservationsPayload.ok && reservationsPayload.data) {
|
||||
for (const entry of reservationsPayload.data) {
|
||||
reservationMap[entry.agent] = entry.scope;
|
||||
}
|
||||
}
|
||||
|
||||
setCoordActivities(mapped);
|
||||
setReservationByAgent(reservationMap);
|
||||
};
|
||||
|
||||
void fetchCoordination();
|
||||
const timer = setInterval(() => {
|
||||
void fetchCoordination();
|
||||
}, 15000);
|
||||
return () => clearInterval(timer);
|
||||
}, [agentRoster, projectRoot]);
|
||||
|
||||
// Subscribe to real-time activity
|
||||
useEffect(() => {
|
||||
|
|
@ -371,7 +386,7 @@ export function ActivityPanel({ issues, collapsed = false, projectRoot }: Activi
|
|||
console.log('[ActivityPanel] Received activity event:', data);
|
||||
// data IS the activity event directly (not wrapped in { event: ... })
|
||||
if (data?.beadId) {
|
||||
setActivities(prev => [data, ...prev].slice(0, 50));
|
||||
setActivities(prev => mergeUniqueActivities(prev, [data]));
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore parse errors
|
||||
|
|
@ -387,13 +402,11 @@ export function ActivityPanel({ issues, collapsed = false, projectRoot }: Activi
|
|||
};
|
||||
}, [projectRoot]);
|
||||
|
||||
const activeAgents = agentRoster.filter(a => a.status === 'active').length;
|
||||
const mergedActivities = useMemo(
|
||||
() => [...coordActivities, ...activities]
|
||||
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
|
||||
.slice(0, 50),
|
||||
[activities, coordActivities],
|
||||
);
|
||||
const activeAgents = agentRoster.filter(a => a.status === 'active').length;
|
||||
const mergedActivities = useMemo(
|
||||
() => mergeUniqueActivities(coordActivities, activities),
|
||||
[activities, coordActivities],
|
||||
);
|
||||
if (collapsed) {
|
||||
return (
|
||||
<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 */}
|
||||
<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(
|
||||
"w-1 h-1 rounded-full",
|
||||
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">
|
||||
<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">
|
||||
<span className={cn(
|
||||
"text-[9px] uppercase tracking-wider font-bold",
|
||||
getAgentTone(agent.status).labelClass
|
||||
)}>
|
||||
{agent.status}
|
||||
</span>
|
||||
{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]}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="text-[9px] text-text-muted/40 font-mono">
|
||||
{agent.lastSeen ? formatRelativeTime(agent.lastSeen) : 'N/A'}
|
||||
</span>
|
||||
<span className={cn(
|
||||
"text-[9px] uppercase tracking-wider font-bold",
|
||||
getAgentTone(agent.status).labelClass
|
||||
)}>
|
||||
{agent.status}
|
||||
</span>
|
||||
{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]}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="text-[9px] text-text-muted/40 font-mono">
|
||||
{agent.lastSeen ? formatRelativeTime(agent.lastSeen) : 'N/A'}
|
||||
</span>
|
||||
</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" />
|
||||
<span className="text-[10px] font-mono text-text-muted">SYNCING...</span>
|
||||
</div>
|
||||
) : mergedActivities.length === 0 ? (
|
||||
) : mergedActivities.length === 0 ? (
|
||||
<div className="p-10 text-center opacity-30">
|
||||
<p className="text-[10px] font-mono">VOID_STREAM_NULL</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-3 space-y-3">
|
||||
{mergedActivities.map((activity) => {
|
||||
{mergedActivities.map((activity) => {
|
||||
const eventTone = getEventTone(activity.kind);
|
||||
return (
|
||||
<div key={activity.id} className="group relative">
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { ActivityPanel } from './activity-panel';
|
|||
import { SwarmCommandFeed } from './swarm-command-feed';
|
||||
import { ThreadDrawer } from '../shared/thread-drawer';
|
||||
import { MissionInspector } from '../mission/mission-inspector';
|
||||
import { AgentStatusPanel } from '../agents/agent-status-panel';
|
||||
import { useSwarmList } from '../../hooks/use-swarm-list';
|
||||
import { useUrlState } from '../../hooks/use-url-state';
|
||||
|
||||
|
|
@ -58,6 +59,10 @@ export function ContextualRightPanel({ epicId, taskId, swarmId, issues, projectR
|
|||
</button>
|
||||
</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">
|
||||
<SwarmCommandFeed
|
||||
epicId={epicId}
|
||||
|
|
@ -116,13 +121,21 @@ function SwarmIdBranch({ swarmId, projectRoot }: { swarmId: string; projectRoot:
|
|||
const assignedAgents = swarm?.agents ?? [];
|
||||
|
||||
return (
|
||||
<MissionInspector
|
||||
missionId={swarmId}
|
||||
missionTitle={missionTitle}
|
||||
projectRoot={projectRoot}
|
||||
assignedAgents={assignedAgents}
|
||||
onClose={() => setSwarmId(null)}
|
||||
onAssign={async () => {}}
|
||||
/>
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
{/* Agent Status for active swarm */}
|
||||
<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">
|
||||
<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 { 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 { ArchetypePicker } from '../swarm/archetype-picker';
|
||||
import { AgentPicker } from '../swarm/agent-picker';
|
||||
import { TemplatePicker } from '../swarm/template-picker';
|
||||
import { useArchetypes } from '../../hooks/use-archetypes';
|
||||
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 => {
|
||||
return template.team.filter(member => member.archetypeId === archetypeId).length;
|
||||
const getArchetypeCountInTeam = (template: SwarmTemplate, agentTypeId: string): number => {
|
||||
return template.team.filter(member => member.agentTypeId === agentTypeId).length;
|
||||
};
|
||||
|
||||
const renderTaskItem = (issue: BeadIssue, showAssignButton: boolean = false, archetypeBadges: AgentArchetype[] = []) => (
|
||||
|
|
@ -290,7 +290,7 @@ export function AssignmentPanel({ selectedIssue, projectRoot, issues, epicId, on
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<ArchetypePicker
|
||||
<AgentPicker
|
||||
archetypes={archetypes}
|
||||
isOpen={showArchetypeList}
|
||||
onClose={() => setShowArchetypeList(false)}
|
||||
|
|
@ -361,12 +361,12 @@ export function AssignmentPanel({ selectedIssue, projectRoot, issues, epicId, on
|
|||
<div>
|
||||
<div className="text-[10px] font-mono text-[var(--text-tertiary)] uppercase tracking-wider mb-2">Team Roster</div>
|
||||
<div className="space-y-1">
|
||||
{Array.from(new Set(epicTemplate.team.map(m => m.archetypeId))).map(archetypeId => {
|
||||
const archetype = archetypes.find((a: AgentArchetype) => a.id === archetypeId);
|
||||
const count = getArchetypeCountInTeam(epicTemplate, archetypeId);
|
||||
{Array.from(new Set(epicTemplate.team.map(m => m.agentTypeId))).map(agentTypeId => {
|
||||
const archetype = archetypes.find((a: AgentArchetype) => a.id === agentTypeId);
|
||||
const count = getArchetypeCountInTeam(epicTemplate, agentTypeId);
|
||||
if (!archetype) return null;
|
||||
return (
|
||||
<div key={archetypeId} className="flex items-center gap-2 text-xs">
|
||||
<div key={agentTypeId} className="flex items-center gap-2 text-xs">
|
||||
<div
|
||||
className="h-4 w-4 rounded flex items-center justify-center text-[10px] font-bold"
|
||||
style={{
|
||||
|
|
@ -557,7 +557,7 @@ export function AssignmentPanel({ selectedIssue, projectRoot, issues, epicId, on
|
|||
</div>
|
||||
|
||||
{inspectingArchetypeId !== null && (
|
||||
<ArchetypeInspector
|
||||
<AgentInspector
|
||||
archetype={archetypes.find((a: AgentArchetype) => a.id === inspectingArchetypeId)}
|
||||
onClose={() => setInspectingArchetypeId(null)}
|
||||
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 { 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 { 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 LeftPanelPriorityFilter = 'all' | 'P0' | 'P1' | 'P2' | 'P3' | 'P4';
|
||||
|
|
@ -27,9 +29,14 @@ export interface LeftPanelProps {
|
|||
filters: LeftPanelFilters;
|
||||
onFiltersChange: (filters: LeftPanelFilters) => void;
|
||||
onAssignMode?: (epicId: string) => void;
|
||||
sidebarMode?: LeftSidebarMode;
|
||||
onSidebarModeChange?: (mode: LeftSidebarMode) => void;
|
||||
orchestrator?: RuntimeInstance;
|
||||
orchestratorThread?: RuntimeConsoleEvent[];
|
||||
projectRoot?: string;
|
||||
}
|
||||
|
||||
interface EpicEntry {
|
||||
interface EpicEntry {
|
||||
epic: BeadIssue;
|
||||
children: BeadIssue[];
|
||||
blockedCount: number;
|
||||
|
|
@ -38,34 +45,34 @@ interface EpicEntry {
|
|||
deferredCount: number;
|
||||
doneCount: number;
|
||||
agentBlockedCount: number;
|
||||
latestTimestamp: string;
|
||||
}
|
||||
|
||||
export 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);
|
||||
}
|
||||
latestTimestamp: string;
|
||||
}
|
||||
|
||||
export 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);
|
||||
}
|
||||
|
||||
function mapStatus(task: BeadIssue): LeftPanelStatusFilter {
|
||||
if (task.status === 'open') return 'ready';
|
||||
|
|
@ -200,12 +207,24 @@ function isTaskMatch(task: BeadIssue, filters: LeftPanelFilters): boolean {
|
|||
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 entries = useMemo(() => buildEntries(issues), [issues]);
|
||||
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: 'graph', label: 'Graph' },
|
||||
];
|
||||
|
|
@ -316,11 +335,40 @@ export function LeftPanel({ issues, selectedEpicId, onEpicSelect, onEpicEdit, fi
|
|||
</button>
|
||||
</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 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 {
|
||||
epic,
|
||||
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 rowBackground = rowTone(entry);
|
||||
|
||||
if (shouldHideEpicEntry({
|
||||
epicStatus: epic.status,
|
||||
matchedChildrenCount: matchedChildren.length,
|
||||
totalChildrenCount: total,
|
||||
isSelected,
|
||||
filters,
|
||||
})) {
|
||||
return null;
|
||||
}
|
||||
if (shouldHideEpicEntry({
|
||||
epicStatus: epic.status,
|
||||
matchedChildrenCount: matchedChildren.length,
|
||||
totalChildrenCount: total,
|
||||
isSelected,
|
||||
filters,
|
||||
})) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<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 { X } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import type { BeadIssue } from '../../lib/types';
|
||||
import type { ProjectScopeOption } from '../../lib/project-scope';
|
||||
import type { BeadIssue } from '../../lib/types';
|
||||
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 { LeftPanel, type LeftPanelFilters } from './left-panel';
|
||||
import { LeftPanel, type LeftPanelFilters } from './left-panel-new';
|
||||
import { RightPanel } from './right-panel';
|
||||
import { MobileNav } from './mobile-nav';
|
||||
import { ThreadDrawer } from './thread-drawer';
|
||||
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 { SmartDag } from '../graph/smart-dag';
|
||||
import { SocialPage } from '../social/social-page';
|
||||
import { buildSocialCards } from '../../lib/social-cards';
|
||||
import { ContextualRightPanel } from '../activity/contextual-right-panel';
|
||||
import { AssignmentPanel } from '../graph/assignment-panel';
|
||||
import { TelemetryStrip } from './telemetry-strip';
|
||||
import { useSwarmList } from '../../hooks/use-swarm-list';
|
||||
import { useBeadsSubscription } from '../../hooks/use-beads-subscription';
|
||||
import { useBdHealth } from '../../hooks/use-bd-health';
|
||||
import { BlockedTriageModal } from './blocked-triage-modal';
|
||||
import { deriveBlockedIds } from '../../lib/kanban';
|
||||
import { TelemetryStrip } from './telemetry-strip';
|
||||
import { useSwarmList } from '../../hooks/use-swarm-list';
|
||||
import { useBeadsSubscription } from '../../hooks/use-beads-subscription';
|
||||
import { useBdHealth } from '../../hooks/use-bd-health';
|
||||
import { BlockedTriageModal } from './blocked-triage-modal';
|
||||
import { deriveBlockedIds } from '../../lib/kanban';
|
||||
import { projectOrchestratorChat } from '../../lib/orchestrator-chat';
|
||||
|
||||
export interface UnifiedShellProps {
|
||||
issues: BeadIssue[];
|
||||
|
|
@ -33,13 +36,27 @@ export interface UnifiedShellProps {
|
|||
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({
|
||||
issues: initialIssues,
|
||||
projectRoot,
|
||||
projectScopeOptions,
|
||||
}: UnifiedShellProps) {
|
||||
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
|
||||
const { issues } = useBeadsSubscription(initialIssues, projectRoot);
|
||||
|
|
@ -66,29 +83,32 @@ export function UnifiedShell({
|
|||
}, []);
|
||||
|
||||
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
|
||||
const [assignMode, setAssignMode] = useState(false);
|
||||
const [selectedAssignIssue, setSelectedAssignIssue] = useState<BeadIssue | null>(null);
|
||||
|
||||
// Remember last non-telemetry state for minimize button
|
||||
const [lastTaskId, setLastTaskId] = useState<string | null>(null);
|
||||
const [lastAssignMode, setLastAssignMode] = useState(false);
|
||||
|
||||
// Blocked triage modal state
|
||||
const [blockedTriageOpen, setBlockedTriageOpen] = useState(false);
|
||||
const handleOpenBlockedTriage = useCallback(() => setBlockedTriageOpen(true), []);
|
||||
const handleCloseBlockedTriage = useCallback(() => setBlockedTriageOpen(false), []);
|
||||
// Remember last non-telemetry state for minimize button
|
||||
const [lastTaskId, setLastTaskId] = useState<string | null>(null);
|
||||
const [lastAssignMode, setLastAssignMode] = useState(false);
|
||||
|
||||
const socialCards = useMemo(() => buildSocialCards(issues), [issues]);
|
||||
const blockedIds = useMemo(() => deriveBlockedIds(issues), [issues]);
|
||||
const blockedCount = useMemo(() => {
|
||||
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;
|
||||
// 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]);
|
||||
const blockedIds = useMemo(() => deriveBlockedIds(issues), [issues]);
|
||||
const blockedCount = useMemo(() => {
|
||||
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 selectedIssue = taskId ? issues.find((issue) => issue.id === taskId) ?? null : null;
|
||||
|
||||
|
|
@ -131,6 +151,116 @@ export function UnifiedShell({
|
|||
setAssignMode(true);
|
||||
}, [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)
|
||||
const handleMinimize = useCallback(() => {
|
||||
if (lastTaskId) {
|
||||
|
|
@ -156,23 +286,23 @@ export function UnifiedShell({
|
|||
|
||||
// Chat Mode Logic: If a card is selected (drawer='open'), we show Chat popup
|
||||
const isChatOpen = drawer === 'open' && (!!taskId || !!swarmId || !!epicId);
|
||||
const selectedEpic = epicId ? issues.find((issue) => issue.id === epicId && issue.issue_type === 'epic') ?? null : null;
|
||||
const drawerTitle = selectedSocialCard?.title || selectedSwarmCard?.title || selectedEpic?.title || '';
|
||||
const drawerId = taskId || swarmId || epicId || '';
|
||||
const selectedItem = selectedEpic ?? selectedIssue;
|
||||
|
||||
useEffect(() => {
|
||||
if (!filters.hideClosed || !epicId) {
|
||||
return;
|
||||
}
|
||||
const epic = issues.find((issue) => issue.id === epicId && issue.issue_type === 'epic');
|
||||
if (!epic) {
|
||||
return;
|
||||
}
|
||||
if (epic.status === 'closed' || epic.status === 'tombstone') {
|
||||
setEpicId(null);
|
||||
}
|
||||
}, [filters.hideClosed, epicId, issues, setEpicId]);
|
||||
const selectedEpic = epicId ? issues.find((issue) => issue.id === epicId && issue.issue_type === 'epic') ?? null : null;
|
||||
const drawerTitle = selectedSocialCard?.title || selectedSwarmCard?.title || selectedEpic?.title || '';
|
||||
const drawerId = taskId || swarmId || epicId || '';
|
||||
const selectedItem = selectedEpic ?? selectedIssue;
|
||||
|
||||
useEffect(() => {
|
||||
if (!filters.hideClosed || !epicId) {
|
||||
return;
|
||||
}
|
||||
const epic = issues.find((issue) => issue.id === epicId && issue.issue_type === 'epic');
|
||||
if (!epic) {
|
||||
return;
|
||||
}
|
||||
if (epic.status === 'closed' || epic.status === 'tombstone') {
|
||||
setEpicId(null);
|
||||
}
|
||||
}, [filters.hideClosed, epicId, issues, setEpicId]);
|
||||
|
||||
// Panel resize hook
|
||||
const { leftWidth, rightWidth, handleLeftResize, handleRightResize } = usePanelResize();
|
||||
|
|
@ -214,6 +344,7 @@ export function UnifiedShell({
|
|||
projectRoot={projectRoot}
|
||||
swarmId={swarmId ?? undefined}
|
||||
onRocketClick={handleSocialRocket}
|
||||
onAskOrchestrator={handleAskOrchestrator}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -262,16 +393,16 @@ export function UnifiedShell({
|
|||
return (
|
||||
<div className="flex flex-col h-screen bg-[var(--surface-backdrop)]" data-testid="unified-shell">
|
||||
{/* TOP BAR: 3rem fixed */}
|
||||
<TopBar
|
||||
totalTasks={issues.filter(i => i.issue_type !== 'epic').length}
|
||||
criticalAlerts={blockedCount}
|
||||
busyCount={issues.filter(i => i.status === 'in_progress').length}
|
||||
idleCount={0}
|
||||
actor={actor}
|
||||
onActorChange={handleActorChange}
|
||||
onLaunchSwarm={() => { setTaskId(null); setAssignMode(true); }}
|
||||
onOpenBlockedTriage={handleOpenBlockedTriage}
|
||||
/>
|
||||
<TopBar
|
||||
totalTasks={issues.filter(i => i.issue_type !== 'epic').length}
|
||||
criticalAlerts={blockedCount}
|
||||
busyCount={issues.filter(i => i.status === 'in_progress').length}
|
||||
idleCount={0}
|
||||
actor={actor}
|
||||
onActorChange={handleActorChange}
|
||||
onLaunchSwarm={() => { setTaskId(null); setAssignMode(true); }}
|
||||
onOpenBlockedTriage={handleOpenBlockedTriage}
|
||||
/>
|
||||
{!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">
|
||||
<span className="font-semibold">BD setup issue:</span> {bdHealth.message}
|
||||
|
|
@ -293,6 +424,11 @@ export function UnifiedShell({
|
|||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
onAssignMode={(epicId) => { setEpicId(epicId); setTaskId(null); setAssignMode(true); }}
|
||||
sidebarMode={leftSidebarMode}
|
||||
onSidebarModeChange={setLeftSidebarMode}
|
||||
orchestrator={orchestrator}
|
||||
orchestratorThread={projectOrchestratorChat(runtimeEvents)}
|
||||
projectRoot={projectRoot}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -344,20 +480,22 @@ export function UnifiedShell({
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
{/* MOBILE NAV: Bottom tab bar */}
|
||||
<MobileNav />
|
||||
|
||||
{/* BLOCKED TRIAGE MODAL */}
|
||||
<BlockedTriageModal
|
||||
isOpen={blockedTriageOpen}
|
||||
onClose={handleCloseBlockedTriage}
|
||||
issues={issues}
|
||||
projectRoot={projectRoot}
|
||||
onSelectTask={(taskId) => {
|
||||
setTaskId(taskId);
|
||||
handleCloseBlockedTriage();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<RuntimeConsole events={runtimeEvents} daemonStatus={daemonLifecycle?.status ?? null} />
|
||||
|
||||
{/* MOBILE NAV: Bottom tab bar */}
|
||||
<MobileNav />
|
||||
|
||||
{/* BLOCKED TRIAGE MODAL */}
|
||||
<BlockedTriageModal
|
||||
isOpen={blockedTriageOpen}
|
||||
onClose={handleCloseBlockedTriage}
|
||||
issues={issues}
|
||||
projectRoot={projectRoot}
|
||||
onSelectTask={(taskId) => {
|
||||
setTaskId(taskId);
|
||||
handleCloseBlockedTriage();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,33 +1,36 @@
|
|||
import { useState } from 'react';
|
||||
import type { KeyboardEvent, MouseEventHandler } from 'react';
|
||||
import { Clock3, GitBranch, Link2, MessageCircle, MessageSquare, Rocket, UserPlus } from 'lucide-react';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import type { SocialCard as SocialCardData, AgentStatus } from '../../lib/social-cards';
|
||||
import { AgentAvatar } from '../shared/agent-avatar';
|
||||
import { useArchetypePicker } from '../../hooks/use-archetype-picker';
|
||||
import type { AgentArchetype } from '../../lib/types-swarm';
|
||||
|
||||
import { Clock3, GitBranch, Link2, MessageCircle, MessageSquare, UserPlus } from 'lucide-react';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import type { SocialCard as SocialCardData, AgentStatus } from '../../lib/social-cards';
|
||||
import { AgentAvatar } from '../shared/agent-avatar';
|
||||
import { AgentActionRow } from '../agents';
|
||||
import { useArchetypePicker } from '../../hooks/use-archetype-picker';
|
||||
import type { AgentArchetype } from '../../lib/types-swarm';
|
||||
|
||||
interface SocialCardProps {
|
||||
data: SocialCardData;
|
||||
className?: string;
|
||||
selected?: boolean;
|
||||
onClick?: MouseEventHandler<HTMLDivElement>;
|
||||
onJumpToGraph?: (id: string) => void;
|
||||
onJumpToActivity?: (id: string) => void;
|
||||
onOpenThread?: () => void;
|
||||
description?: string;
|
||||
updatedLabel?: string;
|
||||
dependencyCount?: number;
|
||||
commentCount?: number;
|
||||
unreadCount?: number;
|
||||
blockedByDetails?: Array<{ id: string; title: string; epic?: string }>;
|
||||
unblocksDetails?: Array<{ id: string; title: string; epic?: string }>;
|
||||
archetypes?: AgentArchetype[];
|
||||
swarmId?: string;
|
||||
data: SocialCardData;
|
||||
className?: string;
|
||||
selected?: boolean;
|
||||
onClick?: MouseEventHandler<HTMLDivElement>;
|
||||
onJumpToGraph?: (id: string) => void;
|
||||
onJumpToActivity?: (id: string) => void;
|
||||
onOpenThread?: () => void;
|
||||
description?: string;
|
||||
updatedLabel?: string;
|
||||
dependencyCount?: number;
|
||||
commentCount?: number;
|
||||
unreadCount?: number;
|
||||
blockedByDetails?: Array<{ id: string; title: string; epic?: string }>;
|
||||
unblocksDetails?: Array<{ id: string; title: string; epic?: string }>;
|
||||
archetypes?: AgentArchetype[];
|
||||
projectRoot?: string;
|
||||
swarmId?: string;
|
||||
onLaunchSwarm?: () => void;
|
||||
onAskOrchestrator?: () => void;
|
||||
agentUnreadByName?: Record<string, number>;
|
||||
agentMessagesByName?: Record<string, Array<{
|
||||
message_id: string;
|
||||
|
|
@ -41,82 +44,82 @@ interface SocialCardProps {
|
|||
agentReservationsByName?: Record<string, string | undefined>;
|
||||
onAckMessage?: (agent: string, messageId: string) => Promise<void> | void;
|
||||
}
|
||||
|
||||
function handleCardKeyDown(event: KeyboardEvent<HTMLDivElement>, onClick?: MouseEventHandler<HTMLDivElement>) {
|
||||
if (!onClick) return;
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
event.preventDefault();
|
||||
onClick(event as unknown as Parameters<MouseEventHandler<HTMLDivElement>>[0]);
|
||||
}
|
||||
|
||||
function statusVisual(status: SocialCardData['status']) {
|
||||
if (status === 'blocked') {
|
||||
return {
|
||||
border: 'var(--accent-danger)',
|
||||
badgeBg: 'var(--status-blocked)',
|
||||
badgeText: '#ffd5df',
|
||||
chipText: 'Blocked',
|
||||
};
|
||||
}
|
||||
|
||||
if (status === 'in_progress') {
|
||||
return {
|
||||
border: 'var(--accent-warning)',
|
||||
badgeBg: 'var(--status-in-progress)',
|
||||
badgeText: '#ffe5c7',
|
||||
chipText: 'Active',
|
||||
};
|
||||
}
|
||||
|
||||
if (status === 'ready') {
|
||||
return {
|
||||
border: 'var(--accent-success)',
|
||||
badgeBg: 'var(--status-ready)',
|
||||
badgeText: '#d6ffe7',
|
||||
chipText: 'Ready',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
border: 'var(--border-default)',
|
||||
badgeBg: 'var(--status-closed)',
|
||||
badgeText: 'var(--text-tertiary)',
|
||||
chipText: 'Closed',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
function handleCardKeyDown(event: KeyboardEvent<HTMLDivElement>, onClick?: MouseEventHandler<HTMLDivElement>) {
|
||||
if (!onClick) return;
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
event.preventDefault();
|
||||
onClick(event as unknown as Parameters<MouseEventHandler<HTMLDivElement>>[0]);
|
||||
}
|
||||
|
||||
function statusVisual(status: SocialCardData['status']) {
|
||||
if (status === 'blocked') {
|
||||
return {
|
||||
border: 'var(--accent-danger)',
|
||||
badgeBg: 'var(--status-blocked)',
|
||||
badgeText: '#ffd5df',
|
||||
chipText: 'Blocked',
|
||||
};
|
||||
}
|
||||
|
||||
if (status === 'in_progress') {
|
||||
return {
|
||||
border: 'var(--accent-warning)',
|
||||
badgeBg: 'var(--status-in-progress)',
|
||||
badgeText: '#ffe5c7',
|
||||
chipText: 'Active',
|
||||
};
|
||||
}
|
||||
|
||||
if (status === 'ready') {
|
||||
return {
|
||||
border: 'var(--accent-success)',
|
||||
badgeBg: 'var(--status-ready)',
|
||||
badgeText: '#d6ffe7',
|
||||
chipText: 'Ready',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
border: 'var(--border-default)',
|
||||
badgeBg: 'var(--status-closed)',
|
||||
badgeText: 'var(--text-tertiary)',
|
||||
chipText: 'Closed',
|
||||
};
|
||||
}
|
||||
|
||||
function dependencyPanel(
|
||||
title: string,
|
||||
color: string,
|
||||
details: Array<{ id: string; title: string; epic?: string }>,
|
||||
) {
|
||||
if (details.length === 0) return null;
|
||||
|
||||
return (
|
||||
<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 }}>
|
||||
{title}
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{details.slice(0, 1).map((item) => (
|
||||
<div
|
||||
key={`${title}-${item.id}`}
|
||||
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">
|
||||
<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>
|
||||
</div>
|
||||
<p className="line-clamp-1 text-xs text-[var(--text-primary)]">{item.title}</p>
|
||||
{item.epic ? (
|
||||
<p className="line-clamp-1 text-[10px] text-[var(--accent-info)]">↳ {item.epic}</p>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{details.length > 1 ? <p className="mt-1 text-[11px] text-[var(--text-tertiary)]">+{details.length - 1} more</p> : null}
|
||||
</div>
|
||||
);
|
||||
title: string,
|
||||
color: string,
|
||||
details: Array<{ id: string; title: string; epic?: string }>,
|
||||
) {
|
||||
if (details.length === 0) return null;
|
||||
|
||||
return (
|
||||
<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 }}>
|
||||
{title}
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{details.slice(0, 1).map((item) => (
|
||||
<div
|
||||
key={`${title}-${item.id}`}
|
||||
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">
|
||||
<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>
|
||||
</div>
|
||||
<p className="line-clamp-1 text-xs text-[var(--text-primary)]">{item.title}</p>
|
||||
{item.epic ? (
|
||||
<p className="line-clamp-1 text-[10px] text-[var(--accent-info)]">↳ {item.epic}</p>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{details.length > 1 ? <p className="mt-1 text-[11px] text-[var(--text-tertiary)]">+{details.length - 1} more</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function categoryBadgeClass(category: 'HANDOFF' | 'BLOCKED' | 'DECISION' | 'INFO'): string {
|
||||
|
|
@ -127,23 +130,24 @@ function categoryBadgeClass(category: 'HANDOFF' | 'BLOCKED' | 'DECISION' | 'INFO
|
|||
}
|
||||
|
||||
export function SocialCard({
|
||||
data,
|
||||
className,
|
||||
selected = false,
|
||||
onClick,
|
||||
onJumpToGraph,
|
||||
onJumpToActivity,
|
||||
onOpenThread,
|
||||
description,
|
||||
updatedLabel = 'just now',
|
||||
dependencyCount,
|
||||
commentCount,
|
||||
unreadCount = 0,
|
||||
blockedByDetails = [],
|
||||
unblocksDetails = [],
|
||||
data,
|
||||
className,
|
||||
selected = false,
|
||||
onClick,
|
||||
onJumpToGraph,
|
||||
onJumpToActivity,
|
||||
description,
|
||||
updatedLabel = 'just now',
|
||||
dependencyCount,
|
||||
commentCount,
|
||||
unreadCount = 0,
|
||||
blockedByDetails = [],
|
||||
unblocksDetails = [],
|
||||
archetypes = [],
|
||||
projectRoot,
|
||||
swarmId,
|
||||
onLaunchSwarm,
|
||||
onAskOrchestrator,
|
||||
agentUnreadByName = {},
|
||||
agentMessagesByName = {},
|
||||
agentReservationsByName = {},
|
||||
|
|
@ -155,52 +159,52 @@ export function SocialCard({
|
|||
const isSwarmHighlighted = swarmId && data.id.includes(swarmId);
|
||||
const [expandedAgent, setExpandedAgent] = useState<string | null>(null);
|
||||
const [ackingMessageId, setAckingMessageId] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
onKeyDown={(event) => handleCardKeyDown(event, onClick)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`Open ${data.title}`}
|
||||
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)]',
|
||||
isSwarmHighlighted && 'ring-2 ring-blue-500',
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
background: 'var(--surface-quaternary)',
|
||||
borderColor: selected ? status.border : 'var(--border-default)',
|
||||
boxShadow: selected
|
||||
? `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)',
|
||||
}}
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between 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 }}>
|
||||
{status.chipText}
|
||||
</Badge>
|
||||
<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>
|
||||
{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)]">
|
||||
{unreadCount}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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)]">
|
||||
{description || 'No summary provided yet.'}
|
||||
</p>
|
||||
|
||||
<div className="mt-2 flex flex-col gap-2">
|
||||
{dependencyPanel('Blocked By', 'var(--accent-danger)', blockedByDetails)}
|
||||
{dependencyPanel('Unblocks', 'var(--accent-success)', unblocksDetails)}
|
||||
</div>
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
onKeyDown={(event) => handleCardKeyDown(event, onClick)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`Open ${data.title}`}
|
||||
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)]',
|
||||
isSwarmHighlighted && 'ring-2 ring-blue-500',
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
background: 'var(--surface-quaternary)',
|
||||
borderColor: selected ? status.border : 'var(--border-default)',
|
||||
boxShadow: selected
|
||||
? `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)',
|
||||
}}
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between 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 }}>
|
||||
{status.chipText}
|
||||
</Badge>
|
||||
<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>
|
||||
{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)]">
|
||||
{unreadCount}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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)]">
|
||||
{description || 'No summary provided yet.'}
|
||||
</p>
|
||||
|
||||
<div className="mt-2 flex flex-col gap-2">
|
||||
{dependencyPanel('Blocked By', 'var(--accent-danger)', blockedByDetails)}
|
||||
{dependencyPanel('Unblocks', 'var(--accent-success)', unblocksDetails)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
{data.agents.slice(0, 3).map((agent) => {
|
||||
const unreadCount = agentUnreadByName[agent.name] ?? 0;
|
||||
|
|
@ -286,87 +290,98 @@ export function SocialCard({
|
|||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showAssign && (
|
||||
<div className="mt-2 flex gap-2 items-center overflow-hidden" onClick={(e) => e.stopPropagation()}>
|
||||
<select
|
||||
value={selectedArchetype ?? ''}
|
||||
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)]"
|
||||
>
|
||||
<option value="" disabled>Select agent role...</option>
|
||||
{archetypes.map((a) => (
|
||||
<option key={a.id} value={a.id}>{a.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
await handleAssign(data.id);
|
||||
}}
|
||||
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'}`}
|
||||
>
|
||||
<UserPlus className="w-3 h-3" />
|
||||
{isAssigning ? '...' : assignSuccess ? '✓' : 'Assign'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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)]">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<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"><MessageCircle className="h-3.5 w-3.5" aria-hidden="true" />{commentCount ?? 0}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
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)]"
|
||||
aria-label="View dependency graph"
|
||||
title="View dependency graph"
|
||||
>
|
||||
<GitBranch className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
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)]"
|
||||
aria-label="View details"
|
||||
title="View details"
|
||||
>
|
||||
<MessageSquare className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
{onLaunchSwarm ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onLaunchSwarm();
|
||||
}}
|
||||
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"
|
||||
aria-label="Launch Swarm"
|
||||
title="Launch Swarm"
|
||||
>
|
||||
<Rocket className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
{showAssign && (
|
||||
<div className="mt-2 flex gap-2 items-center overflow-hidden" onClick={(e) => e.stopPropagation()}>
|
||||
<select
|
||||
value={selectedArchetype ?? ''}
|
||||
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)]"
|
||||
>
|
||||
<option value="" disabled>Select agent role...</option>
|
||||
{archetypes.map((a) => (
|
||||
<option key={a.id} value={a.id}>{a.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
await handleAssign(data.id);
|
||||
}}
|
||||
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'}`}
|
||||
>
|
||||
<UserPlus className="w-3 h-3" />
|
||||
{isAssigning ? '...' : assignSuccess ? '✓' : 'Assign'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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)]">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<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"><MessageCircle className="h-3.5 w-3.5" aria-hidden="true" />{commentCount ?? 0}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
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)]"
|
||||
aria-label="View dependency graph"
|
||||
title="View dependency graph"
|
||||
>
|
||||
<GitBranch className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
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)]"
|
||||
aria-label="View details"
|
||||
title="View details"
|
||||
>
|
||||
<MessageSquare className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
{onAskOrchestrator ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onAskOrchestrator();
|
||||
}}
|
||||
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="Ask orchestrator"
|
||||
title="Ask Orchestrator"
|
||||
>
|
||||
<MessageSquare className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
Ask
|
||||
</button>
|
||||
) : null}
|
||||
{projectRoot && archetypes.length > 0 ? (
|
||||
<AgentActionRow
|
||||
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 { useRouter, useSearchParams } from 'next/navigation';
|
||||
|
||||
import type { BeadIssue } from '../../lib/types';
|
||||
import type { ProjectScopeOption } from '../../lib/project-scope';
|
||||
import { buildSocialCards } from '../../lib/social-cards';
|
||||
import { SocialCard } from './social-card';
|
||||
import { useArchetypes } from '../../hooks/use-archetypes';
|
||||
|
||||
|
||||
import type { BeadIssue } from '../../lib/types';
|
||||
import type { ProjectScopeOption } from '../../lib/project-scope';
|
||||
import { buildSocialCards } from '../../lib/social-cards';
|
||||
import { SocialCard } from './social-card';
|
||||
import { useArchetypes } from '../../hooks/use-archetypes';
|
||||
|
||||
interface SocialPageProps {
|
||||
issues: BeadIssue[];
|
||||
selectedId?: string;
|
||||
onSelect: (id: string) => void;
|
||||
projectScopeOptions?: ProjectScopeOption[];
|
||||
blockedOnly?: boolean;
|
||||
projectRoot: string;
|
||||
swarmId?: string;
|
||||
onRocketClick?: () => void;
|
||||
issues: BeadIssue[];
|
||||
selectedId?: string;
|
||||
onSelect: (id: string) => void;
|
||||
projectScopeOptions?: ProjectScopeOption[];
|
||||
blockedOnly?: boolean;
|
||||
projectRoot: string;
|
||||
swarmId?: string;
|
||||
onRocketClick?: () => void;
|
||||
onAskOrchestrator?: (issueId: string) => void;
|
||||
}
|
||||
|
||||
interface CoordMessage {
|
||||
|
|
@ -30,137 +31,138 @@ interface CoordMessage {
|
|||
state: 'unread' | 'read' | 'acked';
|
||||
requires_ack: boolean;
|
||||
}
|
||||
|
||||
type SectionKey = 'ready' | 'in_progress' | 'blocked' | 'deferred' | 'done';
|
||||
|
||||
const SECTION_LABEL: Record<SectionKey, string> = {
|
||||
ready: 'Ready',
|
||||
in_progress: 'In Progress',
|
||||
blocked: 'Blocked',
|
||||
deferred: 'Deferred',
|
||||
done: 'Done',
|
||||
};
|
||||
|
||||
const SECTION_COLOR: Record<SectionKey, string> = {
|
||||
ready: 'var(--ui-accent-ready)',
|
||||
in_progress: 'var(--ui-accent-warning)',
|
||||
blocked: 'var(--ui-accent-blocked)',
|
||||
deferred: 'var(--ui-accent-info)',
|
||||
done: 'var(--ui-text-muted)',
|
||||
};
|
||||
|
||||
function bucketForStatus(status: string): SectionKey {
|
||||
if (status === 'ready') return 'ready';
|
||||
if (status === 'in_progress') return 'in_progress';
|
||||
if (status === 'blocked') return 'blocked';
|
||||
if (status === 'closed') return 'done';
|
||||
return 'deferred';
|
||||
}
|
||||
|
||||
function formatRelative(timestamp: string): string {
|
||||
const then = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffMins = Math.floor((now.getTime() - then.getTime()) / 60000);
|
||||
|
||||
if (diffMins < 1) return 'just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
return `${diffDays}d ago`;
|
||||
}
|
||||
|
||||
|
||||
type SectionKey = 'ready' | 'in_progress' | 'blocked' | 'deferred' | 'done';
|
||||
|
||||
const SECTION_LABEL: Record<SectionKey, string> = {
|
||||
ready: 'Ready',
|
||||
in_progress: 'In Progress',
|
||||
blocked: 'Blocked',
|
||||
deferred: 'Deferred',
|
||||
done: 'Done',
|
||||
};
|
||||
|
||||
const SECTION_COLOR: Record<SectionKey, string> = {
|
||||
ready: 'var(--ui-accent-ready)',
|
||||
in_progress: 'var(--ui-accent-warning)',
|
||||
blocked: 'var(--ui-accent-blocked)',
|
||||
deferred: 'var(--ui-accent-info)',
|
||||
done: 'var(--ui-text-muted)',
|
||||
};
|
||||
|
||||
function bucketForStatus(status: string): SectionKey {
|
||||
if (status === 'ready') return 'ready';
|
||||
if (status === 'in_progress') return 'in_progress';
|
||||
if (status === 'blocked') return 'blocked';
|
||||
if (status === 'closed') return 'done';
|
||||
return 'deferred';
|
||||
}
|
||||
|
||||
function formatRelative(timestamp: string): string {
|
||||
const then = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffMins = Math.floor((now.getTime() - then.getTime()) / 60000);
|
||||
|
||||
if (diffMins < 1) return 'just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
return `${diffDays}d ago`;
|
||||
}
|
||||
|
||||
export function SocialPage({
|
||||
issues,
|
||||
selectedId,
|
||||
onSelect,
|
||||
projectScopeOptions = [],
|
||||
blockedOnly = false,
|
||||
projectRoot,
|
||||
swarmId,
|
||||
onRocketClick,
|
||||
}: SocialPageProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const cards = useMemo(() => buildSocialCards(issues), [issues]);
|
||||
const { archetypes } = useArchetypes(projectRoot);
|
||||
|
||||
const navigateWithParams = (updates: Record<string, string | null>) => {
|
||||
const next = new URLSearchParams(searchParams.toString());
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (!value) next.delete(key);
|
||||
else next.set(key, value);
|
||||
}
|
||||
const query = next.toString();
|
||||
router.push(query ? `/?${query}` : '/', { scroll: false });
|
||||
};
|
||||
|
||||
const issueById = useMemo(() => {
|
||||
const map = new Map<string, BeadIssue>();
|
||||
for (const issue of issues) map.set(issue.id, issue);
|
||||
return map;
|
||||
}, [issues]);
|
||||
const epicTitleById = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const issue of issues) {
|
||||
if (issue.issue_type === 'epic') {
|
||||
map.set(issue.id, issue.title);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [issues]);
|
||||
|
||||
const toDependencyDetails = (ids: string[]) =>
|
||||
ids.map((id) => {
|
||||
const depIssue = issueById.get(id);
|
||||
const parentEpicId = depIssue?.dependencies.find((dep) => dep.type === 'parent')?.target;
|
||||
return {
|
||||
id,
|
||||
title: depIssue?.title ?? id,
|
||||
epic: parentEpicId ? epicTitleById.get(parentEpicId) : undefined,
|
||||
};
|
||||
});
|
||||
|
||||
const orderedCards = useMemo(
|
||||
() => [...cards].sort((a, b) => b.lastActivity.getTime() - a.lastActivity.getTime()),
|
||||
[cards],
|
||||
);
|
||||
|
||||
const visibleCards = useMemo(
|
||||
() => (blockedOnly ? orderedCards.filter((card) => card.status === 'blocked') : orderedCards),
|
||||
[blockedOnly, orderedCards],
|
||||
);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const map: Record<SectionKey, typeof visibleCards> = {
|
||||
ready: [],
|
||||
in_progress: [],
|
||||
blocked: [],
|
||||
deferred: [],
|
||||
done: [],
|
||||
};
|
||||
|
||||
for (const card of visibleCards) {
|
||||
map[bucketForStatus(card.status)].push(card);
|
||||
}
|
||||
|
||||
return map;
|
||||
}, [visibleCards]);
|
||||
issues,
|
||||
selectedId,
|
||||
onSelect,
|
||||
projectScopeOptions = [],
|
||||
blockedOnly = false,
|
||||
projectRoot,
|
||||
swarmId,
|
||||
onRocketClick,
|
||||
onAskOrchestrator,
|
||||
}: SocialPageProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const cards = useMemo(() => buildSocialCards(issues), [issues]);
|
||||
const { archetypes } = useArchetypes(projectRoot);
|
||||
|
||||
const navigateWithParams = (updates: Record<string, string | null>) => {
|
||||
const next = new URLSearchParams(searchParams.toString());
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (!value) next.delete(key);
|
||||
else next.set(key, value);
|
||||
}
|
||||
const query = next.toString();
|
||||
router.push(query ? `/?${query}` : '/', { scroll: false });
|
||||
};
|
||||
|
||||
const issueById = useMemo(() => {
|
||||
const map = new Map<string, BeadIssue>();
|
||||
for (const issue of issues) map.set(issue.id, issue);
|
||||
return map;
|
||||
}, [issues]);
|
||||
const epicTitleById = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const issue of issues) {
|
||||
if (issue.issue_type === 'epic') {
|
||||
map.set(issue.id, issue.title);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [issues]);
|
||||
|
||||
const toDependencyDetails = (ids: string[]) =>
|
||||
ids.map((id) => {
|
||||
const depIssue = issueById.get(id);
|
||||
const parentEpicId = depIssue?.dependencies.find((dep) => dep.type === 'parent')?.target;
|
||||
return {
|
||||
id,
|
||||
title: depIssue?.title ?? id,
|
||||
epic: parentEpicId ? epicTitleById.get(parentEpicId) : undefined,
|
||||
};
|
||||
});
|
||||
|
||||
const orderedCards = useMemo(
|
||||
() => [...cards].sort((a, b) => b.lastActivity.getTime() - a.lastActivity.getTime()),
|
||||
[cards],
|
||||
);
|
||||
|
||||
const visibleCards = useMemo(
|
||||
() => (blockedOnly ? orderedCards.filter((card) => card.status === 'blocked') : orderedCards),
|
||||
[blockedOnly, orderedCards],
|
||||
);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const map: Record<SectionKey, typeof visibleCards> = {
|
||||
ready: [],
|
||||
in_progress: [],
|
||||
blocked: [],
|
||||
deferred: [],
|
||||
done: [],
|
||||
};
|
||||
|
||||
for (const card of visibleCards) {
|
||||
map[bucketForStatus(card.status)].push(card);
|
||||
}
|
||||
|
||||
return map;
|
||||
}, [visibleCards]);
|
||||
const [expandedSections, setExpandedSections] = useState<Record<SectionKey, boolean>>({
|
||||
ready: false,
|
||||
in_progress: false,
|
||||
blocked: false,
|
||||
deferred: false,
|
||||
done: false,
|
||||
});
|
||||
ready: false,
|
||||
in_progress: false,
|
||||
blocked: false,
|
||||
deferred: false,
|
||||
done: false,
|
||||
});
|
||||
const [collapsedSections, setCollapsedSections] = useState<Record<SectionKey, boolean>>({
|
||||
ready: false,
|
||||
in_progress: false,
|
||||
blocked: false,
|
||||
deferred: true,
|
||||
done: true,
|
||||
ready: false,
|
||||
in_progress: false,
|
||||
blocked: false,
|
||||
deferred: true,
|
||||
done: true,
|
||||
});
|
||||
const [agentMessagesByName, setAgentMessagesByName] = useState<Record<string, CoordMessage[]>>({});
|
||||
const [agentUnreadByName, setAgentUnreadByName] = useState<Record<string, number>>({});
|
||||
|
|
@ -243,100 +245,102 @@ export function SocialPage({
|
|||
});
|
||||
await refreshCoordination();
|
||||
};
|
||||
|
||||
return (
|
||||
<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="mb-4 flex items-center justify-between gap-3 border-b border-[var(--ui-border-soft)] pb-3">
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
<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)]">
|
||||
{projectScopeOptions.length} scopes
|
||||
</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)]">
|
||||
{visibleCards.length} tasks
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="space-y-6">
|
||||
{(Object.keys(SECTION_LABEL) as SectionKey[]).map((key) => {
|
||||
const cardsForSection = grouped[key];
|
||||
return (
|
||||
<div key={key}>
|
||||
<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] }}>
|
||||
{SECTION_LABEL[key]}
|
||||
</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)]">
|
||||
{cardsForSection.length}
|
||||
</span>
|
||||
{(key === 'deferred' || key === 'done') ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
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)]"
|
||||
>
|
||||
{collapsedSections[key] ? 'Expand' : 'Minimize'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{collapsedSections[key] ? (
|
||||
<p className="px-0.5 py-1 text-sm text-[var(--ui-text-muted)]">
|
||||
{cardsForSection.length === 0
|
||||
? `No tasks in ${SECTION_LABEL[key].toLowerCase()}.`
|
||||
: `${cardsForSection.length} tasks hidden.`}
|
||||
</p>
|
||||
) : (
|
||||
<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) => {
|
||||
const issue = issueById.get(card.id);
|
||||
const commentCount = typeof issue?.metadata?.commentCount === 'number' ? issue.metadata.commentCount : 0;
|
||||
const unreadCount = typeof issue?.metadata?.unreadCount === 'number' ? issue.metadata.unreadCount : 0;
|
||||
const dependencyCount = issue?.dependencies.length ?? card.blocks.length + card.unblocks.length;
|
||||
|
||||
return (
|
||||
|
||||
return (
|
||||
<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="mb-4 flex items-center justify-between gap-3 border-b border-[var(--ui-border-soft)] pb-3">
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
<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)]">
|
||||
{projectScopeOptions.length} scopes
|
||||
</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)]">
|
||||
{visibleCards.length} tasks
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="space-y-6">
|
||||
{(Object.keys(SECTION_LABEL) as SectionKey[]).map((key) => {
|
||||
const cardsForSection = grouped[key];
|
||||
return (
|
||||
<div key={key}>
|
||||
<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] }}>
|
||||
{SECTION_LABEL[key]}
|
||||
</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)]">
|
||||
{cardsForSection.length}
|
||||
</span>
|
||||
{(key === 'deferred' || key === 'done') ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
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)]"
|
||||
>
|
||||
{collapsedSections[key] ? 'Expand' : 'Minimize'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{collapsedSections[key] ? (
|
||||
<p className="px-0.5 py-1 text-sm text-[var(--ui-text-muted)]">
|
||||
{cardsForSection.length === 0
|
||||
? `No tasks in ${SECTION_LABEL[key].toLowerCase()}.`
|
||||
: `${cardsForSection.length} tasks hidden.`}
|
||||
</p>
|
||||
) : (
|
||||
<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) => {
|
||||
const issue = issueById.get(card.id);
|
||||
const commentCount = typeof issue?.metadata?.commentCount === 'number' ? issue.metadata.commentCount : 0;
|
||||
const unreadCount = typeof issue?.metadata?.unreadCount === 'number' ? issue.metadata.unreadCount : 0;
|
||||
const dependencyCount = issue?.dependencies.length ?? card.blocks.length + card.unblocks.length;
|
||||
|
||||
return (
|
||||
<SocialCard
|
||||
key={card.id}
|
||||
data={card}
|
||||
selected={selectedId === card.id}
|
||||
onClick={() => onSelect(card.id)}
|
||||
onJumpToGraph={(id) =>
|
||||
navigateWithParams({
|
||||
view: 'graph',
|
||||
graphTab: 'flow',
|
||||
task: id,
|
||||
swarm: null,
|
||||
right: 'open',
|
||||
panel: 'open',
|
||||
drawer: 'closed',
|
||||
})
|
||||
}
|
||||
onJumpToActivity={(id) =>
|
||||
navigateWithParams({
|
||||
task: id,
|
||||
right: 'open',
|
||||
panel: 'open',
|
||||
drawer: 'closed',
|
||||
})
|
||||
}
|
||||
onOpenThread={() => onSelect(card.id)}
|
||||
description={issue?.description ?? undefined}
|
||||
updatedLabel={issue ? formatRelative(issue.updated_at) : 'just now'}
|
||||
dependencyCount={dependencyCount}
|
||||
commentCount={commentCount}
|
||||
unreadCount={unreadCount}
|
||||
blockedByDetails={toDependencyDetails(card.unblocks)}
|
||||
unblocksDetails={toDependencyDetails(card.blocks)}
|
||||
archetypes={archetypes}
|
||||
swarmId={swarmId}
|
||||
key={card.id}
|
||||
data={card}
|
||||
selected={selectedId === card.id}
|
||||
onClick={() => onSelect(card.id)}
|
||||
onJumpToGraph={(id) =>
|
||||
navigateWithParams({
|
||||
view: 'graph',
|
||||
graphTab: 'flow',
|
||||
task: id,
|
||||
swarm: null,
|
||||
right: 'open',
|
||||
panel: 'open',
|
||||
drawer: 'closed',
|
||||
})
|
||||
}
|
||||
onJumpToActivity={(id) =>
|
||||
navigateWithParams({
|
||||
task: id,
|
||||
right: 'open',
|
||||
panel: 'open',
|
||||
drawer: 'closed',
|
||||
})
|
||||
}
|
||||
onOpenThread={() => onSelect(card.id)}
|
||||
description={issue?.description ?? undefined}
|
||||
updatedLabel={issue ? formatRelative(issue.updated_at) : 'just now'}
|
||||
dependencyCount={dependencyCount}
|
||||
commentCount={commentCount}
|
||||
unreadCount={unreadCount}
|
||||
blockedByDetails={toDependencyDetails(card.unblocks)}
|
||||
unblocksDetails={toDependencyDetails(card.blocks)}
|
||||
archetypes={archetypes}
|
||||
projectRoot={projectRoot}
|
||||
swarmId={swarmId}
|
||||
onLaunchSwarm={onRocketClick}
|
||||
onAskOrchestrator={() => onAskOrchestrator?.(card.id)}
|
||||
agentUnreadByName={agentUnreadByName}
|
||||
agentMessagesByName={agentMessagesByName}
|
||||
agentReservationsByName={agentReservationsByName}
|
||||
|
|
@ -344,38 +348,38 @@ export function SocialPage({
|
|||
/>
|
||||
);
|
||||
})}
|
||||
{cardsForSection.length === 0 ? (
|
||||
<p className="px-0.5 py-1 text-sm text-[var(--ui-text-muted)]">
|
||||
No tasks in this lane.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!collapsedSections[key] && cardsForSection.length > 3 ? (
|
||||
<div className="mt-2 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
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)]"
|
||||
>
|
||||
{expandedSections[key] ? 'Show Less' : `Show ${cardsForSection.length - 3} More`}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
|
||||
{visibleCards.length === 0 ? (
|
||||
<p className="mt-5 px-0.5 py-1 text-sm text-[var(--ui-text-muted)]">
|
||||
No blocked tasks right now.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
{cardsForSection.length === 0 ? (
|
||||
<p className="px-0.5 py-1 text-sm text-[var(--ui-text-muted)]">
|
||||
No tasks in this lane.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!collapsedSections[key] && cardsForSection.length > 3 ? (
|
||||
<div className="mt-2 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
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)]"
|
||||
>
|
||||
{expandedSections[key] ? 'Show Less' : `Show ${cardsForSection.length - 3} More`}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
|
||||
{visibleCards.length === 0 ? (
|
||||
<p className="mt-5 px-0.5 py-1 text-sm text-[var(--ui-text-muted)]">
|
||||
No blocked tasks right now.
|
||||
</p>
|
||||
) : null}
|
||||
</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 { useArchetypes } from '../../hooks/use-archetypes';
|
||||
import { useTemplates } from '../../hooks/use-templates';
|
||||
import { ArchetypeInspector } from './archetype-inspector';
|
||||
import { AgentInspector } from './agent-inspector';
|
||||
import { TemplateInspector } from './template-inspector';
|
||||
|
||||
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="flex flex-wrap gap-2">
|
||||
{tpl.team.map((member, idx) => {
|
||||
const arch = archetypes.find(a => a.id === member.archetypeId);
|
||||
const arch = archetypes.find(a => a.id === member.agentTypeId);
|
||||
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 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) : '?'}
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
|
|
@ -350,7 +350,7 @@ export function SwarmWorkspace({ selectedMissionId, issues = [], projectRoot }:
|
|||
|
||||
{/* Popups */}
|
||||
{inspectingArchetypeId !== null && (
|
||||
<ArchetypeInspector
|
||||
<AgentInspector
|
||||
archetype={archetypes.find(a => a.id === inspectingArchetypeId)}
|
||||
onClose={() => setInspectingArchetypeId(null)}
|
||||
onSave={saveArchetype}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,10 @@ export function TemplateInspector({ template, archetypes, onClose, onSave, onDel
|
|||
|
||||
const [name, setName] = useState(template?.name || '');
|
||||
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 [color, setColor] = useState(template?.color || '#f59e0b');
|
||||
const [icon, setIcon] = useState(template?.icon || '');
|
||||
|
|
@ -49,26 +52,26 @@ export function TemplateInspector({ template, archetypes, onClose, onSave, onDel
|
|||
if (template) {
|
||||
setName(template.name);
|
||||
setDescription(template.description);
|
||||
setTeam(template.team);
|
||||
setTeam(template.team?.map(m => ({ agentTypeId: m.agentTypeId, count: m.count })) || []);
|
||||
setProtoFormula(template.protoFormula || '');
|
||||
setColor(template.color || '#f59e0b');
|
||||
setIcon(template.icon || '');
|
||||
}
|
||||
}, [template]);
|
||||
|
||||
const updateTeamMember = (index: number, field: 'archetypeId' | 'count', value: string | number) => {
|
||||
const updateTeamMember = (index: number, field: 'agentTypeId' | 'count', value: string | number) => {
|
||||
const newTeam = [...team];
|
||||
if (field === 'count') {
|
||||
newTeam[index] = { ...newTeam[index], count: Math.max(1, Number(value)) };
|
||||
} else {
|
||||
newTeam[index] = { ...newTeam[index], archetypeId: value as string };
|
||||
newTeam[index] = { ...newTeam[index], agentTypeId: value as string };
|
||||
}
|
||||
setTeam(newTeam);
|
||||
};
|
||||
|
||||
const addTeamMember = () => {
|
||||
const firstAvailableArchetype = archetypes[0]?.id || '';
|
||||
setTeam([...team, { archetypeId: firstAvailableArchetype, count: 1 }]);
|
||||
const firstAvailableAgentType = archetypes[0]?.id || '';
|
||||
setTeam([...team, { agentTypeId: firstAvailableAgentType, count: 1 }]);
|
||||
};
|
||||
|
||||
const removeTeamMember = (index: number) => {
|
||||
|
|
@ -328,8 +331,8 @@ export function TemplateInspector({ template, archetypes, onClose, onSave, onDel
|
|||
{team.map((member, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<select
|
||||
value={member.archetypeId}
|
||||
onChange={(e) => updateTeamMember(index, 'archetypeId', e.target.value)}
|
||||
value={member.agentTypeId}
|
||||
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)]"
|
||||
>
|
||||
{archetypes.map(a => (
|
||||
|
|
@ -403,11 +406,11 @@ export function TemplateInspector({ template, archetypes, onClose, onSave, onDel
|
|||
key={idx}
|
||||
className="px-2 py-0.5 rounded-full text-[10px] font-medium"
|
||||
style={{
|
||||
backgroundColor: `${getArchetypeColor(member.archetypeId)}20`,
|
||||
color: getArchetypeColor(member.archetypeId)
|
||||
backgroundColor: `${getArchetypeColor(member.agentTypeId)}20`,
|
||||
color: getArchetypeColor(member.agentTypeId)
|
||||
}}
|
||||
>
|
||||
{getArchetypeName(member.archetypeId)} ×{member.count}
|
||||
{getArchetypeName(member.agentTypeId)} ×{member.count}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,10 +3,13 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
|
||||
export type ViewType = 'social' | 'graph' | 'activity';
|
||||
export type ViewType = 'social' | 'graph';
|
||||
export type PanelState = 'open' | 'closed';
|
||||
export type DrawerState = 'open' | 'closed';
|
||||
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 {
|
||||
view: ViewType;
|
||||
|
|
@ -22,6 +25,9 @@ export interface UrlState {
|
|||
leftPanel: PanelState;
|
||||
setLeftPanel: (state: PanelState) => void;
|
||||
toggleLeftPanel: () => void;
|
||||
leftSidebarMode: LeftSidebarMode;
|
||||
setLeftSidebarMode: (mode: LeftSidebarMode) => void;
|
||||
toggleLeftSidebarMode: () => void;
|
||||
rightPanel: PanelState;
|
||||
setRightPanel: (state: PanelState) => void;
|
||||
toggleRightPanel: () => void;
|
||||
|
|
@ -42,11 +48,13 @@ const DEFAULT_LEFT_PANEL: PanelState = 'open';
|
|||
const DEFAULT_RIGHT_PANEL: PanelState = 'open';
|
||||
const DEFAULT_DRAWER: DrawerState = 'closed';
|
||||
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_DRAWERS: DrawerState[] = ['open', 'closed'];
|
||||
const VALID_GRAPH_TABS: GraphTabType[] = ['flow', 'overview'];
|
||||
const VALID_LEFT_SIDEBAR_MODES: LeftSidebarMode[] = ['epics', 'orchestrator'];
|
||||
|
||||
const PANEL_STORAGE_KEYS = {
|
||||
left: 'bb.ui.leftPanel',
|
||||
|
|
@ -78,6 +86,13 @@ function isBlockedEnabled(value: string | null): boolean {
|
|||
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(
|
||||
searchParams: URLSearchParams,
|
||||
defaults: PanelDefaults = {
|
||||
|
|
@ -91,6 +106,7 @@ export function parseUrlState(
|
|||
agentId: string | null;
|
||||
epicId: string | null;
|
||||
leftPanel: PanelState;
|
||||
leftSidebarMode: LeftSidebarMode;
|
||||
rightPanel: PanelState;
|
||||
blockedOnly: boolean;
|
||||
panel: PanelState;
|
||||
|
|
@ -114,6 +130,7 @@ export function parseUrlState(
|
|||
const leftPanel = leftPanelFromUrl ?? defaults.leftPanel;
|
||||
const rightPanel = rightPanelFromUrl ?? legacyPanel ?? defaults.rightPanel;
|
||||
const panel = rightPanel;
|
||||
const leftSidebarMode = parseLeftSidebarMode(searchParams.get('leftMode'));
|
||||
|
||||
const blockedOnly = isBlockedEnabled(searchParams.get('blocked'));
|
||||
|
||||
|
|
@ -127,7 +144,7 @@ export function parseUrlState(
|
|||
? (graphTabParam as GraphTabType)
|
||||
: 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(
|
||||
|
|
@ -190,6 +207,14 @@ export function useUrlState(): UrlState {
|
|||
setLeftPanel(state.leftPanel === 'open' ? 'closed' : 'open');
|
||||
}, [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) => {
|
||||
// Keep legacy `panel` in sync while migrating to explicit `right`.
|
||||
updateUrl({ right: next, panel: next });
|
||||
|
|
@ -259,6 +284,9 @@ export function useUrlState(): UrlState {
|
|||
leftPanel: state.leftPanel,
|
||||
setLeftPanel,
|
||||
toggleLeftPanel,
|
||||
leftSidebarMode: state.leftSidebarMode,
|
||||
setLeftSidebarMode,
|
||||
toggleLeftSidebarMode,
|
||||
rightPanel: state.rightPanel,
|
||||
setRightPanel,
|
||||
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);
|
||||
}
|
||||
|
||||
const port = parsed.dolt_server_port;
|
||||
const database = parsed.dolt_database;
|
||||
if (typeof port !== 'number' || typeof database !== 'string') {
|
||||
if (typeof database !== 'string') {
|
||||
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 {
|
||||
dolt_server_port: port,
|
||||
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 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');
|
||||
|
||||
export function slugify(name: string): string {
|
||||
|
|
@ -15,7 +15,7 @@ export function slugify(name: string): string {
|
|||
.replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
export type SaveArchetypeInput = Partial<AgentArchetype> & {
|
||||
export type SaveAgentTypeInput = Partial<AgentType> & {
|
||||
name: string;
|
||||
description: string;
|
||||
systemPrompt: string;
|
||||
|
|
@ -23,8 +23,11 @@ export type SaveArchetypeInput = Partial<AgentArchetype> & {
|
|||
color: string;
|
||||
};
|
||||
|
||||
export async function saveArchetype(input: SaveArchetypeInput): Promise<AgentArchetype> {
|
||||
await fs.mkdir(ARCHE_DIR, { recursive: true });
|
||||
/** @deprecated Use SaveAgentTypeInput instead */
|
||||
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 now = new Date().toISOString();
|
||||
|
|
@ -33,7 +36,7 @@ export async function saveArchetype(input: SaveArchetypeInput): Promise<AgentArc
|
|||
let createdAt = input.createdAt || now;
|
||||
|
||||
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);
|
||||
if (existing.isBuiltIn) {
|
||||
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
|
||||
}
|
||||
|
||||
const archetype: AgentArchetype = {
|
||||
const agentType: AgentType = {
|
||||
id,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
systemPrompt: input.systemPrompt,
|
||||
capabilities: input.capabilities,
|
||||
color: input.color,
|
||||
icon: input.icon,
|
||||
createdAt,
|
||||
updatedAt: now,
|
||||
isBuiltIn
|
||||
};
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(ARCHE_DIR, `${id}.json`),
|
||||
JSON.stringify(archetype, null, 2)
|
||||
path.join(AGENT_DIR, `${id}.json`),
|
||||
JSON.stringify(agentType, null, 2)
|
||||
);
|
||||
|
||||
return archetype;
|
||||
return agentType;
|
||||
}
|
||||
|
||||
export async function deleteArchetype(id: string): Promise<void> {
|
||||
const filePath = path.join(ARCHE_DIR, `${id}.json`);
|
||||
/** @deprecated Use saveAgentType instead */
|
||||
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 {
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
archetype = JSON.parse(content);
|
||||
agentType = JSON.parse(content);
|
||||
} catch {
|
||||
throw new Error(`Archetype not found: ${id}`);
|
||||
throw new Error(`Agent type not found: ${id}`);
|
||||
}
|
||||
|
||||
if (archetype.isBuiltIn) {
|
||||
throw new Error(`Cannot delete built-in archetype: ${id}`);
|
||||
if (agentType.isBuiltIn) {
|
||||
throw new Error(`Cannot delete built-in agent type: ${id}`);
|
||||
}
|
||||
|
||||
await fs.unlink(filePath);
|
||||
}
|
||||
|
||||
const SEED_ARCHETYPES: AgentArchetype[] = [
|
||||
/** @deprecated Use deleteAgentType instead */
|
||||
export const deleteArchetype = deleteAgentType;
|
||||
|
||||
const SEED_AGENTS: AgentType[] = [
|
||||
{
|
||||
id: 'architect',
|
||||
name: 'System Architect',
|
||||
description: 'Designs complex system structures and writes detailed implementation plans.',
|
||||
systemPrompt: 'You are a staff-level software architect focused on high-level system design.',
|
||||
capabilities: ['planning', 'design_docs', 'arch_review'],
|
||||
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. You create clear, actionable plans that other agents can execute.',
|
||||
capabilities: ['system_design', 'work_decomposition', 'technical_decisions', 'risk_assessment', 'documentation'],
|
||||
color: '#3b82f6',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
isBuiltIn: true
|
||||
},
|
||||
{
|
||||
id: 'coder',
|
||||
id: 'engineer',
|
||||
name: 'Implementation Engineer',
|
||||
description: 'Translates plans into precise, type-safe, and tested code.',
|
||||
systemPrompt: 'You are a senior software engineer focused on execution and clean code.',
|
||||
capabilities: ['coding', 'refactoring', 'testing'],
|
||||
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 turning designs and plans into production-quality code. You implement features, fix bugs, and write tests.',
|
||||
capabilities: ['coding', 'refactoring', 'testing', 'debugging', 'documentation'],
|
||||
color: '#10b981',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
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',
|
||||
name: 'Standard Application Swarm',
|
||||
description: 'A balanced team of an Architect and two Coders for standard feature development.',
|
||||
team: [
|
||||
{ archetypeId: 'architect', count: 1 },
|
||||
{ archetypeId: 'coder', count: 2 }
|
||||
],
|
||||
id: 'reviewer',
|
||||
name: 'Code Reviewer',
|
||||
description: 'Conducts rigorous technical code reviews with focus on correctness, performance, maintainability, and test quality.',
|
||||
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.',
|
||||
capabilities: ['code_review', 'quality_gates', 'test_evaluation', 'security_review', 'performance_analysis'],
|
||||
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(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
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 {
|
||||
await fs.mkdir(TEMPLATE_DIR, { recursive: true });
|
||||
const files = await fs.readdir(TEMPLATE_DIR);
|
||||
await fs.mkdir(agentDir, { recursive: true });
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
|
|
@ -174,8 +296,17 @@ export async function getTemplates(): Promise<SwarmTemplate[]> {
|
|||
for (const file of files) {
|
||||
if (!file.endsWith('.json')) continue;
|
||||
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);
|
||||
|
||||
// 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({
|
||||
...parsed,
|
||||
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;
|
||||
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> {
|
||||
await fs.mkdir(TEMPLATE_DIR, { recursive: true });
|
||||
|
||||
const archetypes = await getArchetypes();
|
||||
const validArchetypeIds = new Set(archetypes.map(a => a.id));
|
||||
const agentTypes = await getAgentTypes();
|
||||
const validAgentTypeIds = new Set(agentTypes.map(a => a.id));
|
||||
|
||||
for (const member of input.team) {
|
||||
if (!validArchetypeIds.has(member.archetypeId)) {
|
||||
throw new Error(`Invalid archetype ID in team: ${member.archetypeId}`);
|
||||
// Normalize team: support both agentTypeId and archetypeId
|
||||
const normalizedTeam = input.team.map(member => ({
|
||||
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,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
team: input.team,
|
||||
team: normalizedTeam,
|
||||
protoFormula: input.protoFormula,
|
||||
color: input.color,
|
||||
icon: input.icon,
|
||||
createdAt,
|
||||
updatedAt: now,
|
||||
isBuiltIn
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ export interface SocialCard {
|
|||
agents: AgentInfo[];
|
||||
lastActivity: Date;
|
||||
priority: SocialCardPriority;
|
||||
agentTypeId?: string; // Assigned agent type for spawn button
|
||||
}
|
||||
|
||||
function mapStatus(status: BeadIssue['status']): SocialCardStatus {
|
||||
|
|
@ -63,6 +64,12 @@ function extractAgentName(bead: BeadIssue): string | 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[] {
|
||||
const agents: AgentInfo[] = [];
|
||||
if (bead.assignee) {
|
||||
|
|
@ -141,6 +148,7 @@ export function buildSocialCards(beads: BeadIssue[]): SocialCard[] {
|
|||
agents: extractAgents(bead),
|
||||
lastActivity: new Date(bead.updated_at),
|
||||
priority: mapPriority(bead.priority),
|
||||
agentTypeId: bead.agentTypeId || extractAgentTypeId(bead.labels),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
export interface AgentArchetype {
|
||||
export interface AgentType {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
|
|
@ -12,11 +12,15 @@ export interface AgentArchetype {
|
|||
isBuiltIn: boolean;
|
||||
}
|
||||
|
||||
/** @deprecated Use AgentType instead. Kept for backward compatibility. */
|
||||
export type AgentArchetype = AgentType;
|
||||
|
||||
export interface SwarmTemplate {
|
||||
id: string;
|
||||
name: 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;
|
||||
/** Color for template display. Defaults to amber if not set. */
|
||||
color?: string;
|
||||
|
|
@ -26,3 +30,17 @@ export interface SwarmTemplate {
|
|||
updatedAt: string;
|
||||
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;
|
||||
priority: number;
|
||||
issue_type: BeadIssueType;
|
||||
assignee: string | null;
|
||||
templateId: string | null;
|
||||
owner: string | null;
|
||||
assignee: string | null;
|
||||
templateId: string | null;
|
||||
owner: string | null;
|
||||
labels: string[];
|
||||
dependencies: BeadDependency[];
|
||||
created_at: string;
|
||||
|
|
@ -52,9 +52,13 @@ export interface BeadIssue {
|
|||
created_by: string | null;
|
||||
due_at: string | null;
|
||||
estimated_minutes: number | null;
|
||||
external_ref: string | null;
|
||||
comments_count?: number;
|
||||
metadata: Record<string, unknown>;
|
||||
external_ref: string | null;
|
||||
comments_count?: number;
|
||||
/** 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> {
|
||||
|
|
|
|||
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