feat(telemetry): complete bb-buff.1.3 - Backend Liveness Refactor

STORY:
The session backend needed to aggregate agent health from a live
telemetry stream rather than static bead metadata. This refactor
makes liveness signals real-time and accurate.

COLLABORATION:
We extended the ActivityEvent model with a native 'heartbeat' kind,
updated extendActivityLease() to emit through the activity bus, and
refactored getAgentLivenessMap() to prioritize heartbeat activity
history over stale bead metadata.

DELIVERABLES:
- ActivityEvent extended with 'heartbeat' kind
- extendActivityLease() emits heartbeats through activity bus
- getAgentLivenessMap() prefers telemetry over static metadata
- Registry APIs support projectRoot injection for testing
- Tests verify preference logic via TDD

VERIFICATION:
- 93/93 tests PASSING
- Heartbeat override verified in isolated temp projects

CLOSES: bb-buff.1.3
BLOCKS: bb-buff.3.2, bb-buff.3.3, bb-buff.2.1
This commit is contained in:
zenchantlive 2026-02-15 21:14:05 -08:00
parent 0016b57e37
commit 4ee550c333
36 changed files with 1380 additions and 541 deletions

View file

@ -86,7 +86,7 @@ export async function GET(request: Request): Promise<Response> {
}
if (nextVersion !== lastTouchedVersion) {
lastTouchedVersion = nextVersion;
write(toSseFrame(issuesEventBus.emit(projectRoot, lastTouchedPath, 'changed')));
write(toSseFrame(issuesEventBus.emit(projectRoot, lastTouchedPath, 'telemetry')));
}
};

View file

@ -14,7 +14,7 @@ export async function GET(request: Request): Promise<Response> {
const issues = await readIssuesFromDisk({ projectRoot, preferBd: true });
const activity = activityEventBus.getHistory(projectRoot);
const communication = await getCommunicationSummary();
const livenessMap = await getAgentLivenessMap();
const livenessMap = await getAgentLivenessMap(projectRoot, activity);
const incursions = await calculateIncursions();
const agentsResult = await listAgents({});

View file

@ -1,8 +1,10 @@
import { SessionsPage } from '../../components/sessions/sessions-page';
import type { SwarmGroup } from '../../components/sessions/sessions-header';
import { readIssuesForScope } from '../../lib/aggregate-read';
import { resolveProjectScope } from '../../lib/project-scope';
import { listProjects } from '../../lib/registry';
import { listAgents } from '../../lib/agent-registry';
import { getSwarmMembers } from '../../lib/swarm-molecules';
export const dynamic = 'force-dynamic';
@ -32,6 +34,35 @@ export default async function Page({ searchParams }: PageProps) {
preferBd: true,
});
const epics = issues.filter(i => i.issue_type === 'epic');
const epicsWithSwarm = epics.filter(
i => (i.labels || []).some(l => l.startsWith('swarm:'))
);
const swarmGroups: SwarmGroup[] = [];
const assignedAgentIds = new Set<string>();
for (const epic of epicsWithSwarm) {
const swarmLabel = epic.labels?.find(l => l.startsWith('swarm:'));
if (!swarmLabel) continue;
const swarmId = swarmLabel.replace('swarm:', '');
const memberIds = await getSwarmMembers({ swarmId }, { projectRoot: scope.selected.root });
const members = agents.filter(a => memberIds.includes(a.agent_id));
members.forEach(a => assignedAgentIds.add(a.agent_id));
if (members.length > 0) {
swarmGroups.push({
swarmId,
swarmLabel: epic.id,
members,
});
}
}
const unassignedAgents = agents.filter(a => !assignedAgentIds.has(a.agent_id));
return (
<SessionsPage
issues={issues}
@ -40,6 +71,8 @@ export default async function Page({ searchParams }: PageProps) {
projectScopeKey={scope.selected.key}
projectScopeOptions={scope.options}
projectScopeMode={scope.mode}
swarmGroups={swarmGroups}
unassignedAgents={unassignedAgents}
/>
);
}

View file

@ -9,19 +9,39 @@ interface SessionFeedCardProps {
onSelect: (id: string) => void;
isHighlighted?: boolean;
incursion?: Incursion;
highlightSource?: 'task' | 'agent';
}
export function SessionFeedCard({ card, onSelect, isHighlighted, incursion }: SessionFeedCardProps) {
export function SessionFeedCard({ card, onSelect, isHighlighted, incursion, highlightSource }: SessionFeedCardProps) {
return (
<motion.article
layout
onClick={() => onSelect(card.id)}
className={`relative w-full cursor-pointer rounded-[1.25rem] border p-[1rem] text-left transition-all duration-200 ${
isHighlighted
? 'border-sky-500 bg-sky-500/10 ring-1 ring-sky-500/50 scale-[1.02] shadow-[0_0_20px_rgba(56,189,248,0.15)]'
? highlightSource === 'agent'
? 'border-emerald-500/50 bg-emerald-500/10 ring-1 ring-emerald-500/30 scale-[1.01] shadow-[0_0_16px_rgba(16,185,129,0.15)]'
: 'border-sky-500 bg-sky-500/10 ring-1 ring-sky-500/50 scale-[1.02] shadow-[0_0_20px_rgba(56,189,248,0.15)]'
: `${statusBorder(card.status)} ${statusGradient(card.status)} hover:bg-white/[0.04]`
} ${sessionStateGlow(card.sessionState)} ${incursion ? 'ring-1 ring-rose-500/30' : ''}`}
>
{/* Critical state badges */}
{card.sessionState === 'stuck' && (
<div className="absolute -top-2 right-4 z-10">
<span className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[0.55rem] font-black uppercase tracking-[0.1em] bg-red-500/80 text-white border border-red-400 shadow-lg animate-pulse">
<span aria-hidden="true"></span>
STUCK
</span>
</div>
)}
{card.sessionState === 'dead' && (
<div className="absolute -top-2 right-4 z-10">
<span className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[0.55rem] font-black uppercase tracking-[0.1em] bg-zinc-700/80 text-zinc-300 border border-zinc-600 shadow-lg">
<span aria-hidden="true"></span>
OFFLINE
</span>
</div>
)}
{incursion && (
<div className="absolute -top-2 right-4 z-10">
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[0.55rem] font-black uppercase tracking-[0.1em] border shadow-lg animate-pulse ${

View file

@ -10,6 +10,7 @@ interface SessionTaskFeedProps {
selectedEpicId: string | null;
onSelectTask: (id: string) => void;
highlightTaskId?: string | null;
highlightingAgentId?: string | null;
}
export function IncursionTicker({ incursions }: { incursions: Incursion[] }) {
@ -35,7 +36,7 @@ export function IncursionTicker({ incursions }: { incursions: Incursion[] }) {
);
}
export function SessionTaskFeed({ feed, incursions = [], selectedEpicId, onSelectTask, highlightTaskId }: SessionTaskFeedProps) {
export function SessionTaskFeed({ feed, incursions = [], selectedEpicId, onSelectTask, highlightTaskId, highlightingAgentId }: SessionTaskFeedProps) {
const filteredFeed = useMemo(() => {
if (!selectedEpicId) return feed;
return feed.filter(b => b.epic.id === selectedEpicId);
@ -88,13 +89,15 @@ export function SessionTaskFeed({ feed, incursions = [], selectedEpicId, onSelec
task.owner && inc.agents.includes(task.owner)
);
const isAgentMission = highlightingAgentId ? task.owner === highlightingAgentId : false;
return (
<SessionFeedCard
key={task.id}
card={task}
onSelect={onSelectTask}
isHighlighted={highlightTaskId === task.id}
isHighlighted={highlightTaskId === task.id || isAgentMission}
incursion={taskIncursion}
highlightSource={isAgentMission ? 'agent' : undefined}
/>
);
})}

View file

@ -1,9 +1,16 @@
'use client';
import { useEffect, useState } from 'react';
import type { AgentRecord, AgentLiveness } from '../../lib/agent-registry';
import { ProjectScopeControls } from '../shared/project-scope-controls';
import type { ProjectScopeOption } from '../../lib/project-scope';
import { AgentStation } from './agent-station';
import { getSwarmHealth } from './sessions-header-logic';
export interface SwarmGroup {
swarmId: string;
swarmLabel: string;
members: AgentRecord[];
}
interface SessionsHeaderProps {
agents: AgentRecord[];
@ -18,6 +25,9 @@ interface SessionsHeaderProps {
completed: number;
};
livenessMap?: Record<string, string>;
swarmGroups?: SwarmGroup[];
unassignedAgents?: AgentRecord[];
missionCounts?: Record<string, number>;
}
export function SessionsHeader({
@ -29,6 +39,9 @@ export function SessionsHeader({
projectScopeOptions,
stats,
livenessMap = {},
swarmGroups = [],
unassignedAgents = [],
missionCounts = {},
}: SessionsHeaderProps) {
return (
<header className="sticky top-0 z-50 flex flex-col border-b border-white/5 bg-[#0b0c10]/60 backdrop-blur-3xl shadow-2xl">
@ -40,15 +53,68 @@ export function SessionsHeader({
</div>
<div className="flex items-center gap-2 overflow-x-auto no-scrollbar py-1">
{agents.map((agent) => (
<AgentStation
key={agent.agent_id}
agent={agent}
isSelected={activeAgentId === agent.agent_id}
onSelect={onSelectAgent}
liveness={(livenessMap[agent.agent_id] as AgentLiveness) || 'active'}
/>
))}
{swarmGroups.length > 0 || unassignedAgents.length > 0 ? (
<div className="flex items-center gap-4">
{swarmGroups.map((group) => {
const health = getSwarmHealth(group.members, livenessMap);
return (
<div key={group.swarmId} className="swarm-container flex items-center gap-2">
<div className="flex flex-col items-end">
<span className="ui-text text-[0.55rem] font-black uppercase tracking-[0.15em] text-sky-400/50 whitespace-nowrap">
{group.swarmLabel}
</span>
<span className={`ui-text text-[0.45rem] font-bold uppercase tracking-wider ${health.color} flex items-center gap-0.5`}>
<span className="text-xs"></span>
{health.status}
</span>
</div>
<div className="flex items-center gap-1">
{group.members.map((agent) => (
<AgentStation
key={agent.agent_id}
agent={agent}
isSelected={activeAgentId === agent.agent_id}
onSelect={onSelectAgent}
liveness={(livenessMap[agent.agent_id] as AgentLiveness) || 'active'}
missionCount={missionCounts[agent.agent_id]}
/>
))}
</div>
</div>
);
})}
{unassignedAgents.length > 0 && (
<div className="swarm-container flex items-center gap-2">
<span className="ui-text text-[0.55rem] font-black uppercase tracking-[0.15em] text-zinc-500/40 whitespace-nowrap">
No Swarm
</span>
<div className="flex items-center gap-1">
{unassignedAgents.map((agent) => (
<AgentStation
key={agent.agent_id}
agent={agent}
isSelected={activeAgentId === agent.agent_id}
onSelect={onSelectAgent}
liveness={(livenessMap[agent.agent_id] as AgentLiveness) || 'active'}
missionCount={missionCounts[agent.agent_id]}
/>
))}
</div>
</div>
)}
</div>
) : (
agents.map((agent) => (
<AgentStation
key={agent.agent_id}
agent={agent}
isSelected={activeAgentId === agent.agent_id}
onSelect={onSelectAgent}
liveness={(livenessMap[agent.agent_id] as AgentLiveness) || 'active'}
missionCount={missionCounts[agent.agent_id]}
/>
))
)}
</div>
</div>
@ -78,95 +144,6 @@ export function SessionsHeader({
);
}
function useTimeAgo(isoTimestamp: string) {
const [timeAgo, setTimeAgo] = useState('');
useEffect(() => {
const update = () => {
const seconds = Math.floor((new Date().getTime() - new Date(isoTimestamp).getTime()) / 1000);
if (seconds < 60) setTimeAgo(`${seconds}s`);
else if (seconds < 3600) setTimeAgo(`${Math.floor(seconds / 60)}m`);
else setTimeAgo(`${Math.floor(seconds / 3600)}h`);
};
update();
const interval = setInterval(update, 10000);
return () => clearInterval(interval);
}, [isoTimestamp]);
return timeAgo;
}
function AgentStation({
agent,
isSelected,
onSelect,
liveness
}: {
agent: AgentRecord,
isSelected: boolean,
onSelect: (id: string | null) => void,
liveness: AgentLiveness
}) {
const timeAgo = useTimeAgo(agent.last_seen_at);
const statusStyles = {
active: {
dot: 'bg-emerald-500 animate-pulse shadow-[0_0_10px_rgba(16,185,129,0.8)]',
label: 'On Mission',
color: 'text-emerald-400/60'
},
stale: {
dot: 'bg-amber-500 shadow-[0_0_8px_rgba(245,158,11,0.5)]',
label: 'Lease Expiring',
color: 'text-amber-400/60'
},
evicted: {
dot: 'bg-rose-500/50 shadow-none',
label: 'Disconnected',
color: 'text-rose-400/40'
},
idle: {
dot: 'bg-zinc-700 shadow-none',
label: 'Idle',
color: 'text-zinc-500/30'
}
}[liveness];
return (
<button
onClick={() => onSelect(isSelected ? null : agent.agent_id)}
className={`flex-none group flex w-[10rem] items-center gap-2 rounded-lg border px-2 py-1.5 transition-all duration-300 ${
isSelected
? 'border-sky-500/50 bg-sky-500/10 shadow-[0_0_10px_rgba(14,165,233,0.1)]'
: 'border-white/5 bg-white/[0.01] hover:bg-white/5'
} ${liveness === 'idle' ? 'opacity-40 grayscale-[0.5]' : ''}`}
>
<div className="relative flex-none">
<div className={`h-7 w-7 rounded-md bg-gradient-to-br from-zinc-700 to-zinc-900 flex items-center justify-center border border-white/10 shadow-inner transition-transform duration-300 ${isSelected ? 'scale-90' : 'group-hover:scale-105'}`}>
<span className="ui-text text-[0.6rem] font-black text-zinc-400">
{agent.agent_id.slice(0, 2).toUpperCase()}
</span>
</div>
<span className={`absolute -bottom-0.5 -right-0.5 h-2 w-2 rounded-full border-2 border-[#0b0c10] ${statusStyles.dot}`} />
</div>
<div className="flex flex-col items-start min-w-0 text-left">
<div className="flex items-center gap-1 w-full justify-between pr-1">
<span className={`ui-text text-[0.65rem] font-black truncate transition-colors ${isSelected ? 'text-sky-300' : 'text-text-body'}`}>
{agent.agent_id}
</span>
<span className="system-data text-[0.5rem] font-bold text-text-muted/40">
{timeAgo}
</span>
</div>
<span className={`system-data text-[0.5rem] font-bold uppercase tracking-tighter ${statusStyles.color}`}>
{statusStyles.label}
</span>
</div>
</button>
);
}
function StatPill({ label, value, color }: { label: string, value: number, color: string }) {
return (
<div className="flex items-center gap-1 rounded-full border border-white/5 bg-white/5 px-1.5 py-0.5">

View file

@ -10,7 +10,8 @@ import type { AgentRecord } from '../../lib/agent-registry';
import { EpicChipStrip } from '../shared/epic-chip-strip';
import { SessionTaskFeed } from './session-task-feed';
import { ConversationDrawer } from './conversation-drawer';
import { SessionsHeader } from './sessions-header';
import { SessionsHeader, type SwarmGroup } from './sessions-header';
import { getMissionsByAgent } from '../../lib/agent-sessions';
interface SessionsPageProps {
issues: BeadIssue[];
@ -19,6 +20,8 @@ interface SessionsPageProps {
projectScopeKey: string;
projectScopeOptions: ProjectScopeOption[];
projectScopeMode: 'single' | 'aggregate';
swarmGroups?: SwarmGroup[];
unassignedAgents?: AgentRecord[];
}
export function SessionsPage({
@ -28,10 +31,21 @@ export function SessionsPage({
projectScopeKey,
projectScopeOptions,
projectScopeMode,
swarmGroups = [],
unassignedAgents = [],
}: SessionsPageProps) {
// 2. Session-specific feed
const { feed, incursions, livenessMap, loading, refresh: refreshFeed, stats } = useSessionFeed(projectRoot);
// Compute mission counts for agent header badges
const missionCounts = useMemo(() => {
const counts: Record<string, number> = {};
const missionsByAgent = getMissionsByAgent(feed);
for (const [agentId, missions] of Object.entries(missionsByAgent)) {
counts[agentId] = missions.length;
}
return counts;
}, [feed]);
const {
selectedAgentId,
selectedTaskId,
@ -43,11 +57,11 @@ export function SessionsPage({
const [selectedEpicId, setSelectedEpicId] = useState<string | null>(null);
const [refreshTrigger, setRefreshTrigger] = useState(0);
// 1. Basic subscription for SSE invalidation
const { refresh: refreshIssues, issues: localIssues } = useBeadsSubscription(initialIssues, projectRoot, {
onUpdate: () => {
console.log('[Sessions] SSE update detected. Scheduling silent refresh...');
// Small delay to ensure backend files are flushed
onUpdate: (kind) => {
if (kind === 'telemetry') return;
console.log(`[Sessions] ${kind} update detected. Scheduling silent refresh...`);
setTimeout(() => {
void refreshFeed({ silent: true });
setRefreshTrigger(prev => prev + 1);
@ -74,6 +88,9 @@ export function SessionsPage({
projectScopeOptions={projectScopeOptions}
stats={stats}
livenessMap={livenessMap}
swarmGroups={swarmGroups}
unassignedAgents={unassignedAgents}
missionCounts={missionCounts}
/>
<div className="flex flex-1 overflow-hidden">
@ -102,6 +119,7 @@ export function SessionsPage({
selectedEpicId={selectedEpicId}
onSelectTask={setSelectedTaskId}
highlightTaskId={selectedTaskId}
highlightingAgentId={selectedAgentId}
/>
)}
</div>

View file

@ -50,6 +50,9 @@ export function sessionStateGlow(state: string): string {
switch (state) {
case 'active': return 'shadow-[0_0_12px_rgba(74,222,128,0.3)] border-emerald-500/30';
case 'needs_input': return 'shadow-[0_0_12px_rgba(248,113,113,0.3)] border-rose-500/30';
case 'stuck': return 'ring-2 ring-red-500 animate-pulse shadow-[0_0_16px_rgba(239,68,68,0.5)]';
case 'dead': return 'opacity-40 grayscale';
case 'evicted': return 'opacity-60 grayscale-[0.5]';
case 'stale': return 'opacity-60 grayscale-[0.5]';
case 'completed': return 'opacity-80';
default: return '';

View file

@ -29,7 +29,7 @@ async function fetchIssues(projectRoot: string): Promise<BeadIssue[]> {
export function useBeadsSubscription(
initialIssues: BeadIssue[],
projectRoot: string,
options: { onUpdate?: () => void } = {}
options: { onUpdate?: (kind: 'issues' | 'telemetry' | 'activity') => void } = {}
): UseBeadsSubscriptionResult {
const [issues, setIssues] = useState<BeadIssue[]>(initialIssues);
const refreshInFlightRef = useRef(false);
@ -54,7 +54,7 @@ export function useBeadsSubscription(
try {
const reconciled = await fetchIssues(projectRoot);
setIssues(reconciled);
onUpdate?.();
onUpdate?.('issues');
} catch (error) {
if (!options.silent) {
console.error('[BeadsSubscription] Refresh failed:', error);
@ -77,18 +77,36 @@ export function useBeadsSubscription(
};
const onIssues = (event: MessageEvent) => {
console.log('🚨 SSE RECEIVED:', event.data);
onUpdate?.();
console.log('🚨 SSE ISSUES RECEIVED:', event.data);
onUpdate?.('issues');
void refresh({ silent: true });
};
const onTelemetry = (event: MessageEvent) => {
console.log('📡 SSE TELEMETRY RECEIVED (Silent):', event.data);
// We don't trigger a full refresh or parent update for heartbeats
// This prevents the page from flickering/clearing state while typing.
onUpdate?.('telemetry');
};
const onActivity = (event: MessageEvent) => {
console.log('📝 SSE ACTIVITY RECEIVED:', event.data);
onUpdate?.('activity');
};
source.addEventListener('issues', onIssues as EventListener);
source.addEventListener('telemetry', onTelemetry as EventListener);
source.addEventListener('activity', onActivity as EventListener);
return () => {
console.log('[SSE] Closing connection');
source.removeEventListener('issues', onIssues as EventListener);
source.removeEventListener('telemetry', onTelemetry as EventListener);
source.removeEventListener('activity', onActivity as EventListener);
source.close();
};
// onUpdate is intentionally excluded from deps to avoid re-subscribing on parent re-renders
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projectRoot, refresh]);
return { issues, refresh, updateLocal };

View file

@ -20,7 +20,8 @@ export type ActivityEventKind =
| 'comment_added'
| 'due_date_changed'
| 'estimate_changed'
| 'field_changed';
| 'field_changed'
| 'heartbeat';
/**
* Represents a discrete change or action derived from bead snapshots or interactions.

View file

@ -188,6 +188,21 @@ async function resolveRegisteredAgent(agentId: string): Promise<AgentRecord | nu
return result.ok ? result.data : null;
}
async function resolveRecipients(to: string, from: string): Promise<string[]> {
if (to === 'broadcast') {
const agents = (await listAgents({})).data ?? [];
return agents.map((a) => a.agent_id).filter((id) => id !== from);
}
if (to.startsWith('role:')) {
const role = to.slice(5);
const agents = (await listAgents({ role })).data ?? [];
return agents.map((a) => a.agent_id).filter((id) => id !== from);
}
return [to];
}
export async function sendAgentMessage(
input: SendAgentMessageInput,
deps: Partial<SendAgentMessageDeps> = {},
@ -210,7 +225,9 @@ export async function sendAgentMessage(
return invalid(command, 'UNKNOWN_RECIPIENT', 'Recipient agent is required.');
}
if (to !== 'broadcast' && !(await resolveRegisteredAgent(to))) {
const isRoleOrBroadcast = to === 'broadcast' || to.startsWith('role:');
if (!isRoleOrBroadcast && !(await resolveRegisteredAgent(to))) {
return invalid(command, 'UNKNOWN_RECIPIENT', 'Recipient agent is not registered.');
}
@ -229,12 +246,17 @@ export async function sendAgentMessage(
try {
const now = deps.now ? deps.now() : new Date().toISOString();
const generateId = deps.idGenerator ?? (() => defaultMessageId(now));
const recipientIds =
to === 'broadcast'
? ((await listAgents({})).data ?? []).map((agent) => agent.agent_id).filter((agentId) => agentId !== from)
: [to];
const recipientIds = await resolveRecipients(to, from);
if (recipientIds.length === 0) {
if (to.startsWith('role:')) {
const role = to.slice(5);
const allWithRole = (await listAgents({ role })).data ?? [];
if (allWithRole.length === 0) {
return invalid(command, 'UNKNOWN_RECIPIENT', `no agents found with role '${role}'.`);
}
return invalid(command, 'UNKNOWN_RECIPIENT', 'all recipients were excluded (sender).');
}
return invalid(command, 'UNKNOWN_RECIPIENT', 'No recipients available for broadcast.');
}

View file

@ -1,10 +1,13 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import { randomUUID } from 'node:crypto';
import path from 'node:path';
import { runBdCommand } from './bridge';
import { activityEventBus } from './realtime';
const AGENT_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
export type AgentCommandName = 'agent register' | 'agent list' | 'agent show' | 'agent activity-lease';
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;
@ -24,8 +27,10 @@ export interface AgentRecord {
role: string;
status: string;
created_at: string;
last_seen_at: string; // Used as the base for the Activity Lease
last_seen_at: string;
version: number;
rig?: string;
role_type?: string;
}
export interface RegisterAgentInput {
@ -33,10 +38,12 @@ export interface RegisterAgentInput {
display?: string;
role: string;
forceUpdate?: boolean;
rig?: string;
}
export interface RegisterAgentDeps {
now: () => string;
projectRoot: string;
}
export interface ListAgentsInput {
@ -52,26 +59,83 @@ export interface ActivityLeaseInput {
agent: string;
}
function userProfileRoot(): string {
return process.env.USERPROFILE?.trim() || os.homedir();
/**
* 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}`;
}
export function agentRegistryRoot(): string {
return path.join(userProfileRoot(), '.beadboard', 'agent');
/**
* 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;
}
export function agentsDirectoryPath(): string {
return path.join(agentRegistryRoot(), 'agents');
/**
* 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);
}
export function agentFilePath(agentId: string): string {
return path.join(agentsDirectoryPath(), `${agentId}.json`);
/**
* 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: ['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,
@ -112,50 +176,36 @@ function validateRole(value: string): AgentCommandError | null {
return null;
}
async function readAgent(agentId: string): Promise<AgentRecord | null> {
try {
const raw = await fs.readFile(agentFilePath(agentId), 'utf8');
const parsed = JSON.parse(raw) as AgentRecord;
return parsed;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return null;
function mapBdAgentToRecord(bdAgent: any): AgentRecord {
// Extract role from labels if role_type is not set
let role = bdAgent.role_type || 'agent';
if (role === 'agent' && Array.isArray(bdAgent.labels)) {
const roleLabel = bdAgent.labels.find((l: string) => l.startsWith('role:'));
if (roleLabel) {
role = roleLabel.split(':')[1];
}
throw error;
}
}
async function writeAgent(record: AgentRecord): Promise<void> {
const filePath = agentFilePath(record.agent_id);
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, `${JSON.stringify(record, null, 2)}\n`, 'utf8');
}
async function loadAllAgents(): Promise<AgentRecord[]> {
try {
const entries = await fs.readdir(agentsDirectoryPath(), { withFileTypes: true });
const files = entries.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith('.json'));
const agents: AgentRecord[] = [];
for (const file of files) {
const filePath = path.join(agentsDirectoryPath(), file.name);
try {
const raw = await fs.readFile(filePath, 'utf8');
agents.push(JSON.parse(raw) as AgentRecord);
} catch {
continue;
}
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];
}
return agents.sort((left, right) => left.agent_id.localeCompare(right.agent_id));
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return [];
}
throw error;
}
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,
};
return record;
}
export async function registerAgent(
@ -163,11 +213,12 @@ export async function registerAgent(
deps: Partial<RegisterAgentDeps> = {},
): Promise<AgentCommandResponse<AgentRecord>> {
const command: AgentCommandName = 'agent register';
const agentId = trimOrEmpty(input.name);
const name = trimOrEmpty(input.name);
const role = trimOrEmpty(input.role);
const display = trimOrEmpty(input.display) || agentId;
const display = trimOrEmpty(input.display) || name;
const projectRoot = deps.projectRoot || process.cwd();
const agentIdError = validateAgentId(agentId);
const agentIdError = validateAgentId(name);
if (agentIdError) {
return invalid(command, agentIdError.code, agentIdError.message);
}
@ -178,86 +229,179 @@ export async function registerAgent(
}
try {
const existing = await readAgent(agentId);
const now = deps.now ? deps.now() : new Date().toISOString();
const beadId = toBeadId(name);
// 1. Check if agent exists
const showResult = await runBdCommand({
projectRoot,
args: ['agent', 'show', beadId, '--json'],
});
if (existing && !input.forceUpdate) {
if (showResult.success && !input.forceUpdate) {
return invalid(command, 'DUPLICATE_AGENT_ID', 'Agent is already registered. Use --force-update to change display/role.');
}
if (existing) {
const updated: AgentRecord = {
...existing,
display_name: display || existing.display_name,
role,
last_seen_at: now,
version: existing.version + 1,
};
await writeAgent(updated);
return success(command, updated);
// 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}`);
}
const created: AgentRecord = {
agent_id: agentId,
display_name: display,
role,
status: 'idle',
created_at: now,
last_seen_at: now,
version: 1,
};
// 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}`);
}
await writeAgent(created);
return success(command, created);
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): Promise<AgentCommandResponse<AgentRecord[]>> {
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 agents = await loadAllAgents();
const filtered = agents.filter((agent) => {
if (role && agent.role !== role) {
return false;
}
if (status && agent.status !== status) {
return false;
}
return true;
const listResult = await runBdCommand({
projectRoot,
args: ['list', '--label', 'gt:agent', '--json'],
});
return success(command, filtered);
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): Promise<AgentCommandResponse<AgentRecord>> {
export async function showAgent(
input: ShowAgentInput,
deps: Partial<RegisterAgentDeps> = {},
): Promise<AgentCommandResponse<AgentRecord>> {
const command: AgentCommandName = 'agent show';
const agentId = trimOrEmpty(input.agent);
const name = trimOrEmpty(input.agent);
const projectRoot = deps.projectRoot || process.cwd();
const agentIdError = validateAgentId(agentId);
const agentIdError = validateAgentId(name);
if (agentIdError) {
return invalid(command, agentIdError.code, agentIdError.message);
}
try {
const agent = await readAgent(agentId);
if (!agent) {
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, agent);
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';
/**
@ -285,36 +429,59 @@ export function deriveLiveness(lastSeenAt: string, now: Date = new Date(), stale
}
/**
* Extends the activity lease (last_seen_at timestamp) for a registered agent.
* Equivalent to a "parking permit" extension based on real work.
* 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>> {
): Promise<AgentCommandResponse<AgentRecord | null>> {
const command: AgentCommandName = 'agent activity-lease';
const agentId = trimOrEmpty(input.agent);
const name = trimOrEmpty(input.agent);
const projectRoot = deps.projectRoot || process.cwd();
const agentIdError = validateAgentId(agentId);
const agentIdError = validateAgentId(name);
if (agentIdError) {
return invalid(command, agentIdError.code, agentIdError.message);
}
try {
const existing = await readAgent(agentId);
if (!existing) {
return invalid(command, 'AGENT_NOT_FOUND', 'Agent is not registered.');
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}`);
}
const now = deps.now ? deps.now() : new Date().toISOString();
const updated: AgentRecord = {
...existing,
last_seen_at: now,
version: existing.version + 1,
};
// 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' }
});
await writeAgent(updated);
return success(command, updated);
// 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.');
}

View file

@ -407,7 +407,8 @@ export async function releaseAgentReservation(
try {
const now = deps.now ? deps.now() : new Date().toISOString();
const reservations = await readActiveReservations();
const existing = reservations.find((reservation) => reservation.scope === scope);
const normalizedScope = normalizePath(scope);
const existing = reservations.find((reservation) => reservation.scope === normalizedScope);
if (!existing || isExpired(existing, now)) {
if (existing && isExpired(existing, now)) {

View file

@ -4,7 +4,7 @@ import { listAgents, deriveLiveness } from './agent-registry';
import { inboxAgentMessages, type AgentMessage } from './agent-mail';
import { statusAgentReservations, classifyOverlap } from './agent-reservations';
export type AgentSessionState = 'active' | 'reviewing' | 'deciding' | 'needs_input' | 'completed' | 'stale' | 'evicted' | 'idle';
export type AgentSessionState = 'active' | 'reviewing' | 'deciding' | 'needs_input' | 'completed' | 'stale' | 'evicted' | 'idle' | 'stuck' | 'dead';
export interface SessionTaskCard {
id: string;
@ -38,14 +38,102 @@ export interface CommunicationSummary {
// 15 minutes default stale threshold
const STALE_THRESHOLD_MS = 15 * 60 * 1000;
export async function getAgentLivenessMap(): Promise<Record<string, string>> {
const agentsResult = await listAgents({});
/**
* Derives the session state for a task based on task status, liveness, and ZFC state.
* Priority: completed > stuck > dead > needs_input > evicted > stale > active > deciding
*/
export function deriveSessionState(
task: BeadIssue,
lastEvent: ActivityEvent | null,
pendingRequired: boolean,
ownerLiveness?: string,
ownerZfcState?: string
): AgentSessionState {
if (task.status === 'closed') return 'completed';
if (ownerZfcState === 'stuck') return 'stuck';
if (ownerZfcState === 'dead') return 'dead';
if (task.status === 'blocked' || pendingRequired) return 'needs_input';
if (ownerLiveness === 'evicted') return 'evicted';
if (ownerLiveness === 'stale') return 'stale';
const lastActiveTime = lastEvent ? new Date(lastEvent.timestamp).getTime() : new Date(task.updated_at).getTime();
if (Date.now() - lastActiveTime > STALE_THRESHOLD_MS) return 'stale';
if (task.status === 'in_progress') return 'active';
return 'deciding';
}
/**
* Returns all active (non-closed) tasks owned by a specific agent.
* Used for mission pathing: drawing visual links between working agents and their tasks.
*/
export function getAgentActiveMissions(
feed: EpicBucket[],
agentId: string
): SessionTaskCard[] {
return feed
.flatMap(bucket => bucket.tasks)
.filter(task => task.owner === agentId && task.status !== 'closed');
}
/**
* Returns count of active missions for an agent.
* Used for visual indicators in the sessions header.
*/
export function getActiveMissionCount(feed: EpicBucket[], agentId: string): number {
return getAgentActiveMissions(feed, agentId).length;
}
/**
* Groups all active missions by agent ID.
* Used for efficient batch rendering of mission paths.
*/
export function getMissionsByAgent(feed: EpicBucket[]): Record<string, SessionTaskCard[]> {
const missions: Record<string, SessionTaskCard[]> = {};
for (const bucket of feed) {
for (const task of bucket.tasks) {
if (task.owner && task.status !== 'closed') {
if (!missions[task.owner]) {
missions[task.owner] = [];
}
missions[task.owner].push(task);
}
}
}
return missions;
}
export async function getAgentLivenessMap(
projectRoot: string = process.cwd(),
activityHistory: ActivityEvent[] = []
): Promise<Record<string, string>> {
const agentsResult = await listAgents({}, { projectRoot });
const agents = agentsResult.data ?? [];
const map: Record<string, string> = {};
const now = new Date();
// Group activity by actor to find latest heartbeat
const latestHeartbeatByAgent = new Map<string, string>();
activityHistory
.filter(e => e.kind === 'heartbeat')
.forEach(e => {
const current = latestHeartbeatByAgent.get(e.actor || '');
if (!current || new Date(e.timestamp) > new Date(current)) {
latestHeartbeatByAgent.set(e.actor || '', e.timestamp);
}
});
for (const agent of agents) {
map[agent.agent_id] = deriveLiveness(agent.last_seen_at, now);
const telemetryLastSeen = latestHeartbeatByAgent.get(agent.agent_id);
const metadataLastSeen = agent.last_seen_at;
// Use most recent signal
let effectiveLastSeen = metadataLastSeen;
if (telemetryLastSeen && new Date(telemetryLastSeen) > new Date(metadataLastSeen)) {
effectiveLastSeen = telemetryLastSeen;
}
map[agent.agent_id] = deriveLiveness(effectiveLastSeen, now);
}
return map;

View file

@ -1,9 +1,10 @@
import { execFile as nodeExecFile } from 'node:child_process';
import { exec as nodeExec } from 'node:child_process';
import { promisify } from 'node:util';
import path from 'node:path';
import { BdExecutableNotFoundError, resolveBdExecutable } from './bd-path';
const execFileAsync = promisify(nodeExecFile);
const execAsync = promisify(nodeExec);
export type BdFailureClassification = 'not_found' | 'timeout' | 'non_zero_exit' | 'bad_args' | 'unknown';
@ -27,60 +28,51 @@ export interface RunBdCommandResult {
error: string | null;
}
type ExecFileOptions = {
cwd: string;
timeout: number;
windowsHide: boolean;
env: NodeJS.ProcessEnv;
};
type ExecFileLike = (
command: string,
args: string[],
options: ExecFileOptions,
) => Promise<{ stdout: string; stderr: string }>;
interface RunBdCommandDeps {
resolveBdExecutable: typeof resolveBdExecutable;
execFile: ExecFileLike;
exec: (command: string, options: { cwd: string; timeout: number; env: NodeJS.ProcessEnv }) => Promise<{ stdout: string; stderr: string }>;
env: NodeJS.ProcessEnv;
}
function normalizeOutput(text: unknown): string {
if (typeof text !== 'string') {
return '';
}
if (typeof text !== 'string') return '';
return text.replaceAll('\r\n', '\n').trim();
}
function toErrorMessage(value: unknown): string {
if (value instanceof Error) {
return value.message;
}
if (value instanceof Error) return value.message;
return String(value ?? 'Unknown error');
}
function classifyFailure(error: NodeJS.ErrnoException & { stderr?: string; killed?: boolean; signal?: string }): BdFailureClassification {
if (error.code === 'ENOENT') {
return 'not_found';
}
if (error.code === 'ETIMEDOUT' || error.killed || error.signal === 'SIGTERM') {
return 'timeout';
}
if (error.code === 'ENOENT') return 'not_found';
if (error.code === 'ETIMEDOUT' || error.killed || error.signal === 'SIGTERM') return 'timeout';
const stderr = normalizeOutput(error.stderr);
if (typeof error.code === 'number') {
if (/(unknown|invalid|required|usage)/i.test(stderr)) {
return 'bad_args';
}
if (/(unknown|invalid|required|usage)/i.test(stderr)) return 'bad_args';
return 'non_zero_exit';
}
return 'unknown';
}
function buildShellCommand(executable: string, args: string[]): string {
// Normalize to forward slashes for Windows shell compatibility
const normalizedExe = executable.split(path.sep).join('/');
if (process.platform === 'win32') {
// Windows: quote the executable path, leave simple args unquoted
const quotedExe = `"${normalizedExe}"`;
const quotedArgs = args.map(a => {
if (/[\s&|<>()^"]/.test(a)) return `"${a.replace(/"/g, '""')}"`;
return a;
});
return [quotedExe, ...quotedArgs].join(' ');
} else {
const escapeArg = (a: string) => `'${a.replace(/'/g, "'\''")}'`;
return [normalizedExe, ...args.map(escapeArg)].join(' ');
}
}
export async function runBdCommand(
options: RunBdCommandOptions,
injectedDeps?: Partial<RunBdCommandDeps>,
@ -89,14 +81,17 @@ export async function runBdCommand(
const timeoutMs = options.timeoutMs ?? 30_000;
const cwd = options.projectRoot;
const args = [...options.args];
if (process.env.BD_NO_DAEMON === 'true') {
args.unshift('--no-daemon');
}
const deps: RunBdCommandDeps = {
resolveBdExecutable: injectedDeps?.resolveBdExecutable ?? resolveBdExecutable,
execFile: injectedDeps?.execFile ?? execFileAsync,
exec: injectedDeps?.exec ?? execAsync,
env: injectedDeps?.env ?? process.env,
};
let command = options.explicitBdPath ?? 'bd.exe';
let command = options.explicitBdPath ?? 'bd';
try {
const resolved = await deps.resolveBdExecutable({
@ -105,10 +100,11 @@ export async function runBdCommand(
});
command = resolved.executable;
const { stdout, stderr } = await deps.execFile(command, args, {
const shellCommand = buildShellCommand(command, args);
const { stdout, stderr } = await deps.exec(shellCommand, {
cwd,
timeout: timeoutMs,
windowsHide: true,
env: deps.env,
});

View file

@ -2,6 +2,7 @@ import type { BeadDependency, BeadIssue, ParseableBeadIssue } from './types';
export interface ParseIssuesOptions {
includeTombstones?: boolean;
skipAgentFilter?: boolean;
}
function normalizeDependencies(value: unknown): BeadDependency[] {

View file

@ -14,6 +14,7 @@ export interface ReadIssuesOptions {
projectSource?: ProjectSource;
projectAddedAt?: string | null;
preferBd?: boolean;
skipAgentFilter?: boolean;
}
export function resolveIssuesJsonlPathCandidates(projectRoot: string = process.cwd()): string[] {
@ -108,8 +109,8 @@ async function readIssuesViaBd(options: ReadIssuesOptions, project: ReturnType<t
.filter((issue) => {
// Exclude tombstones
if (issue.status === 'tombstone' && !options.includeTombstones) return false;
// Exclude agent identities from mission lists
if (issue.labels.includes('gt:agent')) return false;
// Exclude agent identities from mission lists unless skipping filter (for watcher/diffing)
if (issue.labels.includes('gt:agent') && !options.skipAgentFilter) return false;
return true;
})
.map((issue) => ({
@ -141,6 +142,7 @@ export async function readIssuesFromDisk(options: ReadIssuesOptions = {}): Promi
const jsonl = await readTextFileWithRetry(issuesPath);
return parseIssuesJsonl(jsonl, {
includeTombstones: options.includeTombstones ?? false,
skipAgentFilter: options.skipAgentFilter ?? false,
}).map((issue) => ({
...issue,
project,

View file

@ -1,7 +1,7 @@
import { canonicalizeWindowsPath, windowsPathKey } from './pathing';
import type { ActivityEvent } from './activity';
export type IssuesChangeKind = 'changed' | 'renamed';
export type IssuesChangeKind = 'changed' | 'renamed' | 'telemetry';
export interface IssuesChangedEvent {
id: number;
@ -184,7 +184,8 @@ if (!globalRegistry.__beadboardActivityEventBus) {
}
export function toSseFrame(event: IssuesChangedEvent): string {
return `id: ${event.id}\nevent: issues\ndata: ${JSON.stringify(event)}\n\n`;
const eventName = event.kind === 'telemetry' ? 'telemetry' : 'issues';
return `id: ${event.id}\nevent: ${eventName}\ndata: ${JSON.stringify(event)}\n\n`;
}
export function toActivitySseFrame(event: ActivityDispatchedEvent): string {

View file

@ -48,14 +48,20 @@ export class IssuesWatchManager {
console.log(`[Watcher] Processing event for ${projectRoot}: ${payload.kind} (${payload.changedPath})`);
// 1. Emit basic file change event
this.eventBus.emit(projectRoot, payload.changedPath, payload.kind);
// 2. Perform snapshot diffing if issues.jsonl changed
// If it's just last-touched or a DB file change, we treat it as telemetry
const changedPath = payload.changedPath || '';
const isIssuesJsonl = changedPath.endsWith('issues.jsonl') || changedPath.endsWith('issues.jsonl.new');
const isLastTouched = changedPath.includes('last-touched');
const isDbPulse = changedPath.includes('beads.db');
const kind = (isLastTouched || isDbPulse) && !isIssuesJsonl ? 'telemetry' : payload.kind;
this.eventBus.emit(projectRoot, payload.changedPath, kind);
// 2. Perform snapshot diffing if issues.jsonl changed
const isBeadsDb = changedPath.includes('beads.db') || isLastTouched;
const isGlobalMessages = changedPath.includes('.beadboard') && changedPath.includes('messages');
if (isIssuesJsonl) {
if (isIssuesJsonl || isBeadsDb) {
console.log(`[Watcher] Issues changed. Syncing activity for ${projectRoot}...`);
await this.syncActivity(projectRoot);
} else if (isGlobalMessages) {
@ -71,7 +77,7 @@ export class IssuesWatchManager {
const previous = this.snapshots.get(projectKey) ?? null;
try {
const current = await readIssuesFromDisk({ projectRoot });
const current = await readIssuesFromDisk({ projectRoot, preferBd: true, skipAgentFilter: true });
const events = diffSnapshots(previous, current);
this.snapshots.set(projectKey, current);
@ -92,7 +98,7 @@ export class IssuesWatchManager {
// Pre-populate snapshot to avoid "all created" burst on first change
try {
const initial = await readIssuesFromDisk({ projectRoot });
const initial = await readIssuesFromDisk({ projectRoot, preferBd: true, skipAgentFilter: true });
this.snapshots.set(projectKey, initial);
} catch {
// Ignore initial read failure, will retry on first change
@ -165,7 +171,7 @@ export class IssuesWatchManager {
}
}
const WATCHER_VERSION = 3; // Bump this to force re-creation on HMR
const WATCHER_VERSION = 4; // Bump this to force re-creation on HMR (v4: fix beads.db telemetry classification)
const globalRegistry = globalThis as typeof globalThis & {
__beadboardWatchManager?: IssuesWatchManager;