fix: orchestrator button + Pi SDK session error

- Move leftSidebarMode from URL state to local useState in unified-shell,
    avoiding force-dynamic router round-trip that made the button appear broken                                           - Replace fileURLToPath(new URL(..., import.meta.url)) with process.cwd()
    in bb-pi-bootstrap.ts — import.meta.url is a webpack:// URL in Next.js,
    causing cross-realm TypeError when passed to Node.js fileURLToPath()
This commit is contained in:
zenchantlive 2026-03-24 15:39:19 -07:00
parent 643fa299dd
commit d335e5bf71
98 changed files with 17851 additions and 944 deletions

72
src/lib/agent-instance.ts Normal file
View file

@ -0,0 +1,72 @@
/**
* Agent Instance Model
*
* An Agent Instance is a running copy of an Agent Type.
* When spawned, it gets a numbered instance (e.g., "Engineer 01", "Engineer 02").
*/
export interface AgentInstance {
/** Unique instance ID (e.g., "engineer-01-abc123") */
id: string;
/** What kind of agent this is (e.g., "engineer", "architect") */
agentTypeId: string;
/** Display name for UI (e.g., "Engineer 01") */
displayName: string;
/** Current status of this instance */
status: 'spawning' | 'working' | 'idle' | 'completed' | 'failed';
/** The bead/task this agent is working on */
currentBeadId?: string;
/** When this instance was spawned */
startedAt: string;
/** When this instance completed/failed */
completedAt?: string;
/** Result summary for completed agents */
result?: string;
/** Error message for failed agents */
error?: string;
}
export interface AgentStatus {
/** Total number of active agents */
totalActive: number;
/** Count by agent type { "engineer": 2, "architect": 1 } */
byType: Record<string, number>;
/** All active instances */
instances: AgentInstance[];
}
/**
* Generate a unique agent instance ID.
* Format: {agentTypeId}-{number}-{random}
*/
export function generateAgentInstanceId(agentTypeId: string, instanceNumber: number): string {
const suffix = String(instanceNumber).padStart(2, '0');
const random = Math.random().toString(36).slice(2, 8);
return `${agentTypeId}-${suffix}-${random}`;
}
/**
* Get display name for an agent instance.
* Format: "{AgentTypeName} {number}" (e.g., "Engineer 01")
*/
export function getAgentDisplayName(agentTypeName: string, instanceNumber: number): string {
const num = String(instanceNumber).padStart(2, '0');
return `${agentTypeName} ${num}`;
}
/**
* Parse an instance ID to extract its components.
*/
export function parseAgentInstanceId(instanceId: string): {
agentTypeId: string;
instanceNumber: number;
random: string;
} | null {
const match = instanceId.match(/^([a-z-]+)-(\d{2})-([a-z0-9]+)$/);
if (!match) return null;
return {
agentTypeId: match[1],
instanceNumber: parseInt(match[2], 10),
random: match[3],
};
}

View file

@ -0,0 +1,93 @@
/**
* Agent Instance Persistence
*
* Persists agent instances to disk so they survive app restarts.
* Uses .beads/agents.jsonl for storage.
*/
import fs from 'fs/promises';
import path from 'path';
import type { AgentInstance } from './agent-instance';
const AGENTS_FILE = (projectRoot: string) => path.join(projectRoot, '.beads', 'agents.jsonl');
/**
* Load all agent instances from disk.
*/
export async function loadAgentInstances(projectRoot: string): Promise<AgentInstance[]> {
try {
const content = await fs.readFile(AGENTS_FILE(projectRoot), 'utf-8');
return content
.trim()
.split('\n')
.filter(Boolean)
.map(line => JSON.parse(line));
} catch {
return [];
}
}
/**
* Save a new agent instance to disk.
*/
export async function saveAgentInstance(projectRoot: string, instance: AgentInstance): Promise<void> {
const agentsPath = AGENTS_FILE(projectRoot);
await fs.mkdir(path.dirname(agentsPath), { recursive: true });
const line = JSON.stringify(instance) + '\n';
await fs.appendFile(agentsPath, line, 'utf-8');
}
/**
* Update an existing agent instance in place.
*/
export async function updateAgentInstance(projectRoot: string, instance: AgentInstance): Promise<void> {
const agentsPath = AGENTS_FILE(projectRoot);
const instances = await loadAgentInstances(projectRoot);
const idx = instances.findIndex(i => i.id === instance.id);
if (idx >= 0) {
instances[idx] = instance;
await fs.writeFile(
agentsPath,
instances.map(i => JSON.stringify(i)).join('\n') + '\n',
'utf-8'
);
}
}
/**
* Get only active instances (spawning, working, idle).
*/
export async function getActiveInstances(projectRoot: string): Promise<AgentInstance[]> {
const all = await loadAgentInstances(projectRoot);
return all.filter(i =>
i.status === 'spawning' ||
i.status === 'working' ||
i.status === 'idle'
);
}
/**
* Get recent completed/failed instances for history.
*/
export async function getRecentInstances(projectRoot: string, limit = 20): Promise<AgentInstance[]> {
const all = await loadAgentInstances(projectRoot);
return all
.filter(i => i.status === 'completed' || i.status === 'failed')
.sort((a, b) =>
new Date(b.completedAt || 0).getTime() - new Date(a.completedAt || 0).getTime()
)
.slice(0, limit);
}
/**
* Clear all agent instances (for testing/reset).
*/
export async function clearAgentInstances(projectRoot: string): Promise<void> {
try {
await fs.unlink(AGENTS_FILE(projectRoot));
} catch {
// File doesn't exist, that's fine
}
}

View file

@ -0,0 +1,41 @@
import { listProjects } from './registry';
import { resolveProjectScope } from './project-scope';
export interface ResolveAgentWorkspaceOptions {
currentProjectRoot?: string;
requestedProjectKey?: string | null;
requestedProjectRoot?: string | null;
}
export interface ResolvedAgentWorkspace {
root: string;
key: string;
source: 'explicit-root' | 'scope-selection';
}
export async function resolveAgentWorkspace(options: ResolveAgentWorkspaceOptions = {}): Promise<ResolvedAgentWorkspace> {
const currentProjectRoot = options.currentProjectRoot ?? process.cwd();
if (options.requestedProjectRoot && options.requestedProjectRoot.trim()) {
const root = options.requestedProjectRoot.trim();
return {
root,
key: root.toLowerCase(),
source: 'explicit-root',
};
}
const registryProjects = await listProjects();
const scope = resolveProjectScope({
currentProjectRoot,
registryProjects,
requestedProjectKey: options.requestedProjectKey ?? null,
requestedMode: 'single',
});
return {
root: process.platform === 'win32' ? scope.selected.root : scope.selected.displayPath,
key: scope.selected.key,
source: 'scope-selection',
};
}

219
src/lib/bb-daemon.ts Normal file
View file

@ -0,0 +1,219 @@
import { embeddedPiDaemon, type HostDaemonStatus } from './embedded-daemon';
import type { LaunchSurface, RuntimeConsoleEvent, RuntimeInstance } from './embedded-runtime';
import { createPiDaemonAdapter, type PiDaemonAdapter } from './pi-daemon-adapter';
import { detectPiRuntimeStrategy, type PiRuntimeResolution } from './pi-runtime-detection';
import type { BeadIssue } from './types';
export type BbDaemonLifecycleStatus = 'stopped' | 'starting' | 'running' | 'stopping' | 'failed';
export interface BbDaemonLifecycle {
status: BbDaemonLifecycleStatus;
startedAt: string | null;
stoppedAt: string | null;
lastError: string | null;
}
export interface RuntimeEventSubscriptionOptions {
projectRoot?: string;
}
export interface BbDaemonStatus extends HostDaemonStatus {
lifecycle: BbDaemonLifecycle;
piRuntime: PiRuntimeResolution | null;
}
export interface BbDaemon {
start(): Promise<BbDaemonLifecycle>;
stop(): Promise<BbDaemonLifecycle>;
ensureRunning(): Promise<BbDaemonLifecycle>;
getLifecycle(): BbDaemonLifecycle;
getStatus(): BbDaemonStatus;
getPiRuntime(): PiRuntimeResolution | null;
ensureProject(projectRoot: string): { projectRoot: string; orchestratorId: string };
ensureOrchestrator(projectRoot: string): Promise<RuntimeInstance>;
listEvents(projectRoot: string): RuntimeConsoleEvent[];
subscribeRuntimeEvents(listener: (event: RuntimeConsoleEvent) => void, options?: RuntimeEventSubscriptionOptions): () => void;
launchFromIssue(params: {
projectRoot: string;
issue: BeadIssue;
origin: LaunchSurface;
swarmId?: string | null;
}): Promise<{ orchestrator: RuntimeInstance; events: RuntimeConsoleEvent[] }>;
prompt(projectRoot: string, text: string): Promise<void>;
resetForTests(): void;
}
function createInitialLifecycle(): BbDaemonLifecycle {
return {
status: 'stopped',
startedAt: null,
stoppedAt: null,
lastError: null,
};
}
interface RuntimeEventSubscriber {
projectRoot?: string;
listener: (event: RuntimeConsoleEvent) => void;
}
class InProcessBbDaemon implements BbDaemon {
private lifecycle: BbDaemonLifecycle = createInitialLifecycle();
private readonly adapter: PiDaemonAdapter;
private readonly subscribers = new Map<number, RuntimeEventSubscriber>();
private nextSubscriberId = 1;
private piRuntime: PiRuntimeResolution | null = null;
constructor(adapter: PiDaemonAdapter = createPiDaemonAdapter()) {
this.adapter = adapter;
}
async start(): Promise<BbDaemonLifecycle> {
if (this.lifecycle.status === 'running') {
return this.getLifecycle();
}
this.lifecycle = {
...this.lifecycle,
status: 'starting',
lastError: null,
};
this.piRuntime = await detectPiRuntimeStrategy();
this.lifecycle = {
status: 'running',
startedAt: this.lifecycle.startedAt ?? new Date().toISOString(),
stoppedAt: null,
lastError: null,
};
return this.getLifecycle();
}
async ensureRunning(): Promise<BbDaemonLifecycle> {
return this.lifecycle.status === 'running' ? this.getLifecycle() : this.start();
}
async stop(): Promise<BbDaemonLifecycle> {
if (this.lifecycle.status === 'stopped') {
return this.getLifecycle();
}
this.lifecycle = {
...this.lifecycle,
status: 'stopping',
};
this.lifecycle = {
status: 'stopped',
startedAt: this.lifecycle.startedAt,
stoppedAt: new Date().toISOString(),
lastError: null,
};
return this.getLifecycle();
}
getLifecycle(): BbDaemonLifecycle {
return { ...this.lifecycle };
}
getPiRuntime(): PiRuntimeResolution | null {
return this.piRuntime;
}
getStatus(): BbDaemonStatus {
return {
...embeddedPiDaemon.getStatus(),
lifecycle: this.getLifecycle(),
piRuntime: this.getPiRuntime(),
};
}
ensureProject(projectRoot: string): { projectRoot: string; orchestratorId: string } {
const state = embeddedPiDaemon.ensureProject(projectRoot);
return {
projectRoot: state.projectRoot,
orchestratorId: state.orchestrator.id,
};
}
async ensureOrchestrator(projectRoot: string): Promise<RuntimeInstance> {
await this.ensureRunning();
const binding = await this.adapter.ensureProjectOrchestrator(projectRoot);
return binding.runtime;
}
listEvents(projectRoot: string): RuntimeConsoleEvent[] {
return this.adapter.listEvents(projectRoot);
}
subscribeRuntimeEvents(listener: (event: RuntimeConsoleEvent) => void, options: RuntimeEventSubscriptionOptions = {}): () => void {
const id = this.nextSubscriberId;
this.nextSubscriberId += 1;
this.subscribers.set(id, {
projectRoot: options.projectRoot,
listener,
});
return () => {
this.subscribers.delete(id);
};
}
private emitRuntimeEvents(projectRoot: string, events: RuntimeConsoleEvent[]) {
for (const event of events) {
for (const subscriber of this.subscribers.values()) {
if (!subscriber.projectRoot || subscriber.projectRoot === projectRoot) {
subscriber.listener(event);
}
}
}
}
async launchFromIssue(params: {
projectRoot: string;
issue: BeadIssue;
origin: LaunchSurface;
swarmId?: string | null;
}): Promise<{ orchestrator: RuntimeInstance; events: RuntimeConsoleEvent[] }> {
await this.ensureRunning();
const result = await this.adapter.launchFromIssue(params);
this.emitRuntimeEvents(params.projectRoot, result.events);
return result;
}
async prompt(projectRoot: string, text: string): Promise<void> {
await this.ensureRunning();
// Fire-and-forget the adapter prompt - adapter stores user message immediately,
// SDK subscription handles real-time events, SSE poller picks up from embeddedPiDaemon
if (typeof this.adapter.prompt === 'function') {
this.adapter.prompt(projectRoot, text).catch((e) => {
console.error('[BbDaemon] Adapter prompt error:', e);
});
}
}
resetForTests(): void {
this.lifecycle = createInitialLifecycle();
this.piRuntime = null;
embeddedPiDaemon.resetForTests();
this.subscribers.clear();
this.nextSubscriberId = 1;
}
}
export function createBbDaemon(adapter?: PiDaemonAdapter): BbDaemon {
return new InProcessBbDaemon(adapter);
}
const globalRegistry = globalThis as typeof globalThis & {
__beadboardBbDaemon?: BbDaemon;
};
export const bbDaemon = globalRegistry.__beadboardBbDaemon ?? createBbDaemon();
if (!globalRegistry.__beadboardBbDaemon) {
globalRegistry.__beadboardBbDaemon = bbDaemon;
}

