Merge origin/main into feature/assign-archetypes-to-tasks-ui

Resolved conflicts:
- .gitignore: kept both bd.sock.startlock and .beadboard/ entries
- package.json: kept feature branch test script (explicit enumeration)
- API routes: kept dynamic export + isValidProjectRoot from main
- globals.css: kept HEAD slideInFromRight animation
- use-beads-subscription.ts: kept HEAD onopen handler
- realtime.ts: kept main console.log in emit()
- snapshot-differ.ts: kept main type-aware dependency diff

Blue colors preserved from feature branch.
This commit is contained in:
openhands 2026-02-26 18:50:18 +00:00
commit a8079813b8
28 changed files with 931 additions and 70 deletions

View file

@ -1 +0,0 @@
63676

152
.github/agents/code-custodian vendored Normal file
View file

@ -0,0 +1,152 @@
---
name: code-custodian
description: |
Comprehensive code quality agent that finds and fixes issues across your codebase:
- Discovers untested functions and writes missing tests
- Runs test suite to verify coverage
- Auto-fixes linting and formatting issues
- Adds missing JSDoc/documentation
- Updates README with current API examples
- Identifies and fixes deprecated API usage
- Keeps ARCHITECTURE.md and CONTRIBUTING.md in sync
- Opens a single PR with all improvements
tools: Write, Read, LS, Glob, Grep, Bash(npm:*), Bash(git:*), Bash(gh:*)
color: blue
---
# Code Custodian
You are a comprehensive code quality and documentation steward. Your mission is to identify gaps in testing, documentation, and code quality—then fix them proactively. You work systematically and carefully, always verifying your changes don't break anything.
## Your Responsibilities
### 1. Test Coverage Analysis & Creation
When analyzing test coverage:
- Use `find` or `ls` to list all source files in `src/`, `lib/`, or `components/`
- For each file, check if a corresponding test file exists (look for `.test.ts`, `.spec.ts`, `.test.js`, etc.)
- Read uncovered files to understand what functions should be tested
- Identify critical functions: main exports, public APIs, business logic
- Write comprehensive Jest/Vitest tests for untested functions
- Include happy path tests
- Include edge cases and error scenarios
- Use meaningful test names describing the behavior
- Follow the existing test style in the repo
- Create test files in the appropriate `__tests__` or `tests/` directory matching source structure
- Run `npm test` or `npm run test` to verify all tests pass
- If tests fail, read the error, fix the test code, and re-run
### 2. Code Quality & Formatting
- Run `npm run lint` or `npx eslint . --fix` to auto-fix formatting
- Search for deprecated patterns (e.g., old API calls, outdated imports)
- Replace with current versions with explanations in commit messages
- Fix obvious issues: console.logs left in production code, dead code, unused imports
### 3. Documentation Updates
#### README.md
- Read current README
- Check if it accurately reflects current API/features
- Update feature list based on recent commits
- Refresh code examples to match latest version
- Add badges for test coverage, build status if missing
- Ensure setup/installation instructions are accurate
#### JSDoc/Comments
- Add missing JSDoc comments to exported functions
- Include @param, @returns, @throws where applicable
- Add brief descriptions for complex logic
#### ARCHITECTURE.md (if exists)
- Read the existing architecture doc
- After major refactors or new modules, update to reflect new components/modules added, changed data flows, new external integrations
#### CONTRIBUTING.md
- Verify test command instructions are correct
- Update with latest dev dependency versions
- Document common npm/pnpm/yarn commands for local development
- Add "golden path" for new contributors
### 4. Execution & PR Creation
When you've identified work:
1. Create branch: `git checkout -b chore/code-custodian-YYYY-MM-DD`
2. Make changes systematically:
- Write/update tests then verify they pass
- Fix linting then verify fixes applied
- Update docs then verify accuracy
- Commit with clear messages
3. Verify everything works:
- Run `npm test` - all tests pass
- Run `npm run lint` - no linting errors
- Review git diff to ensure quality
4. Open PR:
- Use `gh pr create` with detailed body
- List what was added/fixed in each category
- Explain any major decisions
- Note test results and coverage
## Workflow Overview
When invoked:
### Phase 1: ANALYSIS (5-10 min)
- Scan the codebase structure
- Identify untested files
- Check docs for staleness
- List all improvements needed
### Phase 2: TEST COVERAGE (varies)
- Write tests for untested functions
- Run test suite
- Fix any failing tests
### Phase 3: CODE QUALITY (5-10 min)
- Auto-fix linting
- Replace deprecated patterns
### Phase 4: DOCUMENTATION (10-15 min)
- Update README with current examples
- Add JSDoc comments
- Refresh ARCHITECTURE.md if needed
- Update CONTRIBUTING.md
### Phase 5: INTEGRATION & PR (5 min)
- Create branch and commit all changes
- Run full test suite one final time
- Open PR with detailed summary
## Best Practices
**Before making changes:**
- Always read existing code first to understand patterns
- Match the existing code style
- Check for test examples in the repo to replicate patterns
**When writing tests:**
- Use descriptive test names
- Test behavior, not implementation
- Include both success and failure scenarios
- Keep tests focused and independent
**When updating docs:**
- Keep examples short and runnable
- Use actual code from the repo where possible
- Test examples if they're code snippets
- Update dates/version numbers
**Safety first:**
- Always run tests before committing
- If tests fail, debug and fix before proceeding
- Don't make breaking changes without discussion
- When in doubt, add a comment explaining the change
## Communication
After each phase, explain what you found, what you fixed, any decisions made, status of test results, and link to the PR when created. If you encounter issues, describe the problem clearly with error messages and suggest solutions.

6
.gitignore vendored
View file

@ -13,5 +13,9 @@ worktrees/
# local screenshot artifacts
artifacts/
# beads socket lock
.beads/bd.sock.startlock
.beads/bd.sock.startlock
# beadboard runtime artifacts
.beadboard/

View file

@ -36,6 +36,21 @@ Understand the "Why" and "What's Next".
- **Focus Mode**: Minimizable dependency strip and deep-linking support for sharing exact views.
- **Smart Metadata**: See bead counts, priorities, and status health at a glance.
### 4. Agent Sessions Hub (`/sessions`)
Coordinate multi-agent workflows with social-dense visibility.
- **Epic-Grouped Task Feed**: Tasks organized by parent Epic with session state indicators (active, reviewing, needs_input, stale).
- **Cross-Agent Communication**: Built-in messaging with HANDOFF, BLOCKED, and INFO categories.
- **Agent Productivity Metrics**: Real-time stats showing active tasks, completions, and recent wins.
- **Derived Activity Engine**: O(N) snapshot diffing computes project history on-demand without separate event storage.
- **`bb agent` CLI Integration**: Visualizes data from agent registry, reservations, and mailboxes.
### 5. Chronological Timeline (`/timeline`)
Real-time activity feed for all project events.
- **Live Updates**: Server-Sent Events stream changes instantly.
- **Date Grouping**: Events organized by day (Today, Yesterday, etc.).
- **Polymorphic Cards**: Distinct visual styles for Status, Lifecycle, and Diff events.
- **History Buffer**: Recent events preserved across server restarts.
## 🛠️ Stack
- **Framework**: Next.js 15 (App Router)
- **UI Engine**: React 19 + Framer Motion

283
docs/api-reference.md Normal file
View file

@ -0,0 +1,283 @@
# BeadBoard API Reference
## Overview
BeadBoard exposes REST endpoints for reading bead data, managing agent coordination, and streaming real-time activity. All endpoints return JSON (unless noted otherwise).
## Bead Management
### `GET /api/beads/read`
Read beads from the current or specified project.
**Query Parameters:**
- `project` (optional): Project key for scope resolution
- `mode` (optional): `single` or `aggregate`
**Response:**
```json
{
"issues": [...],
"projectRoot": "/path/to/project"
}
```
### `POST /api/beads/create`
Create a new bead.
**Body:**
```json
{
"title": "Task title",
"description": "Optional description",
"status": "open",
"priority": "p2",
"issue_type": "task"
}
```
### `POST /api/beads/update`
Update an existing bead.
**Body:**
```json
{
"id": "bb-abc",
"updates": {
"status": "in_progress",
"assignee": "agent-1"
}
}
```
### `POST /api/beads/close`
Close a bead.
**Body:**
```json
{
"id": "bb-abc",
"reason": "Completion reason"
}
```
### `POST /api/beads/reopen`
Reopen a closed bead.
**Body:**
```json
{
"id": "bb-abc"
}
```
### `POST /api/beads/comment`
Add a comment to a bead.
**Body:**
```json
{
"id": "bb-abc",
"comment": "Comment text",
"author": "agent-1"
}
```
## Agent Coordination
### `GET /api/agents/[agentId]/stats`
Fetch productivity metrics for a specific agent.
**Path Parameters:**
- `agentId`: Agent identifier
**Response:**
```json
{
"activeTasks": 3,
"completedTasks": 12,
"handoffsSent": 8,
"recentWins": [
{ "id": "bb-xyz", "title": "Task title" }
]
}
```
### `GET /api/sessions`
Fetch the agent sessions task feed.
**Query Parameters:**
- `project` (optional): Project scope
- `mode` (optional): `single` or `aggregate`
**Response:**
```json
{
"buckets": [
{
"epic": {
"id": "bb-epic",
"title": "Epic Title",
"status": "open"
},
"tasks": [
{
"id": "bb-task",
"title": "Task Title",
"epicId": "bb-epic",
"status": "in_progress",
"sessionState": "active",
"owner": "agent-1",
"lastActor": "agent-1",
"lastActivityAt": "2026-02-16T05:00:00Z",
"communication": {
"unreadCount": 2,
"pendingRequired": true,
"latestSnippet": "Blocked on API"
}
}
]
}
]
}
```
### `GET /api/sessions/[beadId]/conversation`
Get the full conversation thread for a bead, including comments and agent messages.
**Path Parameters:**
- `beadId`: Bead identifier
**Response:**
```json
{
"comments": [...],
"messages": [...]
}
```
### `POST /api/sessions/[beadId]/comment`
Add a comment to a bead session.
**Path Parameters:**
- `beadId`: Bead identifier
**Body:**
```json
{
"comment": "Comment text",
"author": "agent-1"
}
```
### `POST /api/sessions/[beadId]/messages/[messageId]/read`
Mark an agent message as read.
**Path Parameters:**
- `beadId`: Bead identifier
- `messageId`: Message identifier
### `POST /api/sessions/[beadId]/messages/[messageId]/ack`
Acknowledge an agent message (required for HANDOFF/BLOCKED categories).
**Path Parameters:**
- `beadId`: Bead identifier
- `messageId`: Message identifier
## Activity & Events
### `GET /api/activity`
Fetch recent activity events (history buffer).
**Response:**
```json
{
"events": [
{
"id": "evt-123",
"beadId": "bb-abc",
"kind": "status_changed",
"actor": "agent-1",
"timestamp": "2026-02-16T05:00:00Z",
"changes": {
"from": "todo",
"to": "in_progress"
}
}
]
}
```
### `GET /api/events`
Server-Sent Events stream for real-time activity.
**Response:** SSE stream with `event` and `data` fields.
**Event Types:**
- `activity`: New activity event
- `bead_updated`: Bead state changed
- `agent_registered`: New agent registered
## Project Management
### `GET /api/projects`
List all registered projects.
**Response:**
```json
{
"projects": [
{
"key": "proj-1",
"root": "/path/to/project",
"name": "Project Name"
}
]
}
```
### `POST /api/scan`
Scan filesystem for bead-enabled projects.
**Body:**
```json
{
"paths": ["/path/to/scan"]
}
```
**Response:**
```json
{
"discovered": [
{
"root": "/path/to/project",
"beadCount": 42
}
]
}
```
## Error Handling
All endpoints follow a consistent error format:
```json
{
"error": "Error message",
"code": "ERROR_CODE",
"details": {}
}
```
**Common Error Codes:**
- `INVALID_REQUEST`: Malformed request body or parameters
- `NOT_FOUND`: Resource does not exist
- `PERMISSION_DENIED`: Operation not allowed
- `INTERNAL_ERROR`: Server-side error
## Rate Limiting
No rate limiting is currently enforced for local BeadBoard instances. If deploying publicly, implement rate limiting externally.
## Authentication
BeadBoard runs as a local dashboard with no authentication. If exposing over a network, secure access using reverse proxy authentication or network isolation.

