refactor: extract agent bounded context + fix SSE comments + cleanup unused

- Extract src/lib/agent/ bounded context with types, registry, messaging
- Add comments_count to BeadIssue for SSE comment detection
- Create batch endpoints for mail/reservations APIs
- Add memory validation to session-preflight
- Remove unused empty dirs (mockup, sessions, timeline)
- Move stashes to docs/references, gitignore them
This commit is contained in:
zenchantlive 2026-03-04 22:06:40 -08:00
parent 6f41c4af31
commit 18fbafdce4
34 changed files with 62714 additions and 1970 deletions

View file

@ -1 +1 @@
1910
44920

View file

@ -0,0 +1 @@
1772664767

File diff suppressed because it is too large Load diff

1
.beads/dolt-server.pid Normal file
View file

@ -0,0 +1 @@
45716

1
.beads/dolt-server.port Normal file
View file

@ -0,0 +1 @@
3307

3
.gitignore vendored
View file

@ -19,3 +19,6 @@ artifacts/
# beadboard runtime artifacts
.beadboard/
# Archived stashes for reference
docs/references/stashes/

View file

@ -1,166 +0,0 @@
diff --git a/.gitignore b/.gitignore
index eb35607..596f42a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,6 @@ node_modules/
*.tsbuildinfo
.worktrees/
worktrees/
+
+# bv (beads viewer) local config and caches
+.bv/
diff --git a/src/app/globals.css b/src/app/globals.css
index d17e938..a474080 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -3,15 +3,15 @@
@tailwind utilities;
:root {
- --color-bg: #090c14;
- --color-surface: #101827;
- --color-surface-muted: #192336;
- --color-surface-raised: #22314a;
- --color-text-strong: #f6f8ff;
- --color-text-body: #d8e0f1;
- --color-text-muted: #9caccc;
- --color-border-soft: rgba(145, 166, 204, 0.3);
- --color-border-strong: rgba(187, 209, 246, 0.62);
+ --color-bg: #090909;
+ --color-surface: #161616;
+ --color-surface-muted: #212121;
+ --color-surface-raised: #2a2a2a;
+ --color-text-strong: #f5f5f5;
+ --color-text-body: #d0d0d0;
+ --color-text-muted: #9a9a9a;
+ --color-border-soft: rgba(255, 255, 255, 0.15);
+ --color-border-strong: rgba(255, 255, 255, 0.3);
--status-open: #60a5fa;
--status-progress: #fbbf24;
@@ -38,10 +38,9 @@ body {
body {
background:
- radial-gradient(circle at 10% 12%, rgba(12, 138, 215, 0.34), transparent 36%),
- radial-gradient(circle at 84% 20%, rgba(250, 122, 91, 0.18), transparent 30%),
- radial-gradient(circle at 68% 88%, rgba(57, 189, 154, 0.14), transparent 36%),
- linear-gradient(155deg, #05070d 0%, #0b1322 42%, #121e34 100%);
+ radial-gradient(circle at 14% 12%, rgba(255, 255, 255, 0.05), transparent 36%),
+ radial-gradient(circle at 84% 18%, rgba(255, 180, 80, 0.06), transparent 32%),
+ linear-gradient(160deg, #070707 0%, #101010 48%, #161616 100%);
color: var(--color-text-body);
- font-family: 'Segoe UI', 'Aptos', Inter, system-ui, sans-serif;
+ font-family: 'DM Sans', 'Segoe UI', Inter, system-ui, sans-serif;
}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index ff1ad90..1417e77 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,7 +1,18 @@
import type { Metadata } from 'next';
+import { DM_Sans, JetBrains_Mono } from 'next/font/google';
import type { ReactNode } from 'react';
import './globals.css';
+const dmSans = DM_Sans({
+ subsets: ['latin'],
+ variable: '--font-ui',
+});
+
+const jetbrainsMono = JetBrains_Mono({
+ subsets: ['latin'],
+ variable: '--font-mono',
+});
+
export const metadata: Metadata = {
title: 'BeadBoard',
description: 'Windows-native Beads dashboard',
@@ -10,7 +21,7 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
- <body>{children}</body>
+ <body className={`${dmSans.variable} ${jetbrainsMono.variable}`}>{children}</body>
</html>
);
}
diff --git a/src/components/kanban/kanban-controls.tsx b/src/components/kanban/kanban-controls.tsx
index 78b09f4..e1e04f9 100644
--- a/src/components/kanban/kanban-controls.tsx
+++ b/src/components/kanban/kanban-controls.tsx
@@ -14,7 +14,7 @@ interface KanbanControlsProps {
export function KanbanControls({ filters, stats, onFiltersChange }: KanbanControlsProps) {
const inputClass =
- 'rounded-xl border border-border-soft bg-surface-muted/78 px-3 py-2.5 text-sm text-text-strong outline-none transition placeholder:text-text-muted focus:border-cyan-300/70 focus:ring-2 focus:ring-cyan-300/20';
+ 'rounded-xl border border-border-soft bg-surface-muted/78 px-3 py-2.5 text-sm text-text-strong outline-none transition placeholder:text-text-muted focus:border-border-strong focus:ring-2 focus:ring-white/10';
return (
<section className="grid gap-3">
@@ -57,7 +57,7 @@ export function KanbanControls({ filters, stats, onFiltersChange }: KanbanContro
type="checkbox"
checked={filters.showClosed ?? false}
onChange={(event) => onFiltersChange({ ...filters, showClosed: event.target.checked })}
- className="h-4 w-4 accent-cyan-400"
+ className="h-4 w-4 accent-amber-400"
/>
Show closed
</label>
diff --git a/src/components/shared/chip.tsx b/src/components/shared/chip.tsx
index c1637e6..e29d49d 100644
--- a/src/components/shared/chip.tsx
+++ b/src/components/shared/chip.tsx
@@ -7,7 +7,7 @@ interface ChipProps {
const CHIP_TONE_CLASS: Record<NonNullable<ChipProps['tone']>, string> = {
default: 'border-border-soft bg-surface-muted/75 text-text-body',
- status: 'border-cyan-300/30 bg-cyan-500/20 text-cyan-50',
+ status: 'border-zinc-300/30 bg-zinc-500/20 text-zinc-100',
priority: 'border-amber-300/30 bg-amber-500/20 text-amber-50',
};
diff --git a/tailwind.config.ts b/tailwind.config.ts
index 5ad9067..953965c 100644
--- a/tailwind.config.ts
+++ b/tailwind.config.ts
@@ -5,8 +5,8 @@ const config: Config = {
theme: {
extend: {
fontFamily: {
- ui: ['Segoe UI', 'Inter', 'system-ui', 'sans-serif'],
- mono: ['JetBrains Mono', 'Consolas', 'monospace'],
+ ui: ['var(--font-ui)', 'Segoe UI', 'Inter', 'system-ui', 'sans-serif'],
+ mono: ['var(--font-mono)', 'Consolas', 'monospace'],
},
colors: {
bg: 'var(--color-bg)',
diff --git a/tests/guards/kanban-responsive-contract.test.mjs b/tests/guards/kanban-responsive-contract.test.mjs
index 4e02f28..3efabf4 100644
--- a/tests/guards/kanban-responsive-contract.test.mjs
+++ b/tests/guards/kanban-responsive-contract.test.mjs
@@ -9,11 +9,12 @@ async function read(relativePath) {
return fs.readFile(path.join(ROOT, relativePath), 'utf8');
}
-test('kanban board uses intentional horizontal scroll affordances', async () => {
+test('kanban board uses expandable vertical swimlanes', async () => {
const board = await read('src/components/kanban/kanban-board.tsx');
- assert.match(board, /snap-x/);
- assert.match(board, /overflow-x-auto/);
+ assert.match(board, /aria-expanded/);
+ assert.match(board, /onActivateStatus/);
+ assert.match(board, /max-h-\[50vh\]/);
});
test('kanban page defines mobile detail drawer behavior', async () => {
@@ -21,6 +22,8 @@ test('kanban page defines mobile detail drawer behavior', async () => {
assert.match(page, /fixed inset-0/);
assert.match(page, /lg:hidden/);
+ assert.match(page, /lg:grid-cols-\[minmax\(0,1fr\)_minmax\(22rem,26rem\)\]/);
+ assert.match(page, /lg:border-l/);
});
test('kanban controls use fluid full-width sizing on small viewports', async () => {

File diff suppressed because it is too large Load diff

80
project.md Normal file
View file

@ -0,0 +1,80 @@
# project.md — BeadBoard Driver Session Cache
This file is maintained by agents. A new agent reads this first.
If the Environment Status table shows all `pass`, skip straight to Step 2 of the runbook.
Only re-run a check if its row says `fail` or `unknown`, or if you hit an actual error.
---
## Environment Status Cache
Last updated: YYYY-MM-DD by `<agent-bead-id>`
| Component | Status | Version / Detail | Verified |
|-----------|--------|-----------------|---------|
| `bd` on PATH | `unknown` | | |
| `bb` on PATH | `unknown` | | |
| `.beads` db exists | `unknown` | | |
| `mail.delegate` configured | `unknown` | | |
| `session-preflight` | `unknown` | | |
| `bb agent` registered | `unknown` | `BB_AGENT=` | |
| Tests last run | `unknown` | | |
**Status values:** `pass` · `fail` · `unknown` · `skip` (not applicable to this project)
**Rule:** If every row is `pass` → skip Step 1 entirely and go straight to Step 2.
If any row is `fail` or `unknown` → run only that check, update this table, continue.
---
## Project Identity
- Project name:
- Repository root:
- Primary language/runtime:
- Primary package manager:
## Tooling Baseline
- `bd` installed and on PATH: yes/no — version:
- `bb` installed and on PATH: yes/no — version:
- Detection commands used:
- Shell/platform: (e.g. WSL2/bash, macOS/zsh, Windows/PowerShell)
## BeadBoard/Communication Setup
- `.beads` database: exists/created on YYYY-MM-DD via `bd init`
- Mail delegate: `bd config set mail.delegate "node <abs-path>/scripts/bb-mail-shim.mjs"` — configured YYYY-MM-DD
- Agent identity policy: `export BB_AGENT=<role-name>` (set fresh each session in Step 2)
- `session-preflight` last pass: YYYY-MM-DD
## Agent State + Heartbeat Policy
- Agent bead naming: `bb-<role-name>` (e.g. `bb-silver-scribe`)
- Required state transitions: `spawning → running → working → stuck/done/stopped`
- Heartbeat: LLM agents heartbeat at turn start + before long commands; daemon agents every 5 min
## Command Baseline
- Install:
- Build:
- Typecheck:
- Lint:
- Test:
## Known Workarounds
Document only stable, repeatable workarounds.
1. Trigger:
- Symptom:
- Workaround:
- Verified:
## Session Log (append-only)
Each agent appends one line when they update this file:
| Date | Agent | What changed |
|------|-------|-------------|
| YYYY-MM-DD | `<agent-bead-id>` | Initial project.md created |

View file

@ -315,6 +315,8 @@ Stop and correct if you are about to:
Swarm composition, molecule operations, worker dispatch patterns.
- `references/missions-realtime.md`:
Real-time/watcher/event troubleshooting.
- `references/creating-beads.md`:
Creating epics, tasks, subtasks with proper naming, dependencies, and workflow.
## Bottom Line

View file

@ -1,7 +1,31 @@
# Project Driver Template
# project.md — BeadBoard Driver Session Cache
Use this template to create `project.md` at the target repository root.
The first agent in a repo should create this file; later agents must read and update it before work.
This file is maintained by agents. A new agent reads this first.
If the Environment Status table shows all `pass`, skip straight to Step 2 of the runbook.
Only re-run a check if its row says `fail` or `unknown`, or if you hit an actual error.
---
## Environment Status Cache
Last updated: YYYY-MM-DD by `<agent-bead-id>`
| Component | Status | Version / Detail | Verified |
|-----------|--------|-----------------|---------|
| `bd` on PATH | `unknown` | | |
| `bb` on PATH | `unknown` | | |
| `.beads` db exists | `unknown` | | |
| `mail.delegate` configured | `unknown` | | |
| `session-preflight` | `unknown` | | |
| `bb agent` registered | `unknown` | `BB_AGENT=` | |
| Tests last run | `unknown` | | |
**Status values:** `pass` · `fail` · `unknown` · `skip` (not applicable to this project)
**Rule:** If every row is `pass` → skip Step 1 entirely and go straight to Step 2.
If any row is `fail` or `unknown` → run only that check, update this table, continue.
---
## Project Identity
@ -10,68 +34,33 @@ The first agent in a repo should create this file; later agents must read and up
- Primary language/runtime:
- Primary package manager:
## Tooling Baseline (Global Installs)
## Tooling Baseline
Record what is already installed on this machine so later agents do not re-check unnecessarily.
- `bd` installed and on PATH: yes/no
- `bb` or `beadboard` installed and on PATH: yes/no
- Detection commands used (with date):
- Notes on shell/platform quirks (WSL/Windows/macOS/Linux):
- `bd` installed and on PATH: yes/no — version:
- `bb` installed and on PATH: yes/no — version:
- Detection commands used:
- Shell/platform: (e.g. WSL2/bash, macOS/zsh, Windows/PowerShell)
## BeadBoard/Communication Setup
- Mail delegate command configured:
- `bd config set mail.delegate "node <abs-path>/skills/beadboard-driver/scripts/bb-mail-shim.mjs"`
- Agent identity env var policy:
- Preferred: `BB_AGENT=<agent-id>`
- Fallback: `BD_ACTOR=<agent-id>`
- Delegate validation status:
- `node skills/beadboard-driver/scripts/ensure-bb-mail-configured.mjs` pass/fail
- Session preflight status:
- `node skills/beadboard-driver/scripts/session-preflight.mjs` pass/fail
- `.beads` database: exists/created on YYYY-MM-DD via `bd init`
- Mail delegate: `bd config set mail.delegate "node <abs-path>/scripts/bb-mail-shim.mjs"` — configured YYYY-MM-DD
- Agent identity policy: `export BB_AGENT=<role-name>` (set fresh each session in Step 2)
- `session-preflight` last pass: YYYY-MM-DD
## Agent State + Heartbeat Policy
- Agent bead naming convention for this repo:
- Required state transitions (spawning -> running -> working -> stuck/done/stopped):
- Heartbeat cadence during active work (recommended 30-120s):
- Stuck escalation timeout before user ping:
## Swarm / Formula Defaults
- Primary epic/swarm pattern used by this repo:
- Formula/proto id(s) commonly used (if any):
- Preferred swarm command flow (`bd swarm validate/create/status` etc.):
- Agent bead naming: `bb-<role-name>` (e.g. `bb-silver-scribe`)
- Required state transitions: `spawning → running → working → stuck/done/stopped`
- Heartbeat: LLM agents heartbeat at turn start + before long commands; daemon agents every 5 min
## Command Baseline
- Install command:
- Build command:
- Typecheck command:
- Lint command:
- Test command:
- Smoke command (optional):
## Verification Policy Overrides
- Required gates for this project:
- Known slow gates and timeout guidance:
- Evidence format expected in bead notes:
## Scope and Safety
- Forbidden commands/actions for this repo:
- Paths requiring reservation before edits:
- External systems requiring human approval:
- Secret handling guidance:
## Coordination Defaults
- Default handoff style:
- Blocker escalation policy:
- ACK expectations for `HANDOFF`/`BLOCKED`:
- Reservation conflict policy (`--takeover-stale` rules):
- Install:
- Build:
- Typecheck:
- Lint:
- Test:
## Known Workarounds
@ -80,24 +69,12 @@ Document only stable, repeatable workarounds.
1. Trigger:
- Symptom:
- Workaround:
- Verification:
- Owner:
- Verified:
2. Trigger:
- Symptom:
- Workaround:
- Verification:
- Owner:
## Session Log (append-only)
## Session Closeout Checklist
Each agent appends one line when they update this file:
- [ ] Bead status/assignee updated
- [ ] Verification commands executed and recorded
- [ ] Artifacts attached/linked
- [ ] Memory review performed
- [ ] Follow-up beads created (if needed)
- [ ] `project.md` updated with any new environment facts
## Change Log
- YYYY-MM-DD: Initial `project.md` created from template.
| Date | Agent | What changed |
|------|-------|-------------|
| YYYY-MM-DD | `<agent-bead-id>` | Initial project.md created |

View file

@ -44,7 +44,7 @@ Blocked condition:
```bash
bd agent state bb-silver-scribe stuck
bb agent send --from silver-scribe --to cobalt-ridge --bead beadboard-123 --category BLOCKED --subject "Waiting on schema" --body "Need migration direction before continuing."
bd mail send --to cobalt-ridge --bead beadboard-123 --category BLOCKED --subject "Waiting on schema" --body "Need migration direction before continuing."
```
Work completion:
@ -74,14 +74,16 @@ Use `bd agent heartbeat <agent-bead-id>` to refresh `last_activity` without chan
bd agent heartbeat bb-silver-scribe
```
When to heartbeat:
- At least once every 5-10 minutes during long-running work
**Daemon agents (persistent processes):**
- Normal work: every 5 minutes
- High-risk long operations: every 2-3 minutes
- Immediately before long test/build phases
- Immediately after recovering from interruptions
Recommended cadence:
- Normal work: every 5 minutes
- High-risk long operations: every 2-3 minutes
**LLM agents (Claude Code, turn-based):**
- At turn start (when picking up work)
- Immediately before long-running commands
- Inter-turn silence is expected and not a health signal
## Witness Death Timeout
@ -95,6 +97,8 @@ Operational interpretation:
Agent-side rule:
- If you are alive and still executing, heartbeat before anyone has to guess.
> **Current status:** The Witness enforcement layer is not yet running. Heartbeats are recorded in `last_activity` and visible in the BeadBoard dashboard but are not currently auto-enforced. Agents will not be auto-marked `dead`. Daemon implementation is a future epic.
## Slot Operations (Current Work Attachment)
The `hook` slot links an agent bead to the active task bead.
@ -125,7 +129,7 @@ Important slot constraints:
When blocked:
1. Set state to stuck (`bd agent state ... stuck`)
2. Send explicit BLOCKED coordination event (`bb agent send --category BLOCKED ...`)
2. Send explicit BLOCKED coordination event (`bd mail send --category BLOCKED ...`)
3. Keep heartbeat active while waiting
4. Resume with `running`/`working` once unblocked

View file

@ -8,8 +8,12 @@ Day-to-day runbooks use `bd mail` delegation rather than direct low-level agent
- `node skills/beadboard-driver/scripts/session-preflight.mjs`
- `node skills/beadboard-driver/scripts/ensure-bb-mail-configured.mjs`
- `bd create --title="Agent: <role-name>" --description="<agent scope>" --type=task --priority=0 --label="gt:agent,role:<orchestrator|ui|graph|backend|infra>"`
- `bd agent state <agent-bead-id> spawning`
- `bd agent state <agent-bead-id> running`
- `bd agent state <agent-bead-id> spawning` — agent bead created, environment not yet verified
- `bd agent state <agent-bead-id> running` — environment verified, ready to claim work
- `bd agent state <agent-bead-id> working` — work bead claimed, actively executing
- `bd agent state <agent-bead-id> stuck` — blocked, waiting on intervention or response
- `bd agent state <agent-bead-id> done` — work bead closed, all deliverables complete
- `bd agent state <agent-bead-id> stopped` — session ending cleanly
- `bd agent heartbeat <agent-bead-id>`
- `bd agent show <agent-bead-id>`
@ -75,7 +79,7 @@ Delegate setup and validation:
## Environment and Repair Helpers
- `node skills/beadboard-driver/scripts/resolve-bb.mjs`
- `node skills/beadboard-driver/scripts/readiness-report.mjs --checks <json> --artifacts <json>`
- `node skills/beadboard-driver/scripts/diagnose-env.mjs`
- `node skills/beadboard-driver/scripts/heal-common-issues.mjs [--project-root <path>] [--apply] [--fix-git-index-lock]`
- `node {baseDir}/scripts/setup-mail-delegate.mjs` — configure mail.delegate (self-resolves shim path)
- `node {baseDir}/scripts/readiness-report.mjs --checks <json> --artifacts <json>`
- `node {baseDir}/scripts/diagnose-env.mjs`
- `node {baseDir}/scripts/heal-common-issues.mjs [--project-root <path>] [--apply] [--fix-git-index-lock]`

View file

@ -0,0 +1,286 @@
# Creating and Managing Beads
Complete guide for agents on bead creation, naming, dependencies, and workflow.
---
## Bead Naming Format (CRITICAL)
| Level | Format | Example |
|-------|--------|---------|
| **Epic** | `beadboard-<id>` | `beadboard-abc` |
| **Task** | `beadboard-<epic>.x` | `beadboard-abc.1` |
| **Subtask** | `beadboard-<epic>.x.x` | `beadboard-abc.1.2` |
**Rules:**
- `<id>` is auto-generated by `bd create` (alphanumeric, 2-4 chars)
- Task numbers increment sequentially under epic
- Subtask numbers increment under parent task
- **Never skip levels**: epic → task → subtask
---
## Creating Epics
Epics represent high-level features or initiatives.
```bash
bd create \
--title="[EPIC] User Authentication System" \
--description="Implement secure user login/registration with OAuth support" \
--type=epic \
--priority=2 \
--label="feature,auth"
```
**Output:** `Created beadboard-xyz (epic)`
---
## Creating Tasks Under Epics
Tasks are actionable work units under an epic.
```bash
# Task naming convention: epic.1: Description
bd create \
--title="xyz.1: Implement login form UI" \
--description="Build responsive login form with email/password fields" \
--type=task \
--priority=2 \
--parent=beadboard-xyz
```
**Output:** `Created beadboard-xyz.1 (task)`
### Creating Subtasks
```bash
# Subtask naming: epic.task.subtask: Description
bd create \
--title="xyz.1.1: Add form validation" \
--description="Implement client-side validation for email format" \
--type=task \
--priority=1 \
--parent=beadboard-xyz.1
```
---
## Setting Dependencies
### Blocker Dependencies (Execution Order)
Use when one bead must complete before another starts:
```bash
# xyz.1 cannot start until xyz is done
bd dep add beadboard-xyz.1 beadboard-xyz
# xyz.1.2 is blocked by xyz.1.1
bd dep add beadboard-xyz.1.2 beadboard-xyz.1.1
```
### Parent-Child Relationships
Use for semantic grouping (non-blocking):
```bash
# Link child to parent epic
bd dep relate beadboard-xyz beadboard-xyz.1
# Link subtask to task
bd dep relate beadboard-xyz.1 beadboard-xyz.1.1
```
### Viewing Dependencies
```bash
bd dep list beadboard-xyz # Show all dependencies
bd ready # Show unblocked, ready-to-start beads
```
---
## Writing Descriptions
Every bead description MUST include:
```markdown
**Scope:**
- Specific thing to implement
- Another specific requirement
**Out of Scope:**
- Things explicitly not included
- Future work to avoid
**Success Criteria:**
1. Specific measurable outcome
2. Test passes
3. Evidence of completion
```
### Example Description
```bash
bd create --title="xyz.2: API endpoint" --description="Scope:
- POST /api/login endpoint
- JWT token generation
- Rate limiting (5 req/min)
Out of Scope:
- OAuth providers (in xyz.3)
- Password reset flow
Success Criteria:
1. Endpoint returns 200 with valid JWT
2. Returns 401 for invalid credentials
3. Rate limit enforced" --type=task --priority=2 --parent=beadboard-xyz
```
---
## Closing Beads
### Step 1: Update with Evidence
```bash
bd update beadboard-xyz.1 \
--notes "Tests pass: npm test -- --grep 'login'
Files changed:
- src/components/LoginForm.tsx
- src/lib/validation.ts
Coverage: 94%"
```
### Step 2: Close
```bash
bd close beadboard-xyz.1 --reason "Login form implemented, tested, merged to main"
```
**Rule:** Update first, then close. `bd close` does not accept `--notes`.
---
## Complete Example: End-to-End Workflow
### 1. Create Epic
```bash
$ bd create --title="[EPIC] Payment Integration" --description="Add Stripe payment processing" --type=epic --priority=1 --label="feature,payments"
Created beadboard-pmt (epic)
```
### 2. Create Tasks
```bash
$ bd create --title="pmt.1: Stripe account setup" --description="Scope:
- Create Stripe dev account
- Configure webhook endpoint
- Add API keys to vault
Out of Scope:
- Production account (separate epic)
Success Criteria:
1. Test transactions work in sandbox
2. Webhook receives events" --type=task --priority=1 --parent=beadboard-pmt
Created beadboard-pmt.1 (task)
$ bd create --title="pmt.2: Checkout UI" --description="Scope:
- Payment form component
- Card element integration
- Error handling display
Success Criteria:
1. Form submits to Stripe
2. Shows success/failure states" --type=task --priority=2 --parent=beadboard-pmt
Created beadboard-pmt.2 (task)
```
### 3. Set Dependencies
```bash
# pmt.2 blocked by pmt.1 (need Stripe setup first)
$ bd dep add beadboard-pmt.2 beadboard-pmt.1
Added dependency: beadboard-pmt.2 -> beadboard-pmt.1
```
### 4. Check Ready Status
```bash
$ bd ready
beadboard-pmt.1 [task] pmt.1: Stripe account setup
```
Only `pmt.1` is ready—`pmt.2` is blocked.
### 5. Create Subtask
```bash
$ bd create --title="pmt.1.1: Webhook handler" --description="Scope:
- POST /webhooks/stripe endpoint
- Verify Stripe signature
- Update order status
Success Criteria:
1. Signature verification passes
2. Order updated correctly" --type=task --priority=1 --parent=beadboard-pmt.1
Created beadboard-pmt.1.1 (task)
```
### 6. Work and Close
```bash
# After completing pmt.1.1
$ bd update beadboard-pmt.1.1 --notes "Webhook handler implemented
- src/app/api/webhooks/stripe/route.ts
- Tests: npm test webhook.test.ts (3 passing)"
$ bd close beadboard-pmt.1.1 --reason "Webhook handler complete"
# After completing pmt.1
$ bd update beadboard-pmt.1 --notes "Stripe integration complete
- Sandbox account: acct_xxx
- Webhook: /api/webhooks/stripe
- All tests passing"
$ bd close beadboard-pmt.1 --reason "Stripe account setup finished"
# Now pmt.2 becomes unblocked
$ bd ready
beadboard-pmt.2 [task] pmt.2: Checkout UI
```
---
## Quick Reference
| Command | Purpose |
|---------|---------|
| `bd create --title="..." --type=epic` | Create epic |
| `bd create --title="epic.N: ..." --parent=beadboard-xxx` | Create task |
| `bd dep add <blocked> <blocker>` | Set blocker dependency |
| `bd dep relate <parent> <child>` | Set semantic relationship |
| `bd ready` | List unblocked beads |
| `bd update <id> --notes "..."` | Add evidence |
| `bd close <id> --reason "..."` | Complete bead |
---
## Common Mistakes
1. **Wrong naming**: `task-1` instead of `beadboard-abc.1`
2. **Missing parent**: Tasks must have `--parent=beadboard-<epic>`
3. **Closing before updating**: Always `update` first, then `close`
4. **Wrong dep direction**: `bd dep add <blocked> <blocker>` (blocked first!)
5. **Skipping evidence**: Always include command output in `--notes`

View file

@ -13,12 +13,12 @@ This document tracks high-impact coordination and environment failures for the B
## `BB_NOT_FOUND`
- Signal: `resolve-bb.mjs` or `bb-mail-shim.mjs` reports bb command missing.
- Signal: `session-preflight.mjs` or `bb-mail-shim.mjs` reports bb command missing.
- Cause: global BeadBoard CLI not installed, or not discoverable.
- Recovery:
- Install BeadBoard globally (`bb`/`beadboard` on `PATH`).
- Re-run `node skills/beadboard-driver/scripts/resolve-bb.mjs`.
- Re-run preflight.
- Install BeadBoard globally (`bb`/`beadboard` on `PATH`) — see Bootstrap Step C in SKILL.md.
- Run `node {baseDir}/scripts/setup-mail-delegate.mjs` to reconfigure the mail delegate after `bb` is installed.
- Re-run preflight: `node {baseDir}/scripts/session-preflight.mjs`.
## `MAIL_DELEGATE_MISSING` / `BD_MAIL_DELEGATE_NOT_SET`
@ -33,10 +33,12 @@ This document tracks high-impact coordination and environment failures for the B
- Signal: `ensure-bb-mail-configured.mjs` fails contract checks.
- Cause: delegate points to wrong command, missing shim path, or invalid `BB_AGENT` context.
- Recovery:
- Run session preflight to re-apply expected delegate command.
- Set `BB_AGENT` explicitly.
- Validate with `node skills/beadboard-driver/scripts/ensure-bb-mail-configured.mjs`.
- Recovery (in order):
1. Check delegate is set: `bd config get mail.delegate`
2. Verify shim path: the path shown must be absolute and the `bb-mail-shim.mjs` file must exist on disk
3. Reconfigure if wrong/missing: `node {baseDir}/scripts/setup-mail-delegate.mjs`
4. Verify `BB_AGENT` is set: `echo $BB_AGENT` (must be non-empty)
5. Re-run verification: `node {baseDir}/scripts/ensure-bb-mail-configured.mjs` — expected: `ok: true`
## `DOLT_NOT_RUNNING`
@ -72,4 +74,4 @@ This document tracks high-impact coordination and environment failures for the B
- Do not write `.beads/issues.jsonl` directly.
- Do not close beads without fresh evidence.
- Do not bypass invalid `BB_REPO` values; fix configuration first.
- Do not bypass a misconfigured mail delegate; fix configuration with `{baseDir}/scripts/setup-mail-delegate.mjs` first.

View file

@ -7,8 +7,7 @@ This runbook is the minimum lifecycle contract for agents using BeadBoard Driver
1. Run preflight and discovery checks:
```bash
node skills/beadboard-driver/scripts/session-preflight.mjs
node skills/beadboard-driver/scripts/resolve-bb.mjs
node {baseDir}/scripts/session-preflight.mjs
```
2. Create or identify your agent bead first (required before claiming work):
@ -27,7 +26,8 @@ bd agent state <agent-bead-id> running
4. Query hard memory for your domain before claim:
```bash
bd query "label=memory AND label=mem-canonical AND label=mem-hard AND status=closed"
# Select domain: memory-arch | memory-workflow | memory-agent | memory-ux | memory-reliability
bd query "label=memory AND label=mem-canonical AND label=<domain> AND status=closed" --sort updated --reverse
```
## 2) Discover Work and Read Epic Context
@ -100,7 +100,7 @@ bd agent state <agent-bead-id> stuck
2. Coordination signal:
```bash
bb agent send --from <agent-name> --to <target-agent-or-role> --bead <bead-id> --category BLOCKED --subject "<blocker summary>" --body "<what is needed>"
bd mail send --to <target-agent-or-role> --bead <bead-id> --category BLOCKED --subject "<blocker summary>" --body "<what is needed>"
```
3. Keep heartbeat while waiting:

View file

@ -40,6 +40,38 @@ function configureMailDelegate(bdPath, shimPath) {
};
}
function validateMemorySystem(bdPath) {
try {
const result = spawnSync(bdPath, ['query', 'label=mem-canonical,status=closed', '--limit', '5'], {
stdio: 'pipe',
shell: false,
});
if (result.status !== 0) {
return {
validated: false,
reason: 'Failed to query memory system',
memories_found: 0,
};
}
const output = result.stdout?.toString() || '';
const memoryCount = (output.match(/beadboard-/g) || []).length;
return {
validated: true,
memories_found: memoryCount,
note: 'Remember to read memory beads at session start: bd show beadboard-116 beadboard-60a beadboard-zas',
};
} catch (error) {
return {
validated: false,
reason: error instanceof Error ? error.message : String(error),
memories_found: 0,
};
}
}
async function main() {
const shimPath = join(__dirname, 'bb-mail-shim.mjs');
@ -54,13 +86,14 @@ async function main() {
reason: 'Could not find bd in PATH.',
remediation:
process.platform === 'win32'
? 'Primary: npm i -g beadboard. Fallback: powershell -ExecutionPolicy Bypass -File .\\install\\install.ps1. Then ensure bd is available in PATH.'
? 'Primary: npm i -g beadboard. Fallback: powershell -ExecutionPolicy Bypass -File ./install/install.ps1. Then ensure bd is available in PATH.'
: 'Primary: npm i -g beadboard. Fallback: bash ./install/install.sh. Then ensure bd is available in PATH.',
tools: {
bd: { available: false, path: null },
},
bb: null,
mail: null,
memory: null,
},
null,
2,
@ -86,6 +119,7 @@ async function main() {
configured: false,
reason: 'bb not available — mail delegate requires bb agent commands',
},
memory: null,
},
null,
2,
@ -95,6 +129,7 @@ async function main() {
}
const mail = configureMailDelegate(bdPath, shimPath);
const memory = validateMemorySystem(bdPath);
process.stdout.write(
`${JSON.stringify(
@ -106,6 +141,7 @@ async function main() {
},
bb,
mail,
memory,
},
null,
2,
@ -124,6 +160,7 @@ async function main() {
},
bb: null,
mail: null,
memory: null,
},
null,
2,

View file

@ -0,0 +1,95 @@
#!/usr/bin/env node
/**
* setup-mail-delegate.mjs
*
* Configures bd's mail.delegate to point at the bb-mail-shim.mjs bundled
* alongside this script. Uses import.meta.url to resolve the absolute path
* so the caller never needs to know where the skill is installed.
*
* Usage: node {baseDir}/scripts/setup-mail-delegate.mjs
* Output: JSON { ok, configured, delegate } or { ok, error_code, reason }
*/
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { findCommandInPath } from './lib/driver-lib.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const shimPath = join(__dirname, 'bb-mail-shim.mjs');
const delegateCommand = `node ${shimPath}`;
async function main() {
const dryRun = process.argv.includes('--dry-run');
const bdPath = await findCommandInPath('bd');
if (!bdPath) {
process.stdout.write(
`${JSON.stringify(
{
ok: false,
error_code: 'BD_NOT_FOUND',
reason: 'Could not find bd in PATH. Install with: npm install -g beads-cli',
delegate: null,
},
null,
2,
)}\n`,
);
return;
}
if (dryRun) {
process.stdout.write(
`${JSON.stringify(
{
ok: true,
dry_run: true,
configured: false,
delegate: delegateCommand,
},
null,
2,
)}\n`,
);
return;
}
const result = spawnSync(bdPath, ['config', 'set', 'mail.delegate', delegateCommand], {
stdio: 'pipe',
shell: false,
});
if (result.status !== 0) {
const stderr = result.stderr?.toString().trim() || '';
process.stdout.write(
`${JSON.stringify(
{
ok: false,
error_code: 'BD_CONFIG_FAILED',
reason: stderr || 'bd config set mail.delegate exited non-zero.',
delegate: delegateCommand,
},
null,
2,
)}\n`,
);
return;
}
process.stdout.write(
`${JSON.stringify(
{
ok: true,
configured: true,
delegate: delegateCommand,
},
null,
2,
)}\n`,
);
}
void main();

View file

@ -21,7 +21,7 @@ test('ensure-project-context creates project.md when missing', async () => {
const content = await fs.readFile(path.join(root, 'project.md'), 'utf8');
assert.equal(result.ok, true);
assert.equal(result.created, true);
assert.match(content, /Project Driver Template/);
assert.match(content, /Environment Status Cache/);
} finally {
await fs.rm(root, { recursive: true, force: true });
}

View file

@ -17,6 +17,7 @@ const tests = [
path.join(__dirname, 'diagnose-env.contract.test.mjs'),
path.join(__dirname, 'heal-common-issues.contract.test.mjs'),
path.join(__dirname, 'ensure-project-context.contract.test.mjs'),
path.join(__dirname, 'setup-mail-delegate.contract.test.mjs'),
];
const child = spawn(process.execPath, ['--test', ...tests], {

View file

@ -0,0 +1,67 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
const execFileAsync = promisify(execFile);
const __dirname = dirname(fileURLToPath(import.meta.url));
const scriptPath = path.resolve('skills/beadboard-driver/scripts/setup-mail-delegate.mjs');
const expectedShimPath = path.join(__dirname, '..', 'scripts', 'bb-mail-shim.mjs');
async function withTempDir(run) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'bb-skill-setupdelegate-'));
try {
await run(root);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
}
test('setup-mail-delegate contract: dry-run resolves absolute shim path', async () => {
await withTempDir(async (root) => {
const { stdout } = await execFileAsync(process.execPath, [scriptPath, '--dry-run'], {
cwd: root,
env: { ...process.env },
});
const result = JSON.parse(stdout);
assert.equal(result.ok, true);
assert.equal(result.dry_run, true);
assert.ok(
result.delegate.includes('bb-mail-shim.mjs'),
`delegate should reference bb-mail-shim.mjs, got: ${result.delegate}`,
);
const resolvedPath = result.delegate.replace(/^node\s+/, '');
assert.ok(
path.isAbsolute(resolvedPath),
`delegate path should be absolute, got: ${resolvedPath}`,
);
assert.equal(
resolvedPath,
expectedShimPath,
`delegate should point to the bundled shim`,
);
});
});
test('setup-mail-delegate contract: BD_NOT_FOUND when bd missing', async () => {
await withTempDir(async (root) => {
const { stdout } = await execFileAsync(process.execPath, [scriptPath], {
cwd: root,
env: {
...process.env,
PATH: '/nonexistent',
},
});
const result = JSON.parse(stdout);
assert.equal(result.ok, false);
assert.equal(result.error_code, 'BD_NOT_FOUND');
});
});

View file

@ -0,0 +1,29 @@
import { NextResponse } from 'next/server';
import { inboxAgentMessages } from '../../../../../lib/agent-mail';
export const dynamic = 'force-dynamic';
export async function GET(request: Request): Promise<Response> {
const { searchParams } = new URL(request.url);
const agentsParam = searchParams.get('agents') ?? '';
const limitParam = searchParams.get('limit');
const limit = limitParam ? Number.parseInt(limitParam, 10) : 25;
if (!agentsParam) {
return NextResponse.json({ ok: true, data: [] }, { status: 200 });
}
const agentNames = agentsParam.split(',').map(a => a.trim()).filter(Boolean);
const results = await Promise.all(
agentNames.map(async (agent) => {
const result = await inboxAgentMessages({ agent, limit });
if (!result.ok) {
return { agent, messages: [] };
}
return { agent, messages: result.data ?? [] };
})
);
return NextResponse.json({ ok: true, data: results }, { status: 200 });
}

View file

@ -0,0 +1,33 @@
import { NextResponse } from 'next/server';
import { statusAgentReservations } from '../../../../../lib/agent-reservations';
export const dynamic = 'force-dynamic';
export async function GET(request: Request): Promise<Response> {
const { searchParams } = new URL(request.url);
const agentsParam = searchParams.get('agents') ?? '';
if (!agentsParam) {
return NextResponse.json({ ok: true, data: [] }, { status: 200 });
}
const agentNames = agentsParam.split(',').map(a => a.trim()).filter(Boolean);
const results = await Promise.all(
agentNames.map(async (agent) => {
const result = await statusAgentReservations({ agent });
if (!result.ok || !result.data) {
return { agent, scope: undefined, reservations: [] };
}
const reservations = result.data.reservations ?? [];
const first = reservations[0];
return {
agent,
scope: first?.scope as string | undefined,
reservations
};
})
);
return NextResponse.json({ ok: true, data: results }, { status: 200 });
}

View file

@ -305,29 +305,24 @@ export function ActivityPanel({ issues, collapsed = false, projectRoot }: Activi
return;
}
const mailResponses = await Promise.all(
agentRoster.map(async (agent) => {
const response = await fetch(`/api/agents/mail?agent=${encodeURIComponent(agent.name)}&limit=15`);
const payload = await response.json().catch(() => ({ ok: false }));
return [agent.name, response.ok && payload.ok ? (payload.data as CoordMessage[]) : []] as const;
}),
);
// Use batch endpoints to reduce API calls from 2N to 2
const agentNames = agentRoster.map(a => a.name).join(',');
const reservationResponses = await Promise.all(
agentRoster.map(async (agent) => {
const response = await fetch(`/api/agents/reservations?agent=${encodeURIComponent(agent.name)}`);
const payload = await response.json().catch(() => ({ ok: false }));
if (!response.ok || !payload.ok) {
return [agent.name, undefined] as const;
}
return [agent.name, payload.data?.reservations?.[0]?.scope as string | undefined] as const;
}),
);
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>();
for (const [, messages] of mailResponses) {
for (const message of messages) {
uniqueMessages.set(message.message_id, message);
if (mailPayload.ok && mailPayload.data) {
for (const entry of mailPayload.data) {
for (const message of (entry.messages ?? [])) {
uniqueMessages.set(message.message_id, message);
}
}
}
@ -346,8 +341,16 @@ export function ActivityPanel({ issues, collapsed = false, projectRoot }: Activi
.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(Object.fromEntries(reservationResponses));
setReservationByAgent(reservationMap);
};
void fetchCoordination();

View file

@ -184,38 +184,39 @@ export function SocialPage({
return;
}
const mailPairs = await Promise.all(
agentNames.map(async (agent) => {
const response = await fetch(`/api/agents/mail?agent=${encodeURIComponent(agent)}&limit=25`);
const payload = await response.json().catch(() => ({ ok: false }));
if (!response.ok || !payload.ok) {
return [agent, [] as CoordMessage[]] as const;
}
return [agent, (payload.data ?? []) as CoordMessage[]] as const;
}),
);
// Use batch endpoints to reduce API calls from 2N to 2
const agentsParam = agentNames.join(',');
const reservationsPairs = await Promise.all(
agentNames.map(async (agent) => {
const response = await fetch(`/api/agents/reservations?agent=${encodeURIComponent(agent)}`);
const payload = await response.json().catch(() => ({ ok: false }));
if (!response.ok || !payload.ok) {
return [agent, undefined] as const;
}
const first = (payload.data?.reservations ?? [])[0];
return [agent, first?.scope as string | undefined] as const;
}),
);
const [mailResponse, reservationsResponse] = await Promise.all([
fetch(`/api/agents/mail/batch?agents=${encodeURIComponent(agentsParam)}&limit=25`),
fetch(`/api/agents/reservations/batch?agents=${encodeURIComponent(agentsParam)}`),
]);
const mailPayload = await mailResponse.json().catch(() => ({ ok: false, data: [] }));
const reservationsPayload = await reservationsResponse.json().catch(() => ({ ok: false, data: [] }));
const nextMessages: Record<string, CoordMessage[]> = {};
const nextUnread: Record<string, number> = {};
for (const [agent, messages] of mailPairs) {
nextMessages[agent] = messages;
nextUnread[agent] = messages.filter((m) => m.state === 'unread').length;
const nextReservations: Record<string, string | undefined> = {};
// Process mail results
if (mailPayload.ok && mailPayload.data) {
for (const entry of mailPayload.data) {
nextMessages[entry.agent] = entry.messages ?? [];
nextUnread[entry.agent] = (entry.messages ?? []).filter((m: CoordMessage) => m.state === 'unread').length;
}
}
// Process reservations results
if (reservationsPayload.ok && reservationsPayload.data) {
for (const entry of reservationsPayload.data) {
nextReservations[entry.agent] = entry.scope;
}
}
setAgentMessagesByName(nextMessages);
setAgentUnreadByName(nextUnread);
setAgentReservationsByName(Object.fromEntries(reservationsPairs));
setAgentReservationsByName(nextReservations);
}, [agentNames]);
useEffect(() => {

View file

@ -1,52 +1,3 @@
export type ProtocolEventType = 'HANDOFF' | 'BLOCKED' | 'INCURSION' | 'RESUME' | 'INFO';
export interface ProtocolEventEnvelope<T = any> {
id: string;
version: 'v1';
event_type: ProtocolEventType;
project_root: string;
bead_id: string;
from_agent: string | null;
to_agent: string | null;
scope: string | null;
created_at: string;
payload: T;
}
export type ProtocolEvent = ProtocolEventEnvelope;
export interface CreateProtocolEventInput {
event_type: ProtocolEventType;
project_root: string;
bead_id: string;
from_agent?: string;
to_agent?: string;
scope?: string;
payload: any;
}
export interface ProtocolDeps {
now: () => string;
idGenerator: () => string;
}
export function createProtocolEvent(
input: CreateProtocolEventInput,
deps: Partial<ProtocolDeps> = {}
): ProtocolEvent {
const now = deps.now ? deps.now() : new Date().toISOString();
const generateId = deps.idGenerator ?? (() => `proto_${Date.now()}_${Math.random().toString(16).slice(2, 6)}`);
return {
id: generateId(),
version: 'v1',
event_type: input.event_type,
project_root: input.project_root,
bead_id: input.bead_id,
from_agent: input.from_agent ?? null,
to_agent: input.to_agent ?? null,
scope: input.scope ?? null,
created_at: now,
payload: input.payload,
};
}
// Re-export from new bounded context
// This file is deprecated - import from ./agent/types instead
export * from './agent/types';

View file

@ -1,503 +1,10 @@
import { randomUUID } from 'node:crypto';
import path from 'node:path';
import { runBdCommand } from './bridge';
import { activityEventBus } from './realtime';
const AGENT_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
export type AgentCommandName = 'agent register' | 'agent list' | 'agent show' | 'agent activity-lease' | 'agent state';
export type AgentZfcState = 'idle' | 'spawning' | 'running' | 'working' | 'stuck' | 'done' | 'stopped' | 'dead';
export interface AgentCommandError {
code: string;
message: string;
}
export interface AgentCommandResponse<T> {
ok: boolean;
command: AgentCommandName;
data: T | null;
error: AgentCommandError | null;
}
export interface AgentRecord {
agent_id: string;
display_name: string;
role: string;
status: string;
created_at: string;
last_seen_at: string;
version: number;
rig?: string;
role_type?: string;
swarm_id?: string;
current_task?: string;
}
export interface RegisterAgentInput {
name: string;
display?: string;
role: string;
forceUpdate?: boolean;
rig?: string;
}
export interface RegisterAgentDeps {
now: () => string;
projectRoot: string;
}
export interface ListAgentsInput {
role?: string;
status?: string;
}
export interface ShowAgentInput {
agent: string;
}
export interface ActivityLeaseInput {
agent: string;
}
/**
* Normalizes agent name to bead ID with prefix.
* e.g. "silver-castle" -> "bb-silver-castle"
*/
function toBeadId(name: string): string {
const trimmed = name.trim();
if (trimmed.startsWith('bb-')) return trimmed;
return `bb-${trimmed}`;
}
/**
* Strips prefix from bead ID for display/internal logic.
* e.g. "bb-silver-castle" -> "silver-castle"
*/
function fromBeadId(id: string): string {
if (id.startsWith('bb-')) return id.slice(3);
return id;
}
/**
* Robustly extracts the first JSON block from a potentially noisy string.
* Handles cases where 'bd' outputs warnings or daemon logs before the JSON.
*/
function extractJson(text: string): any {
const start = text.indexOf('{');
const end = text.lastIndexOf('}');
if (start === -1 || end === -1) {
throw new Error('No JSON block found in output');
}
const jsonPart = text.slice(start, end + 1);
return JSON.parse(jsonPart);
}
/**
* Robustly extracts the first JSON array from a potentially noisy string.
*/
function extractJsonArray(text: string): any[] {
const start = text.indexOf('[');
const end = text.lastIndexOf(']');
if (start === -1 || end === -1) {
// Check if it's a single object instead
try {
const single = extractJson(text);
return [single];
} catch {
return [];
}
}
const jsonPart = text.slice(start, end + 1);
return JSON.parse(jsonPart);
}
function trimOrEmpty(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
/**
* Internal helper to fetch and parse agent details robustly.
*/
async function callBdAgentShow(beadId: string, projectRoot: string): Promise<AgentRecord | null> {
const showResult = await runBdCommand({
projectRoot,
args: ['agent', 'show', beadId, '--json'],
});
if (!showResult.success) {
return null;
}
try {
const bdAgent = extractJson(showResult.stdout);
return mapBdAgentToRecord(bdAgent);
} catch {
return null;
}
}
function invalid(command: AgentCommandName, code: string, message: string): AgentCommandResponse<never> {
return {
ok: false,
command,
data: null,
error: { code, message },
};
}
function success<T>(command: AgentCommandName, data: T): AgentCommandResponse<T> {
return {
ok: true,
command,
data,
error: null,
};
}
function validateAgentId(value: string): AgentCommandError | null {
if (!AGENT_ID_PATTERN.test(value) || value.length < 3 || value.length > 48) {
return {
code: 'INVALID_AGENT_ID',
message: 'Agent id must match ^[a-z0-9]+(?:-[a-z0-9]+)*$ and be 3..48 characters.',
};
}
return null;
}
function validateRole(value: string): AgentCommandError | null {
if (!value) {
return {
code: 'INVALID_ROLE',
message: 'Role is required.',
};
}
return null;
}
function mapBdAgentToRecord(bdAgent: any): AgentRecord {
// Extract role from labels if role_type is not set
let role = bdAgent.role_type || 'agent';
let swarmId: string | undefined;
let currentTask: string | undefined;
if (Array.isArray(bdAgent.labels)) {
const roleLabel = bdAgent.labels.find((l: string) => l.startsWith('role:'));
if (roleLabel) {
role = roleLabel.split(':')[1];
}
const swarmLabel = bdAgent.labels.find((l: string) => l.startsWith('swarm:'));
if (swarmLabel) {
swarmId = swarmLabel.split(':')[1];
}
const workingLabel = bdAgent.labels.find((l: string) => l.startsWith('working:'));
if (workingLabel) {
currentTask = workingLabel.split(':')[1];
}
}
let rig = bdAgent.rig;
if (!rig && Array.isArray(bdAgent.labels)) {
const rigLabel = bdAgent.labels.find((l: string) => l.startsWith('rig:'));
if (rigLabel) {
rig = rigLabel.split(':')[1];
}
}
const record: AgentRecord = {
agent_id: fromBeadId(bdAgent.id),
display_name: bdAgent.title?.replace(/^Agent: /, '') || fromBeadId(bdAgent.id),
role,
status: bdAgent.agent_state || 'idle',
created_at: bdAgent.created_at || bdAgent.last_activity || new Date().toISOString(),
last_seen_at: bdAgent.last_activity || new Date().toISOString(),
version: 1,
rig,
role_type: bdAgent.role_type,
swarm_id: swarmId,
current_task: currentTask,
};
return record;
}
export async function registerAgent(
input: RegisterAgentInput,
deps: Partial<RegisterAgentDeps> = {},
): Promise<AgentCommandResponse<AgentRecord>> {
const command: AgentCommandName = 'agent register';
const name = trimOrEmpty(input.name);
const role = trimOrEmpty(input.role);
const display = trimOrEmpty(input.display) || name;
const projectRoot = deps.projectRoot || process.cwd();
const agentIdError = validateAgentId(name);
if (agentIdError) {
return invalid(command, agentIdError.code, agentIdError.message);
}
const roleError = validateRole(role);
if (roleError) {
return invalid(command, roleError.code, roleError.message);
}
try {
const beadId = toBeadId(name);
// 1. Check if agent exists
const showResult = await runBdCommand({
projectRoot,
args: ['agent', 'show', beadId, '--json'],
});
if (showResult.success && !input.forceUpdate) {
return invalid(command, 'DUPLICATE_AGENT_ID', 'Agent is already registered. Use --force-update to change display/role.');
}
// 2. Set state (auto-creates if missing)
const stateResult = await runBdCommand({
projectRoot,
args: ['agent', 'state', beadId, 'idle', '--json'],
});
if (!stateResult.success) {
return invalid(command, 'INTERNAL_ERROR', `Failed to set agent state: ${stateResult.error}`);
}
// 3. Update title, role, and rig via labels
const labels = ['gt:agent'];
if (role) {
labels.push(`role:${role}`);
}
if (input.rig) {
labels.push(`rig:${input.rig}`);
}
const updateArgs = ['update', beadId, '--title', `Agent: ${display}`, '--add-label', labels.join(',')];
const updateResult = await runBdCommand({
projectRoot,
args: [...updateArgs, '--json'],
});
if (!updateResult.success) {
console.error('Update failed:', updateResult.error, updateResult.stdout, updateResult.stderr);
return invalid(command, 'INTERNAL_ERROR', `Failed to update agent details: ${updateResult.error}`);
}
// 4. Force flush to ensure issues.jsonl is updated (critical for tests and sync)
const flushResult = await runBdCommand({
projectRoot,
args: ['admin', 'flush'],
});
if (!flushResult.success) {
console.error('Flush failed:', flushResult.error, flushResult.stdout, flushResult.stderr);
}
// 5. Return the new record
const record = await callBdAgentShow(beadId, projectRoot);
if (!record) {
return invalid(command, 'INTERNAL_ERROR', 'Failed to retrieve final agent state.');
}
return success(command, record);
} catch (error) {
return invalid(command, 'INTERNAL_ERROR', error instanceof Error ? error.message : 'Failed to register agent.');
}
}
export async function listAgents(
input: ListAgentsInput,
deps: Partial<RegisterAgentDeps> = {},
): Promise<AgentCommandResponse<AgentRecord[]>> {
const command: AgentCommandName = 'agent list';
const role = trimOrEmpty(input.role);
const status = trimOrEmpty(input.status);
const projectRoot = deps.projectRoot || process.cwd();
try {
const listResult = await runBdCommand({
projectRoot,
args: ['list', '--label', 'gt:agent', '--json'],
});
if (!listResult.success) {
return invalid(command, 'INTERNAL_ERROR', `Failed to list agents from bd: ${listResult.error}`);
}
const rawList = extractJsonArray(listResult.stdout);
if (rawList.length === 0) {
return success(command, []);
}
const agents: AgentRecord[] = [];
for (const item of rawList) {
// Get detailed agent state for each bead found using show
const record = await callBdAgentShow(item.id, projectRoot);
if (record) {
if (role && record.role !== role) continue;
if (status && record.status !== status) continue;
agents.push(record);
}
}
return success(command, agents.sort((a, b) => a.agent_id.localeCompare(b.agent_id)));
} catch (error) {
return invalid(command, 'INTERNAL_ERROR', error instanceof Error ? error.message : 'Failed to list agents.');
}
}
export async function showAgent(
input: ShowAgentInput,
deps: Partial<RegisterAgentDeps> = {},
): Promise<AgentCommandResponse<AgentRecord>> {
const command: AgentCommandName = 'agent show';
const name = trimOrEmpty(input.agent);
const projectRoot = deps.projectRoot || process.cwd();
const agentIdError = validateAgentId(name);
if (agentIdError) {
return invalid(command, agentIdError.code, agentIdError.message);
}
try {
const beadId = toBeadId(name);
const record = await callBdAgentShow(beadId, projectRoot);
if (!record) {
return invalid(command, 'AGENT_NOT_FOUND', 'Agent is not registered.');
}
return success(command, record);
} catch (error) {
return invalid(command, 'INTERNAL_ERROR', error instanceof Error ? error.message : 'Failed to load agent.');
}
}
/**
* Updates the ZFC state of an agent bead.
*/
export async function setAgentState(
input: { agent: string; state: AgentZfcState },
deps: Partial<RegisterAgentDeps> = {},
): Promise<AgentCommandResponse<AgentRecord>> {
const command: AgentCommandName = 'agent state';
const name = trimOrEmpty(input.agent);
const state = input.state;
const projectRoot = deps.projectRoot || process.cwd();
const agentIdError = validateAgentId(name);
if (agentIdError) {
return invalid(command, agentIdError.code, agentIdError.message);
}
try {
const beadId = toBeadId(name);
const stateResult = await runBdCommand({
projectRoot,
args: ['agent', 'state', beadId, state, '--json'],
});
if (!stateResult.success) {
return invalid(command, 'AGENT_NOT_FOUND', 'Agent is not registered.');
}
const record = await callBdAgentShow(beadId, projectRoot);
if (!record) {
return invalid(command, 'INTERNAL_ERROR', 'Failed to retrieve agent state after update.');
}
return success(command, record);
} catch (error) {
return invalid(command, 'INTERNAL_ERROR', error instanceof Error ? error.message : 'Failed to set agent state.');
}
}
export type AgentLiveness = 'active' | 'stale' | 'evicted' | 'idle';
/**
* Derives the liveness state of an agent based on its last seen timestamp.
* active: < 15m
* stale: 15m - 30m
* evicted: 30m - 60m
* idle: >= 60m
*/
export function deriveLiveness(lastSeenAt: string, now: Date = new Date(), staleMinutes: number = 15): AgentLiveness {
const lastSeen = new Date(lastSeenAt).getTime();
const diffMs = now.getTime() - lastSeen;
const diffMin = diffMs / (1000 * 60);
if (diffMin >= 60) {
return 'idle';
}
if (diffMin >= 2 * staleMinutes) {
return 'evicted';
}
if (diffMin >= staleMinutes) {
return 'stale';
}
return 'active';
}
/**
* Extends the activity lease for a registered agent by emitting a native bd wisp.
* This provides silent observability WITHOUT persistent git churn.
*/
export async function extendActivityLease(
input: ActivityLeaseInput,
deps: Partial<RegisterAgentDeps> = {},
): Promise<AgentCommandResponse<AgentRecord | null>> {
const command: AgentCommandName = 'agent activity-lease';
const name = trimOrEmpty(input.agent);
const projectRoot = deps.projectRoot || process.cwd();
const agentIdError = validateAgentId(name);
if (agentIdError) {
return invalid(command, agentIdError.code, agentIdError.message);
}
try {
const beadId = toBeadId(name);
// We create an ephemeral wisp of type 'heartbeat' tied to the agent bead.
// This refreshes the 'last_activity' in the bd system without mutating issues.jsonl.
const wispResult = await runBdCommand({
projectRoot,
args: [
'create',
`pulse:${name}:${Date.now()}`,
'--type', 'event',
'--wisp-type', 'heartbeat',
'--ephemeral',
'--event-actor', beadId,
'--json'
],
});
if (!wispResult.success) {
return invalid(command, 'INTERNAL_ERROR', `Failed to emit heartbeat wisp: ${wispResult.error}`);
}
// Emit heartbeat to activity bus for real-time aggregation
activityEventBus.emit({
id: randomUUID(),
kind: 'heartbeat',
beadId: beadId,
beadTitle: `Agent: ${name}`,
projectId: projectRoot,
projectName: path.basename(projectRoot),
timestamp: new Date().toISOString(),
actor: name,
payload: { message: 'running' }
});
// We return ok: true. The actual lease state will be aggregated from wisps.
return success(command, null);
} catch (error) {
return invalid(command, 'INTERNAL_ERROR', error instanceof Error ? error.message : 'Failed to extend activity lease.');
}
}
/**
* @deprecated Import from './agent/registry' or './agent/types' instead
*
* This file is kept for backward compatibility.
* All implementations have been moved to src/lib/agent/
*/
// Re-export everything from the new bounded context
export * from './agent/registry';
export * from './agent/types';

15
src/lib/agent/index.ts Normal file
View file

@ -0,0 +1,15 @@
/**
* Agent Bounded Context
*
* This module provides the public API for agent coordination:
* - Registry: agent registration, listing, state management
* - Messaging: mail, inbox, handoffs
* - Reservations: scope locking, resource allocation
* - Sessions: lifecycle, liveness, coordination
*
* Current state: Migration in progress.
* Import from individual modules for now.
*/
// Re-export from types (already migrated)
export * from './types';

437
src/lib/agent/messaging.ts Normal file
View file

@ -0,0 +1,437 @@
/**
* Agent Messaging / Mail System
*
* This module handles agent-to-agent coordination messages:
* - sendAgentMessage: Send a message to another agent
* - inboxAgentMessages: Retrieve messages for an agent
* - readAgentMessage: Mark a message as read
* - ackAgentMessage: Acknowledge a message (for HANDOFF/BLOCKED)
*/
import path from 'node:path';
import { runBdCommand } from '../bridge';
import {
type SendAgentMessageInput,
type SendAgentMessageDeps,
type InboxAgentMessagesInput,
type MessageActionInput,
type MessageMutationDeps,
type AgentMessage,
type MailCommandName,
type MailCommandError,
type MailCommandResponse,
type MessageCategory,
type MessageState,
} from './types';
const MESSAGE_ID_PATTERN = /^msg_/;
const AGENT_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
function invalid<T>(command: MailCommandName, code: string, message: string): MailCommandResponse<T> {
return { ok: false, command, data: null, error: { code, message } };
}
function success<T>(command: MailCommandName, data: T): MailCommandResponse<T> {
return { ok: true, command, data, error: null };
}
function trimOrEmpty(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function extractJson(text: string): any {
const start = text.indexOf('{');
const end = text.lastIndexOf('}');
if (start === -1 || end === -1) {
throw new Error('No JSON block found in output');
}
const jsonPart = text.slice(start, end + 1);
return JSON.parse(jsonPart);
}
function extractJsonArray(text: string): any[] {
const start = text.indexOf('[');
const end = text.lastIndexOf(']');
if (start === -1 || end === -1) {
try {
const single = extractJson(text);
return [single];
} catch {
return [];
}
}
const jsonPart = text.slice(start, end + 1);
return JSON.parse(jsonPart);
}
function toBeadId(name: string): string {
const trimmed = name.trim();
if (trimmed.startsWith('bb-')) return trimmed;
return `bb-${trimmed}`;
}
async function getProjectRoot(deps: { projectRoot?: string }): Promise<string> {
return deps.projectRoot || process.cwd();
}
async function verifyAgentExists(agent: string, projectRoot: string): Promise<boolean> {
const beadId = toBeadId(agent);
const result = await runBdCommand({
projectRoot,
args: ['show', beadId, '--json'],
});
return result.success;
}
function mapRawToAgentMessage(raw: any): AgentMessage {
return {
message_id: raw.message_id || raw.id || '',
thread_id: raw.thread_id || raw.thread || '',
bead_id: raw.bead_id || raw.bead || '',
from_agent: raw.from_agent || raw.from || '',
to_agent: raw.to_agent || raw.to || '',
category: (raw.category as MessageCategory) || 'INFO',
subject: raw.subject || '',
body: raw.body || '',
state: (raw.state as MessageState) || 'unread',
requires_ack: raw.requires_ack ?? (raw.category === 'HANDOFF' || raw.category === 'BLOCKED'),
created_at: raw.created_at || raw.created_at || '',
read_at: raw.read_at || null,
acked_at: raw.acked_at || null,
};
}
export async function sendAgentMessage(
input: SendAgentMessageInput,
deps?: Partial<SendAgentMessageDeps> & { projectRoot?: string },
): Promise<MailCommandResponse<AgentMessage>> {
const command: MailCommandName = 'agent send';
const projectRoot = await getProjectRoot(deps || {});
const now = deps?.now || (() => new Date().toISOString());
const idGenerator = deps?.idGenerator || (() => `msg_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`);
const from = trimOrEmpty(input.from);
const to = trimOrEmpty(input.to);
const bead = trimOrEmpty(input.bead);
const category = input.category;
const subject = trimOrEmpty(input.subject);
const body = trimOrEmpty(input.body);
const thread = trimOrEmpty(input.thread);
if (!from) {
return invalid(command, 'INVALID_INPUT', 'Sender agent is required.');
}
if (!to) {
return invalid(command, 'INVALID_INPUT', 'Recipient agent is required.');
}
if (!bead) {
return invalid(command, 'INVALID_INPUT', 'Bead ID is required.');
}
if (!category) {
return invalid(command, 'INVALID_INPUT', 'Category is required.');
}
if (!subject) {
return invalid(command, 'INVALID_INPUT', 'Subject is required.');
}
if (!body) {
return invalid(command, 'INVALID_INPUT', 'Body is required.');
}
const fromExists = await verifyAgentExists(from, projectRoot);
if (!fromExists) {
return invalid(command, 'UNKNOWN_SENDER', `Sender agent '${from}' is not registered.`);
}
const toExists = await verifyAgentExists(to, projectRoot);
if (!toExists) {
return invalid(command, 'UNKNOWN_RECIPIENT', `Recipient agent '${to}' is not registered.`);
}
const validCategories = ['HANDOFF', 'BLOCKED', 'DECISION', 'INFO'];
if (!validCategories.includes(category)) {
return invalid(command, 'INVALID_CATEGORY', `Category must be one of: ${validCategories.join(', ')}`);
}
try {
const messageId = idGenerator();
const threadId = thread || `thread_${now().replace(/[-:]/g, '').replace('T', '_').split('.')[0]}`;
const requiresAck = category === 'HANDOFF' || category === 'BLOCKED';
const commentArgs = [
'comment',
bead,
'--author', from,
'--body', JSON.stringify({ message_id: messageId, thread_id: threadId, from_agent: from, to_agent: to, category, subject, body, requires_ack: requiresAck, created_at: now() }),
];
const result = await runBdCommand({
projectRoot,
args: [...commentArgs, '--json'],
});
if (!result.success) {
return invalid(command, 'INTERNAL_ERROR', `Failed to send message: ${result.error}`);
}
const message: AgentMessage = {
message_id: messageId,
thread_id: threadId,
bead_id: bead,
from_agent: from,
to_agent: to,
category,
subject,
body,
state: 'unread',
requires_ack: requiresAck,
created_at: now(),
read_at: null,
acked_at: null,
};
return success(command, message);
} catch (error) {
return invalid(command, 'INTERNAL_ERROR', error instanceof Error ? error.message : 'Failed to send message.');
}
}
export async function inboxAgentMessages(
input: InboxAgentMessagesInput,
deps: { projectRoot?: string } = {},
): Promise<MailCommandResponse<AgentMessage[]>> {
const command: MailCommandName = 'agent inbox';
const projectRoot = await getProjectRoot(deps);
const agent = trimOrEmpty(input.agent);
if (!agent) {
return invalid(command, 'INVALID_INPUT', 'Agent name is required.');
}
const agentExists = await verifyAgentExists(agent, projectRoot);
if (!agentExists) {
return invalid(command, 'UNKNOWN_AGENT', `Agent '${agent}' is not registered.`);
}
try {
const listResult = await runBdCommand({
projectRoot,
args: ['list', '--author', toBeadId(agent), '--json'],
});
if (!listResult.success) {
return invalid(command, 'INTERNAL_ERROR', `Failed to list messages: ${listResult.error}`);
}
const rawList = extractJsonArray(listResult.stdout);
const messages: AgentMessage[] = [];
for (const item of rawList) {
try {
const commentBody = JSON.parse(item.body || '{}');
if (commentBody.to_agent === agent || commentBody.from_agent === agent) {
if (input.state && commentBody.state !== input.state) continue;
if (input.bead && commentBody.bead_id !== input.bead) continue;
const msg = mapRawToAgentMessage(commentBody);
if (msg.to_agent === agent) {
messages.push(msg);
}
}
} catch {
// Skip non-message comments
}
}
if (input.limit && messages.length > input.limit) {
messages.length = input.limit;
}
return success(command, messages);
} catch (error) {
return invalid(command, 'INTERNAL_ERROR', error instanceof Error ? error.message : 'Failed to retrieve inbox.');
}
}
export async function readAgentMessage(
input: MessageActionInput,
deps?: Partial<MessageMutationDeps> & { projectRoot?: string },
): Promise<MailCommandResponse<AgentMessage>> {
const command: MailCommandName = 'agent read';
const projectRoot = await getProjectRoot(deps || {});
const now = deps?.now || (() => new Date().toISOString());
const agent = trimOrEmpty(input.agent);
const messageId = trimOrEmpty(input.message);
if (!agent) {
return invalid(command, 'INVALID_INPUT', 'Agent name is required.');
}
if (!messageId) {
return invalid(command, 'INVALID_INPUT', 'Message ID is required.');
}
const agentExists = await verifyAgentExists(agent, projectRoot);
if (!agentExists) {
return invalid(command, 'UNKNOWN_AGENT', `Agent '${agent}' is not registered.`);
}
try {
const listResult = await runBdCommand({
projectRoot,
args: ['list', '--author', toBeadId(agent), '--json'],
});
if (!listResult.success) {
return invalid(command, 'INTERNAL_ERROR', `Failed to find message: ${listResult.error}`);
}
const rawList = extractJsonArray(listResult.stdout);
let foundMessage: any = null;
let foundBead = '';
for (const item of rawList) {
try {
const commentBody = JSON.parse(item.body || '{}');
if (commentBody.message_id === messageId && commentBody.to_agent === agent) {
foundMessage = commentBody;
foundBead = item.id;
break;
}
} catch {
// Skip
}
}
if (!foundMessage) {
return invalid(command, 'MESSAGE_NOT_FOUND', `Message '${messageId}' not found for agent '${agent}'.`);
}
if (foundMessage.state === 'read' || foundMessage.state === 'acked') {
return invalid(command, 'ALREADY_READ', 'Message is already read or acknowledged.');
}
const updatedMessage = {
...foundMessage,
state: 'read' as MessageState,
read_at: now(),
};
const commentArgs = [
'comment',
foundBead,
'--author', agent,
'--body', JSON.stringify(updatedMessage),
];
const updateResult = await runBdCommand({
projectRoot,
args: [...commentArgs, '--json'],
});
if (!updateResult.success) {
return invalid(command, 'INTERNAL_ERROR', `Failed to mark message as read: ${updateResult.error}`);
}
const message = mapRawToAgentMessage(updatedMessage);
return success(command, message);
} catch (error) {
return invalid(command, 'INTERNAL_ERROR', error instanceof Error ? error.message : 'Failed to read message.');
}
}
export async function ackAgentMessage(
input: MessageActionInput,
deps?: Partial<MessageMutationDeps> & { projectRoot?: string },
): Promise<MailCommandResponse<AgentMessage>> {
const command: MailCommandName = 'agent ack';
const projectRoot = await getProjectRoot(deps || {});
const now = deps?.now || (() => new Date().toISOString());
const agent = trimOrEmpty(input.agent);
const messageId = trimOrEmpty(input.message);
if (!agent) {
return invalid(command, 'INVALID_INPUT', 'Agent name is required.');
}
if (!messageId) {
return invalid(command, 'INVALID_INPUT', 'Message ID is required.');
}
const agentExists = await verifyAgentExists(agent, projectRoot);
if (!agentExists) {
return invalid(command, 'UNKNOWN_AGENT', `Agent '${agent}' is not registered.`);
}
try {
const listResult = await runBdCommand({
projectRoot,
args: ['list', '--author', toBeadId(agent), '--json'],
});
if (!listResult.success) {
return invalid(command, 'INTERNAL_ERROR', `Failed to find message: ${listResult.error}`);
}
const rawList = extractJsonArray(listResult.stdout);
let foundMessage: any = null;
let foundBead = '';
for (const item of rawList) {
try {
const commentBody = JSON.parse(item.body || '{}');
if (commentBody.message_id === messageId && commentBody.to_agent === agent) {
foundMessage = commentBody;
foundBead = item.id;
break;
}
} catch {
// Skip
}
}
if (!foundMessage) {
return invalid(command, 'MESSAGE_NOT_FOUND', `Message '${messageId}' not found for agent '${agent}'.`);
}
if (foundMessage.to_agent !== agent) {
return invalid(command, 'ACK_FORBIDDEN', 'Only the recipient can acknowledge this message.');
}
if (!foundMessage.requires_ack) {
return invalid(command, 'ACK_NOT_REQUIRED', 'This message does not require acknowledgment.');
}
if (foundMessage.state === 'acked') {
return invalid(command, 'ALREADY_ACKED', 'Message is already acknowledged.');
}
const updatedMessage = {
...foundMessage,
state: 'acked' as MessageState,
acked_at: now(),
};
const commentArgs = [
'comment',
foundBead,
'--author', agent,
'--body', JSON.stringify(updatedMessage),
];
const updateResult = await runBdCommand({
projectRoot,
args: [...commentArgs, '--json'],
});
if (!updateResult.success) {
return invalid(command, 'INTERNAL_ERROR', `Failed to acknowledge message: ${updateResult.error}`);
}
const message = mapRawToAgentMessage(updatedMessage);
return success(command, message);
} catch (error) {
return invalid(command, 'INTERNAL_ERROR', error instanceof Error ? error.message : 'Failed to acknowledge message.');
}
}

442
src/lib/agent/registry.ts Normal file
View file

@ -0,0 +1,442 @@
import { randomUUID } from 'node:crypto';
import path from 'node:path';
import { runBdCommand } from '../bridge';
import { activityEventBus } from '../realtime';
import type {
AgentCommandName,
AgentZfcState,
AgentCommandError,
AgentCommandResponse,
AgentRecord,
RegisterAgentInput,
RegisterAgentDeps,
ListAgentsInput,
ShowAgentInput,
ActivityLeaseInput,
AgentLiveness,
} from './types';
const AGENT_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
interface CacheEntry<T> {
data: T;
expiresAt: number;
}
const agentCache = new Map<string, CacheEntry<AgentRecord | null>>();
const CACHE_TTL_MS = 30_000;
function getCachedAgent(beadId: string): AgentRecord | null {
const entry = agentCache.get(beadId);
if (entry && entry.expiresAt > Date.now()) {
return entry.data;
}
agentCache.delete(beadId);
return null;
}
function setCachedAgent(beadId: string, data: AgentRecord | null): void {
agentCache.set(beadId, { data, expiresAt: Date.now() + CACHE_TTL_MS });
}
function toBeadId(name: string): string {
const trimmed = name.trim();
if (trimmed.startsWith('bb-')) return trimmed;
return `bb-${trimmed}`;
}
function fromBeadId(id: string): string {
if (id.startsWith('bb-')) return id.slice(3);
return id;
}
function extractJson(text: string): any {
const start = text.indexOf('{');
const end = text.lastIndexOf('}');
if (start === -1 || end === -1) {
throw new Error('No JSON block found in output');
}
const jsonPart = text.slice(start, end + 1);
return JSON.parse(jsonPart);
}
function extractJsonArray(text: string): any[] {
const start = text.indexOf('[');
const end = text.lastIndexOf(']');
if (start === -1 || end === -1) {
try {
const single = extractJson(text);
return [single];
} catch {
return [];
}
}
const jsonPart = text.slice(start, end + 1);
return JSON.parse(jsonPart);
}
function trimOrEmpty(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
async function callBdAgentShow(beadId: string, projectRoot: string): Promise<AgentRecord | null> {
const cached = getCachedAgent(beadId);
if (cached !== undefined) {
return cached;
}
const showResult = await runBdCommand({
projectRoot,
args: ['agent', 'show', beadId, '--json'],
});
let record: AgentRecord | null = null;
if (showResult.success) {
try {
const bdAgent = extractJson(showResult.stdout);
record = mapBdAgentToRecord(bdAgent);
} catch {
record = null;
}
}
setCachedAgent(beadId, record);
return record;
}
function invalid(command: AgentCommandName, code: string, message: string): AgentCommandResponse<never> {
return {
ok: false,
command,
data: null,
error: { code, message },
};
}
function success<T>(command: AgentCommandName, data: T): AgentCommandResponse<T> {
return {
ok: true,
command,
data,
error: null,
};
}
function validateAgentId(value: string): AgentCommandError | null {
if (!AGENT_ID_PATTERN.test(value) || value.length < 3 || value.length > 48) {
return {
code: 'INVALID_AGENT_ID',
message: 'Agent id must match ^[a-z0-9]+(?:-[a-z0-9]+)*$ and be 3..48 characters.',
};
}
return null;
}
function validateRole(value: string): AgentCommandError | null {
if (!value) {
return {
code: 'INVALID_ROLE',
message: 'Role is required.',
};
}
return null;
}
function mapBdAgentToRecord(bdAgent: any): AgentRecord {
let role = bdAgent.role_type || 'agent';
let swarmId: string | undefined;
let currentTask: string | undefined;
if (Array.isArray(bdAgent.labels)) {
const roleLabel = bdAgent.labels.find((l: string) => l.startsWith('role:'));
if (roleLabel) {
role = roleLabel.split(':')[1];
}
const swarmLabel = bdAgent.labels.find((l: string) => l.startsWith('swarm:'));
if (swarmLabel) {
swarmId = swarmLabel.split(':')[1];
}
const workingLabel = bdAgent.labels.find((l: string) => l.startsWith('working:'));
if (workingLabel) {
currentTask = workingLabel.split(':')[1];
}
}
let rig = bdAgent.rig;
if (!rig && Array.isArray(bdAgent.labels)) {
const rigLabel = bdAgent.labels.find((l: string) => l.startsWith('rig:'));
if (rigLabel) {
rig = rigLabel.split(':')[1];
}
}
const record: AgentRecord = {
agent_id: fromBeadId(bdAgent.id),
display_name: bdAgent.title?.replace(/^Agent: /, '') || fromBeadId(bdAgent.id),
role,
status: bdAgent.agent_state || 'idle',
created_at: bdAgent.created_at || bdAgent.last_activity || new Date().toISOString(),
last_seen_at: bdAgent.last_activity || new Date().toISOString(),
version: 1,
rig,
role_type: bdAgent.role_type,
swarm_id: swarmId,
current_task: currentTask,
};
return record;
}
export async function registerAgent(
input: RegisterAgentInput,
deps: Partial<RegisterAgentDeps> = {},
): Promise<AgentCommandResponse<AgentRecord>> {
const command: AgentCommandName = 'agent register';
const name = trimOrEmpty(input.name);
const role = trimOrEmpty(input.role);
const display = trimOrEmpty(input.display) || name;
const projectRoot = deps.projectRoot || process.cwd();
const agentIdError = validateAgentId(name);
if (agentIdError) {
return invalid(command, agentIdError.code, agentIdError.message);
}
const roleError = validateRole(role);
if (roleError) {
return invalid(command, roleError.code, roleError.message);
}
try {
const beadId = toBeadId(name);
const showResult = await runBdCommand({
projectRoot,
args: ['agent', 'show', beadId, '--json'],
});
if (showResult.success && !input.forceUpdate) {
return invalid(command, 'DUPLICATE_AGENT_ID', 'Agent is already registered. Use --force-update to change display/role.');
}
const stateResult = await runBdCommand({
projectRoot,
args: ['agent', 'state', beadId, 'idle', '--json'],
});
if (!stateResult.success) {
return invalid(command, 'INTERNAL_ERROR', `Failed to set agent state: ${stateResult.error}`);
}
const labels = ['gt:agent'];
if (role) {
labels.push(`role:${role}`);
}
if (input.rig) {
labels.push(`rig:${input.rig}`);
}
const updateArgs = ['update', beadId, '--title', `Agent: ${display}`, '--add-label', labels.join(',')];
const updateResult = await runBdCommand({
projectRoot,
args: [...updateArgs, '--json'],
});
if (!updateResult.success) {
console.error('Update failed:', updateResult.error, updateResult.stdout, updateResult.stderr);
return invalid(command, 'INTERNAL_ERROR', `Failed to update agent details: ${updateResult.error}`);
}
const flushResult = await runBdCommand({
projectRoot,
args: ['admin', 'flush'],
});
if (!flushResult.success) {
console.error('Flush failed:', flushResult.error, flushResult.stdout, flushResult.stderr);
}
const record = await callBdAgentShow(beadId, projectRoot);
if (!record) {
return invalid(command, 'INTERNAL_ERROR', 'Failed to retrieve final agent state.');
}
return success(command, record);
} catch (error) {
return invalid(command, 'INTERNAL_ERROR', error instanceof Error ? error.message : 'Failed to register agent.');
}
}
export async function listAgents(
input: ListAgentsInput,
deps: Partial<RegisterAgentDeps> = {},
): Promise<AgentCommandResponse<AgentRecord[]>> {
const command: AgentCommandName = 'agent list';
const role = trimOrEmpty(input.role);
const status = trimOrEmpty(input.status);
const projectRoot = deps.projectRoot || process.cwd();
try {
const listResult = await runBdCommand({
projectRoot,
args: ['list', '--label', 'gt:agent', '--json'],
});
if (!listResult.success) {
return invalid(command, 'INTERNAL_ERROR', `Failed to list agents from bd: ${listResult.error}`);
}
const rawList = extractJsonArray(listResult.stdout);
if (rawList.length === 0) {
return success(command, []);
}
const agents: AgentRecord[] = [];
for (const item of rawList) {
const record = await callBdAgentShow(item.id, projectRoot);
if (record) {
if (role && record.role !== role) continue;
if (status && record.status !== status) continue;
agents.push(record);
}
}
return success(command, agents.sort((a, b) => a.agent_id.localeCompare(b.agent_id)));
} catch (error) {
return invalid(command, 'INTERNAL_ERROR', error instanceof Error ? error.message : 'Failed to list agents.');
}
}
export async function showAgent(
input: ShowAgentInput,
deps: Partial<RegisterAgentDeps> = {},
): Promise<AgentCommandResponse<AgentRecord>> {
const command: AgentCommandName = 'agent show';
const name = trimOrEmpty(input.agent);
const projectRoot = deps.projectRoot || process.cwd();
const agentIdError = validateAgentId(name);
if (agentIdError) {
return invalid(command, agentIdError.code, agentIdError.message);
}
try {
const beadId = toBeadId(name);
const record = await callBdAgentShow(beadId, projectRoot);
if (!record) {
return invalid(command, 'AGENT_NOT_FOUND', 'Agent is not registered.');
}
return success(command, record);
} catch (error) {
return invalid(command, 'INTERNAL_ERROR', error instanceof Error ? error.message : 'Failed to load agent.');
}
}
export async function setAgentState(
input: { agent: string; state: AgentZfcState },
deps: Partial<RegisterAgentDeps> = {},
): Promise<AgentCommandResponse<AgentRecord>> {
const command: AgentCommandName = 'agent state';
const name = trimOrEmpty(input.agent);
const state = input.state;
const projectRoot = deps.projectRoot || process.cwd();
const agentIdError = validateAgentId(name);
if (agentIdError) {
return invalid(command, agentIdError.code, agentIdError.message);
}
try {
const beadId = toBeadId(name);
const stateResult = await runBdCommand({
projectRoot,
args: ['agent', 'state', beadId, state, '--json'],
});
if (!stateResult.success) {
return invalid(command, 'AGENT_NOT_FOUND', 'Agent is not registered.');
}
const record = await callBdAgentShow(beadId, projectRoot);
if (!record) {
return invalid(command, 'INTERNAL_ERROR', 'Failed to retrieve agent state after update.');
}
return success(command, record);
} catch (error) {
return invalid(command, 'INTERNAL_ERROR', error instanceof Error ? error.message : 'Failed to set agent state.');
}
}
export function deriveLiveness(lastSeenAt: string, now: Date = new Date(), staleMinutes: number = 15): AgentLiveness {
const lastSeen = new Date(lastSeenAt).getTime();
const diffMs = now.getTime() - lastSeen;
const diffMin = diffMs / (1000 * 60);
if (diffMin >= 60) {
return 'idle';
}
if (diffMin >= 2 * staleMinutes) {
return 'evicted';
}
if (diffMin >= staleMinutes) {
return 'stale';
}
return 'active';
}
export async function extendActivityLease(
input: ActivityLeaseInput,
deps: Partial<RegisterAgentDeps> = {},
): Promise<AgentCommandResponse<AgentRecord | null>> {
const command: AgentCommandName = 'agent activity-lease';
const name = trimOrEmpty(input.agent);
const projectRoot = deps.projectRoot || process.cwd();
const agentIdError = validateAgentId(name);
if (agentIdError) {
return invalid(command, agentIdError.code, agentIdError.message);
}
try {
const beadId = toBeadId(name);
const wispResult = await runBdCommand({
projectRoot,
args: [
'create',
`pulse:${name}:${Date.now()}`,
'--type', 'event',
'--wisp-type', 'heartbeat',
'--ephemeral',
'--event-actor', beadId,
'--json'
],
});
if (!wispResult.success) {
return invalid(command, 'INTERNAL_ERROR', `Failed to emit heartbeat wisp: ${wispResult.error}`);
}
activityEventBus.emit({
id: randomUUID(),
kind: 'heartbeat',
beadId: beadId,
beadTitle: `Agent: ${name}`,
projectId: projectRoot,
projectName: path.basename(projectRoot),
timestamp: new Date().toISOString(),
actor: name,
payload: { message: 'running' }
});
return success(command, null);
} catch (error) {
return invalid(command, 'INTERNAL_ERROR', error instanceof Error ? error.message : 'Failed to extend activity lease.');
}
}

177
src/lib/agent/types.ts Normal file
View file

@ -0,0 +1,177 @@
export type ProtocolEventType = 'HANDOFF' | 'BLOCKED' | 'INCURSION' | 'RESUME' | 'INFO';
export interface ProtocolEventEnvelope<T = any> {
id: string;
version: 'v1';
event_type: ProtocolEventType;
project_root: string;
bead_id: string;
from_agent: string | null;
to_agent: string | null;
scope: string | null;
created_at: string;
payload: T;
}
export type ProtocolEvent = ProtocolEventEnvelope;
export interface CreateProtocolEventInput {
event_type: ProtocolEventType;
project_root: string;
bead_id: string;
from_agent?: string;
to_agent?: string;
scope?: string;
payload: any;
}
export interface ProtocolDeps {
now: () => string;
idGenerator: () => string;
}
export function createProtocolEvent(
input: CreateProtocolEventInput,
deps: Partial<ProtocolDeps> = {}
): ProtocolEvent {
const now = deps.now ? deps.now() : new Date().toISOString();
const generateId = deps.idGenerator ?? (() => `proto_${Date.now()}_${Math.random().toString(16).slice(2, 6)}`);
return {
id: generateId(),
version: 'v1',
event_type: input.event_type,
project_root: input.project_root,
bead_id: input.bead_id,
from_agent: input.from_agent ?? null,
to_agent: input.to_agent ?? null,
scope: input.scope ?? null,
created_at: now,
payload: input.payload,
};
}
export type AgentCommandName = 'agent register' | 'agent list' | 'agent show' | 'agent activity-lease' | 'agent state';
export type AgentZfcState = 'idle' | 'spawning' | 'running' | 'working' | 'stuck' | 'done' | 'stopped' | 'dead';
export interface AgentCommandError {
code: string;
message: string;
}
export interface AgentCommandResponse<T> {
ok: boolean;
command: AgentCommandName;
data: T | null;
error: AgentCommandError | null;
}
export interface AgentRecord {
agent_id: string;
display_name: string;
role: string;
status: string;
created_at: string;
last_seen_at: string;
version: number;
rig?: string;
role_type?: string;
swarm_id?: string;
current_task?: string;
}
export interface RegisterAgentInput {
name: string;
display?: string;
role: string;
forceUpdate?: boolean;
rig?: string;
}
export interface RegisterAgentDeps {
now: () => string;
projectRoot: string;
}
export interface ListAgentsInput {
role?: string;
status?: string;
}
export interface ShowAgentInput {
agent: string;
}
export interface ActivityLeaseInput {
agent: string;
}
export type AgentLiveness = 'active' | 'stale' | 'evicted' | 'idle';
// Mail/Messaging types
export const MESSAGE_CATEGORIES = ['HANDOFF', 'BLOCKED', 'DECISION', 'INFO'] as const;
export const MESSAGE_STATES = ['unread', 'read', 'acked'] as const;
export type MessageCategory = typeof MESSAGE_CATEGORIES[number];
export type MessageState = typeof MESSAGE_STATES[number];
export type MailCommandName = 'agent send' | 'agent inbox' | 'agent read' | 'agent ack';
export interface MailCommandError {
code: string;
message: string;
}
export interface MailCommandResponse<T> {
ok: boolean;
command: MailCommandName;
data: T | null;
error: MailCommandError | null;
}
export interface AgentMessage {
message_id: string;
thread_id: string;
bead_id: string;
from_agent: string;
to_agent: string;
category: MessageCategory;
subject: string;
body: string;
state: MessageState;
requires_ack: boolean;
created_at: string;
read_at: string | null;
acked_at: string | null;
}
export interface SendAgentMessageInput {
from: string;
to: string;
bead: string;
category: MessageCategory;
subject: string;
body: string;
thread?: string;
}
export interface SendAgentMessageDeps {
now: () => string;
idGenerator: () => string;
}
export interface InboxAgentMessagesInput {
agent: string;
state?: MessageState;
bead?: string;
limit?: number;
}
export interface MessageActionInput {
agent: string;
message: string;
}
export interface MessageMutationDeps {
now: () => string;
}

View file

@ -24,6 +24,7 @@ interface IssueRow extends RowDataPacket {
closed_at: Date | string | null;
due_at: Date | string | null;
labels_concat: string | null;
comments_count: number | null;
}
interface DepRow extends RowDataPacket {
@ -60,6 +61,7 @@ function normalizeRow(row: IssueRow, deps: BeadDependency[]): BeadIssue {
due_at: toIsoString(row.due_at),
estimated_minutes: typeof row.estimated_minutes === 'number' ? row.estimated_minutes : null,
external_ref: row.external_ref ?? null,
comments_count: (row.comments_count ?? 0) as number,
metadata: row.metadata ?? {},
};
}
@ -86,7 +88,9 @@ export async function readIssuesViaDolt(
try {
// Query 1: All issues with comma-separated labels (GROUP_CONCAT avoids N+1)
const [issueRows] = await pool.execute<IssueRow[]>(
`SELECT i.*, GROUP_CONCAT(l.label SEPARATOR ',') AS labels_concat
`SELECT i.*,
GROUP_CONCAT(l.label SEPARATOR ',') AS labels_concat,
(SELECT COUNT(*) FROM comments c WHERE c.issue_id = i.id) AS comments_count
FROM issues i
LEFT JOIN labels l ON l.issue_id = i.id
GROUP BY i.id`

View file

@ -67,22 +67,31 @@ export function diffSnapshots(
events.push(createEvent('estimate_changed', curr, now, { field: 'estimated_minutes', from: prev.estimated_minutes, to: curr.estimated_minutes }));
}
// 4. Collection Changes (Labels)
if (!areArraysEqual(prev.labels, curr.labels)) {
events.push(createEvent('labels_changed', curr, now, {
field: 'labels',
from: prev.labels.join(','),
to: curr.labels.join(',')
}));
}
// 5. Collection Changes (Dependencies)
// 4. Collection Changes (Labels)
if (!areArraysEqual(prev.labels, curr.labels)) {
events.push(createEvent('labels_changed', curr, now, {
field: 'labels',
from: prev.labels.join(','),
to: curr.labels.join(',')
}));
}
// 5. Collection Changes (Comments) - detect comment count changes
if (prev.comments_count !== curr.comments_count) {
events.push(createEvent('comment_added', curr, now, {
field: 'comments_count',
from: String(prev.comments_count),
to: String(curr.comments_count)
}));
}
// 6. Collection Changes (Dependencies)
diffDependencies(prev.dependencies, curr.dependencies).forEach(kindAndTarget => {
events.push(createEvent(kindAndTarget.kind, curr, now, { to: kindAndTarget.target, field: kindAndTarget.type }));
});
});
// 6. Detect Deleted Issues
// 7. Detect Deleted Issues
if (previous) {
const currMap = new Set(current.map(c => c.id));
previous.forEach(prev => {

View file

@ -52,8 +52,9 @@ export interface BeadIssue {
created_by: string | null;
due_at: string | null;
estimated_minutes: number | null;
external_ref: string | null;
metadata: Record<string, unknown>;
external_ref: string | null;
comments_count?: number;
metadata: Record<string, unknown>;
}
export interface ParseableBeadIssue extends Partial<BeadIssue> {