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:
zenchantlive 2026-03-24 15:39:19 -07:00
parent 643fa299dd
commit d335e5bf71
98 changed files with 17851 additions and 944 deletions

4
.gitignore vendored
View file

@ -50,3 +50,7 @@ test-*.ts
.qodo/
.gemini/
.kilocode/
# Dolt database files (added by bd init)
.dolt/
*.db

View file

@ -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 -->

View 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

View 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

File diff suppressed because it is too large Load diff

View 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

View 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

View 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

File diff suppressed because it is too large Load diff

View 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

View 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

View 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

View 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

View 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
```

File diff suppressed because it is too large Load diff

3576
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -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",

View 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,
});
}

View 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,
},
});
}

View 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 }
);
}
}

View 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) });
}

View 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);
}

View 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 },
);
}
}

View 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 },
);
}
}

View 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 });
}
}

View 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());
}

View 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',
},
});
}

View 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 });
}
}

View file

@ -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>
);
}

View file

@ -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

View file

@ -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`);

View file

@ -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">

View file

@ -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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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';

View 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;
}

View 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 };
}

View 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';

View file

@ -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}

View 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>
);
}

View 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>
);
}

View file

@ -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">

View 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>
);
}

View 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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View 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>
);
}

View 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>
);
}

View file

@ -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}

View file

@ -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>

View file

@ -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
View 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],
};
}

View 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
}
}

View 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
View 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
View 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'],
};
}

View file

@ -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
View 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
View 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,
}),
];
}

View 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;
}

View 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();
}

View 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.',
};
}

View file

@ -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

View file

@ -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),
};
});
}

View file

@ -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;
}

View file

@ -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> {

View 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
View 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
View 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
View 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.
`;
}

View 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 },
};
},
};
}

View 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,
};
}
},
},
];
}

View 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 },
};
}
},
};
}

View 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 },
};
}
},
};
}

View 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 },
};
}
},
};
}

View 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 },
};
}
},
};
}

View 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 },
};
}
},
};
}

View 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 },
};
}
},
};
}

View 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: {},
};
}
},
};
}

View 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: {},
};
}
},
};
}

View 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 },
};
}
},
};
}

View 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 },
};
}
},
};
}

View 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
View 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: {} };
}
},
}
];
}

View 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: {} };
}
},
},
];
}

View 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,
},
};
},
};
}

View 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,
};
}
},
};
}

View 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 },
};
}
},
};
}

View 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 },
};
}
},
};
}

View 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 },
};
}
},
};
}

View 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,
};
}
},
};
}

View 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
View 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';