View file

@ -0,0 +1,130 @@
# Agent Sessions Hub
## Overview
The Agent Sessions Hub (`/sessions`) provides a unified command workspace for tracking multi-agent coordination across your BeadBoard projects. It combines task status, agent communication, and derived activity into a social-dense interface optimized for understanding "who's doing what" at a glance.
## Features
### 1. Epic-Grouped Task Feed
Tasks are automatically organized by their parent Epic, providing logical context for understanding work scope.
- **Session State Indicators**: Each task displays its real-time state (active, reviewing, needs_input, completed, stale)
- **Owner & Actor Tracking**: See who owns the task and who last acted on it
- **Communication Badges**: Unread message counts and pending acknowledgment flags
### 2. Agent Communication Integration
Built-in cross-agent messaging system for coordination without leaving the dashboard.
**Message Types:**
- `HANDOFF` - Pass context to another agent
- `BLOCKED` - Request help or flag blockers
- `INFO` - Share updates or documentation
**Communication Features:**
- Inbox view with unread/read/acked states
- Required acknowledgments for critical handoffs
- Per-bead conversation threads
### 3. Agent Statistics & Productivity Metrics
Real-time performance tracking for each registered agent.
**Metrics Tracked:**
- Active tasks (currently in progress)
- Completed tasks (closed beads)
- Handoffs sent (coordination events)
- Recent wins (last 3 completed tasks)
### 4. Derived Activity Engine
Instead of storing history separately, BeadBoard computes activity on-demand by diffing snapshots of `issues.jsonl`.
**Event Types Generated:**
- Bead lifecycle: created, closed, reopened
- Status changes: todo → in_progress → done
- Assignee changes
- Priority, title, description updates
- Label and dependency changes
**Persistence:**
- File-backed ring buffer survives server restarts
- O(N) snapshot diffing algorithm
- No separate event database required
## Architecture
### Backend Components
- **Agent Registry** (`src/lib/agent-registry.ts`): Maintains agent identity and roles
- **Agent Mail** (`src/lib/agent-mail.ts`): Cross-agent messaging with inbox/ack protocol
- **Agent Reservations** (`src/lib/agent-reservations.ts`): File/scope locking to prevent collisions
- **Agent Sessions** (`src/lib/agent-sessions.ts`): Session state derivation and task feed builder
- **Snapshot Differ** (`src/lib/snapshot-differ.ts`): O(N) diffing engine for activity events
- **Activity Persistence** (`src/lib/activity-persistence.ts`): File-backed event buffer
### API Endpoints
- `GET /api/sessions` - Fetch session task feed
- `GET /api/sessions/[beadId]/conversation` - Get full conversation thread for a bead
- `POST /api/sessions/[beadId]/comment` - Add a comment to a bead session
- `POST /api/sessions/[beadId]/messages/[messageId]/read` - Mark message as read
- `POST /api/sessions/[beadId]/messages/[messageId]/ack` - Acknowledge message
- `GET /api/agents/[agentId]/stats` - Fetch agent productivity metrics
### Frontend Components
- **SessionsPage** (`src/components/sessions/sessions-page.tsx`): Main layout and orchestration
- **TaskCard** components: Visual representation of session state
- **AgentStatsPanel**: Metrics dashboard per agent
## Session States
Tasks automatically transition between states based on activity and communication:
| State | Description | Visual Indicator |
|-------|-------------|------------------|
| `active` | Status is `in_progress`, recent activity | Green pulse |
| `reviewing` | Under review or verification | Blue |
| `deciding` | Status is `todo` or `ready`, waiting for claim | Gray |
| `needs_input` | Status is `blocked` or has pending required acknowledgments | Yellow/Orange |
| `completed` | Status is `closed` | Green checkmark |
| `stale` | No activity in 24+ hours | Faded/Red |
## Integration with `bb agent` CLI
The Sessions Hub visualizes data managed by the `bb agent` command-line interface. See `docs/agent-session-flow.md` for the operator workflow.
**Key Commands:**
- `bb agent register --name <name> --role <role>` - Register agent identity
- `bb agent send --from <sender> --to <recipient> --bead <id> --category <type>` - Send message
- `bb agent inbox --agent <name>` - Check messages
- `bb agent reserve --agent <name> --scope <glob> --bead <id>` - Reserve files
- `bb agent status --bead <id>` - Check reservation status
## Data Flow
1. **Source of Truth**: `.beads/issues.jsonl` via `bd` CLI
2. **Activity Generation**: Watcher detects changes → snapshot differ → event bus
3. **Agent Coordination**: `bb` CLI writes to `.beads/agents/` directory
4. **UI Refresh**: SSE stream (`/api/events`) pushes updates to frontend in real-time
## Configuration
No configuration required. The Sessions Hub automatically:
- Discovers registered agents from `.beads/agents/`
- Builds communication graph from mailboxes
- Derives session states from current bead status + activity
## Performance
- **O(N) Diffing**: Snapshot differ scales linearly with number of beads
- **Ring Buffer**: Activity persistence uses fixed-size memory buffer (configurable)
- **Real-time Updates**: SSE keeps UI synchronized without polling
## Limitations
- Comment interactions are not yet streamed to the timeline
- Cross-project agent coordination requires agents to be registered in each project
- Stale threshold is fixed at 24 hours (not user-configurable)
## Related Documentation
- `docs/agent-session-flow.md` - CLI workflow guide for operators
- `docs/features/timeline.md` - Chronological activity feed
- `docs/RFC-001-Agent-Coordination.md` - Agent coordination design
- `docs/adr/2026-02-14-beadboard-driver-skill-and-bb-resolution.md` - beadboard-driver skill

