diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 10077c91..2cc61fff 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -260,3 +260,10 @@ {"id":"agent-relay-inj1","title":"[Core] Harden message injection with verification and retry","description":"## Summary\nMessage injection is core to Relay's real-time communication. Currently both TmuxWrapper and PtyWrapper inject messages blindly without verification. This bead adds injection confirmation, retry logic, and fallback mechanisms.\n\n## Current State\n\n### TmuxWrapper (interactive sessions)\n- Uses `tmux send-keys` for injection\n- Has idle detection (1.5s wait after output)\n- Has output stability check (pane content stops changing)\n- Has cursor position check (prompt detection)\n- Has bracketed paste to prevent interleaving\n- **Missing**: No verification that message appeared in scrollback\n- **Missing**: No retry on failure\n\n### PtyWrapper (spawned workers)\n- Uses `ptyProcess.write()` for direct injection\n- Has message queue with sequential processing\n- Has 500ms delay between messages\n- **Missing**: No idle detection (just blindly writes)\n- **Missing**: No verification\n- **Missing**: No retry logic\n\n## Implementation Plan\n\n### Phase 1: Injection Verification\n\n**TmuxWrapper changes:**\n```typescript\nprivate async verifyInjection(msg: QueuedMessage): Promise {\n // Wait for injection to settle\n await this.sleep(200);\n \n // Capture pane content\n const paneContent = await this.capturePane();\n \n // Check if our message appears in recent output\n // Look for 'Relay message from {from} [{shortId}]'\n const expectedPattern = `Relay message from ${msg.from} [${msg.messageId.substring(0, 8)}]`;\n return paneContent.includes(expectedPattern);\n}\n```\n\n**PtyWrapper changes:**\n```typescript\nprivate async verifyInjection(msg: QueuedMessage): Promise {\n // Wait for output to include our message\n const startTime = Date.now();\n const timeout = 2000;\n const expectedPattern = `Relay message from ${msg.from} [${msg.messageId.substring(0, 8)}]`;\n \n while (Date.now() - startTime < timeout) {\n if (this.rawBuffer.includes(expectedPattern)) {\n return true;\n }\n await this.sleep(100);\n }\n return false;\n}\n```\n\n### Phase 2: Retry with Backoff\n\n```typescript\nprivate async injectWithRetry(\n msg: QueuedMessage,\n maxRetries: number = 3\n): Promise {\n for (let attempt = 0; attempt < maxRetries; attempt++) {\n // Inject the message\n await this.performInjection(msg);\n \n // Verify it landed\n if (await this.verifyInjection(msg)) {\n return { success: true, attempts: attempt + 1 };\n }\n \n // Log retry attempt\n this.logStderr(`Injection not verified, retry ${attempt + 1}/${maxRetries}`);\n \n // Backoff before retry\n await this.sleep(500 * (attempt + 1));\n }\n \n // All retries failed\n return { success: false, attempts: maxRetries };\n}\n```\n\n### Phase 3: Fallback to Inbox\n\nIf injection fails after retries, write to file inbox as durable fallback:\n\n```typescript\nprivate async injectNextMessage(): Promise {\n const msg = this.messageQueue.shift();\n if (!msg) return;\n \n const result = await this.injectWithRetry(msg);\n \n if (!result.success) {\n // Fallback: write to inbox file\n this.inbox?.addMessage(msg.from, msg.body);\n this.logStderr(`Injection failed after ${result.attempts} attempts, wrote to inbox`);\n \n // Emit event for dashboard to show warning\n this.emit('injection-failed', {\n messageId: msg.messageId,\n from: msg.from,\n fallback: 'inbox'\n });\n }\n}\n```\n\n### Phase 4: PtyWrapper Idle Detection\n\nAdd output stability check to PtyWrapper (currently missing):\n\n```typescript\nprivate async waitForOutputStable(timeoutMs: number = 2000): Promise {\n const pollInterval = 200;\n const requiredStablePolls = 2;\n let lastLength = this.rawBuffer.length;\n let stableCount = 0;\n const startTime = Date.now();\n \n while (Date.now() - startTime < timeoutMs) {\n await this.sleep(pollInterval);\n \n if (this.rawBuffer.length === lastLength) {\n stableCount++;\n if (stableCount >= requiredStablePolls) {\n return true;\n }\n } else {\n stableCount = 0;\n lastLength = this.rawBuffer.length;\n }\n }\n \n return false;\n}\n\nprivate async processMessageQueue(): Promise {\n // ... existing checks ...\n \n // NEW: Wait for output to stabilize before injecting\n const stable = await this.waitForOutputStable();\n if (!stable) {\n // Re-queue message and try later\n this.messageQueue.unshift(msg);\n setTimeout(() => this.processMessageQueue(), 500);\n return;\n }\n \n // Proceed with injection\n // ...\n}\n```\n\n### Phase 5: Health Check Before Injection\n\n**TmuxWrapper:**\n```typescript\nprivate async isAgentAlive(): Promise {\n // Check if tmux session still exists\n try {\n await execAsync(`tmux has-session -t ${this.sessionName}`);\n return true;\n } catch {\n return false;\n }\n}\n```\n\n**PtyWrapper:**\n```typescript\nprivate isAgentAlive(): boolean {\n return this.running && this.ptyProcess !== undefined;\n}\n```\n\n### Phase 6: Dashboard Integration\n\nSurface injection status in dashboard:\n\n1. Add `injection_status` field to message records: `pending`, `delivered`, `failed`\n2. Show visual indicator in message list (green check, yellow pending, red X)\n3. Add alert for failed injections\n4. Show retry count in message details\n\n### Phase 7: Metrics & Observability\n\nAdd metrics for monitoring injection reliability:\n\n```typescript\ninterface InjectionMetrics {\n total_injections: number;\n successful_first_try: number;\n successful_with_retry: number;\n failed_to_inbox: number;\n average_retry_count: number;\n}\n```\n\nExpose via `/metrics` endpoint for Prometheus scraping.\n\n## Files to Modify\n\n1. `src/wrapper/tmux-wrapper.ts` - Add verification, retry, fallback\n2. `src/wrapper/pty-wrapper.ts` - Add idle detection, verification, retry, fallback\n3. `src/wrapper/inbox.ts` - Ensure atomic writes, add message ID tracking\n4. `src/dashboard-server/server.ts` - Add injection status to WebSocket updates\n5. `src/storage/adapter.ts` - Add injection_status field to message schema\n6. `src/dashboard/` - UI for injection status indicators\n\n## Testing Plan\n\n1. Unit tests for verification logic\n2. Integration test: inject during active output, verify retry works\n3. Integration test: inject to dead session, verify fallback to inbox\n4. Load test: rapid message injection, measure success rate\n5. Manual test: kill agent mid-injection, verify graceful handling\n\n## Success Criteria\n\n- Injection success rate > 99% under normal conditions\n- Failed injections always fall back to inbox (no message loss)\n- Dashboard shows clear status for all message deliveries\n- Metrics available for monitoring injection health","status":"open","priority":1,"issue_type":"feature","created_at":"2026-01-02T13:00:00Z","updated_at":"2026-01-02T13:00:00Z","labels":["core","reliability","injection","tmux","node-pty","gastown-learnings"]} {"id":"agent-relay-wrap1","title":"[Arch] Extract BaseWrapper abstract class for TmuxWrapper and PtyWrapper","description":"## Summary\nExtract common functionality between TmuxWrapper and PtyWrapper into an abstract base class to:\n1. Ensure feature parity between the two implementations\n2. Make adding new features easier (add once in base, implement specifics in subclasses)\n3. Prevent divergence over time\n4. Reduce code duplication\n\n## Current State Analysis\n\n### Common Code (should be in base class):\n- `RelayClient` initialization and lifecycle\n- `messageQueue` management (same structure in both)\n- `isInjecting` state flag\n- `sentMessageHashes: Set` for deduplication\n- `processedSpawnCommands` and `processedReleaseCommands` Sets\n- `stripAnsi()` method (nearly identical implementations)\n- `sleep()` helper (identical)\n- `sendRelayCommand()` method (similar pattern)\n- `handleIncomingMessage()` signature and queue push\n- Injection string format: `Relay message from ${from} [${shortId}]${hints}: ${body}`\n- Thread/importance/channel hint building\n- Spawn/release command parsing\n- `relayPrefix` handling\n\n### Diverged Code (TmuxWrapper has, PtyWrapper missing):\n- Storage integration (SQLite for summaries)\n- Activity state tracking (active/idle/disconnected)\n- Summary parsing (`[[SUMMARY]]` blocks)\n- Session end handling (`[[SESSION_END]]` blocks)\n- Sophisticated input clearing (cursor stability check)\n- Bracketed paste for safer injection\n- Continuation line joining for TUI output\n\n### Diverged Code (PtyWrapper has, TmuxWrapper missing):\n- EventEmitter extension (TmuxWrapper should also emit events)\n- `injection-failed` event emission\n- `getInjectionMetrics()` method\n- `pendingMessageCount` getter\n- Auto-accept prompts for Claude --dangerously-skip-permissions\n- Terminal escape sequence handling\n- Log file streaming to disk\n\n## Proposed Interface\n\n```typescript\nabstract class BaseWrapper extends EventEmitter {\n // Common state\n protected config: WrapperConfig;\n protected client: RelayClient;\n protected running = false;\n protected messageQueue: QueuedMessage[] = [];\n protected isInjecting = false;\n protected readyForMessages = false;\n protected sentMessageHashes = new Set();\n protected processedSpawnCommands = new Set();\n protected processedReleaseCommands = new Set();\n protected relayPrefix: string;\n protected lastOutputTime = 0;\n protected injectionMetrics = {...};\n\n // Abstract methods (subclasses must implement)\n abstract start(): Promise;\n abstract stop(): void;\n protected abstract performInjection(message: string): Promise;\n protected abstract verifyInjection(shortId: string, from: string): Promise;\n protected abstract getRawOutput(): string;\n protected abstract isAgentAlive(): boolean;\n\n // Common implementations\n protected stripAnsi(str: string): string;\n protected sleep(ms: number): Promise;\n protected buildInjectionString(msg: QueuedMessage): string;\n protected sendRelayCommand(cmd: ParsedCommand): void;\n protected handleIncomingMessage(...): void;\n protected async processMessageQueue(): Promise;\n protected async injectWithRetry(...): Promise;\n protected async waitForOutputStable(): Promise;\n protected parseSpawnReleaseCommands(content: string): void;\n\n // Common getters\n get isRunning(): boolean;\n get name(): string;\n getInjectionMetrics(): InjectionMetrics;\n get pendingMessageCount(): number;\n}\n```\n\n## Implementation Plan\n\n### Phase 1: Create base class (src/wrapper/base-wrapper.ts)\n1. Create BaseWrapper abstract class with EventEmitter extension\n2. Move common types: QueuedMessage, InjectionResult, InjectionMetrics\n3. Implement common methods: stripAnsi, sleep, buildInjectionString\n4. Implement message queue processing with injection retry\n5. Implement spawn/release parsing\n\n### Phase 2: Refactor PtyWrapper to extend BaseWrapper\n1. Remove duplicated code\n2. Implement abstract methods: performInjection, verifyInjection, getRawOutput\n3. Keep PTY-specific code: node-pty integration, log files, auto-accept\n\n### Phase 3: Refactor TmuxWrapper to extend BaseWrapper\n1. Add EventEmitter extension (via base class)\n2. Remove duplicated code\n3. Implement abstract methods\n4. Keep tmux-specific code: session management, attach, capture-pane\n\n### Phase 4: Port missing features\n1. Add injection metrics to TmuxWrapper (inherit from base)\n2. Add `injection-failed` event to TmuxWrapper\n\n### Phase 5: Add shared tests\n1. Create base-wrapper.test.ts with common behavior tests\n2. Test injection retry logic\n3. Test message queue processing\n\n## Files to Create/Modify\n- `src/wrapper/base-wrapper.ts` (NEW)\n- `src/wrapper/pty-wrapper.ts` (MODIFY - extend base)\n- `src/wrapper/tmux-wrapper.ts` (MODIFY - extend base)\n- `src/wrapper/types.ts` (NEW - shared types)\n- `src/wrapper/base-wrapper.test.ts` (NEW)\n\n## Success Criteria\n- Both wrappers extend BaseWrapper\n- All common code lives in base class\n- Adding injection improvements updates both wrappers\n- Injection metrics available in both wrappers\n- No breaking changes to external API\n- All existing tests pass","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-02T15:00:00Z","updated_at":"2026-01-02T15:00:00Z","labels":["architecture","refactor","wrapper","parity"]} {"id": "agent-relay-cloud1", "title": "[Cloud] Implement durable persistence via PtyWrapper events", "description": "## Summary\n\nPtyWrapper now emits 'summary' and 'session-end' events when agents output [[SUMMARY]] and [[SESSION_END]] blocks. Cloud services should implement persistence handlers for these events using durable storage (PostgreSQL, Redis) instead of SQLite.\n\n## Current State\n\n- TmuxWrapper has SQLite storage integration for summaries and session state\n- PtyWrapper now emits events but has no storage (by design - cloud handles it)\n- Cloud deployments use PtyWrapper exclusively (no tmux)\n\n## Event Interface\n\n```typescript\ninterface SummaryEvent {\n agentName: string;\n summary: ParsedSummary; // currentTask, completedTasks, decisions, context, files\n}\n\ninterface SessionEndEvent {\n agentName: string;\n marker: SessionEndMarker; // summary, completedTasks\n}\n\n// PtyWrapperEvents emits:\n// - 'summary': (event: SummaryEvent) => void\n// - 'session-end': (event: SessionEndEvent) => void\n```\n\n## Implementation Requirements\n\n### Cloud Persistence Service\n\n```typescript\nclass CloudPersistenceService {\n constructor(\n private db: PostgresClient | RedisClient,\n private sessionId: string\n ) {}\n\n bindToPtyWrapper(wrapper: PtyWrapper): void {\n wrapper.on('summary', (event) => this.handleSummary(event));\n wrapper.on('session-end', (event) => this.handleSessionEnd(event));\n }\n\n private async handleSummary(event: SummaryEvent): Promise {\n await this.db.query(`\n INSERT INTO agent_summaries (session_id, agent_name, summary, created_at)\n VALUES ($1, $2, $3, NOW())\n ON CONFLICT (session_id, agent_name) \n DO UPDATE SET summary = $3, updated_at = NOW()\n `, [this.sessionId, event.agentName, JSON.stringify(event.summary)]);\n }\n\n private async handleSessionEnd(event: SessionEndEvent): Promise {\n await this.db.query(`\n UPDATE sessions SET ended_at = NOW(), end_marker = $2 WHERE id = $1\n `, [this.sessionId, JSON.stringify(event.marker)]);\n \n // Clean up resources\n await this.cleanupSession(this.sessionId);\n }\n}\n```\n\n### PostgreSQL Schema\n\n```sql\nCREATE TABLE agent_summaries (\n id SERIAL PRIMARY KEY,\n session_id UUID NOT NULL REFERENCES sessions(id),\n agent_name VARCHAR(255) NOT NULL,\n summary JSONB NOT NULL,\n created_at TIMESTAMPTZ DEFAULT NOW(),\n updated_at TIMESTAMPTZ DEFAULT NOW(),\n UNIQUE(session_id, agent_name)\n);\n\nCREATE TABLE sessions (\n id UUID PRIMARY KEY,\n user_id UUID NOT NULL,\n started_at TIMESTAMPTZ DEFAULT NOW(),\n ended_at TIMESTAMPTZ,\n end_marker JSONB\n);\n\nCREATE INDEX idx_summaries_session ON agent_summaries(session_id);\n```\n\n### Redis Alternative\n\nFor high-frequency summary updates:\n\n```typescript\n// Store latest summary per agent\nawait redis.hset(`session:${sessionId}:summaries`, agentName, JSON.stringify(summary));\n\n// TTL for automatic cleanup\nawait redis.expire(`session:${sessionId}:summaries`, 86400); // 24h\n```\n\n## Files to Create\n\n1. `src/cloud/persistence-service.ts` - Event handler for cloud persistence\n2. `src/cloud/schema.sql` - PostgreSQL schema for cloud deployment\n3. `src/cloud/index.ts` - Cloud service initialization\n\n## Testing\n\n1. Unit test: SummaryEvent emitted when [[SUMMARY]] detected\n2. Unit test: SessionEndEvent emitted when [[SESSION_END]] detected \n3. Unit test: Deduplication works (same summary not emitted twice)\n4. Integration: PostgreSQL persistence handler\n5. Integration: Full session lifecycle with events\n\n## Success Criteria\n\n- Cloud services can persist summaries to PostgreSQL/Redis\n- No SQLite dependency in cloud deployments\n- Event-based architecture allows flexible storage backends\n- Session cleanup triggered by session-end events", "status": "closed", "priority": 2, "issue_type": "feature", "created_at": "2026-01-02T16:00:00Z", "updated_at": "2026-01-02T16:00:00Z", "labels": ["cloud", "persistence", "events", "postgresql", "redis"], "closed_at": "2026-01-02T12:00:00Z", "close_reason": "Implemented: CloudPersistenceService, migrations 0002_agent_sessions.sql, query helpers in drizzle.ts"} +{"id": "agent-relay-gt-detect", "title": "[Cloud] Auto-detect Gastown projects and enable integration features", "description": "## Summary\n\nWhen a linked daemon connects to Agent Relay Cloud, detect if the project uses Gastown and automatically enable Gastown-specific features.\n\n## Detection Strategy\n\nCheck for Gastown indicators when daemon links:\n\n```typescript\ninterface GastownDetection {\n detected: boolean;\n version?: string;\n indicators: {\n gtConfigExists: boolean; // .gastown/ or gastown.toml\n beadsFileExists: boolean; // .beads/beads.jsonl\n formulasExist: boolean; // .gastown/formulas/*.toml\n rigsExist: boolean; // .gastown/rigs/\n };\n}\n\nasync function detectGastown(projectRoot: string): Promise {\n const indicators = {\n gtConfigExists: await exists(path.join(projectRoot, '.gastown')) ||\n await exists(path.join(projectRoot, 'gastown.toml')),\n beadsFileExists: await exists(path.join(projectRoot, '.beads/beads.jsonl')),\n formulasExist: await glob(path.join(projectRoot, '.gastown/formulas/*.toml')).length > 0,\n rigsExist: await exists(path.join(projectRoot, '.gastown/rigs')),\n };\n \n return {\n detected: indicators.gtConfigExists || indicators.beadsFileExists,\n indicators,\n };\n}\n```\n\n## Auto-Enabled Features\n\nWhen Gastown detected, automatically enable:\n\n1. **Beads Sync** - Sync .beads/beads.jsonl to cloud dashboard\n2. **Polecat Status** - Show Gastown worker status in dashboard\n3. **Formula Library** - List available formulas in cloud UI\n4. **Molecule Tracking** - Track active molecule progress\n5. **HOP Resolution** - Enable cross-workspace federation\n\n## Implementation\n\n### Daemon Detection (on link)\n\n```typescript\n// src/cloud/api/daemons.ts\nrouter.post('/link', async (req, res) => {\n const daemon = await linkDaemon(req.body);\n \n // Detect Gastown\n const gastown = await detectGastown(daemon.projectRoot);\n \n if (gastown.detected) {\n await db.update(linkedDaemons)\n .set({ \n gastownEnabled: true,\n gastownConfig: gastown.indicators,\n })\n .where(eq(linkedDaemons.id, daemon.id));\n \n // Initialize Gastown integrations\n await initGastownSync(daemon.id);\n }\n \n res.json({ ...daemon, gastown });\n});\n```\n\n### Schema Addition\n\n```sql\nALTER TABLE linked_daemons ADD COLUMN gastown_enabled BOOLEAN DEFAULT FALSE;\nALTER TABLE linked_daemons ADD COLUMN gastown_config JSONB DEFAULT '{}';\n```\n\n### Dashboard Feature Flags\n\n```typescript\n// Cloud returns feature flags based on detection\ninterface WorkspaceFeatures {\n gastownIntegration: boolean;\n beadsSync: boolean;\n formulaExecution: boolean;\n hopFederation: boolean;\n}\n```\n\n## Files to Create/Modify\n\n1. `src/cloud/integrations/gastown-detect.ts` (NEW)\n2. `src/cloud/api/daemons.ts` (MODIFY)\n3. `src/cloud/db/migrations/0003_gastown_fields.sql` (NEW)\n4. `src/dashboard/` - Feature flag handling\n\n## Success Criteria\n\n- Gastown projects auto-detected on daemon link\n- Feature flags returned to dashboard\n- No manual configuration required\n- Works with partial Gastown setups (just Beads, etc.)", "status": "open", "priority": 1, "issue_type": "feature", "created_at": "2026-01-03T13:30:00Z", "updated_at": "2026-01-03T13:30:00Z", "labels": ["cloud", "gastown", "integration", "auto-detect"]} +{"id": "agent-relay-gt-beads", "title": "[Cloud] Sync Beads issues to cloud dashboard", "description": "## Summary\n\nSync .beads/beads.jsonl from Gastown projects to Agent Relay Cloud for unified task visibility.\n\n## Architecture\n\n```\n┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐\n│ .beads/ │ │ Linked Daemon │ │ Agent Relay │\n│ beads.jsonl │────▶│ File Watcher │────▶│ Cloud DB │\n└─────────────────┘ └─────────────────┘ └─────────────────┘\n │\n ▼\n ┌─────────────────┐\n │ Dashboard UI │\n │ Task Board │\n └─────────────────┘\n```\n\n## Daemon-Side Sync\n\n```typescript\n// src/daemon/beads-sync.ts\nexport class BeadsSyncService {\n private watcher: FSWatcher;\n private lastSync: Map = new Map(); // id -> hash\n \n constructor(\n private beadsPath: string,\n private cloudClient: CloudSyncService\n ) {}\n \n start() {\n // Watch for changes\n this.watcher = fs.watch(this.beadsPath, async () => {\n await this.syncToCloud();\n });\n \n // Initial sync\n this.syncToCloud();\n }\n \n private async syncToCloud() {\n const issues = this.parseBeadsFile();\n const changed = issues.filter(i => {\n const hash = this.hashIssue(i);\n if (this.lastSync.get(i.id) === hash) return false;\n this.lastSync.set(i.id, hash);\n return true;\n });\n \n if (changed.length > 0) {\n await this.cloudClient.syncBeadsIssues(changed);\n }\n }\n \n private parseBeadsFile(): BeadsIssue[] {\n const content = fs.readFileSync(this.beadsPath, 'utf-8');\n return content.split('\\n')\n .filter(line => line.trim())\n .map(line => JSON.parse(line));\n }\n}\n```\n\n## Cloud-Side Storage\n\n```sql\n-- Migration: 0004_beads_sync.sql\nCREATE TABLE beads_issues (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,\n beads_id VARCHAR(255) NOT NULL, -- Original Beads ID (e.g., 'agent-relay-123')\n title TEXT NOT NULL,\n description TEXT,\n status VARCHAR(50) DEFAULT 'open',\n priority INTEGER DEFAULT 3,\n issue_type VARCHAR(50),\n labels JSONB DEFAULT '[]',\n assignee VARCHAR(255),\n blocked_by JSONB DEFAULT '[]', -- Array of beads_ids this is blocked by\n created_at TIMESTAMPTZ DEFAULT NOW(),\n updated_at TIMESTAMPTZ DEFAULT NOW(),\n synced_at TIMESTAMPTZ DEFAULT NOW(),\n UNIQUE(workspace_id, beads_id)\n);\n\nCREATE INDEX idx_beads_workspace ON beads_issues(workspace_id);\nCREATE INDEX idx_beads_status ON beads_issues(status);\nCREATE INDEX idx_beads_assignee ON beads_issues(assignee);\n```\n\n## Cloud API\n\n```typescript\n// POST /api/beads/sync\nrouter.post('/sync', requireDaemonAuth, async (req, res) => {\n const { workspaceId, issues } = req.body;\n \n for (const issue of issues) {\n await db.insert(beadsIssues)\n .values({\n workspaceId,\n beadsId: issue.id,\n title: issue.title,\n description: issue.description,\n status: issue.status,\n priority: issue.priority,\n issueType: issue.issue_type,\n labels: issue.labels,\n assignee: issue.assignee,\n blockedBy: issue.blocked_by,\n syncedAt: new Date(),\n })\n .onConflictDoUpdate({\n target: [beadsIssues.workspaceId, beadsIssues.beadsId],\n set: { ...updates, syncedAt: new Date() },\n });\n }\n \n res.json({ synced: issues.length });\n});\n\n// GET /api/beads?workspaceId=xxx\nrouter.get('/', requireAuth, async (req, res) => {\n const issues = await db.select()\n .from(beadsIssues)\n .where(eq(beadsIssues.workspaceId, req.query.workspaceId))\n .orderBy(beadsIssues.priority, beadsIssues.createdAt);\n \n res.json(issues);\n});\n```\n\n## Dashboard Integration\n\n- New 'Tasks' tab in workspace view\n- Kanban board with Beads issues\n- Filter by status, assignee, priority\n- Click to see full description\n- Link to agent that's working on it\n\n## Bidirectional Sync (Future)\n\nPhase 2: Allow updating Beads from cloud:\n\n```typescript\n// Cloud -> Daemon update\nawait daemon.queueMessage({\n type: 'BEADS_UPDATE',\n payload: {\n id: 'agent-relay-123',\n status: 'in_progress',\n assignee: 'Alice',\n }\n});\n```\n\n## Files to Create\n\n1. `src/daemon/beads-sync.ts` (NEW)\n2. `src/cloud/api/beads.ts` (NEW)\n3. `src/cloud/db/migrations/0004_beads_sync.sql` (NEW)\n4. `src/cloud/db/schema.ts` (MODIFY - add beadsIssues)\n5. `src/dashboard/pages/workspace/tasks.tsx` (NEW)\n\n## Success Criteria\n\n- Beads changes sync to cloud within 5 seconds\n- Dashboard shows all Beads issues\n- Status/priority/assignee visible\n- Dependency graph (blocked_by) visualized", "status": "open", "priority": 1, "issue_type": "feature", "created_at": "2026-01-03T13:30:00Z", "updated_at": "2026-01-03T13:30:00Z", "labels": ["cloud", "gastown", "beads", "sync", "dashboard"], "depends_on": ["agent-relay-gt-detect"]} +{"id": "agent-relay-gt-polecat", "title": "[Cloud] Report Gastown polecat/crew status to dashboard", "description": "## Summary\n\nSurface Gastown agent lifecycle (polecats, crew) in Agent Relay Cloud dashboard.\n\n## Gastown Agent Types\n\n| Type | Description | Lifecycle |\n|------|-------------|----------|\n| Polecat | Ephemeral workers spawned by Witness | Auto-spawn/cleanup |\n| Crew | Persistent user-managed workers | Manual |\n\n## Status Reporting\n\n### Daemon Reports Gastown State\n\n```typescript\ninterface GastownAgentReport {\n rigName: string;\n agents: GastownAgent[];\n molecules: ActiveMolecule[];\n}\n\ninterface GastownAgent {\n name: string;\n type: 'polecat' | 'crew';\n status: 'spawning' | 'working' | 'stuck' | 'done' | 'failed';\n worktree?: string;\n currentMolecule?: string;\n hookContents?: string; // What's on their hook\n startedAt: Date;\n lastActivity: Date;\n}\n\ninterface ActiveMolecule {\n id: string;\n formulaName: string;\n currentStep: number;\n totalSteps: number;\n assignedAgents: string[];\n status: 'active' | 'blocked' | 'complete';\n}\n```\n\n### Daemon Integration\n\n```typescript\n// src/daemon/gastown-reporter.ts\nexport class GastownReporter {\n constructor(\n private gastownPath: string,\n private cloudSync: CloudSyncService\n ) {}\n \n async reportStatus() {\n // Read Gastown state files\n const rigs = await this.discoverRigs();\n const report: GastownAgentReport[] = [];\n \n for (const rig of rigs) {\n const agents = await this.getAgentsForRig(rig);\n const molecules = await this.getActiveMolecules(rig);\n report.push({ rigName: rig, agents, molecules });\n }\n \n await this.cloudSync.reportGastownStatus(report);\n }\n \n private async discoverRigs(): Promise {\n // Look for .gastown/rigs/*/\n const rigsPath = path.join(this.gastownPath, 'rigs');\n if (!await exists(rigsPath)) return [];\n return fs.readdirSync(rigsPath).filter(f => \n fs.statSync(path.join(rigsPath, f)).isDirectory()\n );\n }\n \n private async getAgentsForRig(rig: string): Promise {\n // Parse rig state - polecats from worktrees, crew from config\n // This will need to integrate with Gastown's internal state\n }\n}\n```\n\n## Cloud Storage\n\n```sql\n-- Migration: 0005_gastown_agents.sql\nCREATE TABLE gastown_agents (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,\n daemon_id UUID NOT NULL REFERENCES linked_daemons(id) ON DELETE CASCADE,\n rig_name VARCHAR(255) NOT NULL,\n agent_name VARCHAR(255) NOT NULL,\n agent_type VARCHAR(50) NOT NULL, -- 'polecat' or 'crew'\n status VARCHAR(50) DEFAULT 'unknown',\n worktree_path TEXT,\n current_molecule VARCHAR(255),\n hook_contents TEXT,\n started_at TIMESTAMPTZ,\n last_activity TIMESTAMPTZ,\n reported_at TIMESTAMPTZ DEFAULT NOW(),\n UNIQUE(daemon_id, rig_name, agent_name)\n);\n\nCREATE TABLE gastown_molecules (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,\n daemon_id UUID NOT NULL REFERENCES linked_daemons(id) ON DELETE CASCADE,\n molecule_id VARCHAR(255) NOT NULL,\n formula_name VARCHAR(255) NOT NULL,\n current_step INTEGER DEFAULT 0,\n total_steps INTEGER,\n assigned_agents JSONB DEFAULT '[]',\n status VARCHAR(50) DEFAULT 'active',\n reported_at TIMESTAMPTZ DEFAULT NOW(),\n UNIQUE(daemon_id, molecule_id)\n);\n```\n\n## Dashboard UI\n\n### Agents Tab\n\n```\n┌─────────────────────────────────────────────────────────┐\n│ Gastown Agents │\n├─────────────────────────────────────────────────────────┤\n│ Rig: backend │\n│ ┌─────────────┬──────────┬────────────┬───────────────┐ │\n│ │ Agent │ Type │ Status │ Working On │ │\n│ ├─────────────┼──────────┼────────────┼───────────────┤ │\n│ │ polecat-1 │ 🦨 Polecat│ ● Working │ mol-abc: step 2│ │\n│ │ polecat-2 │ 🦨 Polecat│ ● Working │ mol-abc: step 3│ │\n│ │ Alice │ 👤 Crew │ ○ Idle │ - │ │\n│ └─────────────┴──────────┴────────────┴───────────────┘ │\n│ │\n│ Rig: frontend │\n│ ┌─────────────┬──────────┬────────────┬───────────────┐ │\n│ │ polecat-1 │ 🦨 Polecat│ ⚠ Stuck │ mol-xyz: step 1│ │\n│ └─────────────┴──────────┴────────────┴───────────────┘ │\n└─────────────────────────────────────────────────────────┘\n```\n\n### Molecules Progress\n\n```\n┌─────────────────────────────────────────────────────────┐\n│ Active Molecules │\n├─────────────────────────────────────────────────────────┤\n│ mol-abc (code-review) │\n│ [████████░░░░░░░░] Step 2/5 │\n│ Agents: polecat-1, polecat-2 │\n│ │\n│ mol-xyz (feature-impl) │\n│ [██░░░░░░░░░░░░░░] Step 1/8 ⚠ BLOCKED │\n│ Agents: polecat-1 (stuck) │\n└─────────────────────────────────────────────────────────┘\n```\n\n## Files to Create\n\n1. `src/daemon/gastown-reporter.ts` (NEW)\n2. `src/cloud/api/gastown.ts` (NEW)\n3. `src/cloud/db/migrations/0005_gastown_agents.sql` (NEW)\n4. `src/dashboard/components/GastownAgents.tsx` (NEW)\n5. `src/dashboard/components/MoleculeProgress.tsx` (NEW)\n\n## Success Criteria\n\n- Polecat spawn/death visible in real-time\n- Molecule progress tracked\n- Stuck agents highlighted\n- Works without modifying Gastown", "status": "open", "priority": 2, "issue_type": "feature", "created_at": "2026-01-03T13:30:00Z", "updated_at": "2026-01-03T13:30:00Z", "labels": ["cloud", "gastown", "dashboard", "agents", "monitoring"], "depends_on": ["agent-relay-gt-detect"]} +{"id": "agent-relay-gt-formula", "title": "[Cloud] Execute Gastown formulas from cloud dashboard", "description": "## Summary\n\nAllow users to trigger Gastown formula execution from the Agent Relay Cloud dashboard.\n\n## Formula Discovery\n\n### Daemon Reports Available Formulas\n\n```typescript\ninterface Formula {\n name: string;\n description?: string;\n path: string; // e.g., '.gastown/formulas/code-review.toml'\n steps: FormulaStep[];\n parameters: FormulaParam[];\n}\n\ninterface FormulaStep {\n name: string;\n description?: string;\n dependsOn?: string[];\n}\n\ninterface FormulaParam {\n name: string;\n type: 'string' | 'number' | 'boolean' | 'select';\n required: boolean;\n default?: string;\n options?: string[]; // for select type\n description?: string;\n}\n```\n\n### Daemon Sync\n\n```typescript\n// src/daemon/formula-sync.ts\nexport class FormulaSyncService {\n async syncFormulas() {\n const formulaPath = path.join(this.projectRoot, '.gastown/formulas');\n if (!await exists(formulaPath)) return;\n \n const formulas: Formula[] = [];\n const files = await glob('*.toml', { cwd: formulaPath });\n \n for (const file of files) {\n const content = await fs.readFile(path.join(formulaPath, file), 'utf-8');\n const parsed = toml.parse(content);\n formulas.push(this.parseFormula(file, parsed));\n }\n \n await this.cloudClient.syncFormulas(formulas);\n }\n}\n```\n\n## Cloud API\n\n```typescript\n// GET /api/formulas?workspaceId=xxx\nrouter.get('/', requireAuth, async (req, res) => {\n const formulas = await db.select()\n .from(gastownFormulas)\n .where(eq(gastownFormulas.workspaceId, req.query.workspaceId));\n res.json(formulas);\n});\n\n// POST /api/formulas/execute\nrouter.post('/execute', requireAuth, async (req, res) => {\n const { workspaceId, formulaName, params, targetRig } = req.body;\n \n // Find linked daemon for workspace\n const daemon = await db.select()\n .from(linkedDaemons)\n .where(and(\n eq(linkedDaemons.workspaceId, workspaceId),\n eq(linkedDaemons.status, 'online')\n ))\n .limit(1);\n \n if (!daemon[0]) {\n return res.status(400).json({ error: 'No online daemon for workspace' });\n }\n \n // Queue formula execution command to daemon\n await linkedDaemonQueries.queueMessage(daemon[0].id, {\n type: 'EXECUTE_FORMULA',\n payload: {\n formulaName,\n params,\n targetRig,\n requestedBy: req.session.userId,\n requestedAt: new Date().toISOString(),\n }\n });\n \n // Create execution record\n const execution = await db.insert(formulaExecutions).values({\n workspaceId,\n formulaName,\n params,\n status: 'queued',\n requestedBy: req.session.userId,\n }).returning();\n \n res.json({ executionId: execution[0].id, status: 'queued' });\n});\n```\n\n### Daemon Handler\n\n```typescript\n// src/daemon/gastown-executor.ts\nexport class GastownExecutor {\n async handleFormulaExecution(msg: ExecuteFormulaMessage) {\n const { formulaName, params, targetRig } = msg.payload;\n \n // Shell out to Gastown CLI\n const args = [\n 'pour', // or 'cook' then 'pour'\n formulaName,\n '--rig', targetRig || 'default',\n ...Object.entries(params).flatMap(([k, v]) => [`--${k}`, String(v)]),\n ];\n \n const result = await execAsync(`gt ${args.join(' ')}`);\n \n // Report result back to cloud\n await this.cloudClient.reportFormulaResult({\n executionId: msg.payload.executionId,\n status: result.exitCode === 0 ? 'started' : 'failed',\n moleculeId: this.parseMoleculeId(result.stdout),\n error: result.exitCode !== 0 ? result.stderr : undefined,\n });\n }\n}\n```\n\n## Dashboard UI\n\n### Formula Library\n\n```\n┌─────────────────────────────────────────────────────────┐\n│ Formula Library │\n├─────────────────────────────────────────────────────────┤\n│ ┌─────────────────────────────────────────────────────┐ │\n│ │ 📋 code-review │ │\n│ │ Review code changes and provide feedback │ │\n│ │ Steps: lint → review → suggest │ │\n│ │ [▶ Execute] │ │\n│ └─────────────────────────────────────────────────────┘ │\n│ ┌─────────────────────────────────────────────────────┐ │\n│ │ 🔧 feature-impl │ │\n│ │ Implement a feature from spec to tests │ │\n│ │ Steps: design → implement → test → document │ │\n│ │ [▶ Execute] │ │\n│ └─────────────────────────────────────────────────────┘ │\n└─────────────────────────────────────────────────────────┘\n```\n\n### Execution Modal\n\n```\n┌─────────────────────────────────────────────────────────┐\n│ Execute: code-review │\n├─────────────────────────────────────────────────────────┤\n│ Parameters: │\n│ │\n│ Branch: [feature/auth____________] │\n│ │\n│ Reviewer: [○ Auto-assign ● Select] │\n│ [Alice ▼] │\n│ │\n│ Target Rig: [backend ▼] │\n│ │\n│ [Cancel] [▶ Start Execution] │\n└─────────────────────────────────────────────────────────┘\n```\n\n## Files to Create\n\n1. `src/daemon/formula-sync.ts` (NEW)\n2. `src/daemon/gastown-executor.ts` (NEW)\n3. `src/cloud/api/formulas.ts` (NEW)\n4. `src/cloud/db/migrations/0006_formulas.sql` (NEW)\n5. `src/dashboard/pages/workspace/formulas.tsx` (NEW)\n6. `src/dashboard/components/FormulaExecuteModal.tsx` (NEW)\n\n## Success Criteria\n\n- Formulas discovered and displayed in dashboard\n- Parameters rendered as form fields\n- Execution triggered remotely\n- Progress visible via molecule tracking\n- Error handling for offline daemons", "status": "open", "priority": 2, "issue_type": "feature", "created_at": "2026-01-03T13:30:00Z", "updated_at": "2026-01-03T13:30:00Z", "labels": ["cloud", "gastown", "formulas", "dashboard", "remote-execution"], "depends_on": ["agent-relay-gt-detect", "agent-relay-gt-polecat"]} +{"id": "agent-relay-gt-hop", "title": "[Cloud] HOP protocol federation via cloud routing", "description": "## Summary\n\nImplement Gastown's HOP (Hierarchical Object Protocol) for cross-workspace federation, using Agent Relay Cloud as the routing backbone.\n\n## HOP URI Format\n\n```\nhop://entity/chain/rig/issue-id\n\nExamples:\nhop://acme-corp/main/backend/issue-42\nhop://personal/side-project/default/feature-1\nhop://team-alpha/monorepo/frontend/bug-123\n```\n\n## Architecture\n\n```\n┌─────────────────────────────────────────────────────────────────────┐\n│ Agent Relay Cloud │\n│ │\n│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │\n│ │ HOP │ │ Entity │ │ Cross-Org │ │\n│ │ Resolver │───▶│ Registry │───▶│ Router │ │\n│ └─────────────┘ └─────────────┘ └─────────────┘ │\n│ │ │ │\n└─────────┼──────────────────────────────────────┼─────────────────────┘\n │ │\n ▼ ▼\n ┌─────────────┐ ┌─────────────┐\n │ Workspace A │ │ Workspace B │\n │ (acme-corp) │ │ (team-alpha)│\n └─────────────┘ └─────────────┘\n```\n\n## Entity Registry\n\n```sql\n-- Migration: 0007_hop_federation.sql\nCREATE TABLE hop_entities (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n entity_name VARCHAR(255) NOT NULL UNIQUE, -- e.g., 'acme-corp'\n owner_user_id UUID REFERENCES users(id),\n owner_org_id UUID, -- Future: organizations table\n public BOOLEAN DEFAULT FALSE,\n created_at TIMESTAMPTZ DEFAULT NOW()\n);\n\nCREATE TABLE hop_chains (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n entity_id UUID NOT NULL REFERENCES hop_entities(id) ON DELETE CASCADE,\n chain_name VARCHAR(255) NOT NULL, -- e.g., 'main', 'side-project'\n workspace_id UUID REFERENCES workspaces(id),\n created_at TIMESTAMPTZ DEFAULT NOW(),\n UNIQUE(entity_id, chain_name)\n);\n\n-- Access control for cross-org queries\nCREATE TABLE hop_permissions (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n entity_id UUID NOT NULL REFERENCES hop_entities(id) ON DELETE CASCADE,\n grantee_entity_id UUID REFERENCES hop_entities(id),\n permission VARCHAR(50) NOT NULL, -- 'read', 'write', 'execute'\n created_at TIMESTAMPTZ DEFAULT NOW()\n);\n```\n\n## HOP Resolver\n\n```typescript\n// src/cloud/services/hop-resolver.ts\nexport class HopResolver {\n async resolve(hopUri: string): Promise {\n const parsed = this.parseHopUri(hopUri);\n if (!parsed) return null;\n \n // Find entity\n const entity = await db.select()\n .from(hopEntities)\n .where(eq(hopEntities.entityName, parsed.entity))\n .limit(1);\n \n if (!entity[0]) return null;\n \n // Find chain -> workspace mapping\n const chain = await db.select()\n .from(hopChains)\n .where(and(\n eq(hopChains.entityId, entity[0].id),\n eq(hopChains.chainName, parsed.chain)\n ))\n .limit(1);\n \n if (!chain[0]?.workspaceId) return null;\n \n return {\n workspaceId: chain[0].workspaceId,\n rigName: parsed.rig,\n issueId: parsed.issueId,\n entityName: parsed.entity,\n chainName: parsed.chain,\n };\n }\n \n private parseHopUri(uri: string): ParsedHop | null {\n const match = uri.match(/^hop:\\/\\/([^\\/]+)\\/([^\\/]+)\\/([^\\/]+)\\/(.+)$/);\n if (!match) return null;\n return {\n entity: match[1],\n chain: match[2],\n rig: match[3],\n issueId: match[4],\n };\n }\n}\n```\n\n## Cross-Workspace Messaging\n\n```typescript\n// Agent sends message with HOP URI\n->relay:hop://acme-corp/main/backend/issue-42 <<<\nHey team, this is related to our auth work.>>>\n\n// Cloud resolves and routes\nconst target = await hopResolver.resolve('hop://acme-corp/main/backend/issue-42');\nif (target) {\n // Check permissions\n const allowed = await checkHopPermission(senderId, target.workspaceId, 'write');\n if (allowed) {\n await routeToWorkspace(target.workspaceId, {\n ...message,\n hopContext: target,\n });\n }\n}\n```\n\n## Entity Setup Flow\n\n```typescript\n// POST /api/hop/entities\nrouter.post('/entities', requireAuth, async (req, res) => {\n const { entityName } = req.body;\n \n // Validate entity name (alphanumeric, hyphens)\n if (!/^[a-z0-9-]+$/.test(entityName)) {\n return res.status(400).json({ error: 'Invalid entity name' });\n }\n \n // Check availability\n const existing = await db.select().from(hopEntities)\n .where(eq(hopEntities.entityName, entityName)).limit(1);\n if (existing[0]) {\n return res.status(409).json({ error: 'Entity name taken' });\n }\n \n // Create entity\n const entity = await db.insert(hopEntities).values({\n entityName,\n ownerUserId: req.session.userId,\n }).returning();\n \n res.json(entity[0]);\n});\n\n// POST /api/hop/chains\nrouter.post('/chains', requireAuth, async (req, res) => {\n const { entityId, chainName, workspaceId } = req.body;\n \n // Verify ownership\n const entity = await db.select().from(hopEntities)\n .where(and(\n eq(hopEntities.id, entityId),\n eq(hopEntities.ownerUserId, req.session.userId)\n )).limit(1);\n \n if (!entity[0]) {\n return res.status(403).json({ error: 'Not entity owner' });\n }\n \n const chain = await db.insert(hopChains).values({\n entityId,\n chainName,\n workspaceId,\n }).returning();\n \n res.json(chain[0]);\n});\n```\n\n## Dashboard UI\n\n### Federation Settings\n\n```\n┌─────────────────────────────────────────────────────────┐\n│ Federation Settings │\n├─────────────────────────────────────────────────────────┤\n│ Your Entity: acme-corp │\n│ │\n│ Chains: │\n│ ┌─────────────┬─────────────────────┬────────────────┐ │\n│ │ Chain │ Workspace │ HOP URI │ │\n│ ├─────────────┼─────────────────────┼────────────────┤ │\n│ │ main │ Production Backend │ hop://acme-... │ │\n│ │ staging │ Staging Environment │ hop://acme-... │ │\n│ └─────────────┴─────────────────────┴────────────────┘ │\n│ [+ Add Chain] │\n│ │\n│ Permissions: │\n│ ┌─────────────────────┬────────────────────┬─────────┐ │\n│ │ Entity │ Permission │ │ │\n│ ├─────────────────────┼────────────────────┼─────────┤ │\n│ │ partner-corp │ read │ [Revoke]│ │\n│ │ team-alpha │ read, write │ [Revoke]│ │\n│ └─────────────────────┴────────────────────┴─────────┘ │\n│ [+ Grant Permission] │\n└─────────────────────────────────────────────────────────┘\n```\n\n## Files to Create\n\n1. `src/cloud/services/hop-resolver.ts` (NEW)\n2. `src/cloud/api/hop.ts` (NEW)\n3. `src/cloud/db/migrations/0007_hop_federation.sql` (NEW)\n4. `src/dashboard/pages/settings/federation.tsx` (NEW)\n5. `src/daemon/hop-handler.ts` (NEW) - Handle incoming HOP messages\n\n## Success Criteria\n\n- Entities can be registered and claimed\n- Chains map to workspaces\n- HOP URIs resolve across organizations\n- Permission system controls access\n- Messages route to correct workspace\n- Works with Gastown's existing HOP references", "status": "open", "priority": 3, "issue_type": "feature", "created_at": "2026-01-03T13:30:00Z", "updated_at": "2026-01-03T13:30:00Z", "labels": ["cloud", "gastown", "federation", "hop", "cross-org"], "depends_on": ["agent-relay-gt-detect"]} +{"id": "agent-relay-gt-internal-beads", "title": "[Cloud] Internal Beads for all projects - unified task tracking", "description": "## Summary\n\nAgent Relay maintains its own internal Beads database (`.relay/beads.jsonl`) for ANY project, regardless of whether it uses Gastown. This gives all users task tracking, agent work visibility, and cloud sync.\n\n## Architecture\n\n```\n┌─────────────────────────────────────────────────────────────────────┐\n│ Project │\n├─────────────────────────────────────────────────────────────────────┤\n│ │\n│ ┌───────────────────┐ ┌───────────────────┐ │\n│ │ .beads/ │ │ .relay/ │ │\n│ │ beads.jsonl │ │ beads.jsonl │ ◀── NEW │\n│ │ (Gastown native) │ │ (Agent Relay) │ │\n│ └─────────┬─────────┘ └─────────┬─────────┘ │\n│ │ │ │\n│ └──────────┬──────────────────┘ │\n│ │ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ Beads Sync │ │\n│ │ Service │ │\n│ └────────┬────────┘ │\n│ │ │\n└───────────────────────┼──────────────────────────────────────────────┘\n │\n ▼\n ┌─────────────────┐\n │ Agent Relay │\n │ Cloud │\n └─────────────────┘\n```\n\n## Beads Source Priority\n\n```typescript\nfunction getBeadsPath(projectRoot: string): { path: string; source: 'gastown' | 'relay' } {\n // 1. Prefer Gastown's native Beads if project uses it\n const gastownBeads = path.join(projectRoot, '.beads/beads.jsonl');\n if (fs.existsSync(gastownBeads)) {\n return { path: gastownBeads, source: 'gastown' };\n }\n \n // 2. Fall back to Agent Relay's internal Beads\n const relayBeads = path.join(projectRoot, '.relay/beads.jsonl');\n return { path: relayBeads, source: 'relay' };\n}\n```\n\n## Internal Beads Creation\n\n### Auto-create on daemon start\n\n```typescript\n// src/daemon/internal-beads.ts\nexport class InternalBeadsService {\n private beadsPath: string;\n \n constructor(private projectRoot: string) {\n this.beadsPath = path.join(projectRoot, '.relay/beads.jsonl');\n }\n \n async initialize() {\n // Ensure .relay directory exists\n await fs.mkdir(path.join(this.projectRoot, '.relay'), { recursive: true });\n \n // Create beads file if doesn't exist\n if (!await exists(this.beadsPath)) {\n await fs.writeFile(this.beadsPath, '');\n }\n }\n \n async createIssue(issue: BeadsIssue): Promise {\n const id = `relay-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;\n const record = {\n id,\n ...issue,\n created_at: new Date().toISOString(),\n updated_at: new Date().toISOString(),\n };\n \n await fs.appendFile(this.beadsPath, JSON.stringify(record) + '\\n');\n return id;\n }\n \n async updateIssue(id: string, updates: Partial) {\n const issues = await this.readAll();\n const updated = issues.map(i => \n i.id === id \n ? { ...i, ...updates, updated_at: new Date().toISOString() }\n : i\n );\n await this.writeAll(updated);\n }\n}\n```\n\n## Agent Integration\n\nAgents can create/update tasks via relay messages:\n\n```\n->relay:daemon <<<\nTASK_CREATE: {\n \"title\": \"Implement auth module\",\n \"priority\": 1,\n \"assignee\": \"self\"\n}>>>\n\n->relay:daemon <<<\nTASK_UPDATE: {\n \"id\": \"relay-1704000000-abc123\",\n \"status\": \"in_progress\"\n}>>>\n\n->relay:daemon <<<\nTASK_CLOSE: {\n \"id\": \"relay-1704000000-abc123\",\n \"reason\": \"Completed auth module implementation\"\n}>>>\n```\n\n### Daemon Handler\n\n```typescript\n// Parse TASK_* commands from agent output\nif (content.startsWith('TASK_CREATE:')) {\n const payload = JSON.parse(content.slice('TASK_CREATE:'.length));\n const id = await internalBeads.createIssue({\n title: payload.title,\n priority: payload.priority || 3,\n assignee: payload.assignee === 'self' ? agentName : payload.assignee,\n status: 'open',\n issue_type: 'task',\n labels: ['agent-created'],\n });\n // Confirm back to agent\n await injectMessage(agentName, `Task created: ${id}`);\n}\n```\n\n## Auto-Task from Summary\n\nWhen agents emit `[[SUMMARY]]` blocks, auto-create/update tasks:\n\n```typescript\nwrapper.on('summary', async (event) => {\n const { currentTask, completedTasks } = event.summary;\n \n // Create task for current work if not exists\n if (currentTask) {\n const existing = await internalBeads.findByTitle(currentTask);\n if (!existing) {\n await internalBeads.createIssue({\n title: currentTask,\n assignee: event.agentName,\n status: 'in_progress',\n labels: ['auto-tracked'],\n });\n }\n }\n \n // Mark completed tasks as done\n for (const task of completedTasks || []) {\n const existing = await internalBeads.findByTitle(task);\n if (existing && existing.status !== 'closed') {\n await internalBeads.updateIssue(existing.id, { \n status: 'closed',\n closed_at: new Date().toISOString(),\n });\n }\n }\n});\n```\n\n## Cloud Sync\n\nSame sync mechanism as Gastown Beads, but from `.relay/beads.jsonl`:\n\n```typescript\n// BeadsSyncService handles both sources\nexport class BeadsSyncService {\n constructor(projectRoot: string, cloudClient: CloudSyncService) {\n const { path, source } = getBeadsPath(projectRoot);\n this.beadsPath = path;\n this.source = source;\n }\n \n // Sync includes source tag\n async syncToCloud() {\n const issues = await this.parseBeadsFile();\n await this.cloudClient.syncBeadsIssues(issues.map(i => ({\n ...i,\n source: this.source, // 'gastown' or 'relay'\n })));\n }\n}\n```\n\n## Dashboard View\n\nCloud dashboard shows unified task board:\n\n```\n┌─────────────────────────────────────────────────────────────────────┐\n│ Tasks [Gastown ▼] [All]│\n├─────────────────────────────────────────────────────────────────────┤\n│ ┌───────────────────┐ ┌───────────────────┐ ┌───────────────────┐ │\n│ │ Open (3) │ │ In Progress (2) │ │ Done (5) │ │\n│ ├───────────────────┤ ├───────────────────┤ ├───────────────────┤ │\n│ │ ┌───────────────┐ │ │ ┌───────────────┐ │ │ ┌───────────────┐ │ │\n│ │ │ Add tests │ │ │ │ Auth module │ │ │ │ Setup DB │ │ │\n│ │ │ 🔷 Gastown │ │ │ │ 🟢 Relay │ │ │ │ 🟢 Relay │ │ │\n│ │ │ P2 · Alice │ │ │ │ P1 · Bob │ │ │ │ P1 · Alice │ │ │\n│ │ └───────────────┘ │ │ └───────────────┘ │ │ └───────────────┘ │ │\n│ │ ┌───────────────┐ │ │ ┌───────────────┐ │ │ ┌───────────────┐ │ │\n│ │ │ Fix bug #42 │ │ │ │ API endpoint │ │ │ │ Write docs │ │ │\n│ │ │ 🔷 Gastown │ │ │ │ 🟢 Relay │ │ │ │ 🔷 Gastown │ │ │\n│ │ └───────────────┘ │ │ └───────────────┘ │ │ └───────────────┘ │ │\n│ └───────────────────┘ └───────────────────┘ └───────────────────┘ │\n└─────────────────────────────────────────────────────────────────────┘\n\n🔷 = From Gastown .beads/ 🟢 = From Relay internal\n```\n\n## .gitignore Handling\n\nAdd to project's .gitignore if not tracked:\n\n```typescript\nasync function ensureGitignore(projectRoot: string) {\n const gitignorePath = path.join(projectRoot, '.gitignore');\n let content = '';\n \n if (await exists(gitignorePath)) {\n content = await fs.readFile(gitignorePath, 'utf-8');\n }\n \n if (!content.includes('.relay/')) {\n await fs.appendFile(gitignorePath, '\\n# Agent Relay internal data\\n.relay/\\n');\n }\n}\n```\n\n## Benefits\n\n1. **Zero Setup** - Task tracking works immediately for any project\n2. **Agent Visibility** - See what agents are working on in dashboard\n3. **Auto-Tracking** - Tasks created from [[SUMMARY]] blocks\n4. **Unified View** - Gastown + Relay tasks in one board\n5. **Cloud Sync** - Progress visible across machines\n6. **Graceful Upgrade** - If user adopts Gastown later, switch to native Beads\n\n## Files to Create\n\n1. `src/daemon/internal-beads.ts` (NEW)\n2. `src/daemon/beads-commands.ts` (NEW) - Parse TASK_* commands\n3. `src/daemon/beads-sync.ts` (MODIFY) - Handle both sources\n4. `src/cloud/db/schema.ts` (MODIFY) - Add source field\n\n## Success Criteria\n\n- `.relay/beads.jsonl` created on daemon start\n- Agents can create tasks via TASK_CREATE\n- [[SUMMARY]] blocks auto-create tasks\n- Cloud syncs both Gastown and Relay Beads\n- Dashboard shows unified view with source indicator", "status": "open", "priority": 1, "issue_type": "feature", "created_at": "2026-01-03T14:00:00Z", "updated_at": "2026-01-03T14:00:00Z", "labels": ["cloud", "beads", "internal", "zero-config", "auto-tracking"]} +{"id": "agent-relay-gt-detect-v2", "title": "[Cloud] Refined detection: Beads standalone vs Gastown vs neither", "description": "## Summary\n\nUpdate detection logic to handle three scenarios:\n1. **Gastown project** - Full Gastown with formulas, rigs, polecats\n2. **Beads-only project** - Uses Beads for task tracking but not Gastown\n3. **Neither** - Use internal `.relay/beads.jsonl`\n\n## Detection Matrix\n\n| Indicator | Gastown | Beads-only | Neither |\n|-----------|---------|------------|--------|\n| `.gastown/` or `gastown.toml` | ✅ | ❌ | ❌ |\n| `.beads/beads.jsonl` | ✅ | ✅ | ❌ |\n| `.gastown/formulas/` | ✅ | ❌ | ❌ |\n| `.gastown/rigs/` | ✅ | ❌ | ❌ |\n\n## Updated Detection\n\n```typescript\ninterface ProjectCapabilities {\n // Task tracking source\n beads: {\n enabled: true;\n source: 'gastown' | 'standalone' | 'relay-internal';\n path: string;\n };\n \n // Gastown-specific features (only if full Gastown)\n gastown: {\n detected: boolean;\n formulas: boolean; // Can execute formulas\n polecats: boolean; // Has rig/polecat infrastructure\n federation: boolean; // HOP protocol available\n };\n}\n\nasync function detectCapabilities(projectRoot: string): Promise {\n const hasGastownConfig = await exists(path.join(projectRoot, '.gastown')) ||\n await exists(path.join(projectRoot, 'gastown.toml'));\n const hasBeadsFile = await exists(path.join(projectRoot, '.beads/beads.jsonl'));\n const hasFormulas = (await glob(path.join(projectRoot, '.gastown/formulas/*.toml'))).length > 0;\n const hasRigs = await exists(path.join(projectRoot, '.gastown/rigs'));\n \n // Determine Beads source\n let beadsSource: 'gastown' | 'standalone' | 'relay-internal';\n let beadsPath: string;\n \n if (hasBeadsFile) {\n beadsPath = path.join(projectRoot, '.beads/beads.jsonl');\n beadsSource = hasGastownConfig ? 'gastown' : 'standalone';\n } else {\n beadsPath = path.join(projectRoot, '.relay/beads.jsonl');\n beadsSource = 'relay-internal';\n }\n \n return {\n beads: {\n enabled: true, // Always enabled\n source: beadsSource,\n path: beadsPath,\n },\n gastown: {\n detected: hasGastownConfig,\n formulas: hasFormulas,\n polecats: hasRigs,\n federation: hasGastownConfig, // HOP requires Gastown\n },\n };\n}\n```\n\n## Feature Enablement\n\n| Feature | Gastown | Beads-only | Neither |\n|---------|---------|------------|--------|\n| Task sync to cloud | ✅ `.beads/` | ✅ `.beads/` | ✅ `.relay/` |\n| Dashboard task board | ✅ | ✅ | ✅ |\n| Agent TASK_* commands | ✅ | ✅ | ✅ |\n| Auto-track from [[SUMMARY]] | ✅ | ✅ | ✅ |\n| Formula execution | ✅ | ❌ | ❌ |\n| Polecat monitoring | ✅ | ❌ | ❌ |\n| HOP federation | ✅ | ❌ | ❌ |\n| Molecule tracking | ✅ | ❌ | ❌ |\n\n## Dashboard UI Adaptation\n\n```typescript\n// Show different UI based on capabilities\nfunction WorkspaceDashboard({ capabilities }: Props) {\n return (\n <>\n {/* Always show - task board */}\n \n \n {/* Only for Gastown projects */}\n {capabilities.gastown.formulas && (\n \n )}\n \n {capabilities.gastown.polecats && (\n \n )}\n \n {capabilities.gastown.federation && (\n \n )}\n \n );\n}\n```\n\n## Source Badges in UI\n\n```\n┌─────────────────────────────────────────────────────────┐\n│ Tasks │\n├─────────────────────────────────────────────────────────┤\n│ Source: 🔷 Gastown Beads | 📋 Standalone Beads | 🟢 Relay│\n│ │\n│ ┌─────────────────────────────────────────────────────┐ │\n│ │ Implement auth module P1 · Alice │ │\n│ │ [in_progress] · Updated 5m ago │ │\n│ └─────────────────────────────────────────────────────┘ │\n└─────────────────────────────────────────────────────────┘\n```\n\n## Beads CLI Compatibility\n\nFor standalone Beads users, respect `bd` CLI:\n\n```typescript\n// When source is 'standalone', use bd CLI if available\nif (capabilities.beads.source === 'standalone') {\n // Check if bd is installed\n const bdAvailable = await commandExists('bd');\n \n if (bdAvailable) {\n // Use bd for operations to maintain compatibility\n await exec('bd create --title \"...\" --priority 1');\n } else {\n // Direct file manipulation\n await appendToBeadsFile(issue);\n }\n}\n```\n\n## Migration Path\n\n```\nNeither → Standalone Beads:\n User runs: bd init\n Relay detects .beads/, switches source\n\nStandalone Beads → Gastown:\n User installs Gastown, creates .gastown/\n Relay detects, enables full feature set\n\nRelay Internal → Standalone:\n Relay offers: \"Export to Beads?\"\n Copies .relay/beads.jsonl → .beads/beads.jsonl\n```\n\n## Files to Modify\n\n1. `src/daemon/capabilities.ts` (NEW) - Detection logic\n2. `src/cloud/api/daemons.ts` (MODIFY) - Store capabilities\n3. `src/cloud/db/schema.ts` (MODIFY) - capabilities JSONB field\n4. `src/dashboard/` - Conditional UI rendering\n\n## Supersedes\n\nThis bead supersedes `agent-relay-gt-detect` with refined detection logic.\n\n## Success Criteria\n\n- Standalone Beads projects get task sync without Gastown features\n- Gastown projects get full feature set\n- Neither projects get internal Beads\n- Dashboard adapts UI based on capabilities\n- bd CLI compatibility for standalone Beads", "status": "open", "priority": 1, "issue_type": "feature", "created_at": "2026-01-03T14:15:00Z", "updated_at": "2026-01-03T14:15:00Z", "labels": ["cloud", "beads", "gastown", "detection", "capabilities"], "supersedes": ["agent-relay-gt-detect"]}