194
src/lib/bb-pi-bootstrap.ts Normal file
View file

@ -0,0 +1,194 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { getManagedPiPaths } from './pi-runtime-detection';
export interface BootstrapManagedPiResult {
managedRoot: string;
sdkPath: string;
agentDir: string;
created: boolean;
alreadyInstalled: boolean;
installedPackages: string[];
}
export interface BootstrapManagedPiOptions {
version?: string;
home?: string;
output?: { write(chunk: string): void };
execFile?: (
file: string,
args: string[],
options: { cwd: string; env: NodeJS.ProcessEnv },
) => Promise<void>;
}
function getManagedShellPath(): string | null {
if (process.platform === 'win32') {
return process.env.ComSpec ?? 'C:\\Windows\\System32\\cmd.exe';
}
const candidates = ['/bin/sh', '/usr/bin/sh', '/bin/bash', '/usr/bin/bash'];
return candidates.find((candidate) => require('node:fs').existsSync(candidate)) ?? null;
}
export async function ensureManagedPiSettings(agentDir: string): Promise<void> {
const settingsPath = path.join(agentDir, 'settings.json');
let settings: Record<string, unknown> = {};
try {
const existing = await fs.readFile(settingsPath, 'utf8');
settings = JSON.parse(existing) as Record<string, unknown>;
} catch {
settings = {};
}
const shellPath = getManagedShellPath();
const nextSettings = {
defaultProvider: settings.defaultProvider ?? null,
defaultModel: settings.defaultModel ?? null,
...(shellPath ? { shellPath } : {}),
...settings,
};
if (shellPath) {
nextSettings.shellPath = shellPath;
}
await fs.mkdir(agentDir, { recursive: true });
await fs.writeFile(settingsPath, JSON.stringify(nextSettings, null, 2) + '\n', 'utf8');
}
const AGENTS_MD_CONTENT = `# BeadBoard Orchestrator
You are the BeadBoard Orchestrator, the central embedded intelligence of the BeadBoard project management and agent coordination system.
## Your Role
You are not a generic coding assistant. You are a headless, autonomous daemon responsible for coordinating work across the repository.
1. **Dolt Data Awareness**: You read and understand the project's task topology via Dolt (BeadBoard's versioned SQL backend).
2. **Mailbox Management**: You read, route, and respond to agent coordination messages (HANDOFF, BLOCKED, INFO).
3. **Session Presence**: You broadcast your status and presence state so the BeadBoard frontend can render what you are doing in real-time.
4. **Worker Dispatch**: You evaluate mission templates, select archetypes, and dispatch worker sub-agents to complete specific tasks.
## Operating Constraints
- You operate in a headless environment. You must NEVER prompt for human CLI input.
- You must always query the Dolt backend before making assumptions about task state.
- You must always acknowledge (ACK) messages in the BLOCKED or HANDOFF categories.
`;
export async function ensureManagedPiAgentsMd(agentDir: string): Promise<void> {
const agentsPath = path.join(agentDir, 'AGENTS.md');
await fs.mkdir(agentDir, { recursive: true });
await fs.writeFile(agentsPath, AGENTS_MD_CONTENT, 'utf8');
}
async function pathExists(target: string): Promise<boolean> {
try {
await fs.access(target);
return true;
} catch {
return false;
}
}
async function readDependencyVersions(): Promise<{ pi: string; minimatch: string }> {
// Use process.cwd() rather than import.meta.url: in Next.js webpack context,
// import.meta.url is a webpack:// URL, not a file:// URL, so fileURLToPath()
// would throw a cross-realm TypeError. process.cwd() reliably resolves to the
// project root in both dev and production Next.js server environments.
const packageJsonPath = path.join(process.cwd(), 'package.json');
const raw = await fs.readFile(packageJsonPath, 'utf8');
const pkg = JSON.parse(raw) as {
dependencies?: Record<string, string>;
};
return {
pi: pkg.dependencies?.['@mariozechner/pi-coding-agent'] ?? '^0.30.2',
minimatch: pkg.dependencies?.minimatch ?? '^10.2.4',
};
}
async function defaultExecFile(file: string, args: string[], options: { cwd: string; env: NodeJS.ProcessEnv }): Promise<void> {
const { execFile } = await import('node:child_process');
await new Promise<void>((resolve, reject) => {
execFile(file, args, options, (error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}
function npmCommand(): string {
return process.platform === 'win32' ? 'npm.cmd' : 'npm';
}
export async function bootstrapManagedPi(options: BootstrapManagedPiOptions = {}): Promise<BootstrapManagedPiResult> {
const version = options.version ?? '0.1.0';
const home = options.home ?? os.homedir();
const output = options.output;
const execFile = options.execFile ?? defaultExecFile;
const managed = getManagedPiPaths(version, home);
if (await pathExists(managed.sdkPath)) {
await ensureManagedPiSettings(managed.agentDir);
await ensureManagedPiAgentsMd(managed.agentDir);
return {
managedRoot: managed.managedRoot,
sdkPath: managed.sdkPath,
agentDir: managed.agentDir,
created: false,
alreadyInstalled: true,
installedPackages: [],
};
}
const versions = await readDependencyVersions();
await fs.mkdir(managed.managedRoot, { recursive: true });
await fs.mkdir(managed.agentDir, { recursive: true });
await fs.writeFile(
path.join(managed.managedRoot, 'package.json'),
JSON.stringify(
{
name: 'bb-managed-pi-runtime',
private: true,
type: 'module',
dependencies: {
'@mariozechner/pi-coding-agent': versions.pi,
minimatch: versions.minimatch,
},
},
null,
2,
) + '\n',
'utf8',
);
await ensureManagedPiSettings(managed.agentDir);
await ensureManagedPiAgentsMd(managed.agentDir);
output?.write(`[bb bootstrap] Installing BeadBoard agent runtime at ${managed.managedRoot}\n`);
await execFile(npmCommand(), ['install', '--no-package-lock', '--no-fund', '--no-audit'], {
cwd: managed.managedRoot,
env: {
...process.env,
PATH: process.env.PATH ?? '',
HOME: process.env.HOME ?? home,
},
});
output?.write('[bb bootstrap] Agent runtime installed.\n');
return {
managedRoot: managed.managedRoot,
sdkPath: managed.sdkPath,
agentDir: managed.agentDir,
created: true,
alreadyInstalled: false,
installedPackages: ['@mariozechner/pi-coding-agent', 'minimatch'],
};
}

View file

@ -35,14 +35,42 @@ async function readDoltMetadata(projectRoot: string): Promise<DoltMetadata> {
throw new DoltConnectionError(`Invalid JSON in ${metadataPath}`, err);
}
const port = parsed.dolt_server_port;
const database = parsed.dolt_database;
if (typeof port !== 'number' || typeof database !== 'string') {
if (typeof database !== 'string') {
throw new DoltConnectionError(
`${metadataPath} is missing required fields: dolt_server_port (number) and dolt_database (string)`
`${metadataPath} is missing required field: dolt_database (string)`
);
}
// Try port file first (preferred by bd), fall back to metadata.json
let port: number;
try {
const portPath = path.join(projectRoot, '.beads', 'dolt-server.port');
const portRaw = await fs.readFile(portPath, 'utf-8');
const portNum = parseInt(portRaw.trim(), 10);
if (!isNaN(portNum) && portNum > 0) {
port = portNum;
} else {
// Fall back to metadata.json port
const metadataPort = parsed.dolt_server_port;
if (typeof metadataPort !== 'number') {
throw new DoltConnectionError(
`${metadataPath} is missing valid port and .beads/dolt-server.port is missing or invalid`
);
}
port = metadataPort;
}
} catch {
// Fall back to metadata.json port
const metadataPort = parsed.dolt_server_port;
if (typeof metadataPort !== 'number') {
throw new DoltConnectionError(
`${metadataPath} is missing required field: dolt_server_port (number) and .beads/dolt-server.port is missing`
);
}
port = metadataPort;
}
return {
dolt_server_port: port,
dolt_database: database,

156
src/lib/embedded-daemon.ts Normal file
View file

@ -0,0 +1,156 @@
import { buildLaunchRequest, createLaunchConsoleEvents, createOrchestratorInstance, type LaunchSurface, type RuntimeConsoleEvent, type RuntimeInstance } from './embedded-runtime';
import type { BeadIssue } from './types';
export interface ProjectRuntimeState {
projectRoot: string;
orchestrator: RuntimeInstance;
events: RuntimeConsoleEvent[];
updatedAt: string;
}
export interface HostDaemonStatus {
ok: true;
daemon: {
backend: 'pi';
status: 'online';
projectCount: number;
};
projects: Array<{
projectRoot: string;
orchestratorId: string;
orchestratorStatus: RuntimeInstance['status'];
eventCount: number;
updatedAt: string;
}>;
}
export class EmbeddedPiDaemon {
private readonly projects = new Map<string, ProjectRuntimeState>();
private readonly orchestratorBooted = new Set<string>();
ensureProject(projectRoot: string): ProjectRuntimeState {
const existing = this.projects.get(projectRoot);
if (existing) {
return existing;
}
const orchestrator = createOrchestratorInstance(projectRoot);
const state: ProjectRuntimeState = {
projectRoot,
orchestrator,
events: [],
updatedAt: new Date().toISOString(),
};
this.projects.set(projectRoot, state);
return state;
}
ensureOrchestrator(projectRoot: string): RuntimeInstance {
const state = this.ensureProject(projectRoot);
const projectId = state.orchestrator.projectId;
// Only add boot event once per project (track via Set)
if (!this.orchestratorBooted.has(projectId)) {
state.events.unshift({
id: `${projectId}:boot:${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
projectId,
kind: 'launch.started',
title: 'Host daemon attached project orchestrator',
detail: 'BeadBoard host bridge registered project orchestrator.',
timestamp: new Date().toISOString(),
status: 'idle',
actorLabel: state.orchestrator.label,
});
state.updatedAt = new Date().toISOString();
this.orchestratorBooted.add(projectId);
}
return state.orchestrator;
}
launchFromIssue(params: {
projectRoot: string;
issue: BeadIssue;
origin: LaunchSurface;
swarmId?: string | null;
}): { orchestrator: RuntimeInstance; events: RuntimeConsoleEvent[] } {
const state = this.ensureProject(params.projectRoot);
state.orchestrator.status = 'planning';
const request = buildLaunchRequest({
issue: params.issue,
origin: params.origin,
projectRoot: params.projectRoot,
swarmId: params.swarmId ?? null,
});
const launchEvents = createLaunchConsoleEvents(request);
state.events.unshift(...launchEvents);
state.updatedAt = new Date().toISOString();
return {
orchestrator: state.orchestrator,
events: launchEvents,
};
}
listEvents(projectRoot: string): RuntimeConsoleEvent[] {
return [...(this.projects.get(projectRoot)?.events ?? [])];
}
appendEvent(projectRoot: string, event: Omit<RuntimeConsoleEvent, 'id' | 'timestamp' | 'projectId'>): void {
const state = this.ensureProject(projectRoot);
const fullEvent: RuntimeConsoleEvent = {
...event,
id: `evt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
projectId: state.orchestrator.projectId,
timestamp: new Date().toISOString(),
};
state.events.unshift(fullEvent);
state.updatedAt = new Date().toISOString();
}
appendWorkerEvent(projectRoot: string, workerId: string, event: {
kind: 'worker.spawned' | 'worker.updated' | 'worker.completed' | 'worker.failed';
title: string;
detail: string;
status?: RuntimeConsoleEvent['status'];
metadata?: Record<string, unknown>;
}): void {
this.appendEvent(projectRoot, {
...event,
metadata: { workerId, ...(event.metadata || {}) },
});
}
getStatus(): HostDaemonStatus {
return {
ok: true,
daemon: {
backend: 'pi',
status: 'online',
projectCount: this.projects.size,
},
projects: [...this.projects.values()].map((state) => ({
projectRoot: state.projectRoot,
orchestratorId: state.orchestrator.id,
orchestratorStatus: state.orchestrator.status,
eventCount: state.events.length,
updatedAt: state.updatedAt,
})),
};
}
resetForTests(): void {
this.projects.clear();
this.orchestratorBooted.clear();
}
}
const globalRegistry = globalThis as typeof globalThis & {
__beadboardEmbeddedPiDaemon?: EmbeddedPiDaemon;
};
export const embeddedPiDaemon = globalRegistry.__beadboardEmbeddedPiDaemon ?? new EmbeddedPiDaemon();
if (!globalRegistry.__beadboardEmbeddedPiDaemon) {
globalRegistry.__beadboardEmbeddedPiDaemon = embeddedPiDaemon;
}

238
src/lib/embedded-runtime.ts Normal file
View file

@ -0,0 +1,238 @@
import type { BeadIssue } from './types';
export type RuntimeBackendId = 'pi';
export type AgentInstanceKind = 'orchestrator' | 'worker';
export type LaunchSurface = 'social' | 'graph' | 'swarm' | 'sessions' | 'activity' | 'task';
export type RuntimeEventKind =
| 'orchestrator.message'
| 'launch.requested'
| 'launch.planned'
| 'launch.started'
| 'worker.spawned'
| 'worker.updated'
| 'worker.completed'
| 'worker.failed'
| 'deviation.proposed'
| 'deviation.approved'
| 'deviation.rejected';
export type RuntimeStatus =
| 'idle'
| 'planning'
| 'launching'
| 'working'
| 'waiting'
| 'blocked'
| 'completed'
| 'failed'
| 'stale';
export type TemplateDeviationSeverity = 'minor' | 'major';
export interface AgentTypeDefinition {
id: string;
archetypeId: string;
label: string;
backend: RuntimeBackendId;
defaultModel?: string | null;
}
export interface RuntimeInstance {
id: string;
projectId: string;
backend: RuntimeBackendId;
kind: AgentInstanceKind;
agentTypeId: string;
label: string;
status: RuntimeStatus;
taskId: string | null;
epicId: string | null;
swarmId: string | null;
}
export interface LaunchRequest {
id: string;
projectId: string;
backend: RuntimeBackendId;
origin: LaunchSurface;
taskId: string;
epicId: string | null;
swarmId: string | null;
templateId: string | null;
requestedAgentTypeId: string | null;
contextSummary: string;
issueTitle: string;
dependencyIds: string[];
createdAt: string;
}
export interface TemplateDeviationRecord {
id: string;
launchRequestId: string;
severity: TemplateDeviationSeverity;
summary: string;
reason: string;
requiresApproval: boolean;
createdAt: string;
}
export interface RuntimeConsoleEvent {
id: string;
projectId: string;
kind: RuntimeEventKind;
title: string;
detail: string;
timestamp: string;
status?: RuntimeStatus;
actorLabel?: string;
taskId?: string | null;
swarmId?: string | null;
metadata?: Record<string, unknown>;
}
function stableProjectId(projectRoot: string): string {
return projectRoot
.replace(/^[A-Za-z]:/, '')
.replaceAll('\\', '/')
.split('/')
.filter(Boolean)
.join('-')
.replace(/[^a-zA-Z0-9-]/g, '-')
.replace(/-+/g, '-')
.toLowerCase() || 'root';
}
export function getProjectRuntimeId(projectRoot: string): string {
// Client-safe path normalization (removes trailing slashes)
const normalizedRoot = projectRoot.replace(/[/\\]+$/, '') || projectRoot;
return stableProjectId(normalizedRoot);
}
export function getOrchestratorAgentType(): AgentTypeDefinition {
return {
id: 'pi-orchestrator',
archetypeId: 'orchestrator',
label: 'Project Orchestrator',
backend: 'pi',
defaultModel: null,
};
}
export function createOrchestratorInstance(projectRoot: string): RuntimeInstance {
const projectId = getProjectRuntimeId(projectRoot);
return {
id: `${projectId}:orchestrator`,
projectId,
backend: 'pi',
kind: 'orchestrator',
agentTypeId: getOrchestratorAgentType().id,
label: 'Main Orchestrator',
status: 'idle',
taskId: null,
epicId: null,
swarmId: null,
};
}
export function buildLaunchRequest(params: {
issue: BeadIssue;
origin: LaunchSurface;
projectRoot: string;
swarmId?: string | null;
requestedAgentTypeId?: string | null;
}): LaunchRequest {
const { issue, origin, projectRoot, swarmId = null, requestedAgentTypeId = null } = params;
const projectId = getProjectRuntimeId(projectRoot);
const epicId = issue.dependencies.find((dep) => dep.type === 'parent')?.target ?? null;
const dependencyIds = issue.dependencies
.filter((dep) => dep.type !== 'parent')
.map((dep) => dep.target)
.sort();
return {
id: `${projectId}:${origin}:${issue.id}`,
projectId,
backend: 'pi',
origin,
taskId: issue.id,
epicId,
swarmId,
templateId: issue.templateId ?? null,
requestedAgentTypeId,
contextSummary: `Launch ${issue.id} from ${origin} with ${dependencyIds.length} dependency link(s).`,
issueTitle: issue.title,
dependencyIds,
createdAt: new Date().toISOString(),
};
}
export function createDeviationRecord(params: {
launchRequest: LaunchRequest;
severity: TemplateDeviationSeverity;
summary: string;
reason: string;
}): TemplateDeviationRecord {
const { launchRequest, severity, summary, reason } = params;
return {
id: `${launchRequest.id}:deviation`,
launchRequestId: launchRequest.id,
severity,
summary,
reason,
requiresApproval: severity === 'major',
createdAt: new Date().toISOString(),
};
}
export function createRuntimeConsoleEvent(params: {
projectId: string;
kind: RuntimeEventKind;
title: string;
detail: string;
status?: RuntimeStatus;
actorLabel?: string;
taskId?: string | null;
swarmId?: string | null;
metadata?: Record<string, unknown>;
}): RuntimeConsoleEvent {
const { projectId, kind, title, detail, status, actorLabel, taskId = null, swarmId = null, metadata } = params;
return {
id: `${projectId}:${kind}:${taskId ?? 'global'}:${Date.now()}`,
projectId,
kind,
title,
detail,
timestamp: new Date().toISOString(),
status,
actorLabel,
taskId,
swarmId,
metadata,
};
}
export function createLaunchConsoleEvents(request: LaunchRequest): RuntimeConsoleEvent[] {
return [
createRuntimeConsoleEvent({
projectId: request.projectId,
kind: 'launch.requested',
title: `Launch requested for ${request.taskId}`,
detail: `${request.issueTitle} queued from ${request.origin}.`,
status: 'planning',
actorLabel: 'Main Orchestrator',
taskId: request.taskId,
swarmId: request.swarmId,
metadata: { templateId: request.templateId, requestedAgentTypeId: request.requestedAgentTypeId },
}),
createRuntimeConsoleEvent({
projectId: request.projectId,
kind: 'orchestrator.message',
title: 'Orchestrator reviewing launch context',
detail: request.contextSummary,
status: 'planning',
actorLabel: 'Main Orchestrator',
taskId: request.taskId,
swarmId: request.swarmId,
}),
];
}

View file

@ -0,0 +1,159 @@
import type { RuntimeConsoleEvent } from './embedded-runtime';
export interface OrchestratorChatMessage {
id: string;
role: 'user' | 'assistant';
text: string;
timestamp: string;
}
export function projectOrchestratorChat(events: RuntimeConsoleEvent[]): OrchestratorChatMessage[] {
const ordered = [...events]
.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
const messages: OrchestratorChatMessage[] = [];
let currentAssistantIndex: number | null = null;
for (const event of ordered) {
if (event.actorLabel === 'human' && event.detail.trim()) {
messages.push({
id: `chat-${event.id}`,
role: 'user',
text: event.detail,
timestamp: event.timestamp,
});
currentAssistantIndex = null;
continue;
}
if (event.title === 'Orchestrator Responding') {
if (currentAssistantIndex === null) {
messages.push({
id: `chat-${event.id}`,
role: 'assistant',
text: '…',
timestamp: event.timestamp,
});
currentAssistantIndex = messages.length - 1;
}
continue;
}
if (event.title === 'Orchestrator Thinking' && event.detail.trim()) {
if (currentAssistantIndex === null) {
messages.push({
id: `chat-${event.id}`,
role: 'assistant',
text: event.detail,
timestamp: event.timestamp,
});
currentAssistantIndex = messages.length - 1;
} else {
const last = messages[currentAssistantIndex];
messages[currentAssistantIndex] = {
...last,
text: `${last.text === '…' ? '' : last.text}${event.detail}`,
timestamp: event.timestamp,
};
}
continue;
}
if (event.title === 'Orchestrator Reply' && event.detail.trim()) {
if (currentAssistantIndex !== null && messages[currentAssistantIndex]?.role === 'assistant') {
const last = messages[currentAssistantIndex];
const nextText = event.detail;
const mergedText = nextText.startsWith(last.text) || nextText.length >= last.text.length
? nextText
: `${last.text === '…' ? '' : last.text}${nextText}`;
messages[currentAssistantIndex] = {
...last,
text: mergedText,
timestamp: event.timestamp,
};
} else {
messages.push({
id: `chat-${event.id}`,
role: 'assistant',
text: event.detail,
timestamp: event.timestamp,
});
currentAssistantIndex = messages.length - 1;
}
continue;
}
if (event.title === 'Session Error') {
currentAssistantIndex = null;
continue;
}
// Worker events - when worker completes, orchestrator should report it
if (event.kind === 'worker.spawned') {
if (currentAssistantIndex === null) {
messages.push({
id: `chat-${event.id}`,
role: 'assistant',
text: `Spawning worker${event.metadata?.taskId ? ` for task ${event.metadata.taskId}` : ''}...`,
timestamp: event.timestamp,
});
currentAssistantIndex = messages.length - 1;
}
continue;
}
if (event.kind === 'worker.updated') {
if (currentAssistantIndex !== null && messages[currentAssistantIndex]?.role === 'assistant') {
const last = messages[currentAssistantIndex];
messages[currentAssistantIndex] = {
...last,
text: `${last.text === '…' ? '' : last.text}Worker is now working${event.metadata?.taskId ? ` on task ${event.metadata.taskId}` : ''}.`,
timestamp: event.timestamp,
};
}
continue;
}
if (event.kind === 'worker.completed') {
if (currentAssistantIndex === null) {
messages.push({
id: `chat-${event.id}`,
role: 'assistant',
text: `Worker${event.metadata?.workerId ? ` ${event.metadata.workerId}` : ''} completed${event.metadata?.taskId ? ` task ${event.metadata.taskId}` : ''}.`,
timestamp: event.timestamp,
});
currentAssistantIndex = messages.length - 1;
} else if (currentAssistantIndex !== null && messages[currentAssistantIndex]?.role === 'assistant') {
const last = messages[currentAssistantIndex];
messages[currentAssistantIndex] = {
...last,
text: `${last.text === '…' ? '' : last.text}Worker${event.metadata?.workerId ? ` ${event.metadata.workerId}` : ''} completed${event.metadata?.taskId ? ` task ${event.metadata.taskId}` : ''}.`,
timestamp: event.timestamp,
};
}
continue;
}
if (event.kind === 'worker.failed') {
if (currentAssistantIndex === null) {
messages.push({
id: `chat-${event.id}`,
role: 'assistant',
text: `Worker${event.metadata?.workerId ? ` ${event.metadata.workerId}` : ''} failed${event.metadata?.taskId ? ` task ${event.metadata.taskId}` : ''}${event.detail ? `: ${event.detail}` : ''}.`,
timestamp: event.timestamp,
});
currentAssistantIndex = messages.length - 1;
} else if (currentAssistantIndex !== null && messages[currentAssistantIndex]?.role === 'assistant') {
const last = messages[currentAssistantIndex];
messages[currentAssistantIndex] = {
...last,
text: `${last.text === '…' ? '' : last.text}Worker${event.metadata?.workerId ? ` ${event.metadata.workerId}` : ''} failed${event.metadata?.taskId ? ` task ${event.metadata.taskId}` : ''}${event.detail ? `: ${event.detail}` : ''}.`,
timestamp: event.timestamp,
};
}
continue;
}
}
return messages;
}

View file

@ -0,0 +1,276 @@
import { embeddedPiDaemon } from './embedded-daemon';
import type { LaunchSurface, RuntimeConsoleEvent, RuntimeInstance } from './embedded-runtime';
import type { BeadIssue } from './types';
import { detectPiRuntimeStrategy } from './pi-runtime-detection';
import { ensureManagedPiSettings, bootstrapManagedPi } from './bb-pi-bootstrap';
import { buildBeadBoardSystemPrompt } from '../tui/system-prompt';
import path from 'node:path';
import { createDoltReadTool } from '../tui/tools/bb-dolt-read';
import { createMailboxTools } from '../tui/tools/bb-mailbox';
import { createPresenceTools } from '../tui/tools/bb-presence';
import { createDeviationTool } from '../tui/tools/bb-deviation';
import { createSpawnWorkerTool } from '../tui/tools/bb-spawn-worker';
import { createSpawnTemplateTool } from '../tui/tools/bb-spawn-template';
import { createWorkerStatusTool } from '../tui/tools/bb-worker-status';
import { createAssignAgentTool } from '../tui/tools/bb-assign-agent';
import { createListAgentsTool } from '../tui/tools/bb-list-agents';
import { createCreateAgentTool } from '../tui/tools/bb-create-agent';
import { createUpdateAgentTool } from '../tui/tools/bb-update-agent';
import { createDeleteAgentTool } from '../tui/tools/bb-delete-agent';
import { createListTemplatesTool } from '../tui/tools/bb-list-templates';
import { createCreateTemplateTool } from '../tui/tools/bb-create-template';
import { createUpdateTemplateTool } from '../tui/tools/bb-update-template';
import { createDeleteTemplateTool } from '../tui/tools/bb-delete-template';
import { createBeadCrudTools } from '../tui/tools/bb-bead-crud';
import { createWorkerResultsTool } from '../tui/tools/bb-worker-results';
export interface PiDaemonBinding {
id: string;
backend: 'pi';
kind: RuntimeInstance['kind'];
projectRoot: string;
attachMode: 'in-process' | 'host-daemon';
launchTarget: 'embedded-pi-daemon';
runtime: RuntimeInstance;
}
export interface PiDaemonAdapter {
ensureProjectOrchestrator(projectRoot: string): Promise<PiDaemonBinding>;
listEvents(projectRoot: string): RuntimeConsoleEvent[];
launchFromIssue(params: {
projectRoot: string;
issue: BeadIssue;
origin: LaunchSurface;
swarmId?: string | null;
}): Promise<{ orchestrator: RuntimeInstance; events: RuntimeConsoleEvent[] }>;
prompt?(projectRoot: string, text: string): Promise<void>;
}
class InProcessPiDaemonAdapter implements PiDaemonAdapter {
private activeSessions = new Map<string, any>(); // Map<projectRoot, AgentSession>
private recentEventKeys = new Set<string>(); // Deduplicate events within same second
private async getOrCreateSession(projectRoot: string): Promise<any> {
if (this.activeSessions.has(projectRoot)) {
return this.activeSessions.get(projectRoot);
}
let resolution = await detectPiRuntimeStrategy();
// Auto-bootstrap if Pi not installed
if (!resolution.sdkPath || resolution.installState === 'bootstrap-required') {
console.log('[Agent] SDK not found, auto-bootstrapping...');
const bootstrapResult = await bootstrapManagedPi();
console.log('[Agent] Bootstrap complete:', bootstrapResult.managedRoot);
// Re-detect after bootstrap
resolution = await detectPiRuntimeStrategy();
if (!resolution.sdkPath) {
throw new Error('Auto-bootstrap completed but SDK still not available. Check npm install logs.');
}
}
const managedAgentDir = resolution.agentDir;
await ensureManagedPiSettings(managedAgentDir);
process.env.PI_CODING_AGENT_DIR = managedAgentDir;
// Dynamically load the PI SDK
const { pathToFileURL } = await import('node:url');
const sdk = await import(/* webpackIgnore: true */ pathToFileURL(resolution.sdkPath).href);
const authStorage = new sdk.AuthStorage(path.join(managedAgentDir, 'auth.json'));
const modelRegistry = new sdk.ModelRegistry(authStorage, path.join(managedAgentDir, 'models.json'));
const settingsManager = sdk.SettingsManager.create(projectRoot, managedAgentDir);
const sessionManager = sdk.SessionManager.create(projectRoot);
const dynamicPrompt = await buildBeadBoardSystemPrompt(projectRoot, `You are a headless orchestrator for the BeadBoard system.`);
const res = await sdk.createAgentSession({
cwd: projectRoot,
agentDir: managedAgentDir,
authStorage,
modelRegistry,
settingsManager,
sessionManager,
systemPrompt: dynamicPrompt,
tools: [
sdk.createReadTool(projectRoot),
sdk.createBashTool(projectRoot),
sdk.createEditTool(projectRoot),
sdk.createWriteTool(projectRoot),
],
hooks: [],
skills: [],
contextFiles: [],
slashCommands: [],
customTools: [
{ tool: createDoltReadTool(projectRoot) },
{ tool: createDeviationTool(projectRoot) },
{ tool: createSpawnWorkerTool(projectRoot) },
{ tool: createSpawnTemplateTool(projectRoot) },
{ tool: createWorkerStatusTool(projectRoot) },
{ tool: createAssignAgentTool(projectRoot) },
// Agent CRUD tools
{ tool: createListAgentsTool(projectRoot) },
{ tool: createCreateAgentTool(projectRoot) },
{ tool: createUpdateAgentTool(projectRoot) },
{ tool: createDeleteAgentTool(projectRoot) },
// Template CRUD tools
{ tool: createListTemplatesTool(projectRoot) },
{ tool: createCreateTemplateTool(projectRoot) },
{ tool: createUpdateTemplateTool(projectRoot) },
{ tool: createDeleteTemplateTool(projectRoot) },
// Bead CRUD tools
...createBeadCrudTools(projectRoot).map((tool) => ({ tool: tool as any })),
// Worker results tool
{ tool: createWorkerResultsTool(projectRoot) },
...createMailboxTools().map((tool) => ({ tool: tool as any })),
...createPresenceTools().map((tool) => ({ tool: tool as any })),
],
});
const session = res.session;
// Helper: deduplicate and emit events
const emitEvent = (kind: RuntimeConsoleEvent['kind'], title: string, detail: string, status?: RuntimeConsoleEvent['status']) => {
const normalizedDetail = detail.trim();
const eventKey = `${kind}:${title}:${status || 'none'}:${normalizedDetail}`;
if (this.recentEventKeys.has(eventKey)) {
return;
}
this.recentEventKeys.add(eventKey);
setTimeout(() => this.recentEventKeys.delete(eventKey), 1000);
embeddedPiDaemon.appendEvent(projectRoot, {
kind,
title,
detail,
status,
});
};
session.subscribe((event: any) => {
console.log('[Pi SDK Event]', event.type, event);
// Map PI SDK events to BeadBoard runtime console events
if (event.type === 'message_start' && event.message.role === 'assistant') {
emitEvent('orchestrator.message', 'Orchestrator Responding', 'Processing request...', 'working');
}
if (event.type === 'tool_execution_start') {
emitEvent('orchestrator.message', `Tool: ${event.toolName}`, `Executing ${event.toolName}...`, 'working');
}
if (event.type === 'tool_execution_end') {
emitEvent('orchestrator.message', `Tool Complete: ${event.toolName}`, `Finished ${event.toolName}`, 'completed');
}
if (event.type === 'message_update') {
const ame = event.assistantMessageEvent;
if (ame.type === 'error') {
emitEvent('orchestrator.message', 'Error', ame.error.errorMessage, 'failed');
} else if (ame.type === 'thinking_delta') {
const delta = ame.delta || '';
if (delta) {
emitEvent('orchestrator.message', 'Orchestrator Thinking', delta, 'working');
}
} else if (ame.type === 'text_delta') {
const delta = ame.delta || '';
if (delta) {
emitEvent('orchestrator.message', 'Orchestrator Reply', delta, 'completed');
}
} else if (ame.type === 'text_done') {
const text = ame.text || '';
if (text) {
emitEvent('orchestrator.message', 'Orchestrator Reply', text, 'completed');
}
}
}
if (event.type === 'agent_end') {
const lastMsg = event.messages?.[event.messages.length - 1];
if (lastMsg?.role === 'assistant') {
if (lastMsg.stopReason === 'error' && lastMsg.errorMessage) {
emitEvent('orchestrator.message', 'Execution Failed', lastMsg.errorMessage, 'failed');
} else {
const txt = lastMsg.content?.filter((c: any) => c.type === 'text').map((c: any) => c.text).join('\n') || 'Completed.';
emitEvent('orchestrator.message', 'Orchestrator Reply', txt.substring(0, 500), 'completed');
}
}
}
});
this.activeSessions.set(projectRoot, session);
return session;
}
async ensureProjectOrchestrator(projectRoot: string): Promise<PiDaemonBinding> {
const runtime = embeddedPiDaemon.ensureOrchestrator(projectRoot);
// eager initialize the session if we can
this.getOrCreateSession(projectRoot).catch(() => {});
return {
id: runtime.id,
backend: 'pi',
kind: runtime.kind,
projectRoot,
attachMode: 'in-process',
launchTarget: 'embedded-pi-daemon',
runtime,
};
}
listEvents(projectRoot: string): RuntimeConsoleEvent[] {
return embeddedPiDaemon.listEvents(projectRoot);
}
async launchFromIssue(params: {
projectRoot: string;
issue: BeadIssue;
origin: LaunchSurface;
swarmId?: string | null;
}): Promise<{ orchestrator: RuntimeInstance; events: RuntimeConsoleEvent[] }> {
const result = embeddedPiDaemon.launchFromIssue(params);
// Send it to the orchestrator as a prompt
const text = `I am launching a task from the UI.\n\nTask: ${params.issue.title}\nID: ${params.issue.id}\n\nPlease read the current state of the project using your tools and proceed with the necessary steps to orchestrate this task.`;
this.prompt(params.projectRoot, text).catch(() => {});
return result;
}
async prompt(projectRoot: string, text: string): Promise<void> {
console.log('[Pi Daemon] Prompt called for projectRoot:', projectRoot, 'text:', text);
// Emit user message immediately so UI shows it
embeddedPiDaemon.appendEvent(projectRoot, {
kind: 'orchestrator.message',
title: 'User Prompt',
detail: text,
actorLabel: 'human',
status: 'idle',
});
// Fire-and-forget the session prompt - SDK subscription handles real-time event emission
this.getOrCreateSession(projectRoot)
.then((session) => {
console.log('[Pi Daemon] Session obtained, calling session.prompt()');
return session.prompt(text);
})
.then(() => {
console.log('[Pi Daemon] Session prompt completed');
})
.catch((e) => {
console.error('[Pi Daemon] Session error:', e);
embeddedPiDaemon.appendEvent(projectRoot, {
kind: 'orchestrator.message',
title: 'Session Error',
detail: e instanceof Error ? e.message : String(e),
status: 'failed',
});
});
}
}
export function createPiDaemonAdapter(): PiDaemonAdapter {
return new InProcessPiDaemonAdapter();
}

View file

@ -0,0 +1,103 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { getRuntimePaths } from './runtime-manager';
export type PiRuntimeMode = 'linked-pi' | 'bb-managed-pi';
export type PiInstallState = 'ready' | 'bootstrap-required';
export interface PiRuntimeResolution {
mode: PiRuntimeMode;
installState: PiInstallState;
sdkPath: string | null;
authPath: string | null;
agentDir: string;
version: string;
managedRoot: string;
reason: string;
}
async function pathExists(target: string): Promise<boolean> {
try {
await fs.access(target);
return true;
} catch {
return false;
}
}
export function getManagedPiPaths(version: string, home: string = os.homedir()) {
const runtime = getRuntimePaths(home, version);
const managedRoot = path.join(runtime.runtimeRoot, 'pi');
return {
managedRoot,
sdkPath: path.join(managedRoot, 'node_modules', '@mariozechner', 'pi-coding-agent', 'dist', 'index.js'),
agentDir: path.join(managedRoot, 'agent'),
authPath: path.join(managedRoot, 'agent', 'auth.json'),
};
}
export async function detectPiRuntimeStrategy(params: {
cwd?: string;
version?: string;
home?: string;
globalPiRoot?: string;
allowLinkedPi?: boolean;
} = {}): Promise<PiRuntimeResolution> {
const cwd = params.cwd ?? process.cwd();
const version = params.version ?? '0.1.0';
const home = params.home ?? os.homedir();
const globalPiRoot = params.globalPiRoot ?? '/home/clawdbot/npm-global/lib/node_modules/@mariozechner/pi-coding-agent';
const allowLinkedPi = params.allowLinkedPi ?? false;
const localSdkPath = path.join(cwd, 'node_modules', '@mariozechner', 'pi-coding-agent', 'dist', 'index.js');
const globalSdkPath = path.join(globalPiRoot, 'dist', 'index.js');
const linkedAuthPath = path.join(home, '.pi', 'agent', 'auth.json');
const linkedAgentDir = path.join(home, '.pi', 'agent');
const managed = getManagedPiPaths(version, home);
if (allowLinkedPi && await pathExists(localSdkPath)) {
return {
mode: 'linked-pi',
installState: 'ready',
sdkPath: localSdkPath,
authPath: (await pathExists(linkedAuthPath)) ? linkedAuthPath : null,
agentDir: linkedAgentDir,
version,
managedRoot: managed.managedRoot,
reason: 'Using project-local Pi SDK and linked ~/.pi/agent state when available.',
};
}
if (allowLinkedPi && await pathExists(globalSdkPath)) {
return {
mode: 'linked-pi',
installState: 'ready',
sdkPath: globalSdkPath,
authPath: (await pathExists(linkedAuthPath)) ? linkedAuthPath : null,
agentDir: linkedAgentDir,
version,
managedRoot: managed.managedRoot,
reason: 'Using globally installed Pi SDK and linked ~/.pi/agent state when available.',
};
}
const managedSdkExists = await pathExists(managed.sdkPath);
const managedAuthExists = await pathExists(managed.authPath);
return {
mode: 'bb-managed-pi',
installState: managedSdkExists ? 'ready' : 'bootstrap-required',
sdkPath: managedSdkExists ? managed.sdkPath : null,
authPath: managedAuthExists ? managed.authPath : null,
agentDir: managed.agentDir,
version,
managedRoot: managed.managedRoot,
reason: managedSdkExists
? 'Using BeadBoard-managed Pi runtime.'
: allowLinkedPi
? 'No linked Pi installation found. BeadBoard-managed Pi bootstrap is required.'
: 'BeadBoard-managed Pi bootstrap is required.',
};
}

View file

@ -1,8 +1,8 @@
import fs from 'fs/promises';
import path from 'path';
import { AgentArchetype, SwarmTemplate } from '../types-swarm';
import { AgentType, AgentArchetype, SwarmTemplate } from '../types-swarm';
const ARCHE_DIR = path.join(process.cwd(), '.beads', 'archetypes');
const AGENT_DIR = path.join(process.cwd(), '.beads', 'archetypes');
const TEMPLATE_DIR = path.join(process.cwd(), '.beads', 'templates');
export function slugify(name: string): string {
@ -15,7 +15,7 @@ export function slugify(name: string): string {
.replace(/^-|-$/g, '');
}
export type SaveArchetypeInput = Partial<AgentArchetype> & {
export type SaveAgentTypeInput = Partial<AgentType> & {
name: string;
description: string;
systemPrompt: string;
@ -23,8 +23,11 @@ export type SaveArchetypeInput = Partial<AgentArchetype> & {
color: string;
};
export async function saveArchetype(input: SaveArchetypeInput): Promise<AgentArchetype> {
await fs.mkdir(ARCHE_DIR, { recursive: true });
/** @deprecated Use SaveAgentTypeInput instead */
export type SaveArchetypeInput = SaveAgentTypeInput;
export async function saveAgentType(input: SaveAgentTypeInput): Promise<AgentType> {
await fs.mkdir(AGENT_DIR, { recursive: true });
const id = input.id || slugify(input.name);
const now = new Date().toISOString();
@ -33,7 +36,7 @@ export async function saveArchetype(input: SaveArchetypeInput): Promise<AgentArc
let createdAt = input.createdAt || now;
try {
const existingContent = await fs.readFile(path.join(ARCHE_DIR, `${id}.json`), 'utf-8');
const existingContent = await fs.readFile(path.join(AGENT_DIR, `${id}.json`), 'utf-8');
const existing = JSON.parse(existingContent);
if (existing.isBuiltIn) {
isBuiltIn = true; // Protect built-in status
@ -45,127 +48,246 @@ export async function saveArchetype(input: SaveArchetypeInput): Promise<AgentArc
// File doesn't exist, which is fine
}
const archetype: AgentArchetype = {
const agentType: AgentType = {
id,
name: input.name,
description: input.description,
systemPrompt: input.systemPrompt,
capabilities: input.capabilities,
color: input.color,
icon: input.icon,
createdAt,
updatedAt: now,
isBuiltIn
};
await fs.writeFile(
path.join(ARCHE_DIR, `${id}.json`),
JSON.stringify(archetype, null, 2)
path.join(AGENT_DIR, `${id}.json`),
JSON.stringify(agentType, null, 2)
);
return archetype;
return agentType;
}
export async function deleteArchetype(id: string): Promise<void> {
const filePath = path.join(ARCHE_DIR, `${id}.json`);
/** @deprecated Use saveAgentType instead */
export const saveArchetype = saveAgentType;
let archetype: AgentArchetype;
export async function deleteAgentType(id: string): Promise<void> {
const filePath = path.join(AGENT_DIR, `${id}.json`);
let agentType: AgentType;
try {
const content = await fs.readFile(filePath, 'utf-8');
archetype = JSON.parse(content);
agentType = JSON.parse(content);
} catch {
throw new Error(`Archetype not found: ${id}`);
throw new Error(`Agent type not found: ${id}`);
}
if (archetype.isBuiltIn) {
throw new Error(`Cannot delete built-in archetype: ${id}`);
if (agentType.isBuiltIn) {
throw new Error(`Cannot delete built-in agent type: ${id}`);
}
await fs.unlink(filePath);
}
const SEED_ARCHETYPES: AgentArchetype[] = [
/** @deprecated Use deleteAgentType instead */
export const deleteArchetype = deleteAgentType;
const SEED_AGENTS: AgentType[] = [
{
id: 'architect',
name: 'System Architect',
description: 'Designs complex system structures and writes detailed implementation plans.',
systemPrompt: 'You are a staff-level software architect focused on high-level system design.',
capabilities: ['planning', 'design_docs', 'arch_review'],
description: 'Designs system structures, decomposes work into actionable tasks, and makes technical decisions.',
systemPrompt: 'You are a staff-level software architect focused on high-level system design. You create clear, actionable plans that other agents can execute.',
capabilities: ['system_design', 'work_decomposition', 'technical_decisions', 'risk_assessment', 'documentation'],
color: '#3b82f6',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
isBuiltIn: true
},
{
id: 'coder',
id: 'engineer',
name: 'Implementation Engineer',
description: 'Translates plans into precise, type-safe, and tested code.',
systemPrompt: 'You are a senior software engineer focused on execution and clean code.',
capabilities: ['coding', 'refactoring', 'testing'],
description: 'Translates plans into precise, type-safe, and tested code. Focuses on clean implementation and maintainability.',
systemPrompt: 'You are a senior software engineer focused on turning designs and plans into production-quality code. You implement features, fix bugs, and write tests.',
capabilities: ['coding', 'refactoring', 'testing', 'debugging', 'documentation'],
color: '#10b981',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
isBuiltIn: true
}
];
export async function getArchetypes(): Promise<AgentArchetype[]> {
try {
await fs.mkdir(ARCHE_DIR, { recursive: true });
const files = await fs.readdir(ARCHE_DIR);
if (files.filter(f => f.endsWith('.json')).length === 0) {
// Seed defaults
for (const arch of SEED_ARCHETYPES) {
await fs.writeFile(path.join(ARCHE_DIR, `${arch.id}.json`), JSON.stringify(arch, null, 2));
}
return SEED_ARCHETYPES;
}
const archetypes: AgentArchetype[] = [];
for (const file of files) {
if (!file.endsWith('.json')) continue;
try {
const content = await fs.readFile(path.join(ARCHE_DIR, file), 'utf-8');
const parsed = JSON.parse(content);
archetypes.push({
...parsed,
id: file.replace('.json', '')
});
} catch (err) {
console.error(`Failed to parse archetype file: ${file}`, err);
}
}
return archetypes;
} catch (e) {
console.error('Error in getArchetypes:', e);
return [];
}
}
const SEED_TEMPLATES: SwarmTemplate[] = [
},
{
id: 'standard-app',
name: 'Standard Application Swarm',
description: 'A balanced team of an Architect and two Coders for standard feature development.',
team: [
{ archetypeId: 'architect', count: 1 },
{ archetypeId: 'coder', count: 2 }
],
id: 'reviewer',
name: 'Code Reviewer',
description: 'Conducts rigorous technical code reviews with focus on correctness, performance, maintainability, and test quality.',
systemPrompt: 'You are a senior systems engineer conducting rigorous technical code reviews. Your analysis prioritizes technical correctness, performance, maintainability, and simplicity. Be direct about problems and constructive with solutions. You do NOT modify files.',
capabilities: ['code_review', 'quality_gates', 'test_evaluation', 'security_review', 'performance_analysis'],
color: '#f59e0b',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
isBuiltIn: true
},
{
id: 'tester',
name: 'Test Engineer',
description: 'Designs and implements comprehensive test suites, discovers edge cases, and ensures code correctness through rigorous verification.',
systemPrompt: 'You are a senior test engineer focused on ensuring code correctness through comprehensive test design and implementation. You think adversarially about code, always looking for ways it could fail.',
capabilities: ['test_design', 'test_implementation', 'edge_case_discovery', 'coverage_analysis', 'quality_assurance'],
color: '#8b5cf6',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
isBuiltIn: true
},
{
id: 'investigator',
name: 'Investigator',
description: 'Debugs complex issues, performs root cause analysis, and researches unknowns to unblock development.',
systemPrompt: 'You are a senior engineer specializing in debugging, root cause analysis, and technical research. You excel at unraveling complex problems. You do NOT modify files unless implementing a confirmed fix.',
capabilities: ['debugging', 'root_cause_analysis', 'research', 'documentation', 'problem_solving'],
color: '#ef4444',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
isBuiltIn: true
},
{
id: 'shipper',
name: 'Shipper',
description: 'Manages CI/CD pipelines, deployments, and release processes. Ensures safe and reliable software delivery.',
systemPrompt: 'You are a senior DevOps/release engineer focused on safe, reliable software delivery. You manage CI/CD pipelines, deployment processes, and release coordination.',
capabilities: ['ci_cd', 'deployment', 'release_management', 'monitoring', 'incident_response'],
color: '#06b6d4',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
isBuiltIn: true
}
];
export async function getTemplates(): Promise<SwarmTemplate[]> {
/** @deprecated Use SEED_AGENTS instead */
const SEED_ARCHETYPES = SEED_AGENTS;
export async function getAgentTypes(projectRoot: string = process.cwd()): Promise<AgentType[]> {
const agentDir = path.join(projectRoot, '.beads', 'archetypes');
try {
await fs.mkdir(TEMPLATE_DIR, { recursive: true });
const files = await fs.readdir(TEMPLATE_DIR);
await fs.mkdir(agentDir, { recursive: true });
const files = await fs.readdir(agentDir);
if (files.filter(f => f.endsWith('.json')).length === 0) {
// Seed defaults
for (const agent of SEED_AGENTS) {
await fs.writeFile(path.join(agentDir, `${agent.id}.json`), JSON.stringify(agent, null, 2));
}
return SEED_AGENTS;
}
const agentTypes: AgentType[] = [];
for (const file of files) {
if (!file.endsWith('.json')) continue;
try {
const content = await fs.readFile(path.join(agentDir, file), 'utf-8');
const parsed = JSON.parse(content);
agentTypes.push({
...parsed,
id: file.replace('.json', '')
});
} catch (err) {
console.error(`Failed to parse agent type file: ${file}`, err);
}
}
return agentTypes;
} catch (e) {
console.error('Error in getAgentTypes:', e);
return [];
}
}
/** @deprecated Use getAgentTypes instead */
export const getArchetypes = getAgentTypes;
const SEED_TEMPLATES: SwarmTemplate[] = [
{
id: 'standard-app',
name: 'Standard Application',
description: 'Classic balanced team for routine application development. One Architect for design, two Engineers for implementation.',
team: [{ agentTypeId: 'architect', count: 1 }, { agentTypeId: 'engineer', count: 2 }],
color: '#f59e0b', icon: '📦',
createdAt: '2026-02-21T03:22:04.089Z', updatedAt: '2026-02-25T00:00:00.000Z', isBuiltIn: true
},
{
id: 'feature-dev',
name: 'Feature Development',
description: 'Balanced team for implementing new features. Architect plans, Engineers build, Reviewer ensures quality, Tester verifies behavior.',
team: [{ agentTypeId: 'architect', count: 1 }, { agentTypeId: 'engineer', count: 2 }, { agentTypeId: 'reviewer', count: 1 }, { agentTypeId: 'tester', count: 1 }],
color: '#3b82f6', icon: '✨',
createdAt: '2026-02-25T00:00:00.000Z', updatedAt: '2026-02-25T00:00:00.000Z', isBuiltIn: true
},
{
id: 'bug-fix',
name: 'Bug Fix Squad',
description: 'Focused team for debugging and fixing issues. Investigator finds root cause, Engineer implements fix, Tester verifies resolution.',
team: [{ agentTypeId: 'investigator', count: 1 }, { agentTypeId: 'engineer', count: 1 }, { agentTypeId: 'tester', count: 1 }],
color: '#ef4444', icon: '🐛',
createdAt: '2026-02-25T00:00:00.000Z', updatedAt: '2026-02-25T00:00:00.000Z', isBuiltIn: true
},
{
id: 'code-review',
name: 'Code Review',
description: 'Lightweight team for reviewing and improving existing code. Reviewer analyzes, Engineer makes improvements.',
team: [{ agentTypeId: 'reviewer', count: 1 }, { agentTypeId: 'engineer', count: 1 }],
color: '#f59e0b', icon: '👁️',
createdAt: '2026-02-25T00:00:00.000Z', updatedAt: '2026-02-25T00:00:00.000Z', isBuiltIn: true
},
{
id: 'greenfield',
name: 'Greenfield Project',
description: 'Full team for starting new projects from scratch.',
team: [{ agentTypeId: 'architect', count: 1 }, { agentTypeId: 'engineer', count: 3 }, { agentTypeId: 'tester', count: 1 }, { agentTypeId: 'shipper', count: 1 }],
color: '#10b981', icon: '🌱',
createdAt: '2026-02-25T00:00:00.000Z', updatedAt: '2026-02-25T00:00:00.000Z', isBuiltIn: true
},
{
id: 'investigation',
name: 'Investigation Team',
description: 'Specialized team for research and analysis.',
team: [{ agentTypeId: 'investigator', count: 1 }, { agentTypeId: 'tester', count: 1 }],
color: '#8b5cf6', icon: '🔍',
createdAt: '2026-02-25T00:00:00.000Z', updatedAt: '2026-02-25T00:00:00.000Z', isBuiltIn: true
},
{
id: 'refactor',
name: 'Refactoring Team',
description: 'Team for improving existing code without changing behavior.',
team: [{ agentTypeId: 'architect', count: 1 }, { agentTypeId: 'engineer', count: 2 }, { agentTypeId: 'tester', count: 1 }],
color: '#64748b', icon: '🔧',
createdAt: '2026-02-25T00:00:00.000Z', updatedAt: '2026-02-25T00:00:00.000Z', isBuiltIn: true
},
{
id: 'release',
name: 'Release Team',
description: 'Team focused on safe deployments.',
team: [{ agentTypeId: 'tester', count: 1 }, { agentTypeId: 'reviewer', count: 1 }, { agentTypeId: 'shipper', count: 1 }],
color: '#06b6d4', icon: '🚀',
createdAt: '2026-02-25T00:00:00.000Z', updatedAt: '2026-02-25T00:00:00.000Z', isBuiltIn: true
},
{
id: 'full-squad',
name: 'Full Development Squad',
description: 'Complete team for complex projects requiring all capabilities.',
team: [{ agentTypeId: 'architect', count: 1 }, { agentTypeId: 'engineer', count: 2 }, { agentTypeId: 'reviewer', count: 1 }, { agentTypeId: 'tester', count: 1 }, { agentTypeId: 'investigator', count: 1 }, { agentTypeId: 'shipper', count: 1 }],
color: '#ec4899', icon: '🎯',
createdAt: '2026-02-25T00:00:00.000Z', updatedAt: '2026-02-25T00:00:00.000Z', isBuiltIn: true
}
];
export async function getTemplates(projectRoot: string = process.cwd()): Promise<SwarmTemplate[]> {
const templateDir = path.join(projectRoot, '.beads', 'templates');
try {
await fs.mkdir(templateDir, { recursive: true });
const files = await fs.readdir(templateDir);
if (files.filter(f => f.endsWith('.json')).length === 0) {
for (const tpl of SEED_TEMPLATES) {
await fs.writeFile(path.join(TEMPLATE_DIR, `${tpl.id}.json`), JSON.stringify(tpl, null, 2));
await fs.writeFile(path.join(templateDir, `${tpl.id}.json`), JSON.stringify(tpl, null, 2));
}
return SEED_TEMPLATES;
}
@ -174,8 +296,17 @@ export async function getTemplates(): Promise<SwarmTemplate[]> {
for (const file of files) {
if (!file.endsWith('.json')) continue;
try {
const content = await fs.readFile(path.join(TEMPLATE_DIR, file), 'utf-8');
const content = await fs.readFile(path.join(templateDir, file), 'utf-8');
const parsed = JSON.parse(content);
// Normalize legacy archetypeId → agentTypeId
if (parsed.team && Array.isArray(parsed.team)) {
parsed.team = parsed.team.map((member: any) => ({
agentTypeId: member.agentTypeId || member.archetypeId,
count: member.count,
}));
}
templates.push({
...parsed,
id: file.replace('.json', '')
@ -192,21 +323,35 @@ export async function getTemplates(): Promise<SwarmTemplate[]> {
}
}
export type SaveTemplateInput = Partial<SwarmTemplate> & {
export type SaveTemplateInput = {
id?: string;
name: string;
description: string;
team: { archetypeId: string; count: number }[];
/** Team composition. Accepts both agentTypeId and archetypeId (for backward compat) */
team: { agentTypeId?: string; archetypeId?: string; count: number }[];
protoFormula?: string;
color?: string;
icon?: string;
isBuiltIn?: boolean;
createdAt?: string;
updatedAt?: string;
};
export async function saveTemplate(input: SaveTemplateInput): Promise<SwarmTemplate> {
await fs.mkdir(TEMPLATE_DIR, { recursive: true });
const archetypes = await getArchetypes();
const validArchetypeIds = new Set(archetypes.map(a => a.id));
const agentTypes = await getAgentTypes();
const validAgentTypeIds = new Set(agentTypes.map(a => a.id));
for (const member of input.team) {
if (!validArchetypeIds.has(member.archetypeId)) {
throw new Error(`Invalid archetype ID in team: ${member.archetypeId}`);
// Normalize team: support both agentTypeId and archetypeId
const normalizedTeam = input.team.map(member => ({
agentTypeId: member.agentTypeId || member.archetypeId || '',
count: member.count,
}));
for (const member of normalizedTeam) {
if (!validAgentTypeIds.has(member.agentTypeId)) {
throw new Error(`Invalid agent type ID in team: ${member.agentTypeId}`);
}
}
@ -233,8 +378,10 @@ export async function saveTemplate(input: SaveTemplateInput): Promise<SwarmTempl
id,
name: input.name,
description: input.description,
team: input.team,
team: normalizedTeam,
protoFormula: input.protoFormula,
color: input.color,
icon: input.icon,
createdAt,
updatedAt: now,
isBuiltIn

View file

@ -21,6 +21,7 @@ export interface SocialCard {
agents: AgentInfo[];
lastActivity: Date;
priority: SocialCardPriority;
agentTypeId?: string; // Assigned agent type for spawn button
}
function mapStatus(status: BeadIssue['status']): SocialCardStatus {
@ -63,6 +64,12 @@ function extractAgentName(bead: BeadIssue): string | null {
return null;
}
function extractAgentTypeId(labels: string[] | undefined): string | undefined {
if (!labels) return undefined;
const agentLabel = labels.find(l => l.startsWith('agent:'));
return agentLabel ? agentLabel.replace('agent:', '') : undefined;
}
function extractAgents(bead: BeadIssue): AgentInfo[] {
const agents: AgentInfo[] = [];
if (bead.assignee) {
@ -141,6 +148,7 @@ export function buildSocialCards(beads: BeadIssue[]): SocialCard[] {
agents: extractAgents(bead),
lastActivity: new Date(bead.updated_at),
priority: mapPriority(bead.priority),
agentTypeId: bead.agentTypeId || extractAgentTypeId(bead.labels),
};
});
}

View file

@ -1,4 +1,4 @@
export interface AgentArchetype {
export interface AgentType {
id: string;
name: string;
description: string;
@ -12,11 +12,15 @@ export interface AgentArchetype {
isBuiltIn: boolean;
}
/** @deprecated Use AgentType instead. Kept for backward compatibility. */
export type AgentArchetype = AgentType;
export interface SwarmTemplate {
id: string;
name: string;
description: string;
team: { archetypeId: string; count: number }[];
/** Team composition. Use agentTypeId (archetypeId is deprecated but still supported for backward compat) */
team: { agentTypeId: string; count: number }[];
protoFormula?: string;
/** Color for template display. Defaults to amber if not set. */
color?: string;
@ -26,3 +30,17 @@ export interface SwarmTemplate {
updatedAt: string;
isBuiltIn: boolean;
}
/** @deprecated Internal type for backward compatibility when reading old template files */
export interface LegacySwarmTemplate {
id: string;
name: string;
description: string;
team: { archetypeId: string; count: number }[];
protoFormula?: string;
color?: string;
icon?: string;
createdAt: string;
updatedAt: string;
isBuiltIn: boolean;
}

View file

@ -39,9 +39,9 @@ export interface BeadIssue {
status: BeadStatus;
priority: number;
issue_type: BeadIssueType;
assignee: string | null;
templateId: string | null;
owner: string | null;
assignee: string | null;
templateId: string | null;
owner: string | null;
labels: string[];
dependencies: BeadDependency[];
created_at: string;
@ -52,9 +52,13 @@ export interface BeadIssue {
created_by: string | null;
due_at: string | null;
estimated_minutes: number | null;
external_ref: string | null;
comments_count?: number;
metadata: Record<string, unknown>;
external_ref: string | null;
comments_count?: number;
/** Which agent type should work on this bead */
agentTypeId?: string;
/** Which specific agent instance is assigned (if running) */
agentInstanceId?: string;
metadata: Record<string, unknown>;
}
export interface ParseableBeadIssue extends Partial<BeadIssue> {

View file

@ -0,0 +1,505 @@
import { detectPiRuntimeStrategy } from './pi-runtime-detection';
import { ensureManagedPiSettings } from './bb-pi-bootstrap';
import { embeddedPiDaemon } from './embedded-daemon';
import { getAgentTypes } from './server/beads-fs';
import type { AgentType } from './types-swarm';
import path from 'node:path';
export type WorkerStatus = 'spawning' | 'working' | 'completed' | 'failed';
export interface WorkerSession {
id: string;
projectId: string;
projectRoot: string;
taskId: string;
beadId?: string; // Bead ID the worker is assigned to
status: WorkerStatus;
session: any; // Pi SDK session
createdAt: string;
completedAt: string | null;
result: string | null;
error: string | null;
/** @deprecated Use agentTypeId instead */
archetypeId?: string;
/** Agent type ID (e.g., "engineer", "architect") */
agentTypeId?: string;
/** Unique instance ID (e.g., "engineer-01-abc123") */
agentInstanceId?: string;
/** Display name for UI (e.g., "Engineer 01") */
displayName?: string;
}
/**
* Map capabilities to tool access.
* Full access: coding, implementation, testing
* Read-only: planning, design_docs, review, arch_review, research, all others
*/
export function getToolsForCapabilities(capabilities: string[]): {
allowEdit: boolean;
allowWrite: boolean;
allowBash: boolean;
} {
// Full tool access: agent types that write/implement code
const fullAccessCapabilities = [
'coding', 'implementation', 'testing',
'test_design', 'test_implementation', // tester
'refactoring', 'debugging', // engineer
'ci_cd', 'deployment', // shipper
];
const hasFullAccess = capabilities.some(c => fullAccessCapabilities.includes(c));
if (hasFullAccess) {
return { allowEdit: true, allowWrite: true, allowBash: true };
}
// Read-only: architect, reviewer, investigator
return { allowEdit: false, allowWrite: false, allowBash: false };
}
/**
* Generate a unique agent instance ID.
* Format: {agentTypeId}-{number}-{random}
*/
function generateAgentInstanceId(agentTypeId: string, instanceNumber: number): string {
const suffix = String(instanceNumber).padStart(2, '0');
const random = Math.random().toString(36).slice(2, 8);
return `${agentTypeId}-${suffix}-${random}`;
}
/**
* Get display name for an agent instance.
* Format: "{AgentTypeName} {number}" (e.g., "Engineer 01")
*/
function getAgentDisplayName(agentTypeName: string, instanceNumber: number): string {
const num = String(instanceNumber).padStart(2, '0');
return `${agentTypeName} ${num}`;
}
export interface SpawnWorkerParams {
projectRoot: string;
taskId: string;
taskContext: string;
/** @deprecated Use agentType instead */
archetype?: string;
/** Agent type ID to spawn (e.g., "engineer", "architect") */
agentType?: string;
/** Bead ID for the worker to claim and work on */
beadId?: string;
}
class WorkerSessionManagerImpl {
private workers = new Map<string, WorkerSession>();
private nextWorkerId = 1;
async spawnWorker(params: SpawnWorkerParams): Promise<WorkerSession> {
// Support both old and new param names
const agentTypeId = params.agentType || params.archetype;
const { projectRoot, taskId, taskContext, beadId } = params;
// Generate worker ID
const workerId = `worker-${Date.now()}-${this.nextWorkerId++}`;
// Get project ID for events
const projectId = projectRoot
.replace(/^[A-Za-z]:/, '')
.replaceAll('\\', '/')
.split('/')
.filter(Boolean)
.join('-')
.replace(/[^a-zA-Z0-9-]/g, '-')
.replace(/-+/g, '-')
.toLowerCase() || 'root';
// Load agent type to get name for display
let agentTypeName = agentTypeId || 'Agent';
let agentType: AgentType | undefined;
if (agentTypeId) {
try {
const agentTypes = await getAgentTypes(projectRoot);
agentType = agentTypes.find(a => a.id === agentTypeId);
if (agentType) {
agentTypeName = agentType.name;
}
} catch (error) {
console.warn(`[WorkerSessionManager] Failed to load agent types:`, error);
}
}
// Calculate instance number for this agent type
const existingOfType = [...this.workers.values()]
.filter(w => w.agentTypeId === agentTypeId)
.length;
const instanceNumber = existingOfType + 1;
// Generate instance ID and display name
const agentInstanceId = agentTypeId
? generateAgentInstanceId(agentTypeId, instanceNumber)
: undefined;
const displayName = agentTypeId
? getAgentDisplayName(agentTypeName, instanceNumber)
: undefined;
// Create initial worker record
const worker: WorkerSession = {
id: workerId,
projectId,
projectRoot,
taskId,
beadId,
status: 'spawning',
session: null,
createdAt: new Date().toISOString(),
completedAt: null,
result: null,
error: null,
archetypeId: agentTypeId, // backward compat
agentTypeId,
agentInstanceId,
displayName,
};
this.workers.set(workerId, worker);
// Emit spawning event with agent instance info
embeddedPiDaemon.appendWorkerEvent(projectRoot, workerId, {
kind: 'worker.spawned',
title: displayName ? `${displayName} spawned` : `Worker spawned for ${taskId}`,
detail: `Agent starting. Type: ${agentTypeId || 'default'}`,
status: 'working',
metadata: {
workerId,
agentInstanceId,
agentTypeId,
displayName,
taskId,
},
});
// Spawn worker session asynchronously
this.createWorkerSession(worker, taskContext, agentType, beadId).catch((error) => {
console.error(`[WorkerSessionManager] Failed to create worker session:`, error);
worker.status = 'failed';
worker.error = error instanceof Error ? error.message : String(error);
worker.completedAt = new Date().toISOString();
embeddedPiDaemon.appendEvent(projectRoot, {
kind: 'worker.failed',
title: displayName ? `${displayName} failed` : `Worker ${workerId} failed`,
detail: worker.error,
status: 'failed',
metadata: { workerId, agentInstanceId, taskId },
});
});
return worker;
}
private async createWorkerSession(
worker: WorkerSession,
taskContext: string,
agentType?: AgentType,
beadId?: string
): Promise<void> {
const { projectRoot, taskId, id: workerId, displayName } = worker;
const agentTypeId = worker.agentTypeId;
// Resolve Pi SDK
const resolution = await detectPiRuntimeStrategy();
if (!resolution.sdkPath || resolution.installState === 'bootstrap-required') {
throw new Error('Pi SDK not available. Run bootstrap first.');
}
const managedAgentDir = resolution.agentDir;
await ensureManagedPiSettings(managedAgentDir);
process.env.PI_CODING_AGENT_DIR = managedAgentDir;
// Dynamically load Pi SDK
const { pathToFileURL } = await import('node:url');
const sdk = await import(/* webpackIgnore: true */ pathToFileURL(resolution.sdkPath).href);
const authStorage = new sdk.AuthStorage(path.join(managedAgentDir, 'auth.json'));
const modelRegistry = new sdk.ModelRegistry(authStorage, path.join(managedAgentDir, 'models.json'));
const settingsManager = sdk.SettingsManager.create(projectRoot, managedAgentDir);
// Create unique session directory for worker
const workerSessionDir = path.join(managedAgentDir, 'worker-sessions', workerId);
const sessionManager = sdk.SessionManager.create(workerSessionDir);
const capabilities = agentType?.capabilities ?? [];
const toolAccess = getToolsForCapabilities(capabilities);
// Build worker-specific system prompt with agent type
const systemPrompt = this.buildWorkerPrompt(taskId, taskContext, agentType, beadId, displayName);
// Import worker-safe tools (no spawn tool for workers)
const { createDoltReadTool } = await import('../tui/tools/bb-dolt-read');
const { createMailboxTools } = await import('../tui/tools/bb-mailbox');
const { createPresenceTools } = await import('../tui/tools/bb-presence');
const { createBeadCrudTools } = await import('../tui/tools/bb-bead-crud');
// Build tools based on agent type capabilities
const tools = [sdk.createReadTool(projectRoot)];
if (toolAccess.allowBash) {
tools.push(sdk.createBashTool(projectRoot));
}
if (toolAccess.allowEdit) {
tools.push(sdk.createEditTool(projectRoot));
}
if (toolAccess.allowWrite) {
tools.push(sdk.createWriteTool(projectRoot));
}
const res = await sdk.createAgentSession({
cwd: projectRoot,
agentDir: managedAgentDir,
authStorage,
modelRegistry,
settingsManager,
sessionManager,
systemPrompt,
tools,
hooks: [],
skills: [],
contextFiles: [],
slashCommands: [],
customTools: [
{ tool: createDoltReadTool(projectRoot) },
...createMailboxTools().map((tool) => ({ tool: tool as any })),
...createPresenceTools().map((tool) => ({ tool: tool as any })),
...createBeadCrudTools(projectRoot).map((tool) => ({ tool: tool as any })),
],
});
const session = res.session;
worker.session = session;
worker.status = 'working';
// Subscribe to worker events
session.subscribe((event: any) => {
this.handleWorkerEvent(worker, event);
});
// Emit working event
embeddedPiDaemon.appendEvent(projectRoot, {
kind: 'worker.updated',
title: displayName ? `${displayName} started` : `Worker ${workerId} started`,
detail: `Agent is now executing task ${taskId}`,
status: 'working',
metadata: { workerId, agentInstanceId: worker.agentInstanceId, taskId },
});
// Send the task as initial prompt
try {
await session.prompt(taskContext);
} catch (error) {
console.error(`[WorkerSessionManager] Worker prompt failed:`, error);
worker.status = 'failed';
worker.error = error instanceof Error ? error.message : String(error);
worker.completedAt = new Date().toISOString();
embeddedPiDaemon.appendWorkerEvent(projectRoot, workerId, {
kind: 'worker.failed',
title: displayName ? `${displayName} failed` : `Worker ${workerId} failed`,
detail: worker.error,
status: 'failed',
});
}
}
private buildWorkerPrompt(
taskId: string,
taskContext: string,
agentType?: AgentType,
beadId?: string,
displayName?: string
): string {
const agentSection = agentType
? `## Your Role
${agentType.systemPrompt}
`
: '';
const beadWorkflow = beadId ? `
## IMPORTANT: Bead Workflow
You are working on bead: ${beadId}
Your display name: ${displayName || 'Worker'}
**You MUST follow this workflow:**
1. **Claim the bead** (first thing you do):
\`\`\`
bb_update(id="${beadId}", status="in_progress", assignee="${displayName || 'Worker'}")
\`\`\`
2. **Update progress** (add notes as you work):
\`\`\`
bb_update(id="${beadId}", notes="Found the issue in auth.ts...")
\`\`\`
3. **When blocked**, update status:
\`\`\`
bb_update(id="${beadId}", status="blocked", notes="Waiting for API key from infra team")
\`\`\`
4. **When complete**, close the bead:
\`\`\`
bb_close(id="${beadId}", reason="Fixed by updating auth.ts line 42. Tests passing.")
\`\`\`
**Never skip this workflow.** The bead tracks your work for coordination.
` : '';
return `You are a worker agent for BeadBoard. Your job is to execute a specific task.
Task ID: ${taskId}
${beadId ? `Bead ID: ${beadId}` : ''}
Task Context:
${taskContext}
${agentSection}${beadWorkflow}## Instructions
- Focus ONLY on this specific task
- Report progress using the bb_update tool on your bead
- When complete, close the bead with a clear summary
- If you encounter blockers, set the bead status to "blocked" and explain what is blocking you
- You CANNOT spawn more workers - execute this task yourself
- Be thorough but efficient
- If you need to read project files, use bb_dolt_read
- If you need to send messages to other agents, use bb_mailbox_send`;
}
private handleWorkerEvent(worker: WorkerSession, event: any): void {
const { projectRoot, taskId, id: workerId, displayName } = worker;
// Track completion
if (event.type === 'agent_end') {
const lastMsg = event.messages?.[event.messages.length - 1];
if (lastMsg?.role === 'assistant') {
worker.status = 'completed';
worker.completedAt = new Date().toISOString();
if (lastMsg.stopReason === 'error' && lastMsg.errorMessage) {
worker.status = 'failed';
worker.error = lastMsg.errorMessage;
embeddedPiDaemon.appendWorkerEvent(projectRoot, workerId, {
kind: 'worker.failed',
title: displayName ? `${displayName} failed` : `Worker ${workerId} failed`,
detail: worker.error || 'Unknown error',
status: 'failed',
});
} else {
// Extract result text
const text = lastMsg.content
?.filter((c: any) => c.type === 'text')
.map((c: any) => c.text)
.join('\n') || 'Completed';
worker.result = text.substring(0, 1000);
embeddedPiDaemon.appendWorkerEvent(projectRoot, workerId, {
kind: 'worker.completed',
title: displayName ? `${displayName} completed` : `Worker ${workerId} completed`,
detail: (worker.result || 'Completed').substring(0, 200),
status: 'completed',
});
}
}
}
}
getWorker(workerId: string): WorkerSession | undefined {
return this.workers.get(workerId);
}
listWorkers(projectRoot: string): WorkerSession[] {
return [...this.workers.values()].filter((w) => w.projectRoot === projectRoot);
}
getAllWorkers(): WorkerSession[] {
return [...this.workers.values()];
}
async terminateWorker(workerId: string): Promise<void> {
const worker = this.workers.get(workerId);
if (!worker) return;
if (worker.session && typeof worker.session.stop === 'function') {
try {
await worker.session.stop();
} catch (error) {
console.error(`[WorkerSessionManager] Error stopping worker session:`, error);
}
}
worker.status = 'failed';
worker.error = 'Terminated by user';
worker.completedAt = new Date().toISOString();
embeddedPiDaemon.appendEvent(worker.projectRoot, {
kind: 'worker.failed',
title: worker.displayName ? `${worker.displayName} terminated` : `Worker ${workerId} terminated`,
detail: 'Worker was manually terminated',
status: 'failed',
metadata: { workerId, agentInstanceId: worker.agentInstanceId, taskId: worker.taskId },
});
}
async waitForWorker(workerId: string, timeoutMs = 300000): Promise<string> {
const worker = this.workers.get(workerId);
if (!worker) {
throw new Error(`Worker ${workerId} not found`);
}
const startTime = Date.now();
return new Promise((resolve, reject) => {
const checkInterval = setInterval(() => {
const w = this.workers.get(workerId);
if (!w) {
clearInterval(checkInterval);
reject(new Error(`Worker ${workerId} not found`));
return;
}
if (w.status === 'completed') {
clearInterval(checkInterval);
resolve(w.result || 'Completed with no result');
return;
}
if (w.status === 'failed') {
clearInterval(checkInterval);
reject(new Error(w.error || 'Worker failed'));
return;
}
if (Date.now() - startTime > timeoutMs) {
clearInterval(checkInterval);
reject(new Error('Worker timeout'));
}
}, 1000);
});
}
// For testing
reset(): void {
this.workers.clear();
this.nextWorkerId = 1;
}
}
// Singleton
const globalRegistry = globalThis as typeof globalThis & {
__beadboardWorkerSessionManager?: WorkerSessionManagerImpl;
};
export const workerSessionManager = globalRegistry.__beadboardWorkerSessionManager ?? new WorkerSessionManagerImpl();
if (!globalRegistry.__beadboardWorkerSessionManager) {
globalRegistry.__beadboardWorkerSessionManager = workerSessionManager;
}