View file

@ -18,7 +18,7 @@ import { parseArgs } from 'node:util';
import fs from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import { execSync } from 'node:child_process';
import { execSync, execFileSync } from 'node:child_process';
function log(obj) {
process.stdout.write(`${JSON.stringify(obj, null, 2)}
@ -48,20 +48,20 @@ async function resolveBbPath() {
const tsEntry = path.join(process.cwd(), 'tools', 'bb.ts');
try {
await fs.access(tsEntry);
return `npx tsx ${tsEntry}`;
return { type: 'tsx', path: tsEntry };
} catch {}
if (envRepo) {
const p = path.join(envRepo, 'bb.ps1');
try {
await fs.access(p);
return p;
return { type: 'powershell', path: p };
} catch {}
const envTs = path.join(envRepo, 'tools', 'bb.ts');
try {
await fs.access(envTs);
return `npx tsx ${envTs}`;
return { type: 'tsx', path: envTs };
} catch {}
}
@ -82,9 +82,9 @@ async function main() {
const isNonInteractive = values['non-interactive'];
const projectRoot = values['project-root'] || process.cwd();
const bbPath = await resolveBbPath();
const bbResult = await resolveBbPath();
if (!bbPath) {
if (!bbResult) {
error('BB_NOT_FOUND', 'Could not resolve bb.ps1 or tools/bb.ts');
}
@ -103,8 +103,6 @@ async function main() {
}
try {
const bbExec = bbPath.includes('npx tsx') ? bbPath : `powershell.exe -NoProfile -Command "& '${bbPath}'"`;
// Compose environment fingerprint (Rig)
const rigId = `${os.platform()}-${os.arch()}-${os.hostname()}`;
@ -112,10 +110,18 @@ async function main() {
if (mode === 'register') {
const role = values.role || 'agent';
execSync(`${bbExec} agent register --name ${agentId} --role ${role} --rig ${rigId} --json`, { stdio: 'ignore', cwd: projectRoot, env });
const registerArgs = bbResult.type === 'tsx'
? ['tsx', bbResult.path, 'agent', 'register', '--name', agentId, '--role', role, '--rig', rigId, '--json']
: ['agent', 'register', '--name', agentId, '--role', role, '--rig', rigId, '--json'];
const registerCmd = bbResult.type === 'tsx' ? 'npx' : bbResult.path;
execFileSync(registerCmd, registerArgs, { stdio: 'ignore', cwd: projectRoot, env });
} else {
// Start/Extend the lease to show we are now active
execSync(`${bbExec} agent activity-lease --agent ${agentId} --json`, { stdio: 'ignore', cwd: projectRoot, env });
const leaseArgs = bbResult.type === 'tsx'
? ['tsx', bbResult.path, 'agent', 'activity-lease', '--agent', agentId, '--json']
: ['agent', 'activity-lease', '--agent', agentId, '--json'];
const leaseCmd = bbResult.type === 'tsx' ? 'npx' : bbResult.path;
execFileSync(leaseCmd, leaseArgs, { stdio: 'ignore', cwd: projectRoot, env });
}
log({

View file

@ -1,6 +1,7 @@
#!/usr/bin/env node
import fs from 'node:fs/promises';
import path from 'node:path';
function parseArgs(argv) {
const output = {};
@ -43,7 +44,8 @@ async function withArtifactExistence(artifacts) {
};
if (typeof artifact.path === 'string' && artifact.path.trim()) {
try {
await fs.access(artifact.path);
const resolved = path.resolve(artifact.path);
await fs.access(resolved);
item.exists = true;
} catch {
item.exists = false;

View file

@ -1,11 +1,40 @@
import { NextResponse } from 'next/server';
import path from 'node:path';
import { activityEventBus } from '../../../lib/realtime';
export const dynamic = 'force-dynamic';
function isValidProjectRoot(root: string): boolean {
try {
const resolved = path.resolve(root);
if (!path.isAbsolute(resolved)) {
return false;
}
// Prevent path traversal by ensuring resolved path stays within the project root
const allowedBase = process.cwd();
const relative = path.relative(allowedBase, resolved);
// If "resolved" is outside "allowedBase", "relative" will start with ".."
if (relative.startsWith('..') || path.isAbsolute(relative)) {
return false;
}
return true;
} catch {
return false;
}
}
export async function GET(request: Request): Promise<Response> {
const url = new URL(request.url);
const projectRoot = url.searchParams.get('projectRoot') || undefined;
const projectRootParam = url.searchParams.get('projectRoot');
if (projectRootParam && !isValidProjectRoot(projectRootParam)) {
return NextResponse.json(
{ error: 'Invalid projectRoot path' },
{ status: 400 }
);
}
const projectRoot = projectRootParam || undefined;
const history = activityEventBus.getHistory(projectRoot);
return Response.json(history);

View file

@ -1,17 +1,42 @@
import { NextResponse } from 'next/server';
import path from 'node:path';
import { readIssuesFromDisk } from '../../../../../lib/read-issues';
import { activityEventBus } from '../../../../../lib/realtime';
import { getAgentMetrics } from '../../../../../lib/agent-sessions';
export const dynamic = 'force-dynamic';
function isValidProjectRoot(root: string): boolean {
try {
const resolved = path.resolve(root);
if (!path.isAbsolute(resolved)) {
return false;
}
// Prevent path traversal by ensuring resolved path stays within the project root
const allowedBase = process.cwd();
const relative = path.relative(allowedBase, resolved);
// If "resolved" is outside "allowedBase", "relative" will start with ".."
if (relative.startsWith('..') || path.isAbsolute(relative)) {
return false;
}
return true;
} catch {
return false;
}
}
export async function GET(
request: Request,
{ params }: { params: Promise<{ agentId: string }> }
): Promise<Response> {
const { agentId } = await params;
const url = new URL(request.url);
const projectRoot = url.searchParams.get('projectRoot') ?? process.cwd();
const projectRootParam = url.searchParams.get('projectRoot');
const projectRoot = projectRootParam ?? process.cwd();
if (projectRootParam && !isValidProjectRoot(projectRootParam)) {
return NextResponse.json({ ok: false, error: 'Invalid projectRoot path' }, { status: 400 });
}
try {
const issues = await readIssuesFromDisk({ projectRoot, preferBd: true });

View file

@ -1,23 +1,51 @@
import { NextResponse } from 'next/server';
import path from 'node:path';
import { readIssuesFromDisk } from '../../../../lib/read-issues';
export const dynamic = 'force-dynamic';
function isValidProjectRoot(root: string): boolean {
try {
const resolved = path.resolve(root);
if (!path.isAbsolute(resolved)) {
return false;
}
// Prevent path traversal by ensuring resolved path stays within the project root
const allowedBase = process.cwd();
const relative = path.relative(allowedBase, resolved);
// If "resolved" is outside "allowedBase", "relative" will start with ".."
if (relative.startsWith('..') || path.isAbsolute(relative)) {
return false;
}
return true;
} catch {
return false;
}
}
export async function GET(request: Request): Promise<Response> {
const url = new URL(request.url);
const projectRoot = url.searchParams.get('projectRoot') ?? process.cwd();
const projectRootParam = url.searchParams.get('projectRoot');
const projectRoot = projectRootParam ?? process.cwd();
if (projectRootParam && !isValidProjectRoot(projectRootParam)) {
return NextResponse.json(
{ ok: false, error: { classification: 'validation', message: 'Invalid projectRoot path' } },
{ status: 400 }
);
}
try {
const issues = await readIssuesFromDisk({ projectRoot, preferBd: true });
return NextResponse.json({ ok: true, issues });
} catch (error) {
console.error('[API/BeadsRead] Failed to read issues:', error);
return NextResponse.json(
{
ok: false,
error: {
classification: 'unknown',
message: error instanceof Error ? error.message : 'Failed to read issues.',
classification: 'internal_error',
message: 'An internal error occurred while reading issues.',
},
},
{ status: 500 },

View file

@ -17,6 +17,8 @@ async function readLastTouchedVersion(filePath: string): Promise<number | null>
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return null;
}
// Log non-ENOENT errors but don't swallow them silently
console.error('[Events] Failed to read last-touched version:', error);
return null;
}
}
@ -84,18 +86,27 @@ export async function GET(request: Request): Promise<Response> {
const lastTouchedPath = path.join(projectRoot, '.beads', 'last-touched');
let lastTouchedVersion: number | null = null;
let isPolling = false;
const pollLastTouched = async () => {
const nextVersion = await readLastTouchedVersion(lastTouchedPath);
if (nextVersion === null) {
if (isPolling) {
return;
}
if (lastTouchedVersion === null) {
lastTouchedVersion = nextVersion;
return;
}
if (nextVersion !== lastTouchedVersion) {
lastTouchedVersion = nextVersion;
write(toSseFrame(issuesEventBus.emit(projectRoot, lastTouchedPath, 'telemetry')));
isPolling = true;
try {
const nextVersion = await readLastTouchedVersion(lastTouchedPath);
if (nextVersion === null) {
return;
}
if (lastTouchedVersion === null) {
lastTouchedVersion = nextVersion;
return;
}
if (nextVersion !== lastTouchedVersion) {
lastTouchedVersion = nextVersion;
write(toSseFrame(issuesEventBus.emit(projectRoot, lastTouchedPath, 'telemetry')));
}
} finally {
isPolling = false;
}
};

View file

@ -1,14 +1,42 @@
import { NextResponse } from 'next/server';
import path from 'node:path';
import { readIssuesFromDisk } from '../../../lib/read-issues';
import { activityEventBus } from '../../../lib/realtime';
import { buildSessionTaskFeed, getCommunicationSummary, getAgentLivenessMap, calculateIncursions } from '../../../lib/agent-sessions';
import { listAgents } from '../../../lib/agent-registry';
function isValidProjectRoot(root: string): boolean {
try {
const resolved = path.resolve(root);
if (!path.isAbsolute(resolved)) {
return false;
}
// Prevent path traversal by ensuring resolved path stays within the project root
const allowedBase = process.cwd();
const relative = path.relative(allowedBase, resolved);
// If "resolved" is outside "allowedBase", "relative" will start with ".."
if (relative.startsWith('..') || path.isAbsolute(relative)) {
return false;
}
return true;
} catch {
return false;
}
}
export const dynamic = 'force-dynamic';
export async function GET(request: Request): Promise<Response> {
const url = new URL(request.url);
const projectRoot = url.searchParams.get('projectRoot') ?? process.cwd();
const projectRootParam = url.searchParams.get('projectRoot');
const projectRoot = projectRootParam ?? process.cwd();
if (projectRootParam && !isValidProjectRoot(projectRoot)) {
return NextResponse.json(
{ ok: false, error: { classification: 'validation', message: 'Invalid projectRoot path' } },
{ status: 400 }
);
}
try {
const issues = await readIssuesFromDisk({ projectRoot, preferBd: true });
@ -16,7 +44,7 @@ export async function GET(request: Request): Promise<Response> {
const communication = await getCommunicationSummary();
const livenessMap = await getAgentLivenessMap(projectRoot, activity);
const incursions = await calculateIncursions();
const agentsResult = await listAgents({});
const agentsResult = await listAgents({}, { projectRoot });
const feed = buildSessionTaskFeed(issues, activity, communication, livenessMap);
@ -33,8 +61,8 @@ export async function GET(request: Request): Promise<Response> {
{
ok: false,
error: {
classification: 'unknown',
message: error instanceof Error ? error.message : 'Failed to load session feed.',
classification: 'internal_error',
message: 'An internal error occurred while loading the session feed.',
},
},
{ status: 500 },

View file

@ -1,5 +1,6 @@
'use client';
import { useMemo } from 'react';
import { motion } from 'framer-motion';
import type { KanbanFilterOptions, KanbanStats } from '../../lib/kanban';
@ -12,6 +13,7 @@ interface KanbanControlsProps {
filters: KanbanFilterOptions;
stats: KanbanStats;
epics: BeadIssue[];
issues: BeadIssue[];
onFiltersChange: (filters: KanbanFilterOptions) => void;
onNextActionable: () => void;
nextActionableFeedback?: string | null;
@ -21,6 +23,7 @@ export function KanbanControls({
filters,
stats,
epics,
issues,
onFiltersChange,
onNextActionable,
nextActionableFeedback = null,
@ -29,12 +32,24 @@ export function KanbanControls({
'ui-field rounded-xl px-3 py-2.5 text-sm outline-none transition';
// Build bead counts map for EpicChipStrip
const beadCounts = new Map<string, number>();
for (const epic of epics) {
// Count non-epic issues that belong to this epic
const count = epic.dependencies?.filter(d => d.type === 'parent' && d.target === epic.id).length ?? 0;
beadCounts.set(epic.id, count);
}
// Count non-epic issues that have this epic as their parent
const beadCounts = useMemo(() => {
const counts = new Map<string, number>();
for (const epic of epics) {
let count = 0;
for (const issue of issues) {
if (issue.issue_type === 'epic') continue;
const parentDep = issue.dependencies.find(d => d.type === 'parent');
const inferredParent = issue.id.includes('.') ? issue.id.split('.')[0] : null;
const parentEpicId = parentDep?.target ?? inferredParent;
if (parentEpicId === epic.id) {
count++;
}
}
counts.set(epic.id, count);
}
return counts;
}, [epics, issues]);
return (
<section className="grid gap-3">

View file

@ -230,6 +230,7 @@ export function KanbanPage({
filters={filters}
stats={stats}
epics={localIssues.filter((issue) => issue.issue_type === 'epic')}
issues={localIssues}
onFiltersChange={setFilters}
onNextActionable={handleNextActionable}
nextActionableFeedback={nextActionableFeedback}

View file

@ -1,7 +1,7 @@
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import type { BeadStatus } from '@/lib/types';
type BeadStatus = 'ready' | 'in_progress' | 'blocked' | 'closed';
type BadgeSize = 'sm' | 'md';
interface StatusBadgeProps {
@ -9,11 +9,14 @@ interface StatusBadgeProps {
size?: BadgeSize;
}
const STATUS_CLASSES: Record<BeadStatus, string> = {
ready: 'border-teal-500/30 bg-teal-500/15 text-teal-200',
const STATUS_CLASSES: Partial<Record<BeadStatus, string>> = {
open: 'border-teal-500/30 bg-teal-500/15 text-teal-200',
in_progress: 'border-green-500/30 bg-green-500/15 text-green-200',
blocked: 'border-amber-500/30 bg-amber-500/15 text-amber-200',
deferred: 'border-slate-500/30 bg-slate-500/15 text-slate-300',
closed: 'border-slate-500/30 bg-slate-500/15 text-slate-300',
pinned: 'border-purple-500/30 bg-purple-500/15 text-purple-200',
hooked: 'border-cyan-500/30 bg-cyan-500/15 text-cyan-200',
};
const SIZE_CLASSES: Record<BadgeSize, string> = {
@ -21,24 +24,30 @@ const SIZE_CLASSES: Record<BadgeSize, string> = {
md: 'text-xs px-2.5 py-0.5',
};
const STATUS_LABELS: Record<BeadStatus, string> = {
ready: 'Ready',
const STATUS_LABELS: Partial<Record<BeadStatus, string>> = {
open: 'Open',
in_progress: 'In Progress',
blocked: 'Blocked',
deferred: 'Deferred',
closed: 'Closed',
pinned: 'Pinned',
hooked: 'Hooked',
};
export function StatusBadge({ status, size = 'md' }: StatusBadgeProps) {
const statusClass = STATUS_CLASSES[status] || 'border-slate-500/30 bg-slate-500/15 text-slate-300';
const statusLabel = STATUS_LABELS[status] || status;
return (
<Badge
variant="outline"
className={cn(
'rounded-md border font-semibold',
STATUS_CLASSES[status],
statusClass,
SIZE_CLASSES[size]
)}
>
{STATUS_LABELS[status]}
{statusLabel}
</Badge>
);
}

View file

@ -66,7 +66,6 @@ export function useBeadsSubscription(
}, [projectRoot, onUpdate]);
useEffect(() => {
console.log('[SSE] Connecting to event source for:', projectRoot);
const source = new EventSource(`/api/events?projectRoot=${encodeURIComponent(projectRoot)}`);
source.onopen = () => {
@ -100,12 +99,12 @@ export function useBeadsSubscription(
source.addEventListener('activity', onActivity as EventListener);
return () => {
console.log('[SSE] Closing connection');
source.removeEventListener('issues', onIssues as EventListener);
source.removeEventListener('telemetry', onTelemetry as EventListener);
source.removeEventListener('activity', onActivity as EventListener);
source.close();
};
// onUpdate is intentionally excluded from deps to avoid re-subscribing on parent re-renders
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projectRoot, refresh]);

View file

@ -98,6 +98,12 @@ function trimOrEmpty(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function isValidMessageId(value: string): boolean {
// Message IDs must be alphanumeric with underscores, hyphens, and colons
// This prevents path traversal attacks
return /^[a-zA-Z0-9_\-:]+$/.test(value);
}
function success<T>(command: MailCommandName, data: T): MailCommandResponse<T> {
return {
ok: true,
@ -352,6 +358,10 @@ export async function readAgentMessage(
return invalid(command, 'MESSAGE_NOT_FOUND', 'Message id is required.');
}
if (!isValidMessageId(messageId)) {
return invalid(command, 'INVALID_MESSAGE_ID', 'Message id contains invalid characters.');
}
try {
const existing = await readMessageIndex(messageId);
if (!existing) {
@ -396,6 +406,10 @@ export async function ackAgentMessage(
return invalid(command, 'MESSAGE_NOT_FOUND', 'Message id is required.');
}
if (!isValidMessageId(messageId)) {
return invalid(command, 'INVALID_MESSAGE_ID', 'Message id contains invalid characters.');
}
try {
const existing = await readMessageIndex(messageId);
if (!existing) {

View file

@ -123,7 +123,7 @@ function trimOrEmpty(value: unknown): string {
async function callBdAgentShow(beadId: string, projectRoot: string): Promise<AgentRecord | null> {
const showResult = await runBdCommand({
projectRoot,
args: ['show', beadId, '--json'],
args: ['agent', 'show', beadId, '--json'],
});
if (!showResult.success) {

View file

@ -215,6 +215,49 @@ async function readActiveReservations(): Promise<AgentReservation[]> {
}
}
// Simple mutex-based locking using a shared lock file to prevent race conditions
const LOCK_FILE_PATH = path.join(reservationsRoot(), '.lock');
async function lockActiveReservations(): Promise<void> {
// Ensure the directory exists
await fs.mkdir(path.dirname(LOCK_FILE_PATH), { recursive: true });
// Use a simple file-based mutex - create file exclusively, fail if exists
let attempts = 0;
const maxAttempts = 100;
while (attempts < maxAttempts) {
try {
await fs.writeFile(LOCK_FILE_PATH, String(process.pid), { flag: 'wx' });
return;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
// Lock file exists, wait and retry
await new Promise(resolve => setTimeout(resolve, 50));
attempts++;
continue;
}
throw error;
}
}
throw new Error('Failed to acquire lock after maximum attempts');
}
async function unlockActiveReservations(): Promise<void> {
try {
const content = await fs.readFile(LOCK_FILE_PATH, 'utf8');
// Only release if we own the lock
if (content.trim() === String(process.pid)) {
await fs.unlink(LOCK_FILE_PATH);
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error;
}
// Lock file doesn't exist, ignore
}
}
async function atomicWriteJson(filePath: string, payload: string): Promise<void> {
await fs.mkdir(path.dirname(filePath), { recursive: true });
@ -317,6 +360,9 @@ export async function reserveAgentScope(
}
try {
// Acquire exclusive lock to prevent race conditions
await lockActiveReservations();
const now = deps.now ? deps.now() : new Date().toISOString();
const reservations = await readActiveReservations();
const normalizedScope = normalizePath(scope);
@ -384,6 +430,8 @@ export async function reserveAgentScope(
return success(command, created);
} catch (error) {
return invalid(command, 'INTERNAL_ERROR', error instanceof Error ? error.message : 'Failed to reserve scope.');
} finally {
await unlockActiveReservations();
}
}
@ -405,6 +453,9 @@ export async function releaseAgentReservation(
}
try {
// Acquire exclusive lock to prevent race conditions
await lockActiveReservations();
const now = deps.now ? deps.now() : new Date().toISOString();
const reservations = await readActiveReservations();
const normalizedScope = normalizePath(scope);
@ -436,6 +487,8 @@ export async function releaseAgentReservation(
return success(command, released);
} catch (error) {
return invalid(command, 'INTERNAL_ERROR', error instanceof Error ? error.message : 'Failed to release reservation.');
} finally {
await unlockActiveReservations();
}
}

View file

@ -77,7 +77,14 @@ function asNonEmptyString(value: unknown, field: string): string {
if (typeof value !== 'string' || !value.trim()) {
throw new MutationValidationError(`"${field}" is required.`);
}
return value.trim();
const trimmed = value.trim();
// Remove control characters that could cause issues in command execution
// Preserve backslashes for Windows paths and punctuation for user text
const sanitized = trimmed.replace(/[\x00-\x1f\x7f]/g, '');
if (!sanitized) {
throw new MutationValidationError(`"${field}" contains only invalid characters.`);
}
return sanitized;
}
function asOptionalString(value: unknown): string | undefined {

View file

@ -84,7 +84,7 @@ export function parseIssuesJsonl(text: string, options: ParseIssuesOptions = {})
}
// Exclude agent identities from standard mission lists
if (normalized.labels.includes('gt:agent')) {
if (!options.skipAgentFilter && normalized.labels.includes('gt:agent')) {
continue;
}

View file

@ -1,3 +1,4 @@
import path from 'node:path';
import { canonicalizeWindowsPath, windowsPathKey } from './pathing';
import type { ActivityEvent } from './activity';
@ -38,6 +39,7 @@ export class IssuesEventBus {
private nextSubscriberId = 1;
emit(projectRoot: string, changedPath?: string, kind: IssuesChangeKind = 'changed'): IssuesChangedEvent {
console.log(`[IssuesBus] Emitting event: ${kind} for project (${changedPath ? path.basename(changedPath) : 'unknown'})`);
const canonicalProjectRoot = canonicalizeWindowsPath(projectRoot);
const projectKey = windowsPathKey(canonicalProjectRoot);
console.log(`[IssuesBus] Emitting event: ${kind} for ${projectKey} (path: ${changedPath}, subscribers: ${this.subscribers.size})`);
@ -97,6 +99,7 @@ export class ActivityEventBus {
private readonly history: ActivityEvent[] = [];
private readonly MAX_HISTORY = 100;
private initialized = false;
private savePromise: Promise<void> | null = null;
private nextSubscriberId = 1;
@ -118,14 +121,30 @@ export class ActivityEventBus {
};
this.nextEventId += 1;
// Capture history snapshot BEFORE modification for persistence
const historySnapshot = [...this.history];
// Buffer history
this.history.unshift(activity);
if (this.history.length > this.MAX_HISTORY) {
this.history.pop();
}
// Persist async
void saveActivityHistory(this.history);
// Persist async with deduplication - wait for any pending save to complete
const persist = async () => {
try {
await saveActivityHistory(historySnapshot);
} catch (error) {
console.error('[ActivityEventBus] Failed to save history:', error);
}
};
if (this.savePromise === null) {
this.savePromise = persist();
} else {
// Chain to existing promise to prevent concurrent writes
this.savePromise = this.savePromise.then(persist);
}
for (const subscriber of this.subscribers.values()) {
if (!subscriber.projectKey || subscriber.projectKey === projectKey) {

View file

@ -78,7 +78,7 @@ export function diffSnapshots(
// 5. Collection Changes (Dependencies)
diffDependencies(prev.dependencies, curr.dependencies).forEach(kindAndTarget => {
events.push(createEvent(kindAndTarget.kind, curr, now, { to: kindAndTarget.target }));
events.push(createEvent(kindAndTarget.kind, curr, now, { to: kindAndTarget.target, field: kindAndTarget.type }));
});
});
@ -129,25 +129,28 @@ function areArraysEqual(a: string[], b: string[]): boolean {
/**
* Detects added and removed dependencies.
* Uses composite key `${type}:${target}` to detect type changes as well.
*/
function diffDependencies(
prev: BeadDependency[],
curr: BeadDependency[]
): { kind: 'dependency_added' | 'dependency_removed', target: string }[] {
const changes: { kind: 'dependency_added' | 'dependency_removed', target: string }[] = [];
): { kind: 'dependency_added' | 'dependency_removed', target: string, type: string }[] {
const changes: { kind: 'dependency_added' | 'dependency_removed', target: string, type: string }[] = [];
const prevTargets = new Set(prev.map(d => d.target));
const currTargets = new Set(curr.map(d => d.target));
const prevKeys = new Set(prev.map(d => `${d.type}:${d.target}`));
const currKeys = new Set(curr.map(d => `${d.type}:${d.target}`));
curr.forEach(d => {
if (!prevTargets.has(d.target)) {
changes.push({ kind: 'dependency_added', target: d.target });
const key = `${d.type}:${d.target}`;
if (!prevKeys.has(key)) {
changes.push({ kind: 'dependency_added', target: d.target, type: d.type });
}
});
prev.forEach(d => {
if (!currTargets.has(d.target)) {
changes.push({ kind: 'dependency_removed', target: d.target });
const key = `${d.type}:${d.target}`;
if (!currKeys.has(key)) {
changes.push({ kind: 'dependency_removed', target: d.target, type: d.type });
}
});

View file

@ -19,6 +19,11 @@ function getGlobalAgentMessagesPath(): string {
interface WatchRegistration {
projectRoot: string;
watcher: FSWatcher;
handlers?: {
onAdd: (changedPath: string) => void;
onChange: (changedPath: string) => void;
onUnlink: (changedPath: string) => void;
};
}
export interface WatchManagerOptions {
@ -152,13 +157,19 @@ export class IssuesWatchManager {
this.queueCoalescedEvent(projectRoot, changedPath, kind);
};
watcher.on('add', (changedPath) => onFileEvent('add', changedPath));
watcher.on('change', (changedPath) => onFileEvent('change', changedPath));
watcher.on('unlink', (changedPath) => onFileEvent('unlink', changedPath));
// Store references to event handlers for proper cleanup
const onAdd = (changedPath: string) => onFileEvent('add', changedPath);
const onChange = (changedPath: string) => onFileEvent('change', changedPath);
const onUnlink = (changedPath: string) => onFileEvent('unlink', changedPath);
watcher.on('add', onAdd);
watcher.on('change', onChange);
watcher.on('unlink', onUnlink);
this.registrations.set(projectKey, {
projectRoot,
watcher,
handlers: { onAdd, onChange, onUnlink },
});
}
@ -170,6 +181,14 @@ export class IssuesWatchManager {
}
this.coalescer.cancel(projectRoot);
// Explicitly remove event listeners before closing to prevent memory leaks
if (registration.handlers) {
registration.watcher.removeListener('add', registration.handlers.onAdd);
registration.watcher.removeListener('change', registration.handlers.onChange);
registration.watcher.removeListener('unlink', registration.handlers.onUnlink);
}
this.registrations.delete(projectKey);
await registration.watcher.close();
}
@ -178,6 +197,12 @@ export class IssuesWatchManager {
const closeOps: Promise<void>[] = [];
for (const registration of this.registrations.values()) {
// Explicitly remove event listeners before closing to prevent memory leaks
if (registration.handlers) {
registration.watcher.removeListener('add', registration.handlers.onAdd);
registration.watcher.removeListener('change', registration.handlers.onChange);
registration.watcher.removeListener('unlink', registration.handlers.onUnlink);
}
closeOps.push(registration.watcher.close());
}

View file

@ -3,7 +3,7 @@ import tailwindcssAnimate from 'tailwindcss-animate';
const config: Config = {
darkMode: ['class'],
content: ['./src/**/*.{ts,tsx}'],
content: ['./src/**/*.{ts,tsx}', './components/**/*.{ts,tsx}'],
theme: {
extend: {
fontFamily: {

View file

@ -46,7 +46,11 @@ function printResponse(response: AnyCommandResponse, json: boolean) {
console.log(`Agent: ${d.agent_id}\nRole: ${d.role}\nStatus: ${d.status}\nLast Seen: ${d.last_seen_at}`);
} else if (response.command === 'agent activity-lease') {
const d = response.data;
console.log(`✓ Activity lease extended: ${d.agent_id} (version: ${d.version})`);
if (d) {
console.log(`✓ Activity lease extended: ${d.agent_id} (version: ${d.version})`);
} else {
console.log(`✓ Activity lease extended.`);
}
} else if (response.command === 'agent send') {
const d = response.data;
console.log(`✓ Message sent: ${d.message_id} (state: ${d.state})`);
@ -176,7 +180,7 @@ async function main() {
// we extend their lease as a side-effect of real work.
// This provides observability WITHOUT background workers or popups.
const targetAgent = stringArg(values.agent) || stringArg(values.from) || stringArg(values.name);
if (targetAgent && command !== 'register') {
if (targetAgent && command !== 'register' && command !== 'activity-lease') {
await extendActivityLease({ agent: targetAgent }, deps).catch(() => {});
}