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:
parent
6f41c4af31
commit
18fbafdce4
34 changed files with 62714 additions and 1970 deletions
|
|
@ -1 +1 @@
|
||||||
1910
|
44920
|
||||||
1
.beads/dolt-server.activity
Normal file
1
.beads/dolt-server.activity
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
1772664767
|
||||||
60827
.beads/dolt-server.log
60827
.beads/dolt-server.log
File diff suppressed because it is too large
Load diff
1
.beads/dolt-server.pid
Normal file
1
.beads/dolt-server.pid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
45716
|
||||||
1
.beads/dolt-server.port
Normal file
1
.beads/dolt-server.port
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
3307
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -19,3 +19,6 @@ artifacts/
|
||||||
|
|
||||||
# beadboard runtime artifacts
|
# beadboard runtime artifacts
|
||||||
.beadboard/
|
.beadboard/
|
||||||
|
|
||||||
|
# Archived stashes for reference
|
||||||
|
docs/references/stashes/
|
||||||
|
|
|
||||||
|
|
@ -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
80
project.md
Normal 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 |
|
||||||
|
|
@ -315,6 +315,8 @@ Stop and correct if you are about to:
|
||||||
Swarm composition, molecule operations, worker dispatch patterns.
|
Swarm composition, molecule operations, worker dispatch patterns.
|
||||||
- `references/missions-realtime.md`:
|
- `references/missions-realtime.md`:
|
||||||
Real-time/watcher/event troubleshooting.
|
Real-time/watcher/event troubleshooting.
|
||||||
|
- `references/creating-beads.md`:
|
||||||
|
Creating epics, tasks, subtasks with proper naming, dependencies, and workflow.
|
||||||
|
|
||||||
## Bottom Line
|
## Bottom Line
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
This file is maintained by agents. A new agent reads this first.
|
||||||
The first agent in a repo should create this file; later agents must read and update it before work.
|
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 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 language/runtime:
|
||||||
- Primary package manager:
|
- 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 — version:
|
||||||
|
- `bb` installed and on PATH: yes/no — version:
|
||||||
- `bd` installed and on PATH: yes/no
|
- Detection commands used:
|
||||||
- `bb` or `beadboard` installed and on PATH: yes/no
|
- Shell/platform: (e.g. WSL2/bash, macOS/zsh, Windows/PowerShell)
|
||||||
- Detection commands used (with date):
|
|
||||||
- Notes on shell/platform quirks (WSL/Windows/macOS/Linux):
|
|
||||||
|
|
||||||
## BeadBoard/Communication Setup
|
## BeadBoard/Communication Setup
|
||||||
|
|
||||||
- Mail delegate command configured:
|
- `.beads` database: exists/created on YYYY-MM-DD via `bd init`
|
||||||
- `bd config set mail.delegate "node <abs-path>/skills/beadboard-driver/scripts/bb-mail-shim.mjs"`
|
- Mail delegate: `bd config set mail.delegate "node <abs-path>/scripts/bb-mail-shim.mjs"` — configured YYYY-MM-DD
|
||||||
- Agent identity env var policy:
|
- Agent identity policy: `export BB_AGENT=<role-name>` (set fresh each session in Step 2)
|
||||||
- Preferred: `BB_AGENT=<agent-id>`
|
- `session-preflight` last pass: YYYY-MM-DD
|
||||||
- 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
|
|
||||||
|
|
||||||
## Agent State + Heartbeat Policy
|
## Agent State + Heartbeat Policy
|
||||||
|
|
||||||
- Agent bead naming convention for this repo:
|
- Agent bead naming: `bb-<role-name>` (e.g. `bb-silver-scribe`)
|
||||||
- Required state transitions (spawning -> running -> working -> stuck/done/stopped):
|
- Required state transitions: `spawning → running → working → stuck/done/stopped`
|
||||||
- Heartbeat cadence during active work (recommended 30-120s):
|
- Heartbeat: LLM agents heartbeat at turn start + before long commands; daemon agents every 5 min
|
||||||
- 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.):
|
|
||||||
|
|
||||||
## Command Baseline
|
## Command Baseline
|
||||||
|
|
||||||
- Install command:
|
- Install:
|
||||||
- Build command:
|
- Build:
|
||||||
- Typecheck command:
|
- Typecheck:
|
||||||
- Lint command:
|
- Lint:
|
||||||
- Test command:
|
- Test:
|
||||||
- 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):
|
|
||||||
|
|
||||||
## Known Workarounds
|
## Known Workarounds
|
||||||
|
|
||||||
|
|
@ -80,24 +69,12 @@ Document only stable, repeatable workarounds.
|
||||||
1. Trigger:
|
1. Trigger:
|
||||||
- Symptom:
|
- Symptom:
|
||||||
- Workaround:
|
- Workaround:
|
||||||
- Verification:
|
- Verified:
|
||||||
- Owner:
|
|
||||||
|
|
||||||
2. Trigger:
|
## Session Log (append-only)
|
||||||
- Symptom:
|
|
||||||
- Workaround:
|
|
||||||
- Verification:
|
|
||||||
- Owner:
|
|
||||||
|
|
||||||
## Session Closeout Checklist
|
Each agent appends one line when they update this file:
|
||||||
|
|
||||||
- [ ] Bead status/assignee updated
|
| Date | Agent | What changed |
|
||||||
- [ ] Verification commands executed and recorded
|
|------|-------|-------------|
|
||||||
- [ ] Artifacts attached/linked
|
| YYYY-MM-DD | `<agent-bead-id>` | Initial project.md created |
|
||||||
- [ ] 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.
|
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ Blocked condition:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bd agent state bb-silver-scribe stuck
|
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:
|
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
|
bd agent heartbeat bb-silver-scribe
|
||||||
```
|
```
|
||||||
|
|
||||||
When to heartbeat:
|
**Daemon agents (persistent processes):**
|
||||||
- At least once every 5-10 minutes during long-running work
|
- Normal work: every 5 minutes
|
||||||
|
- High-risk long operations: every 2-3 minutes
|
||||||
- Immediately before long test/build phases
|
- Immediately before long test/build phases
|
||||||
- Immediately after recovering from interruptions
|
- Immediately after recovering from interruptions
|
||||||
|
|
||||||
Recommended cadence:
|
**LLM agents (Claude Code, turn-based):**
|
||||||
- Normal work: every 5 minutes
|
- At turn start (when picking up work)
|
||||||
- High-risk long operations: every 2-3 minutes
|
- Immediately before long-running commands
|
||||||
|
- Inter-turn silence is expected and not a health signal
|
||||||
|
|
||||||
## Witness Death Timeout
|
## Witness Death Timeout
|
||||||
|
|
||||||
|
|
@ -95,6 +97,8 @@ Operational interpretation:
|
||||||
Agent-side rule:
|
Agent-side rule:
|
||||||
- If you are alive and still executing, heartbeat before anyone has to guess.
|
- 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)
|
## Slot Operations (Current Work Attachment)
|
||||||
|
|
||||||
The `hook` slot links an agent bead to the active task bead.
|
The `hook` slot links an agent bead to the active task bead.
|
||||||
|
|
@ -125,7 +129,7 @@ Important slot constraints:
|
||||||
|
|
||||||
When blocked:
|
When blocked:
|
||||||
1. Set state to stuck (`bd agent state ... stuck`)
|
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
|
3. Keep heartbeat active while waiting
|
||||||
4. Resume with `running`/`working` once unblocked
|
4. Resume with `running`/`working` once unblocked
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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/session-preflight.mjs`
|
||||||
- `node skills/beadboard-driver/scripts/ensure-bb-mail-configured.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 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> spawning` — agent bead created, environment not yet verified
|
||||||
- `bd agent state <agent-bead-id> running`
|
- `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 heartbeat <agent-bead-id>`
|
||||||
- `bd agent show <agent-bead-id>`
|
- `bd agent show <agent-bead-id>`
|
||||||
|
|
||||||
|
|
@ -75,7 +79,7 @@ Delegate setup and validation:
|
||||||
|
|
||||||
## Environment and Repair Helpers
|
## Environment and Repair Helpers
|
||||||
|
|
||||||
- `node skills/beadboard-driver/scripts/resolve-bb.mjs`
|
- `node {baseDir}/scripts/setup-mail-delegate.mjs` — configure mail.delegate (self-resolves shim path)
|
||||||
- `node skills/beadboard-driver/scripts/readiness-report.mjs --checks <json> --artifacts <json>`
|
- `node {baseDir}/scripts/readiness-report.mjs --checks <json> --artifacts <json>`
|
||||||
- `node skills/beadboard-driver/scripts/diagnose-env.mjs`
|
- `node {baseDir}/scripts/diagnose-env.mjs`
|
||||||
- `node skills/beadboard-driver/scripts/heal-common-issues.mjs [--project-root <path>] [--apply] [--fix-git-index-lock]`
|
- `node {baseDir}/scripts/heal-common-issues.mjs [--project-root <path>] [--apply] [--fix-git-index-lock]`
|
||||||
|
|
|
||||||
286
skills/beadboard-driver/references/creating-beads.md
Normal file
286
skills/beadboard-driver/references/creating-beads.md
Normal 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`
|
||||||
|
|
@ -13,12 +13,12 @@ This document tracks high-impact coordination and environment failures for the B
|
||||||
|
|
||||||
## `BB_NOT_FOUND`
|
## `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.
|
- Cause: global BeadBoard CLI not installed, or not discoverable.
|
||||||
- Recovery:
|
- Recovery:
|
||||||
- Install BeadBoard globally (`bb`/`beadboard` on `PATH`).
|
- Install BeadBoard globally (`bb`/`beadboard` on `PATH`) — see Bootstrap Step C in SKILL.md.
|
||||||
- Re-run `node skills/beadboard-driver/scripts/resolve-bb.mjs`.
|
- Run `node {baseDir}/scripts/setup-mail-delegate.mjs` to reconfigure the mail delegate after `bb` is installed.
|
||||||
- Re-run preflight.
|
- Re-run preflight: `node {baseDir}/scripts/session-preflight.mjs`.
|
||||||
|
|
||||||
## `MAIL_DELEGATE_MISSING` / `BD_MAIL_DELEGATE_NOT_SET`
|
## `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.
|
- Signal: `ensure-bb-mail-configured.mjs` fails contract checks.
|
||||||
- Cause: delegate points to wrong command, missing shim path, or invalid `BB_AGENT` context.
|
- Cause: delegate points to wrong command, missing shim path, or invalid `BB_AGENT` context.
|
||||||
- Recovery:
|
- Recovery (in order):
|
||||||
- Run session preflight to re-apply expected delegate command.
|
1. Check delegate is set: `bd config get mail.delegate`
|
||||||
- Set `BB_AGENT` explicitly.
|
2. Verify shim path: the path shown must be absolute and the `bb-mail-shim.mjs` file must exist on disk
|
||||||
- Validate with `node skills/beadboard-driver/scripts/ensure-bb-mail-configured.mjs`.
|
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`
|
## `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 write `.beads/issues.jsonl` directly.
|
||||||
- Do not close beads without fresh evidence.
|
- 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.
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,7 @@ This runbook is the minimum lifecycle contract for agents using BeadBoard Driver
|
||||||
1. Run preflight and discovery checks:
|
1. Run preflight and discovery checks:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node skills/beadboard-driver/scripts/session-preflight.mjs
|
node {baseDir}/scripts/session-preflight.mjs
|
||||||
node skills/beadboard-driver/scripts/resolve-bb.mjs
|
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Create or identify your agent bead first (required before claiming work):
|
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:
|
4. Query hard memory for your domain before claim:
|
||||||
|
|
||||||
```bash
|
```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
|
## 2) Discover Work and Read Epic Context
|
||||||
|
|
@ -100,7 +100,7 @@ bd agent state <agent-bead-id> stuck
|
||||||
2. Coordination signal:
|
2. Coordination signal:
|
||||||
|
|
||||||
```bash
|
```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:
|
3. Keep heartbeat while waiting:
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
async function main() {
|
||||||
const shimPath = join(__dirname, 'bb-mail-shim.mjs');
|
const shimPath = join(__dirname, 'bb-mail-shim.mjs');
|
||||||
|
|
||||||
|
|
@ -54,13 +86,14 @@ async function main() {
|
||||||
reason: 'Could not find bd in PATH.',
|
reason: 'Could not find bd in PATH.',
|
||||||
remediation:
|
remediation:
|
||||||
process.platform === 'win32'
|
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.',
|
: 'Primary: npm i -g beadboard. Fallback: bash ./install/install.sh. Then ensure bd is available in PATH.',
|
||||||
tools: {
|
tools: {
|
||||||
bd: { available: false, path: null },
|
bd: { available: false, path: null },
|
||||||
},
|
},
|
||||||
bb: null,
|
bb: null,
|
||||||
mail: null,
|
mail: null,
|
||||||
|
memory: null,
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
2,
|
2,
|
||||||
|
|
@ -86,6 +119,7 @@ async function main() {
|
||||||
configured: false,
|
configured: false,
|
||||||
reason: 'bb not available — mail delegate requires bb agent commands',
|
reason: 'bb not available — mail delegate requires bb agent commands',
|
||||||
},
|
},
|
||||||
|
memory: null,
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
2,
|
2,
|
||||||
|
|
@ -95,6 +129,7 @@ async function main() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const mail = configureMailDelegate(bdPath, shimPath);
|
const mail = configureMailDelegate(bdPath, shimPath);
|
||||||
|
const memory = validateMemorySystem(bdPath);
|
||||||
|
|
||||||
process.stdout.write(
|
process.stdout.write(
|
||||||
`${JSON.stringify(
|
`${JSON.stringify(
|
||||||
|
|
@ -106,6 +141,7 @@ async function main() {
|
||||||
},
|
},
|
||||||
bb,
|
bb,
|
||||||
mail,
|
mail,
|
||||||
|
memory,
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
2,
|
2,
|
||||||
|
|
@ -124,6 +160,7 @@ async function main() {
|
||||||
},
|
},
|
||||||
bb: null,
|
bb: null,
|
||||||
mail: null,
|
mail: null,
|
||||||
|
memory: null,
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
2,
|
2,
|
||||||
|
|
|
||||||
95
skills/beadboard-driver/scripts/setup-mail-delegate.mjs
Normal file
95
skills/beadboard-driver/scripts/setup-mail-delegate.mjs
Normal 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();
|
||||||
|
|
@ -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');
|
const content = await fs.readFile(path.join(root, 'project.md'), 'utf8');
|
||||||
assert.equal(result.ok, true);
|
assert.equal(result.ok, true);
|
||||||
assert.equal(result.created, true);
|
assert.equal(result.created, true);
|
||||||
assert.match(content, /Project Driver Template/);
|
assert.match(content, /Environment Status Cache/);
|
||||||
} finally {
|
} finally {
|
||||||
await fs.rm(root, { recursive: true, force: true });
|
await fs.rm(root, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ const tests = [
|
||||||
path.join(__dirname, 'diagnose-env.contract.test.mjs'),
|
path.join(__dirname, 'diagnose-env.contract.test.mjs'),
|
||||||
path.join(__dirname, 'heal-common-issues.contract.test.mjs'),
|
path.join(__dirname, 'heal-common-issues.contract.test.mjs'),
|
||||||
path.join(__dirname, 'ensure-project-context.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], {
|
const child = spawn(process.execPath, ['--test', ...tests], {
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
29
src/app/api/agents/mail/batch/route.ts
Normal file
29
src/app/api/agents/mail/batch/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
33
src/app/api/agents/reservations/batch/route.ts
Normal file
33
src/app/api/agents/reservations/batch/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
|
@ -305,29 +305,24 @@ export function ActivityPanel({ issues, collapsed = false, projectRoot }: Activi
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mailResponses = await Promise.all(
|
// Use batch endpoints to reduce API calls from 2N to 2
|
||||||
agentRoster.map(async (agent) => {
|
const agentNames = agentRoster.map(a => a.name).join(',');
|
||||||
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;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const reservationResponses = await Promise.all(
|
const [mailResponse, reservationsResponse] = await Promise.all([
|
||||||
agentRoster.map(async (agent) => {
|
fetch(`/api/agents/mail/batch?agents=${encodeURIComponent(agentNames)}&limit=15`),
|
||||||
const response = await fetch(`/api/agents/reservations?agent=${encodeURIComponent(agent.name)}`);
|
fetch(`/api/agents/reservations/batch?agents=${encodeURIComponent(agentNames)}`),
|
||||||
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 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>();
|
const uniqueMessages = new Map<string, CoordMessage>();
|
||||||
for (const [, messages] of mailResponses) {
|
if (mailPayload.ok && mailPayload.data) {
|
||||||
for (const message of messages) {
|
for (const entry of mailPayload.data) {
|
||||||
uniqueMessages.set(message.message_id, message);
|
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())
|
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
|
||||||
.slice(0, 25);
|
.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);
|
setCoordActivities(mapped);
|
||||||
setReservationByAgent(Object.fromEntries(reservationResponses));
|
setReservationByAgent(reservationMap);
|
||||||
};
|
};
|
||||||
|
|
||||||
void fetchCoordination();
|
void fetchCoordination();
|
||||||
|
|
|
||||||
|
|
@ -184,38 +184,39 @@ export function SocialPage({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mailPairs = await Promise.all(
|
// Use batch endpoints to reduce API calls from 2N to 2
|
||||||
agentNames.map(async (agent) => {
|
const agentsParam = agentNames.join(',');
|
||||||
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;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const reservationsPairs = await Promise.all(
|
const [mailResponse, reservationsResponse] = await Promise.all([
|
||||||
agentNames.map(async (agent) => {
|
fetch(`/api/agents/mail/batch?agents=${encodeURIComponent(agentsParam)}&limit=25`),
|
||||||
const response = await fetch(`/api/agents/reservations?agent=${encodeURIComponent(agent)}`);
|
fetch(`/api/agents/reservations/batch?agents=${encodeURIComponent(agentsParam)}`),
|
||||||
const payload = await response.json().catch(() => ({ ok: false }));
|
]);
|
||||||
if (!response.ok || !payload.ok) {
|
|
||||||
return [agent, undefined] as const;
|
const mailPayload = await mailResponse.json().catch(() => ({ ok: false, data: [] }));
|
||||||
}
|
const reservationsPayload = await reservationsResponse.json().catch(() => ({ ok: false, data: [] }));
|
||||||
const first = (payload.data?.reservations ?? [])[0];
|
|
||||||
return [agent, first?.scope as string | undefined] as const;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const nextMessages: Record<string, CoordMessage[]> = {};
|
const nextMessages: Record<string, CoordMessage[]> = {};
|
||||||
const nextUnread: Record<string, number> = {};
|
const nextUnread: Record<string, number> = {};
|
||||||
for (const [agent, messages] of mailPairs) {
|
const nextReservations: Record<string, string | undefined> = {};
|
||||||
nextMessages[agent] = messages;
|
|
||||||
nextUnread[agent] = messages.filter((m) => m.state === 'unread').length;
|
// 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);
|
setAgentMessagesByName(nextMessages);
|
||||||
setAgentUnreadByName(nextUnread);
|
setAgentUnreadByName(nextUnread);
|
||||||
setAgentReservationsByName(Object.fromEntries(reservationsPairs));
|
setAgentReservationsByName(nextReservations);
|
||||||
}, [agentNames]);
|
}, [agentNames]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -1,52 +1,3 @@
|
||||||
export type ProtocolEventType = 'HANDOFF' | 'BLOCKED' | 'INCURSION' | 'RESUME' | 'INFO';
|
// Re-export from new bounded context
|
||||||
|
// This file is deprecated - import from ./agent/types instead
|
||||||
export interface ProtocolEventEnvelope<T = any> {
|
export * from './agent/types';
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,503 +1,10 @@
|
||||||
import { randomUUID } from 'node:crypto';
|
/**
|
||||||
import path from 'node:path';
|
* @deprecated Import from './agent/registry' or './agent/types' instead
|
||||||
import { runBdCommand } from './bridge';
|
*
|
||||||
import { activityEventBus } from './realtime';
|
* This file is kept for backward compatibility.
|
||||||
|
* All implementations have been moved to src/lib/agent/
|
||||||
const AGENT_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
*/
|
||||||
|
|
||||||
export type AgentCommandName = 'agent register' | 'agent list' | 'agent show' | 'agent activity-lease' | 'agent state';
|
// Re-export everything from the new bounded context
|
||||||
|
export * from './agent/registry';
|
||||||
export type AgentZfcState = 'idle' | 'spawning' | 'running' | 'working' | 'stuck' | 'done' | 'stopped' | 'dead';
|
export * from './agent/types';
|
||||||
|
|
||||||
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.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
15
src/lib/agent/index.ts
Normal file
15
src/lib/agent/index.ts
Normal 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
437
src/lib/agent/messaging.ts
Normal 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
442
src/lib/agent/registry.ts
Normal 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
177
src/lib/agent/types.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -24,6 +24,7 @@ interface IssueRow extends RowDataPacket {
|
||||||
closed_at: Date | string | null;
|
closed_at: Date | string | null;
|
||||||
due_at: Date | string | null;
|
due_at: Date | string | null;
|
||||||
labels_concat: string | null;
|
labels_concat: string | null;
|
||||||
|
comments_count: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DepRow extends RowDataPacket {
|
interface DepRow extends RowDataPacket {
|
||||||
|
|
@ -60,6 +61,7 @@ function normalizeRow(row: IssueRow, deps: BeadDependency[]): BeadIssue {
|
||||||
due_at: toIsoString(row.due_at),
|
due_at: toIsoString(row.due_at),
|
||||||
estimated_minutes: typeof row.estimated_minutes === 'number' ? row.estimated_minutes : null,
|
estimated_minutes: typeof row.estimated_minutes === 'number' ? row.estimated_minutes : null,
|
||||||
external_ref: row.external_ref ?? null,
|
external_ref: row.external_ref ?? null,
|
||||||
|
comments_count: (row.comments_count ?? 0) as number,
|
||||||
metadata: row.metadata ?? {},
|
metadata: row.metadata ?? {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -86,7 +88,9 @@ export async function readIssuesViaDolt(
|
||||||
try {
|
try {
|
||||||
// Query 1: All issues with comma-separated labels (GROUP_CONCAT avoids N+1)
|
// Query 1: All issues with comma-separated labels (GROUP_CONCAT avoids N+1)
|
||||||
const [issueRows] = await pool.execute<IssueRow[]>(
|
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
|
FROM issues i
|
||||||
LEFT JOIN labels l ON l.issue_id = i.id
|
LEFT JOIN labels l ON l.issue_id = i.id
|
||||||
GROUP BY i.id`
|
GROUP BY i.id`
|
||||||
|
|
|
||||||
|
|
@ -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 }));
|
events.push(createEvent('estimate_changed', curr, now, { field: 'estimated_minutes', from: prev.estimated_minutes, to: curr.estimated_minutes }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Collection Changes (Labels)
|
// 4. Collection Changes (Labels)
|
||||||
if (!areArraysEqual(prev.labels, curr.labels)) {
|
if (!areArraysEqual(prev.labels, curr.labels)) {
|
||||||
events.push(createEvent('labels_changed', curr, now, {
|
events.push(createEvent('labels_changed', curr, now, {
|
||||||
field: 'labels',
|
field: 'labels',
|
||||||
from: prev.labels.join(','),
|
from: prev.labels.join(','),
|
||||||
to: curr.labels.join(',')
|
to: curr.labels.join(',')
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Collection Changes (Dependencies)
|
// 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 => {
|
diffDependencies(prev.dependencies, curr.dependencies).forEach(kindAndTarget => {
|
||||||
events.push(createEvent(kindAndTarget.kind, curr, now, { to: kindAndTarget.target, field: kindAndTarget.type }));
|
events.push(createEvent(kindAndTarget.kind, curr, now, { to: kindAndTarget.target, field: kindAndTarget.type }));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// 6. Detect Deleted Issues
|
// 7. Detect Deleted Issues
|
||||||
if (previous) {
|
if (previous) {
|
||||||
const currMap = new Set(current.map(c => c.id));
|
const currMap = new Set(current.map(c => c.id));
|
||||||
previous.forEach(prev => {
|
previous.forEach(prev => {
|
||||||
|
|
|
||||||
|
|
@ -52,8 +52,9 @@ export interface BeadIssue {
|
||||||
created_by: string | null;
|
created_by: string | null;
|
||||||
due_at: string | null;
|
due_at: string | null;
|
||||||
estimated_minutes: number | null;
|
estimated_minutes: number | null;
|
||||||
external_ref: string | null;
|
external_ref: string | null;
|
||||||
metadata: Record<string, unknown>;
|
comments_count?: number;
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ParseableBeadIssue extends Partial<BeadIssue> {
|
export interface ParseableBeadIssue extends Partial<BeadIssue> {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue