feat(ux): consolidate Launch Swarm + telemetry UX with minimized strip

- Removed broken LaunchSwarmDialog (formula-based) from TopBar/LeftPanel
- All Rocket buttons (TopBar, LeftPanel, DAG nodes, social cards) now open
  AssignmentPanel (archetype-based) which actually works
- Every Rocket clears taskId first so assignMode && !taskId condition passes
- Conversation button priority: taskId always shows conversation, not assign panel
- Added TelemetryStrip: minimized right sidebar with status dots when non-telemetry
  panel (conversation/assignment) is active
- Live feed has minimize button → restores last taskId or assignMode
- DAG nodes: Signal icon → restores telemetry feed
- Social button on DAG nodes: single router.push to avoid race (setView + setTaskId)
- Fixed social card message button: opens right panel with drawer:closed (no popup)

Co-Authored-By: Oz <oz-agent@warp.dev>
This commit is contained in:
zenchantlive 2026-03-01 18:17:58 -08:00
parent 65d69ecbbc
commit c246ceaf21
165 changed files with 13730 additions and 1132 deletions

View file

@ -1,8 +1,13 @@
import type { ActivityEvent } from './activity';
import type { BeadIssue } from './types';
import { listAgents, deriveLiveness } from './agent-registry';
import { inboxAgentMessages, type AgentMessage } from './agent-mail';
import { statusAgentReservations, classifyOverlap } from './agent-reservations';
import type { AgentMessage } from './agent-mail';
import {
calculateReservationIncursions,
projectInboxFromDisk,
projectReservations,
readCoordEventsFromDisk,
} from './coord-projections';
export type AgentSessionState = 'active' | 'reviewing' | 'deciding' | 'needs_input' | 'completed' | 'stale' | 'evicted' | 'idle' | 'stuck' | 'dead';
@ -148,56 +153,21 @@ export interface Incursion {
/**
* Calculates global incursions by comparing all active reservations.
*/
export async function calculateIncursions(): Promise<Incursion[]> {
const statusResult = await statusAgentReservations({});
if (!statusResult.ok || !statusResult.data) return [];
const reservations = statusResult.data.reservations;
const incursions: Incursion[] = [];
const processedPairs = new Set<string>();
for (let i = 0; i < reservations.length; i++) {
for (let j = i + 1; j < reservations.length; j++) {
const resA = reservations[i];
const resB = reservations[j];
// Don't compare an agent against themselves
if (resA.agent_id === resB.agent_id) continue;
const overlap = classifyOverlap(resA.scope, resB.scope);
if (overlap !== 'disjoint') {
const key = [resA.agent_id, resB.agent_id].sort().join(':') + ':' + [resA.scope, resB.scope].sort().join('|');
if (processedPairs.has(key)) continue;
processedPairs.add(key);
incursions.push({
scope: overlap === 'exact' ? resA.scope : `${resA.scope}${resB.scope}`,
agents: [resA.agent_id, resB.agent_id],
severity: overlap
});
}
}
}
return incursions;
export async function calculateIncursions(
projectRoot: string = process.cwd(),
agentLivenessMap: Record<string, string> = {},
): Promise<Incursion[]> {
const events = await readCoordEventsFromDisk(projectRoot);
const reservations = projectReservations(events, agentLivenessMap);
return calculateReservationIncursions(reservations);
}
/**
* Gathers all relevant communication for all agents to build a summary for aggregation.
*/
export async function getCommunicationSummary(): Promise<CommunicationSummary> {
const agentsResult = await listAgents({});
const agents = agentsResult.data ?? [];
const allMessages: AgentMessage[] = [];
for (const agent of agents) {
const inbox = await inboxAgentMessages({ agent: agent.agent_id });
if (inbox.data) {
allMessages.push(...inbox.data);
}
}
return { messages: allMessages };
export async function getCommunicationSummary(projectRoot: string = process.cwd()): Promise<CommunicationSummary> {
const coordMessages = await projectInboxFromDisk(projectRoot);
return { messages: coordMessages };
}
export interface AgentMetrics {

View file

@ -1,78 +0,0 @@
import fs from 'node:fs/promises';
import path from 'node:path';
export interface ResolveBdExecutableOptions {
explicitPath?: string | null;
env?: NodeJS.ProcessEnv;
}
export interface BdExecutableResolution {
executable: string;
source: 'config' | 'path';
}
export class BdExecutableNotFoundError extends Error {
readonly code = 'BD_NOT_FOUND';
constructor(message: string) {
super(message);
this.name = 'BdExecutableNotFoundError';
}
}
async function fileExists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
function splitEnvPath(env: NodeJS.ProcessEnv = process.env): string[] {
const value = env.Path ?? env.PATH ?? '';
if (!value.trim()) {
return [];
}
return value.split(';').map((segment) => segment.trim()).filter(Boolean);
}
function executableCandidates(directory: string): string[] {
return ['bd.exe', 'bd.cmd', 'bd.bat', 'bd'].map((name) => path.join(directory, name));
}
function buildNotFoundMessage(explicitPath?: string | null): string {
const lines = [
'bd.exe was not found.',
'Install it with: npm install -g @beads/bd',
'Or configure an explicit executable path in request payload/config.',
];
if (explicitPath) {
lines.push(`Configured path was not found: ${explicitPath}`);
}
return lines.join(' ');
}
export async function resolveBdExecutable(options: ResolveBdExecutableOptions = {}): Promise<BdExecutableResolution> {
if (options.explicitPath && options.explicitPath.trim()) {
const explicit = path.resolve(options.explicitPath);
if (await fileExists(explicit)) {
return { executable: explicit, source: 'config' };
}
throw new BdExecutableNotFoundError(buildNotFoundMessage(options.explicitPath));
}
for (const dir of splitEnvPath(options.env)) {
for (const candidate of executableCandidates(dir)) {
if (await fileExists(candidate)) {
return { executable: candidate, source: 'path' };
}
}
}
throw new BdExecutableNotFoundError(buildNotFoundMessage());
}

View file

@ -1,10 +1,7 @@
import { exec as nodeExec } from 'node:child_process';
import { promisify } from 'node:util';
import { spawn } from 'node:child_process';
import path from 'node:path';
import { BdExecutableNotFoundError, resolveBdExecutable } from './bd-path';
const execAsync = promisify(nodeExec);
import { normalizeProjectRootForRuntime } from './project-root';
export type BdFailureClassification = 'not_found' | 'timeout' | 'non_zero_exit' | 'bad_args' | 'unknown';
@ -12,7 +9,9 @@ export interface RunBdCommandOptions {
projectRoot: string;
args: string[];
timeoutMs?: number;
// Deprecated: accepted for payload compatibility, ignored by runner.
explicitBdPath?: string | null;
stdinText?: string;
}
export interface RunBdCommandResult {
@ -29,8 +28,10 @@ export interface RunBdCommandResult {
}
interface RunBdCommandDeps {
resolveBdExecutable: typeof resolveBdExecutable;
exec: (command: string, options: { cwd: string; timeout: number; env: NodeJS.ProcessEnv }) => Promise<{ stdout: string; stderr: string }>;
exec: (
command: string,
options: { cwd: string; timeout: number; env: NodeJS.ProcessEnv; stdinText?: string },
) => Promise<{ stdout: string; stderr: string }>;
env: NodeJS.ProcessEnv;
}
@ -39,29 +40,51 @@ function normalizeOutput(text: unknown): string {
return text.replaceAll('\r\n', '\n').trim();
}
function getExitCode(error: unknown): number | null {
if (!error || typeof error !== 'object') return null;
const value = (error as { exitCode?: unknown }).exitCode;
return typeof value === 'number' ? value : null;
}
function toErrorMessage(value: unknown): string {
if (value instanceof Error) return value.message;
return String(value ?? 'Unknown error');
}
function classifyFailure(error: NodeJS.ErrnoException & { stderr?: string; killed?: boolean; signal?: string }): BdFailureClassification {
const exitCode = getExitCode(error);
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 (
/not recognized as an internal or external command/i.test(stderr) ||
/command not found/i.test(stderr) ||
/["']bd["'] is not recognized/i.test(stderr) ||
/bd: not found/i.test(stderr)
) {
return 'not_found';
}
if (typeof error.code === 'number' || exitCode !== null) {
if (/(unknown|invalid|required|usage)/i.test(stderr)) return 'bad_args';
return 'non_zero_exit';
}
return 'unknown';
}
function buildBdNotFoundMessage(): string {
return 'bd command not found in PATH. Install with: npm install -g @beads/bd';
}
function buildShellCommand(executable: string, args: string[]): string {
const sanitizedExecutable = executable.replace(/^['"]+|['"]+$/g, '');
// Normalize to forward slashes for Windows shell compatibility
const normalizedExe = executable.split(path.sep).join('/');
const normalizedExe = sanitizedExecutable.split(path.sep).join('/');
if (process.platform === 'win32') {
// Windows: quote the executable path, leave simple args unquoted
const quotedExe = `"${normalizedExe}"`;
// Windows: do not quote plain command tokens like `bd`; quote only when needed.
const quotedExe = /[\s&|<>()^"]/.test(normalizedExe)
? `"${normalizedExe.replace(/"/g, '""')}"`
: normalizedExe;
const quotedArgs = args.map(a => {
if (/[\s&|<>()^"]/.test(a)) return `"${a.replace(/"/g, '""')}"`;
return a;
@ -73,45 +96,117 @@ function buildShellCommand(executable: string, args: string[]): string {
}
}
async function execShellCommand(
command: string,
options: { cwd: string; timeout: number; env: NodeJS.ProcessEnv; stdinText?: string },
): Promise<{ stdout: string; stderr: string }> {
return new Promise((resolve, reject) => {
const shell = process.platform === 'win32' ? 'cmd.exe' : '/bin/sh';
const shellArgs = process.platform === 'win32' ? ['/d', '/s', '/c', command] : ['-lc', command];
const child = spawn(shell, shellArgs, {
cwd: options.cwd,
env: options.env,
stdio: 'pipe',
});
let stdout = '';
let stderr = '';
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
child.kill('SIGTERM');
}, options.timeout);
child.stdout.on('data', (chunk: Buffer | string) => {
stdout += chunk.toString();
});
child.stderr.on('data', (chunk: Buffer | string) => {
stderr += chunk.toString();
});
child.on('error', (error) => {
clearTimeout(timer);
const wrapped = error as NodeJS.ErrnoException & { stdout?: string; stderr?: string };
wrapped.stdout = stdout;
wrapped.stderr = stderr;
reject(wrapped);
});
child.on('close', (code, signal) => {
clearTimeout(timer);
if (code === 0 && !timedOut) {
resolve({ stdout, stderr });
return;
}
const error = new Error(`Command failed with code ${code ?? 'null'}`) as NodeJS.ErrnoException & {
stdout?: string;
stderr?: string;
killed?: boolean;
signal?: string;
};
error.code = timedOut ? 'ETIMEDOUT' : 'BD_EXIT';
error.stdout = stdout;
error.stderr = stderr;
error.killed = timedOut;
error.signal = signal ?? undefined;
(error as { exitCode?: number }).exitCode = code ?? 1;
reject(error);
});
if (options.stdinText !== undefined) {
child.stdin.write(options.stdinText);
}
child.stdin.end();
});
}
export async function runBdCommand(
options: RunBdCommandOptions,
injectedDeps?: Partial<RunBdCommandDeps>,
): Promise<RunBdCommandResult> {
const startedAt = Date.now();
const timeoutMs = options.timeoutMs ?? 30_000;
const cwd = options.projectRoot;
const cwd = normalizeProjectRootForRuntime(options.projectRoot);
const args = [...options.args];
if (process.env.BD_NO_DAEMON === 'true') {
args.unshift('--no-daemon');
}
const deps: RunBdCommandDeps = {
resolveBdExecutable: injectedDeps?.resolveBdExecutable ?? resolveBdExecutable,
exec: injectedDeps?.exec ?? execAsync,
exec: injectedDeps?.exec ?? execShellCommand,
env: injectedDeps?.env ?? process.env,
};
let command = options.explicitBdPath ?? 'bd';
const command = 'bd';
try {
const resolved = await deps.resolveBdExecutable({
explicitPath: options.explicitBdPath,
env: deps.env,
});
command = resolved.executable;
const shellCommand = buildShellCommand(command, args);
const mingwBin = 'C:\\msys64\\mingw64\\bin';
const existingPath = deps.env.Path ?? deps.env.PATH ?? '';
const enhancedPath = existingPath.includes('mingw64')
? existingPath
: `${mingwBin};${existingPath}`;
let env = deps.env;
if (process.platform === 'win32') {
const mingwBin = 'C:\\msys64\\mingw64\\bin';
const existingPath = deps.env.Path ?? deps.env.PATH ?? '';
const enhancedPath = existingPath.includes('mingw64')
? existingPath
: `${mingwBin};${existingPath}`;
env = { ...deps.env, Path: enhancedPath, PATH: enhancedPath };
} else {
// Ensure ~/.local/bin is in PATH so bd is found regardless of how the server was started
const home = deps.env.HOME ?? '';
const localBin = `${home}/.local/bin`;
const existingPath = deps.env.PATH ?? '';
if (home && !existingPath.includes(localBin)) {
env = { ...deps.env, PATH: `${localBin}:${existingPath}` };
}
}
const { stdout, stderr } = await deps.exec(shellCommand, {
cwd,
timeout: timeoutMs,
env: { ...deps.env, Path: enhancedPath, PATH: enhancedPath },
env,
stdinText: options.stdinText,
});
return {
@ -127,39 +222,25 @@ export async function runBdCommand(
error: null,
};
} catch (rawError) {
if (rawError instanceof BdExecutableNotFoundError) {
return {
success: false,
classification: 'not_found',
command,
args,
cwd,
stdout: '',
stderr: '',
code: null,
durationMs: Date.now() - startedAt,
error: rawError.message,
};
}
const error = rawError as NodeJS.ErrnoException & {
stderr?: string;
stdout?: string;
killed?: boolean;
signal?: string;
};
const classification = classifyFailure(error);
return {
success: false,
classification: classifyFailure(error),
classification,
command,
args,
cwd,
stdout: normalizeOutput(error.stdout),
stderr: normalizeOutput(error.stderr),
code: typeof error.code === 'number' ? error.code : null,
code: typeof error.code === 'number' ? error.code : getExitCode(error),
durationMs: Date.now() - startedAt,
error: toErrorMessage(error),
error: classification === 'not_found' ? buildBdNotFoundMessage() : toErrorMessage(error),
};
}
}

73
src/lib/coord-events.ts Normal file
View file

@ -0,0 +1,73 @@
import { runBdCommand, type RunBdCommandResult } from './bridge';
import { validateCoordEventEnvelope, type CoordEventEnvelope } from './coord-schema';
export interface WriteCoordEventOptions {
projectRoot: string;
}
export interface WriteCoordEventError {
classification: 'bad_args' | 'non_zero_exit' | 'not_found' | 'timeout' | 'unknown';
message: string;
}
export type WriteCoordEventResult =
| { ok: true; eventId: string; commandResult: RunBdCommandResult }
| { ok: false; error: WriteCoordEventError };
interface WriteCoordEventDeps {
runBdCommand: typeof runBdCommand;
}
function buildAuditEntry(event: CoordEventEnvelope): Record<string, unknown> {
return {
version: event.version,
kind: event.kind,
issue_id: event.issue_id,
actor: event.actor,
timestamp: event.timestamp,
data: event.data,
};
}
export async function writeCoordEvent(
input: unknown,
options: WriteCoordEventOptions,
deps?: Partial<WriteCoordEventDeps>,
): Promise<WriteCoordEventResult> {
const validated = validateCoordEventEnvelope(input);
if (!validated.ok) {
return {
ok: false,
error: {
classification: 'bad_args',
message: validated.error,
},
};
}
const event = validated.value;
const auditEntry = buildAuditEntry(event);
const runner = deps?.runBdCommand ?? runBdCommand;
const commandResult = await runner({
projectRoot: options.projectRoot,
args: ['audit', 'record', '--stdin', '--json'],
stdinText: `${JSON.stringify(auditEntry)}\n`,
});
if (!commandResult.success) {
return {
ok: false,
error: {
classification: commandResult.classification ?? 'unknown',
message: commandResult.error ?? commandResult.stderr ?? 'Failed to record coordination event',
},
};
}
return {
ok: true,
eventId: event.data.event_id,
commandResult,
};
}

View file

@ -0,0 +1,272 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import type { AgentMessage } from './agent-mail';
import { classifyOverlap, normalizePath } from './agent-reservations';
import type { CoordEventEnvelope } from './coord-schema';
export type CoordProtocolEvent = CoordEventEnvelope;
export type TakeoverMode = 'stale' | 'evicted';
export interface ProjectedCoordMessage {
message_id: string;
thread_id: string;
bead_id: string;
from_agent: string;
to_agent: string;
category: 'HANDOFF' | 'BLOCKED' | 'DECISION' | 'INFO';
subject: string;
body: string;
state: 'unread' | 'read' | 'acked';
requires_ack: boolean;
created_at: string;
read_at: string | null;
acked_at: string | null;
}
export interface ProjectedReservation {
scope: string;
normalized_scope: string;
agent_id: string;
bead_id: string;
state: 'active';
created_at: string;
takeover_mode: TakeoverMode | null;
}
export interface ProjectedReservationIncursion {
scope: string;
agents: string[];
severity: 'exact' | 'partial';
}
type MessageState = 'unread' | 'read' | 'acked';
type EventRefMap = Map<string, { state: MessageState; readAt: string | null; ackedAt: string | null }>;
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function toCategory(value: unknown): ProjectedCoordMessage['category'] {
if (value === 'HANDOFF' || value === 'BLOCKED' || value === 'DECISION' || value === 'INFO') {
return value;
}
return 'INFO';
}
function requiresAck(category: ProjectedCoordMessage['category']): boolean {
return category === 'HANDOFF' || category === 'BLOCKED';
}
export async function readCoordEventsFromDisk(projectRoot: string): Promise<CoordProtocolEvent[]> {
const filePath = path.join(projectRoot, '.beads', 'interactions.jsonl');
let raw = '';
try {
raw = await fs.readFile(filePath, 'utf8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return [];
}
throw error;
}
const events: CoordProtocolEvent[] = [];
for (const line of raw.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const parsed = JSON.parse(trimmed) as unknown;
if (!isObject(parsed)) continue;
if (parsed.version !== 'coord.v1' || parsed.kind !== 'coord_event') continue;
events.push(parsed as unknown as CoordProtocolEvent);
} catch {
continue;
}
}
return events;
}
export function projectMessageState(events: CoordProtocolEvent[]): Map<string, MessageState> {
const stateMap = new Map<string, MessageState>();
const sorted = [...events].sort((a, b) => a.timestamp.localeCompare(b.timestamp));
for (const event of sorted) {
if (event.data.event_type === 'SEND') {
stateMap.set(event.data.event_id, 'unread');
continue;
}
const ref = event.data.event_ref;
if (!ref || !stateMap.has(ref)) continue;
if (event.data.event_type === 'READ') {
stateMap.set(ref, 'read');
}
if (event.data.event_type === 'ACK') {
stateMap.set(ref, 'acked');
}
}
return stateMap;
}
function projectMessageStateDetails(events: CoordProtocolEvent[]): EventRefMap {
const details: EventRefMap = new Map();
const sorted = [...events].sort((a, b) => a.timestamp.localeCompare(b.timestamp));
for (const event of sorted) {
if (event.data.event_type === 'SEND') {
details.set(event.data.event_id, { state: 'unread', readAt: null, ackedAt: null });
continue;
}
const ref = event.data.event_ref;
if (!ref) continue;
const current = details.get(ref);
if (!current) continue;
if (event.data.event_type === 'READ' && current.state === 'unread') {
current.state = 'read';
current.readAt = event.timestamp;
}
if (event.data.event_type === 'ACK') {
current.state = 'acked';
if (!current.readAt) current.readAt = event.timestamp;
current.ackedAt = event.timestamp;
}
}
return details;
}
export function projectInbox(
events: CoordProtocolEvent[],
beadId?: string,
agentId?: string,
): ProjectedCoordMessage[] {
const messageState = projectMessageStateDetails(events);
const messages: ProjectedCoordMessage[] = [];
const sorted = [...events].sort((a, b) => b.timestamp.localeCompare(a.timestamp));
for (const event of sorted) {
if (event.data.event_type !== 'SEND') continue;
if (beadId && event.issue_id !== beadId) continue;
if (agentId && event.data.to_agent !== agentId) continue;
const payload = isObject(event.data.payload) ? event.data.payload : {};
const category = toCategory(payload.category);
const state = messageState.get(event.data.event_id) ?? { state: 'unread', readAt: null, ackedAt: null };
messages.push({
message_id: event.data.event_id,
thread_id: `bead:${event.issue_id}`,
bead_id: event.issue_id,
from_agent: event.actor,
to_agent: typeof event.data.to_agent === 'string' ? event.data.to_agent : 'unknown',
category,
subject: typeof payload.subject === 'string' ? payload.subject : '',
body: typeof payload.body === 'string' ? payload.body : '',
state: state.state,
requires_ack: requiresAck(category),
created_at: event.timestamp,
read_at: state.readAt,
acked_at: state.ackedAt,
});
}
return messages;
}
export async function projectInboxFromDisk(projectRoot: string, beadId?: string, agentId?: string): Promise<AgentMessage[]> {
const events = await readCoordEventsFromDisk(projectRoot);
return projectInbox(events, beadId, agentId);
}
export function isTakeoverAllowed(ownerLiveness: string, mode: TakeoverMode): boolean {
if (ownerLiveness === 'active') return false;
if (ownerLiveness === 'stale') return mode === 'stale';
if (ownerLiveness === 'evicted') return mode === 'stale' || mode === 'evicted';
return false;
}
export function projectReservations(
events: CoordProtocolEvent[],
livenessMap: Record<string, string> = {},
): ProjectedReservation[] {
const activeByScope = new Map<string, ProjectedReservation>();
const sorted = [...events].sort((a, b) => a.timestamp.localeCompare(b.timestamp));
for (const event of sorted) {
const type = event.data.event_type;
if (type !== 'RESERVE' && type !== 'RELEASE' && type !== 'TAKEOVER') continue;
const rawScope = typeof event.data.scope === 'string' ? event.data.scope : '';
if (!rawScope) continue;
const normalizedScope = normalizePath(rawScope.replace(/\*$/, ''));
const key = `${event.data.project_root}:${normalizedScope}`;
if (type === 'RELEASE') {
activeByScope.delete(key);
continue;
}
if (type === 'RESERVE') {
activeByScope.set(key, {
scope: rawScope,
normalized_scope: normalizedScope,
agent_id: event.actor,
bead_id: event.issue_id,
state: 'active',
created_at: event.timestamp,
takeover_mode: null,
});
continue;
}
if (type === 'TAKEOVER') {
const mode = event.data.takeover_mode;
if (mode !== 'stale' && mode !== 'evicted') continue;
const existing = activeByScope.get(key);
if (existing) {
const ownerLiveness = livenessMap[existing.agent_id] ?? 'active';
if (!isTakeoverAllowed(ownerLiveness, mode)) {
continue;
}
}
activeByScope.set(key, {
scope: rawScope,
normalized_scope: normalizedScope,
agent_id: event.actor,
bead_id: event.issue_id,
state: 'active',
created_at: event.timestamp,
takeover_mode: mode,
});
}
}
return [...activeByScope.values()];
}
export function calculateReservationIncursions(reservations: ProjectedReservation[]): ProjectedReservationIncursion[] {
const incursions: ProjectedReservationIncursion[] = [];
const processedPairs = new Set<string>();
for (let i = 0; i < reservations.length; i++) {
for (let j = i + 1; j < reservations.length; j++) {
const left = reservations[i];
const right = reservations[j];
if (left.agent_id === right.agent_id) continue;
const overlap = classifyOverlap(left.scope, right.scope);
if (overlap === 'disjoint') continue;
const key = [left.agent_id, right.agent_id].sort().join(':') + ':' + [left.scope, right.scope].sort().join('|');
if (processedPairs.has(key)) continue;
processedPairs.add(key);
incursions.push({
scope: overlap === 'exact' ? left.scope : `${left.scope}${right.scope}`,
agents: [left.agent_id, right.agent_id],
severity: overlap,
});
}
}
return incursions;
}

115
src/lib/coord-schema.ts Normal file
View file

@ -0,0 +1,115 @@
export const COORD_SCHEMA_VERSION = 'coord.v1' as const;
export const COORD_EVENT_KIND = 'coord_event' as const;
export type CoordEventType =
| 'SEND'
| 'READ'
| 'ACK'
| 'RESERVE'
| 'RELEASE'
| 'TAKEOVER'
| 'RESUME'
| 'BLOCKED'
| 'HANDOFF'
| 'INCURSION';
export type TakeoverMode = 'stale' | 'evicted';
export interface CoordEventData {
event_type: CoordEventType;
event_id: string;
project_root: string;
payload: Record<string, unknown>;
to_agent?: string;
scope?: string;
state?: 'unread' | 'read' | 'acked';
event_ref?: string;
takeover_mode?: TakeoverMode;
reason?: string;
}
export interface CoordEventEnvelope {
version: typeof COORD_SCHEMA_VERSION;
kind: typeof COORD_EVENT_KIND;
issue_id: string;
actor: string;
timestamp: string;
data: CoordEventData;
}
export type CoordValidationResult =
| { ok: true; value: CoordEventEnvelope }
| { ok: false; error: string };
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function nonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0;
}
function isEventType(value: unknown): value is CoordEventType {
if (!nonEmptyString(value)) return false;
return (
value === 'SEND' ||
value === 'READ' ||
value === 'ACK' ||
value === 'RESERVE' ||
value === 'RELEASE' ||
value === 'TAKEOVER' ||
value === 'RESUME' ||
value === 'BLOCKED' ||
value === 'HANDOFF' ||
value === 'INCURSION'
);
}
function fail(error: string): CoordValidationResult {
return { ok: false, error };
}
export function validateCoordEventEnvelope(input: unknown): CoordValidationResult {
if (!isObject(input)) return fail('Envelope must be an object');
if (input.version !== COORD_SCHEMA_VERSION) {
return fail(`version must be "${COORD_SCHEMA_VERSION}"`);
}
if (input.kind !== COORD_EVENT_KIND) {
return fail(`kind must be "${COORD_EVENT_KIND}"`);
}
if (!nonEmptyString(input.issue_id)) return fail('issue_id is required');
if (!nonEmptyString(input.actor)) return fail('actor is required');
if (!nonEmptyString(input.timestamp)) return fail('timestamp is required');
if (!isObject(input.data)) return fail('data object is required');
const data = input.data;
if (!isEventType(data.event_type)) return fail('data.event_type is invalid');
if (!nonEmptyString(data.event_id)) return fail('data.event_id is required');
if (!nonEmptyString(data.project_root)) return fail('data.project_root is required');
if (!isObject(data.payload)) return fail('data.payload must be an object');
if ((data.event_type === 'READ' || data.event_type === 'ACK') && !nonEmptyString(data.event_ref)) {
return fail('data.event_ref is required for READ/ACK');
}
if (data.event_type === 'TAKEOVER') {
if (!nonEmptyString(data.scope)) return fail('data.scope is required for TAKEOVER');
if (data.takeover_mode !== 'stale' && data.takeover_mode !== 'evicted') {
return fail('data.takeover_mode must be stale or evicted');
}
if (!nonEmptyString(data.reason)) return fail('data.reason is required for TAKEOVER');
}
if (data.event_type === 'SEND') {
if (!nonEmptyString(data.to_agent)) return fail('data.to_agent is required for SEND');
if (data.state !== 'unread' && data.state !== 'read' && data.state !== 'acked') {
return fail('data.state must be unread/read/acked for SEND');
}
if (!nonEmptyString(data.payload.subject)) return fail('data.payload.subject is required for SEND');
if (!nonEmptyString(data.payload.body)) return fail('data.payload.body is required for SEND');
}
return { ok: true, value: input as unknown as CoordEventEnvelope };
}

View file

@ -7,6 +7,7 @@ export type MutationStatus = 'open' | 'in_progress' | 'blocked' | 'deferred' | '
interface MutationBasePayload {
projectRoot: string;
bdPath?: string;
actor?: string;
}
export interface CreateMutationPayload extends MutationBasePayload {
@ -155,6 +156,7 @@ function parseBasePayload(raw: unknown): MutationBasePayload {
return {
projectRoot: asNonEmptyString(data.projectRoot, 'projectRoot'),
bdPath: asOptionalString(data.bdPath),
actor: asOptionalString(data.actor),
};
}
@ -235,7 +237,7 @@ function pushOptionalArg(args: string[], flag: string, value: string | undefined
function pushOptionalLabels(args: string[], labels: string[] | undefined): void {
if (labels && labels.length > 0) {
args.push('-l', labels.join(','));
args.push('--set-labels', labels.join(','));
}
}
@ -267,7 +269,7 @@ export function buildBdMutationArgs(operation: MutationOperation, payload: Mutat
pushOptionalArg(args, '-a', data.assignee);
pushOptionalLabels(args, data.labels);
if (data.metadata) {
args.push('--metadata', JSON.stringify(data.metadata));
args.push(`--metadata=${JSON.stringify(data.metadata)}`);
}
args.push('--json');
return args;
@ -303,11 +305,12 @@ export async function executeMutation(
deps: Partial<ExecuteMutationDeps> = {},
): Promise<MutationResponse> {
const runner = deps.runBdCommand ?? runBdCommand;
const args = buildBdMutationArgs(operation, payload);
const args = payload.actor
? ['--actor', payload.actor, ...buildBdMutationArgs(operation, payload)]
: buildBdMutationArgs(operation, payload);
const command = await runner({
projectRoot: payload.projectRoot,
args,
explicitBdPath: payload.bdPath,
});
if (!command.success) {
@ -317,7 +320,7 @@ export async function executeMutation(
command,
error: {
classification: command.classification ?? 'unknown',
message: command.error ?? (command.stderr || 'Mutation command failed.'),
message: command.stderr || command.error || 'Mutation command failed.',
},
};
}

26
src/lib/project-root.ts Normal file
View file

@ -0,0 +1,26 @@
import path from 'node:path';
function isWindowsAbsolute(input: string): boolean {
return /^[A-Za-z]:[\\/]/.test(input);
}
function windowsToPosixMount(input: string): string {
const drive = input[0].toLowerCase();
const tail = input.slice(2).replace(/\\/g, '/').replace(/^\/+/, '');
return `/mnt/${drive}/${tail}`;
}
export function normalizeProjectRootForRuntime(input: string): string {
const trimmed = input.trim();
if (!trimmed) return trimmed;
if (process.platform === 'win32') {
return path.resolve(trimmed);
}
if (isWindowsAbsolute(trimmed)) {
return path.resolve(windowsToPosixMount(trimmed));
}
return path.resolve(trimmed);
}