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:
parent
643fa299dd
commit
d335e5bf71
98 changed files with 17851 additions and 944 deletions
72
src/lib/agent-instance.ts
Normal file
72
src/lib/agent-instance.ts
Normal 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],
|
||||
};
|
||||
}
|
||||
93
src/lib/agent-persistence.ts
Normal file
93
src/lib/agent-persistence.ts
Normal 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
|
||||
}
|
||||
}
|
||||
41
src/lib/agent-workspace.ts
Normal file
41
src/lib/agent-workspace.ts
Normal 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
219
src/lib/bb-daemon.ts
Normal 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
194
src/lib/bb-pi-bootstrap.ts
Normal 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'],
|
||||
};
|
||||
}
|
||||
|
|
@ -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
156
src/lib/embedded-daemon.ts
Normal 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
238
src/lib/embedded-runtime.ts
Normal 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,
|
||||
}),
|
||||
];
|
||||
}
|
||||
159
src/lib/orchestrator-chat.ts
Normal file
159
src/lib/orchestrator-chat.ts
Normal 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;
|
||||
}
|
||||
276
src/lib/pi-daemon-adapter.ts
Normal file
276
src/lib/pi-daemon-adapter.ts
Normal 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();
|
||||
}
|
||||
103
src/lib/pi-runtime-detection.ts
Normal file
103
src/lib/pi-runtime-detection.ts
Normal 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.',
|
||||
};
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
505
src/lib/worker-session-manager.ts
Normal file
505
src/lib/worker-session-manager.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue