diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 93ada2cbb..f085082a4 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -6,23 +6,24 @@ {"id":"agent-relay-1le","title":"Agent name truncated in tmux pane/status bar","description":"Agent name gets cut off in tmux display. May need to adjust tmux status bar width or session naming convention. Check: tmux status-left-length, session name format (relay-{name}-{pid}).","status":"closed","priority":3,"issue_type":"bug","created_at":"2025-12-20T22:11:15.795453+01:00","updated_at":"2025-12-22T14:55:02.472816+01:00","closed_at":"2025-12-22T14:55:02.472818+01:00"} {"id":"agent-relay-1t7","title":"Project name display in bridge header","description":"Show selected project name in the bridge interface header for clarity","status":"closed","priority":2,"issue_type":"feature","created_at":"2025-12-24T11:47:39.531513+01:00","updated_at":"2025-12-24T11:59:16.98688+01:00","closed_at":"2025-12-24T11:59:16.98688+01:00"} {"id":"agent-relay-290","title":"Interface parity: bridge vs dashboard features","description":"Audit and synchronize features between bridge and dashboard interfaces to ensure feature parity","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-24T11:47:58.911802+01:00","updated_at":"2025-12-24T12:01:06.6157+01:00","closed_at":"2025-12-24T12:01:06.6157+01:00"} -{"id":"agent-relay-291","title":"Add auto-update notification for new versions","description":"","status":"closed","priority":2,"issue_type":"feature","created_at":"2025-12-28T20:47:26.14765-05:00","updated_at":"2025-12-28T20:55:46.186959-05:00","closed_at":"2025-12-28T20:55:46.186959-05:00"} +{"id":"agent-relay-291","title":"Add auto-update notification for new versions","status":"closed","priority":2,"issue_type":"feature","created_at":"2025-12-28T20:47:26.14765-05:00","updated_at":"2025-12-28T20:55:46.186959-05:00","closed_at":"2025-12-28T20:55:46.186959-05:00"} {"id":"agent-relay-2lw","title":"Add agent metadata tracking","description":"Track program, model, task description in agents.json. Better agent discovery.","status":"closed","priority":2,"issue_type":"feature","assignee":"Implementer","created_at":"2025-12-20T21:36:19.741328+01:00","updated_at":"2025-12-22T17:17:25.919228+01:00","closed_at":"2025-12-22T17:17:25.919228+01:00"} -{"id":"agent-relay-2sn","title":"Competitive Analysis: mcp_agent_mail vs agent-relay","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-20T21:34:44.49366+01:00","updated_at":"2025-12-20T21:36:26.362391+01:00","closed_at":"2025-12-20T21:36:26.362391+01:00"} +{"id":"agent-relay-2sn","title":"Competitive Analysis: mcp_agent_mail vs agent-relay","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-20T21:34:44.49366+01:00","updated_at":"2025-12-20T21:36:26.362391+01:00","closed_at":"2025-12-20T21:36:26.362391+01:00"} {"id":"agent-relay-2uf","title":"Add message threading support","description":"@relay:Bob [thread:feature-123] pattern. Group related messages for better context.","notes":"Current state: thread parsing + protocol/storage support implemented (ParsedCommand.thread, SendPayload.thread, DB messages.thread + filter). Remaining: wire cmd.thread through tmux-wrapper sendRelayCommand -\u003e RelayClient.sendMessage(..., thread) and include thread in injected display/Inbox. See src/wrapper/tmux-wrapper.ts sendRelayCommand + handleIncomingMessage.","status":"closed","priority":2,"issue_type":"feature","assignee":"SecondLead","created_at":"2025-12-20T21:36:19.631448+01:00","updated_at":"2025-12-22T17:17:42.876826+01:00","closed_at":"2025-12-22T17:17:42.876826+01:00"} {"id":"agent-relay-2z1","title":"ACK messages not used for reliability","description":"In connection.ts:114-116, ACK messages are accepted but not processed. The protocol supports reliable delivery with ACK/NACK but it's not implemented. Need to: (1) Track unACKed messages, (2) Implement retry logic, (3) Add configurable TTL for messages.","status":"closed","priority":2,"issue_type":"feature","assignee":"LeadDev","created_at":"2025-12-20T00:17:43.615251+01:00","updated_at":"2025-12-20T21:56:07.202292+01:00","closed_at":"2025-12-20T21:56:07.202292+01:00"} +{"id":"agent-relay-30fu","title":"Fix dashboard conversation routing logic","description":"Conversation routing in the dashboard needs fixing: 1) If conversation starts in DM, it should continue in DM. 2) If conversation starts in general channel, it should flow to general. 3) Messages without '@' should naturally broadcast to everyone.","status":"closed","priority":1,"issue_type":"bug","created_at":"2025-12-28T22:05:12.683854-05:00","updated_at":"2025-12-28T22:06:29.421436-05:00","closed_at":"2025-12-28T22:06:29.421436-05:00","close_reason":"Fixed by Frontend agent"} {"id":"agent-relay-37i","title":"Message deduplication uses in-memory Set without limits","description":"In tmux-wrapper.ts:65, sentMessageHashes is a Set that grows unbounded. For long-running sessions, this could cause memory issues. Add: (1) Max size with LRU eviction, (2) Time-based expiration, (3) Bloom filter alternative for memory efficiency.","status":"open","priority":2,"issue_type":"task","created_at":"2025-12-20T00:18:47.229988+01:00","updated_at":"2025-12-20T00:18:47.229988+01:00"} {"id":"agent-relay-3px","title":"Add playbook system for batch automation","description":"Implement playbook system (like Maestro's Auto Run) for batch-processing task lists through agents. Define workflows in YAML/markdown, execute automatically with context isolation. Enables reproducible multi-step automation.","status":"open","priority":3,"issue_type":"feature","created_at":"2025-12-23T17:04:54.464749+01:00","updated_at":"2025-12-23T17:04:54.464749+01:00"} -{"id":"agent-relay-3tx","title":"PR-9 Review: Document configurable timeouts","description":"","status":"open","priority":3,"issue_type":"task","created_at":"2025-12-22T21:54:15.789418+01:00","updated_at":"2025-12-22T21:54:15.789418+01:00"} -{"id":"agent-relay-3y1","title":"Fix dashboard auto-scroll interfering with manual scrolling","description":"","status":"closed","priority":1,"issue_type":"bug","created_at":"2025-12-24T15:09:41.112636+01:00","updated_at":"2025-12-24T15:10:59.748796+01:00","closed_at":"2025-12-24T15:10:59.748796+01:00"} +{"id":"agent-relay-3tx","title":"PR-9 Review: Document configurable timeouts","status":"open","priority":3,"issue_type":"task","created_at":"2025-12-22T21:54:15.789418+01:00","updated_at":"2025-12-22T21:54:15.789418+01:00"} +{"id":"agent-relay-3y1","title":"Fix dashboard auto-scroll interfering with manual scrolling","status":"closed","priority":1,"issue_type":"bug","created_at":"2025-12-24T15:09:41.112636+01:00","updated_at":"2025-12-24T15:10:59.748796+01:00","closed_at":"2025-12-24T15:10:59.748796+01:00"} {"id":"agent-relay-41f","title":"BUG: better-sqlite3 bindings fail on Node 25","description":"agent-relay read command fails with 'Could not locate the bindings file' on Node v25.2.1. Need to rebuild bindings or use compatible Node version.","status":"closed","priority":2,"issue_type":"bug","created_at":"2025-12-20T21:46:24.882216+01:00","updated_at":"2025-12-20T21:49:56.89756+01:00","closed_at":"2025-12-20T21:49:56.89756+01:00"} {"id":"agent-relay-47z","title":"Express 5 may have breaking changes from Express 4 patterns","description":"package.json uses express@5.2.1 which is a major version with breaking changes from Express 4. Verify: (1) Error handling middleware patterns, (2) Router behavior, (3) Body parsing (express.json vs body-parser).","status":"open","priority":2,"issue_type":"task","created_at":"2025-12-20T00:18:49.269841+01:00","updated_at":"2025-12-20T00:18:49.269841+01:00"} {"id":"agent-relay-4e0","title":"Fix message truncation - messages cut off at source","description":"Root cause found: parser.ts:40 inline regex only captures single line. Multi-line messages are split by parsePassThrough() at line 206. Fix options: (1) Allow continuation lines in inline format, (2) Use block format for multi-line, (3) Add heuristic to join lines until next @relay pattern.","status":"closed","priority":2,"issue_type":"bug","assignee":"MistyShelter","created_at":"2025-12-19T23:40:35.082717+01:00","updated_at":"2025-12-20T00:03:54.806087+01:00","closed_at":"2025-12-20T00:03:54.806087+01:00"} -{"id":"agent-relay-4ft","title":"Merge project info into status command","description":"","status":"closed","priority":2,"issue_type":"task","assignee":"Pruner","created_at":"2025-12-19T21:59:52.685495+01:00","updated_at":"2025-12-19T22:06:44.276187+01:00","closed_at":"2025-12-19T22:06:44.276187+01:00"} +{"id":"agent-relay-4ft","title":"Merge project info into status command","status":"closed","priority":2,"issue_type":"task","assignee":"Pruner","created_at":"2025-12-19T21:59:52.685495+01:00","updated_at":"2025-12-19T22:06:44.276187+01:00","closed_at":"2025-12-19T22:06:44.276187+01:00"} {"id":"agent-relay-4jv","title":"Add session discovery from existing Claude Code","description":"Auto-discover and import existing Claude Code sessions (like Maestro). Detect running claude processes, offer to wrap them with agent-relay messaging. Reduces friction for adoption - users don't have to restart their agents.","status":"open","priority":3,"issue_type":"feature","created_at":"2025-12-23T17:05:01.003851+01:00","updated_at":"2025-12-23T17:05:01.003851+01:00"} {"id":"agent-relay-4oy","title":"Fix beads sync prefix mismatch errors","description":"During bd sync, getting 'prefix mismatch detected: database uses agent-relay- but found issues with prefixes: agent- (5 issues)'. Need to investigate and fix the prefix inconsistency or use --rename-on-import flag.","status":"open","priority":2,"issue_type":"bug","created_at":"2025-12-23T23:10:12.720183+01:00","updated_at":"2025-12-23T23:10:12.720183+01:00"} -{"id":"agent-relay-4vl","title":"PR-9 Review: Add unit tests for AgentSpawner","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-22T21:54:03.195295+01:00","updated_at":"2025-12-22T22:00:57.315234+01:00","closed_at":"2025-12-22T22:00:57.315234+01:00"} -{"id":"agent-relay-4xs","title":"Dashboard: Enter to send, Shift+Enter for newline (Slack-style)","description":"","status":"closed","priority":1,"issue_type":"feature","created_at":"2025-12-24T15:25:01.248707+01:00","updated_at":"2025-12-24T15:26:24.619384+01:00","closed_at":"2025-12-24T15:26:24.619384+01:00"} +{"id":"agent-relay-4vl","title":"PR-9 Review: Add unit tests for AgentSpawner","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-22T21:54:03.195295+01:00","updated_at":"2025-12-22T22:00:57.315234+01:00","closed_at":"2025-12-22T22:00:57.315234+01:00"} +{"id":"agent-relay-4xs","title":"Dashboard: Enter to send, Shift+Enter for newline (Slack-style)","status":"closed","priority":1,"issue_type":"feature","created_at":"2025-12-24T15:25:01.248707+01:00","updated_at":"2025-12-24T15:26:24.619384+01:00","closed_at":"2025-12-24T15:26:24.619384+01:00"} {"id":"agent-relay-51u","title":"Outgoing messages truncated before delivery","description":"Agent messages are being cut off mid-sentence. Examples: 'Updates for mcl/2z1:' and 'Signing off. Progress report:' - both end abruptly. May be related to capture-pane buffer limits, parser issues, or injection timing. Investigate: (1) capture-pane -S - scrollback limits, (2) parser line joining, (3) message storage truncation.","notes":"PROGRESS: Reproduced truncation in tmux wrapper/relay flow. Likely due to display truncation on injection length and terminal capture limitations. No code changes yet.","status":"closed","priority":1,"issue_type":"bug","assignee":"Implementer","created_at":"2025-12-20T21:50:11.411952+01:00","updated_at":"2025-12-22T13:48:45.264136+01:00","closed_at":"2025-12-22T13:48:45.264136+01:00"} {"id":"agent-relay-52d","title":"Add metrics/observability for daemon","description":"No way to monitor daemon health, message throughput, or agent activity. Add: (1) /metrics endpoint for Prometheus, (2) Message count/rate stats, (3) Connection lifecycle events, (4) Error rate tracking.","status":"closed","priority":2,"issue_type":"feature","assignee":"FEDev","created_at":"2025-12-20T00:18:48.378728+01:00","updated_at":"2025-12-25T13:59:43.376287+01:00","closed_at":"2025-12-25T13:59:43.376287+01:00"} {"id":"agent-relay-52e","title":"Unify bridge view with dashboard UI","description":"Make bridge view feel like the main dashboard with project tabs, same agent/messaging UI, spawn capability at bridge level, and ability to click into individual project dashboards","status":"closed","priority":2,"issue_type":"feature","created_at":"2025-12-25T13:44:11.491787+01:00","updated_at":"2025-12-25T13:49:36.007645+01:00","closed_at":"2025-12-25T13:49:36.007645+01:00"} @@ -33,22 +34,23 @@ {"id":"agent-relay-6dl8","title":"Multi-line relay messages truncated in dashboard display","description":"Multi-line messages sent via -\u003erelay: are being cut off after first line when displayed in dashboard. This blocks communication between agents. Need to: (1) Verify daemon sends full message, (2) Fix dashboard rendering to handle newlines properly.","status":"closed","priority":1,"issue_type":"bug","created_at":"2025-12-26T12:28:39.746942+01:00","updated_at":"2025-12-26T12:38:36.844034+01:00","closed_at":"2025-12-26T12:38:36.844034+01:00"} {"id":"agent-relay-6mo","title":"Lead spawn capability not working (@relay:spawn pattern)","description":"The lead command advertises spawn capability via @relay:spawn pattern but it's not fully implemented. Agents started with 'agent-relay lead' should be able to spawn workers, but the parser extension mentioned in the code is not implemented.","status":"closed","priority":1,"issue_type":"bug","created_at":"2025-12-23T12:14:59.492237+01:00","updated_at":"2025-12-23T12:18:14.114911+01:00","closed_at":"2025-12-23T12:18:14.114911+01:00"} {"id":"agent-relay-6nx","title":"Add activity state tracking for better injection timing","description":"Track active/idle/disconnected state with timestamps. When session goes idle (30s no activity), trigger message injection opportunity. Improves injection timing vs current fixed 1.5s wait. See docs/TMUX_IMPROVEMENTS.md for implementation details.","status":"closed","priority":2,"issue_type":"feature","assignee":"LeadDev","created_at":"2025-12-20T21:28:46.993856+01:00","updated_at":"2025-12-20T21:33:42.223042+01:00","closed_at":"2025-12-20T21:33:42.223042+01:00"} -{"id":"agent-relay-6ny","title":"Fix message truncation: store full message in SQLite first, include ID in relay","description":"","status":"closed","priority":1,"issue_type":"bug","created_at":"2025-12-19T22:04:36.168862+01:00","updated_at":"2025-12-19T22:08:28.207532+01:00","closed_at":"2025-12-19T22:08:28.207532+01:00"} +{"id":"agent-relay-6ny","title":"Fix message truncation: store full message in SQLite first, include ID in relay","status":"closed","priority":1,"issue_type":"bug","created_at":"2025-12-19T22:04:36.168862+01:00","updated_at":"2025-12-19T22:08:28.207532+01:00","closed_at":"2025-12-19T22:08:28.207532+01:00"} {"id":"agent-relay-6rz","title":"Message injection timing can cause race conditions","description":"In tmux-wrapper.ts:564-569, injection waits for 'idle' (1.5s since last output) but this is fragile. If agent produces output during injection, messages could interleave. Consider: (1) Input buffer detection, (2) Bracketed paste mode, (3) Agent-specific injection strategies.","status":"closed","priority":2,"issue_type":"bug","created_at":"2025-12-20T00:18:17.76865+01:00","updated_at":"2025-12-25T14:31:59.858855+01:00","closed_at":"2025-12-25T14:31:59.858855+01:00"} {"id":"agent-relay-7b2","title":"CRITICAL: Agents cycling between offline/online status","description":"Dashboard reports agents cycling between offline/online. Root cause analysis: Heartbeat timeout in connection.ts line 230 - if PONG not received within 10 seconds (2x heartbeat interval), connection is killed. This is too aggressive for AI agents that may be processing. Fix: increase timeout tolerance or make configurable. See connection.ts:223-244.","status":"closed","priority":0,"issue_type":"bug","assignee":"Lead","created_at":"2025-12-23T22:57:53.052261+01:00","updated_at":"2025-12-23T23:02:41.925425+01:00","closed_at":"2025-12-23T23:02:41.925425+01:00"} {"id":"agent-relay-7bp","title":"Memory storage adapter has fixed 1000 message limit","description":"In storage/adapter.ts:60-63, MemoryStorageAdapter hard-codes 1000 message limit. This should be configurable and potentially use LRU eviction instead of slice.","status":"open","priority":2,"issue_type":"task","created_at":"2025-12-20T00:17:46.132735+01:00","updated_at":"2025-12-20T00:17:46.132735+01:00"} -{"id":"agent-relay-7fx","title":"PR-9 Review: Fix @relay vs -\u003erelay syntax inconsistency in docs","description":"","status":"closed","priority":2,"issue_type":"bug","created_at":"2025-12-22T22:02:02.782958+01:00","updated_at":"2025-12-22T22:04:02.664058+01:00","closed_at":"2025-12-22T22:04:02.664058+01:00"} +{"id":"agent-relay-7fx","title":"PR-9 Review: Fix @relay vs -\u003erelay syntax inconsistency in docs","status":"closed","priority":2,"issue_type":"bug","created_at":"2025-12-22T22:02:02.782958+01:00","updated_at":"2025-12-22T22:04:02.664058+01:00","closed_at":"2025-12-22T22:04:02.664058+01:00"} {"id":"agent-relay-7mm","title":"Add orphaned tmux session cleanup","description":"When agent-relay wrapper crashes or gets SIGKILL, the tmux session remains. Need: 1) CLI command to gc orphaned sessions, 2) Daemon-side cleanup on client disconnect","status":"closed","priority":1,"issue_type":"bug","created_at":"2025-12-22T15:52:01.58781+01:00","updated_at":"2025-12-22T15:54:59.840769+01:00","closed_at":"2025-12-22T15:54:59.840769+01:00"} {"id":"agent-relay-7tk","title":"Dashboard truncates multi-line messages","description":"Messages containing line breaks only show the first line in the dashboard. Need to render newlines properly - either with CSS white-space or by converting newlines to \u003cbr\u003e tags.","status":"closed","priority":1,"issue_type":"bug","assignee":"FE-Dev","created_at":"2025-12-23T23:05:03.86361+01:00","updated_at":"2025-12-23T23:05:59.96346+01:00","closed_at":"2025-12-23T23:05:59.96346+01:00"} {"id":"agent-relay-7tu","title":"Fix storage portability: better-sqlite3 native binding failures","description":"The read command fails in some Node versions (e.g., Node 25) due to missing better-sqlite3 bindings. Decide path: pin supported Node LTS + ensure rebuild, or replace with a non-native option (e.g., Node sqlite, wasm, or a pure JS adapter).","design":"Implement driver selection/fallback: try better-sqlite3, fallback to node:sqlite DatabaseSync when bindings missing. Add env override AGENT_RELAY_SQLITE_DRIVER=node|better-sqlite3. Convert queries to positional params so both drivers share code.","acceptance_criteria":"- dist CLI read works on supported Node versions\\n- CI/build docs reflect supported runtime or replacement adapter","notes":"Implemented sqlite portability: dynamic better-sqlite3 import + fallback to node:sqlite (env override AGENT_RELAY_SQLITE_DRIVER). Updated sqlite adapter to use positional params for compatibility; added/updated tests; npx vitest run passes (216).","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-20T21:44:57.868091+01:00","updated_at":"2025-12-20T21:56:13.384399+01:00","closed_at":"2025-12-20T21:56:13.3844+01:00","labels":["build","storage"]} -{"id":"agent-relay-7yo","title":"Update CLAUDE.md with new CLI commands","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-19T22:00:02.661859+01:00","updated_at":"2025-12-19T22:07:27.766701+01:00","closed_at":"2025-12-19T22:07:27.766701+01:00","dependencies":[{"issue_id":"agent-relay-7yo","depends_on_id":"agent-relay-85z","type":"blocks","created_at":"2025-12-19T22:00:25.731921+01:00","created_by":"daemon"},{"issue_id":"agent-relay-7yo","depends_on_id":"agent-relay-4ft","type":"blocks","created_at":"2025-12-19T22:00:25.772241+01:00","created_by":"daemon"},{"issue_id":"agent-relay-7yo","depends_on_id":"agent-relay-bd0","type":"blocks","created_at":"2025-12-19T22:00:25.80776+01:00","created_by":"daemon"},{"issue_id":"agent-relay-7yo","depends_on_id":"agent-relay-f3q","type":"blocks","created_at":"2025-12-19T22:00:25.843131+01:00","created_by":"daemon"}]} -{"id":"agent-relay-85z","title":"Merge dashboard into start command","description":"","status":"closed","priority":2,"issue_type":"task","assignee":"InterfaceManager","created_at":"2025-12-19T21:59:51.61716+01:00","updated_at":"2025-12-19T22:06:44.27487+01:00","closed_at":"2025-12-19T22:06:44.27487+01:00"} +{"id":"agent-relay-7yo","title":"Update CLAUDE.md with new CLI commands","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-19T22:00:02.661859+01:00","updated_at":"2025-12-19T22:07:27.766701+01:00","closed_at":"2025-12-19T22:07:27.766701+01:00","dependencies":[{"issue_id":"agent-relay-7yo","depends_on_id":"agent-relay-85z","type":"blocks","created_at":"2025-12-19T22:00:25.731921+01:00","created_by":"daemon","metadata":"{}"},{"issue_id":"agent-relay-7yo","depends_on_id":"agent-relay-4ft","type":"blocks","created_at":"2025-12-19T22:00:25.772241+01:00","created_by":"daemon","metadata":"{}"},{"issue_id":"agent-relay-7yo","depends_on_id":"agent-relay-bd0","type":"blocks","created_at":"2025-12-19T22:00:25.80776+01:00","created_by":"daemon","metadata":"{}"},{"issue_id":"agent-relay-7yo","depends_on_id":"agent-relay-f3q","type":"blocks","created_at":"2025-12-19T22:00:25.843131+01:00","created_by":"daemon","metadata":"{}"}]} +{"id":"agent-relay-85z","title":"Merge dashboard into start command","status":"closed","priority":2,"issue_type":"task","assignee":"InterfaceManager","created_at":"2025-12-19T21:59:51.61716+01:00","updated_at":"2025-12-19T22:06:44.27487+01:00","closed_at":"2025-12-19T22:06:44.27487+01:00"} {"id":"agent-relay-8ap","title":"CRITICAL: send command triggers infinite reconnection loop","description":"When sending relay messages to Gem agent, the send command triggers an infinite reconnection loop. Client repeatedly connects with RESUME_TOO_OLD errors, never actually delivers the message. Each connection gets a new session ID and immediately reconnects (~90-110ms apart). This is a critical client/daemon issue, not just Gemini-specific.","notes":"CLI send command removed (keeping surface area small). Root cause still needs investigation - client reconnection loop with RESUME_TOO_OLD errors.","status":"closed","priority":1,"issue_type":"bug","created_at":"2025-12-22T12:18:13.272843+01:00","updated_at":"2025-12-22T14:37:19.435751+01:00","closed_at":"2025-12-22T14:37:19.435751+01:00"} {"id":"agent-relay-8fc","title":"Agent auto-complete menu is transparent/invisible","description":"The agent auto-complete dropdown menu in the dashboard appears to be transparent or has visibility issues, making it unusable. Need to debug CSS/z-index/display issues in bridge.html agent selector component.","status":"closed","priority":2,"issue_type":"bug","assignee":"FE-Dev","created_at":"2025-12-24T15:04:53.390674+01:00","updated_at":"2025-12-24T15:30:23.72431+01:00","closed_at":"2025-12-24T15:30:23.72431+01:00"} {"id":"agent-relay-8ff","title":"Add agent list command to CLI","description":"CLI has up/down/status/read but no way to list connected agents or see message history from command line. Add: (1) agent-relay agents - list connected agents, (2) agent-relay history - show recent messages, (3) agent-relay send \u003cagent\u003e \u003cmsg\u003e - send from CLI.","status":"closed","priority":2,"issue_type":"feature","created_at":"2025-12-20T00:18:34.400432+01:00","updated_at":"2025-12-22T15:35:44.695605+01:00","closed_at":"2025-12-22T15:35:44.695605+01:00"} {"id":"agent-relay-8nx","title":"SIGINT/SIGTERM handlers don't await cleanup","description":"In cli/index.ts:74-77 and daemon setup, SIGINT handlers call stop() but process.exit(0) runs immediately. The stop() is async but not awaited, potentially leaving socket files or incomplete shutdown.","status":"open","priority":2,"issue_type":"bug","created_at":"2025-12-20T00:18:19.189514+01:00","updated_at":"2025-12-20T00:18:19.189514+01:00"} -{"id":"agent-relay-8z1","title":"Add CLI tests for new command structure","description":"","status":"closed","priority":2,"issue_type":"task","assignee":"Lead","created_at":"2025-12-19T22:00:04.561793+01:00","updated_at":"2025-12-22T17:10:28.100961+01:00","closed_at":"2025-12-22T17:10:28.100961+01:00","dependencies":[{"issue_id":"agent-relay-8z1","depends_on_id":"agent-relay-85z","type":"blocks","created_at":"2025-12-19T22:00:27.632396+01:00","created_by":"daemon"},{"issue_id":"agent-relay-8z1","depends_on_id":"agent-relay-4ft","type":"blocks","created_at":"2025-12-19T22:00:27.671868+01:00","created_by":"daemon"},{"issue_id":"agent-relay-8z1","depends_on_id":"agent-relay-bd0","type":"blocks","created_at":"2025-12-19T22:00:27.713889+01:00","created_by":"daemon"},{"issue_id":"agent-relay-8z1","depends_on_id":"agent-relay-f3q","type":"blocks","created_at":"2025-12-19T22:00:27.752052+01:00","created_by":"daemon"}]} +{"id":"agent-relay-8z1","title":"Add CLI tests for new command structure","status":"closed","priority":2,"issue_type":"task","assignee":"Lead","created_at":"2025-12-19T22:00:04.561793+01:00","updated_at":"2025-12-22T17:10:28.100961+01:00","closed_at":"2025-12-22T17:10:28.100961+01:00","dependencies":[{"issue_id":"agent-relay-8z1","depends_on_id":"agent-relay-85z","type":"blocks","created_at":"2025-12-19T22:00:27.632396+01:00","created_by":"daemon","metadata":"{}"},{"issue_id":"agent-relay-8z1","depends_on_id":"agent-relay-4ft","type":"blocks","created_at":"2025-12-19T22:00:27.671868+01:00","created_by":"daemon","metadata":"{}"},{"issue_id":"agent-relay-8z1","depends_on_id":"agent-relay-bd0","type":"blocks","created_at":"2025-12-19T22:00:27.713889+01:00","created_by":"daemon","metadata":"{}"},{"issue_id":"agent-relay-8z1","depends_on_id":"agent-relay-f3q","type":"blocks","created_at":"2025-12-19T22:00:27.752052+01:00","created_by":"daemon","metadata":"{}"}]} {"id":"agent-relay-90h","title":"Show 'needs attention' indicator when agent is waiting for permissions","description":"Frontend complete: needsAttention field, pulsing indicator, badge. Backend piece still needed: detect when agents are waiting for permissions and set the flag.","status":"closed","priority":1,"issue_type":"feature","assignee":"FE-Dev","created_at":"2025-12-23T22:55:26.87753+01:00","updated_at":"2025-12-24T11:48:32.679452+01:00","closed_at":"2025-12-24T11:48:32.679452+01:00"} +{"id":"agent-relay-9igw","title":"Agents not closing multi-line relay messages with \u003e\u003e\u003e","description":"Agent Relay requires multi-line messages to be wrapped in \u003c\u003c\u003c \u003e\u003e\u003e format with \u003e\u003e\u003e on its own line. Agents are sending multi-line messages without proper closure. This breaks message parsing and relay communication quality.","status":"open","priority":1,"issue_type":"bug","created_at":"2025-12-28T19:29:43.950026-05:00","updated_at":"2025-12-28T19:29:43.950026-05:00"} {"id":"agent-relay-9kc","title":"Add skip-permissions flag for spawned Claude agents","description":"When spawning Claude agents via agent-relay, automatically use --dangerously-skip-permissions to avoid permission dialog delays. Update spawn command in CLI to pass this flag to spawned sessions.","status":"closed","priority":1,"issue_type":"task","assignee":"FEDev","created_at":"2025-12-25T14:09:00.07082+01:00","updated_at":"2025-12-25T14:15:45.925839+01:00","closed_at":"2025-12-25T14:15:45.925839+01:00"} {"id":"agent-relay-9kw","title":"Add multi-machine agent support","description":"Enable agents to run across multiple machines with centralized coordination. AI Maestro scales to 100+ agents across machines. Extend bridge mode to support remote daemon connections over TCP/WebSocket. Critical for scaling beyond single machine.","status":"open","priority":2,"issue_type":"feature","created_at":"2025-12-23T17:04:35.521241+01:00","updated_at":"2025-12-23T17:04:35.521241+01:00"} {"id":"agent-relay-9qa","title":"Dashboard websocket 'Invalid frame header' error on localhost:3888","description":"Websocket connection to ws://localhost:3888/ws fails with 'Invalid frame header' error from browser. Blocking dashboard functionality.","notes":"WS handshake fixed: restored default WebSocket compression (removed perMessageDeflate:false), removed HTTP polling fallback. FE-Dev confirmed 101 upgrade on 127.0.0.1:3888 after rebuild/refresh.","status":"closed","priority":1,"issue_type":"bug","created_at":"2025-12-23T16:42:29.342087+01:00","updated_at":"2025-12-23T17:04:04.246054+01:00","closed_at":"2025-12-23T17:04:04.246054+01:00"} @@ -58,55 +60,72 @@ {"id":"agent-relay-a4k","title":"Add safety mechanisms for dangerous commands","description":"Implement safety layer for dangerous operations (like ACFS's two-person rule). Require confirmation from second agent or human for destructive commands (rm -rf, git push -f, DROP TABLE). Configurable blocklist and approval workflow.","status":"open","priority":2,"issue_type":"feature","created_at":"2025-12-23T17:05:21.032206+01:00","updated_at":"2025-12-23T17:05:21.032206+01:00"} {"id":"agent-relay-ahe","title":"Session resume not implemented - RESUME_TOO_OLD always sent","description":"In connection.ts:140-143, session resume tokens are received but not persisted or validated. The server always responds with RESUME_TOO_OLD. This breaks the resume capability advertised in the protocol. Need to implement token persistence and session state recovery.","status":"closed","priority":2,"issue_type":"bug","created_at":"2025-12-20T00:17:30.605649+01:00","updated_at":"2025-12-22T15:31:05.795905+01:00","closed_at":"2025-12-22T15:31:05.795905+01:00"} {"id":"agent-relay-aq2","title":"Implement agent-to-agent spawning via relay","description":"Allow agents to spawn new agents through relay messaging. Enable -\u003erelay:spawn pattern so agents can dynamically create specialized sub-agents. This unblocks multi-agent workflows and team hierarchies.","status":"closed","priority":1,"issue_type":"feature","assignee":"BESpecialist","created_at":"2025-12-25T14:14:02.110506+01:00","updated_at":"2025-12-25T14:32:10.400627+01:00","closed_at":"2025-12-25T14:32:10.400627+01:00"} -{"id":"agent-relay-aqy","title":"Display cross-project messages with project badge in dashboard","description":"When messages use @project:agent format, display them in the dashboard with a project badge/indicator. Parse project:agent from the 'to' field and render as [ProjectBadge] @AgentName. Depends on agent-relay-cross-project-parser completing first.","status":"closed","priority":2,"issue_type":"feature","assignee":"FE-Dev","created_at":"2025-12-24T10:29:37.528325+01:00","updated_at":"2025-12-24T11:46:39.341486+01:00","closed_at":"2025-12-24T11:46:39.341486+01:00","dependencies":[{"issue_id":"agent-relay-aqy","depends_on_id":"agent-relay-ytl","type":"blocks","created_at":"2025-12-24T10:29:48.712573+01:00","created_by":"khaliqgant"}]} +{"id":"agent-relay-aqy","title":"Display cross-project messages with project badge in dashboard","description":"When messages use @project:agent format, display them in the dashboard with a project badge/indicator. Parse project:agent from the 'to' field and render as [ProjectBadge] @AgentName. Depends on agent-relay-cross-project-parser completing first.","status":"closed","priority":2,"issue_type":"feature","assignee":"FE-Dev","created_at":"2025-12-24T10:29:37.528325+01:00","updated_at":"2025-12-24T11:46:39.341486+01:00","closed_at":"2025-12-24T11:46:39.341486+01:00","dependencies":[{"issue_id":"agent-relay-aqy","depends_on_id":"agent-relay-ytl","type":"blocks","created_at":"2025-12-24T10:29:48.712573+01:00","created_by":"khaliqgant","metadata":"{}"}]} {"id":"agent-relay-b11","title":"Cannot scroll in agent tmux window","description":"User unable to scroll in the tmux window when running agent-relay wrapped agents. Need to fix mouse scroll or keyboard scroll behavior.","status":"closed","priority":1,"issue_type":"bug","created_at":"2025-12-23T15:01:55.791186+01:00","updated_at":"2025-12-23T15:04:51.896303+01:00","closed_at":"2025-12-23T15:04:51.896303+01:00"} -{"id":"agent-relay-bai","title":"Fix bridge reconnection loop caused by agent name conflict","description":"","status":"closed","priority":2,"issue_type":"bug","created_at":"2025-12-25T13:26:46.025963+01:00","updated_at":"2025-12-25T13:27:04.323001+01:00","closed_at":"2025-12-25T13:27:04.323001+01:00"} -{"id":"agent-relay-bd0","title":"Consolidate team-* commands under team subcommand","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-19T21:59:54.102815+01:00","updated_at":"2025-12-19T22:06:16.220013+01:00","closed_at":"2025-12-19T22:06:16.220013+01:00"} +{"id":"agent-relay-b919","title":"Fix Cmd+N spawn shortcut conflict","description":"Cmd+N shortcut for spawning agents has a conflict that needs to be resolved","status":"closed","priority":2,"issue_type":"bug","created_at":"2025-12-28T22:08:23.539814-05:00","updated_at":"2025-12-28T22:09:20.683421-05:00","closed_at":"2025-12-28T22:09:20.683421-05:00","close_reason":"Fixed by Frontend - changed shortcut to Cmd+Shift+S with keyboard handler"} +{"id":"agent-relay-bai","title":"Fix bridge reconnection loop caused by agent name conflict","status":"closed","priority":2,"issue_type":"bug","created_at":"2025-12-25T13:26:46.025963+01:00","updated_at":"2025-12-25T13:27:04.323001+01:00","closed_at":"2025-12-25T13:27:04.323001+01:00"} +{"id":"agent-relay-bd0","title":"Consolidate team-* commands under team subcommand","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-19T21:59:54.102815+01:00","updated_at":"2025-12-19T22:06:16.220013+01:00","closed_at":"2025-12-19T22:06:16.220013+01:00"} {"id":"agent-relay-bei","title":"Gemini (CleverBeacon) bash syntax errors - unexpected EOF","description":"Gemini agent gets bash errors: 'unexpected EOF while looking for matching quote' and 'syntax error: unexpected end of file' when running shell commands. Possibly related to quote escaping in the relay wrapper.","status":"closed","priority":2,"issue_type":"bug","assignee":"GraniteElk","created_at":"2025-12-19T23:40:36.464079+01:00","updated_at":"2025-12-19T23:45:46.05609+01:00","closed_at":"2025-12-19T23:45:46.05609+01:00"} {"id":"agent-relay-blj","title":"Add metrics and observability endpoint","description":"Add metrics collection and observability for production monitoring. Include: message throughput, agent health, latency percentiles, error rates, queue depths. Expose via HTTP endpoint (Prometheus format) and/or dashboard integration. Currently flying blind in production.","status":"closed","priority":2,"issue_type":"feature","created_at":"2025-12-23T17:03:41.964359+01:00","updated_at":"2025-12-25T13:33:56.132695+01:00","closed_at":"2025-12-25T13:33:56.132695+01:00"} {"id":"agent-relay-bx0","title":"Filter stale/internal agents from agents list","description":"agent-relay agents shows __status__ and cli agents which are temporary internal connections. Should filter these out by default, with --all flag to show everything.","status":"closed","priority":2,"issue_type":"bug","created_at":"2025-12-22T15:44:51.944084+01:00","updated_at":"2025-12-22T15:45:44.262049+01:00","closed_at":"2025-12-22T15:45:44.262049+01:00"} {"id":"agent-relay-coh","title":"Add tests for heartbeat timeout behavior","description":"The heartbeat timeout fix (10s-\u003e30s) was made but no new tests were added. Add unit tests for: 1) configurable heartbeatTimeoutMultiplier, 2) connection survives slow pong responses, 3) connection dies after timeout exceeded.","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-23T23:10:18.599194+01:00","updated_at":"2025-12-25T13:31:23.910414+01:00","closed_at":"2025-12-25T13:31:23.910414+01:00"} +{"id":"agent-relay-cuer","title":"Single agent flickering between active/inactive on dashboard","description":"When a single agent is spawned, the dashboard flickers rapidly between showing the agent as active and inactive. This blocks reliable agent monitoring. Need to investigate: (1) WebSocket connection stability, (2) Status update frequency, (3) State sync race conditions.","status":"closed","priority":1,"issue_type":"bug","created_at":"2025-12-26T16:53:20.385225+01:00","updated_at":"2025-12-26T17:20:58.613208+01:00","closed_at":"2025-12-26T17:20:58.613208+01:00"} {"id":"agent-relay-cw8","title":"Add procedural memory for learned patterns","description":"Implement procedural memory system (like ACFS's cass-memory) to store learned patterns and successful approaches. Agents can query 'how did we solve X before?' Enable cross-session learning and pattern reuse.","status":"open","priority":3,"issue_type":"feature","created_at":"2025-12-23T17:05:14.667585+01:00","updated_at":"2025-12-23T17:05:14.667585+01:00"} {"id":"agent-relay-d07","title":"Message metadata: subject/thread/importance/replyTo","description":"Add first-class metadata fields to messages (subject, threadId, importance, ackRequired, replyTo/correlationId). Carry them via SendPayload.data and/or [[RELAY]] blocks; persist in storage; update injection formatting + dashboard filters.","acceptance_criteria":"- Can send/receive messages with threadId + importance\\n- Dashboard can filter/group by threadId\\n- Injection shows importance/thread hint without breaking TUIs","notes":"SecondLead: Implemented thread support end-to-end. SendMeta added to client.sendMessage(). tmux-wrapper converts ParsedMessageMetadata to SendMeta. Injection shows [thread:xxx] hint. Dashboard displays thread badge. REMAINING: importance field requires protocol update - DeliverEnvelope needs payload_meta to carry importance through to receivers.","status":"closed","priority":2,"issue_type":"task","assignee":"SecondLead","created_at":"2025-12-20T21:44:18.094598+01:00","updated_at":"2025-12-22T17:15:37.261368+01:00","closed_at":"2025-12-22T17:15:37.261368+01:00","labels":["protocol","ux"]} {"id":"agent-relay-d2f","title":"Add optional Git audit trail","description":"Commit messages to .agent-relay/messages/ directory. Recoverable history, compliance-friendly.","status":"open","priority":2,"issue_type":"feature","created_at":"2025-12-20T21:36:19.689795+01:00","updated_at":"2025-12-20T21:36:19.689795+01:00"} +{"id":"agent-relay-d5vn","title":"Add ability to kill spawned agents from dashboard","description":"Currently there is no way to kill/terminate a spawned agent from the dashboard UI. Need to add a kill/terminate button or action for spawned agents.","status":"in_progress","priority":2,"issue_type":"feature","created_at":"2025-12-28T22:11:48.358972-05:00","updated_at":"2025-12-28T22:13:38.077981-05:00"} {"id":"agent-relay-dd8","title":"Add file reservation system (from mcp_agent_mail)","description":"Advisory locks with TTL, @relay:lock pattern, pre-commit hook integration. Critical for multi-agent file editing.","notes":"No progress today; added as future work from swarm-mail comparison: implement reservations/locks with TTL + conflict detection. See swarm-mail reservations + locks patterns.","status":"open","priority":2,"issue_type":"feature","created_at":"2025-12-20T21:36:19.573504+01:00","updated_at":"2025-12-20T22:00:37.608017+01:00"} {"id":"agent-relay-dkx","title":"Add metrics dashboard for system health monitoring","description":"Track agent metrics (throughput, uptime, online status), session lifecycle (active/closed/error rates), and per-agent message counts. Provide Prometheus-format export for monitoring integration. Includes metrics.ts utilities and metrics.html dashboard page.","status":"closed","priority":2,"issue_type":"feature","created_at":"2025-12-25T14:07:28.416696+01:00","updated_at":"2025-12-26T11:41:20.321264+01:00","closed_at":"2025-12-26T11:41:20.321264+01:00"} {"id":"agent-relay-dyr","title":"No authentication between agents","description":"Any process can connect to the daemon socket and impersonate any agent name. Consider: (1) Per-agent tokens/secrets, (2) Socket permission checks, (3) Optional TLS for non-localhost deployments.","status":"open","priority":2,"issue_type":"feature","created_at":"2025-12-20T00:18:16.215889+01:00","updated_at":"2025-12-20T00:18:16.215889+01:00"} {"id":"agent-relay-eek","title":"Change relay prefix from @relay to \u003e\u003erelay","description":"Unify the relay protocol prefix to \u003e\u003erelay (instead of @relay) so it works for all agents including Gemini. Update parser, documentation, and any hardcoded references.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-22T12:20:35.35848+01:00","updated_at":"2025-12-22T12:30:21.696239+01:00","closed_at":"2025-12-22T12:30:21.696239+01:00"} +{"id":"agent-relay-ekk9","title":"Add @-mention autocomplete to v2 message composer","description":"V1 dashboard has @-mention autocomplete in the message composer. When typing @A, it shows a dropdown with matching agents (@Alice, @Admin, etc.). V2's MessageComposer lacks this feature. Need to port the autocomplete logic from v1's components.ts (showMentionAutocomplete, completeMention functions) to v2.","status":"closed","priority":1,"issue_type":"bug","created_at":"2025-12-27T05:36:28.283162+01:00","updated_at":"2025-12-27T05:44:05.597544+01:00","closed_at":"2025-12-27T05:44:05.597544+01:00","close_reason":"Added missing mention-autocomplete CSS styles to globals.css"} {"id":"agent-relay-ex7","title":"Competitive analysis: Agent-to-agent messaging solutions","description":"Research and compare 17 multi-agent orchestration tools against agent-relay. Analyze: agent communication patterns, pros/cons, server/mobile support, memory architecture.","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-23T16:34:31.174109+01:00","updated_at":"2025-12-23T16:55:19.264056+01:00","closed_at":"2025-12-23T16:55:19.264056+01:00"} {"id":"agent-relay-ey9","title":"Dashboard message sending not working","description":"User reports sending messages from dashboard doesn't work. Need to investigate.","status":"closed","priority":2,"issue_type":"bug","assignee":"SecondLead","created_at":"2025-12-22T15:54:48.975146+01:00","updated_at":"2025-12-22T17:07:48.705786+01:00","closed_at":"2025-12-22T17:07:48.705786+01:00"} -{"id":"agent-relay-f3q","title":"Make msg-read a subcommand of send or message","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-19T21:59:55.123772+01:00","updated_at":"2025-12-19T22:04:51.112763+01:00","closed_at":"2025-12-19T22:04:51.112763+01:00"} +{"id":"agent-relay-f3q","title":"Make msg-read a subcommand of send or message","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-19T21:59:55.123772+01:00","updated_at":"2025-12-19T22:04:51.112763+01:00","closed_at":"2025-12-19T22:04:51.112763+01:00"} {"id":"agent-relay-f9k","title":"Fix WebSocket Invalid frame header error","description":"Dashboard WebSocket throws Invalid frame header with 415KB payload. Missing wss.on('connection') handler causes race condition.","status":"closed","priority":2,"issue_type":"bug","created_at":"2025-12-23T17:17:26.412812+01:00","updated_at":"2025-12-23T17:18:30.4994+01:00","closed_at":"2025-12-23T17:18:30.4994+01:00"} {"id":"agent-relay-fb3","title":"Add configurable relay prefix for Gemini compatibility","description":"Gemini CLI uses @ for file references, conflicting with @relay:. Add --prefix flag to CLI and relayPrefix config option. Auto-detect CLI type and use appropriate default (\u003e\u003e for Gemini, @relay: for Claude/Codex). Update parser to use dynamic prefix. See docs/TMUX_IMPROVEMENTS.md for full implementation plan.","status":"closed","priority":2,"issue_type":"feature","assignee":"Coordinator","created_at":"2025-12-20T21:28:52.685002+01:00","updated_at":"2025-12-20T21:40:13.066766+01:00","closed_at":"2025-12-20T21:40:13.066766+01:00"} +{"id":"agent-relay-fuob","title":"Fix spawner tests for resolved tmux path","description":"Update src/bridge/spawner.test.ts mocks to expect resolved tmux binary path (e.g., \"/opt/homebrew/bin/tmux\") instead of just \"tmux\". 9 tests failing due to exact command string assertions.","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-28T19:28:46.381693-05:00","updated_at":"2025-12-28T19:32:17.034494-05:00","closed_at":"2025-12-28T19:32:17.034494-05:00","close_reason":"Tests updated to mock tmux-resolver - all 514 tests pass"} {"id":"agent-relay-ghy","title":"Team config: auto-spawn agents or auto-assign names from teams.json","description":"When a project has a teams.json config file, agent-relay up should either:\n\n1. **Auto-spawn option**: Automatically kick off terminal sessions for each agent defined in teams.json\n2. **Auto-assign option**: When users manually start sessions with `agent-relay -n \u003cname\u003e claude`, validate the name against teams.json and auto-assign roles/permissions\n\n## teams.json format\n```json\n{\n \"team\": \"my-project\",\n \"agents\": [\n {\"name\": \"Coordinator\", \"cli\": \"claude\", \"role\": \"coordinator\"},\n {\"name\": \"LeadDev\", \"cli\": \"claude\", \"role\": \"developer\"},\n {\"name\": \"Reviewer\", \"cli\": \"claude\", \"role\": \"reviewer\"}\n ],\n \"autoSpawn\": false\n}\n```\n\n## Behavior\n- If `autoSpawn: true`, `agent-relay up` spawns tmux sessions for each agent\n- If `autoSpawn: false`, validate names against config when agents connect\n- Store teams.json in project root or .agent-relay/teams.json\n\n## Commands\n- `agent-relay up --spawn` - force spawn all agents\n- `agent-relay up --no-spawn` - just start daemon, manual agent starts","status":"open","priority":2,"issue_type":"feature","created_at":"2025-12-19T23:13:02.482971+01:00","updated_at":"2025-12-22T22:11:38.639588+01:00"} {"id":"agent-relay-go9","title":"PostgreSQL storage adapter not implemented","description":"In storage/adapter.ts:152-162, PostgreSQL is listed as a storage option but throws 'not yet implemented'. For production multi-node deployments, SQLite won't scale. Implement PostgreSQL adapter for distributed storage.","status":"open","priority":2,"issue_type":"feature","created_at":"2025-12-20T00:17:45.065487+01:00","updated_at":"2025-12-20T00:17:45.065487+01:00"} {"id":"agent-relay-hgd","title":"Reliable delivery: resume/replay via cursor/checkpoint","description":"Make ACK/RESUME real: persist a per-agent delivery cursor (last delivered/acked seq) and on reconnect replay missed messages from storage. Use existing delivery.seq as stream position, similar to swarm-mail DurableCursor checkpointing.","acceptance_criteria":"- Client can reconnect and receive missed messages\\n- Duplicate suppression is deterministic (id/seq based)\\n- Cursor persists across daemon restarts","notes":"Persistent resume tokens with stored cursors; replay unacked messages on reconnect; ack tracking + dedup; tests passing.","status":"in_progress","priority":1,"issue_type":"task","assignee":"BE-Dev","created_at":"2025-12-20T21:44:02.016428+01:00","updated_at":"2025-12-26T12:13:54.547885+01:00","labels":["durability","protocol"]} {"id":"agent-relay-hgn","title":"Agent team hierarchies with specialized sub-agents","description":"Each main agent should be able to spawn and coordinate a team of specialized sub-agents:\n\n- **Lead** → Operations Consultant (monitors behavior, provides retrospective feedback on coordination)\n- **Implementer** → Code Reviewer (reviews code before commits, suggests improvements)\n- **Designer** → UX Reviewer (validates design decisions)\n- **Architect** → Security Auditor (checks for vulnerabilities)\n\nImplementation ideas:\n1. Define agent profiles with 'team' configurations in .claude/agents/\n2. When an agent starts, it can auto-spawn its team members\n3. Sub-agents monitor the parent agent's work and provide feedback\n4. At session end, sub-agents provide retrospective summaries\n5. Use -\u003erelay:spawn to create sub-agents with specific roles\n\nThis enables each agent to have a dedicated support team for quality assurance and improvement.","status":"open","priority":2,"issue_type":"feature","created_at":"2025-12-23T15:03:56.608847+01:00","updated_at":"2025-12-23T15:03:56.608847+01:00"} -{"id":"agent-relay-hgw","title":"PR-9 Review: Add reconnection logic to MultiProjectClient","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-22T21:54:09.712003+01:00","updated_at":"2025-12-24T11:50:55.708361+01:00","closed_at":"2025-12-24T11:50:55.708361+01:00"} +{"id":"agent-relay-hgw","title":"PR-9 Review: Add reconnection logic to MultiProjectClient","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-22T21:54:09.712003+01:00","updated_at":"2025-12-24T11:50:55.708361+01:00","closed_at":"2025-12-24T11:50:55.708361+01:00"} {"id":"agent-relay-hks","title":"Increase test coverage for daemon/server.ts, dashboard, and CLI","description":"Current coverage is only 39% overall. Key files with 0% coverage: daemon/server.ts, dashboard/server.ts, cli/index.ts, wrapper/client.ts, wrapper/tmux-wrapper.ts, utils/project-namespace.ts. Need integration tests for the daemon startup/shutdown lifecycle and CLI commands.","status":"closed","priority":2,"issue_type":"task","assignee":"Lead","created_at":"2025-12-20T00:17:29.603137+01:00","updated_at":"2025-12-22T17:14:41.611717+01:00","closed_at":"2025-12-22T17:14:41.611717+01:00"} {"id":"agent-relay-hr1","title":"Show project reconnection status in dashboard","description":"Display project connection status (Connected/Reconnecting/Disconnected) in dashboard header. Use onProjectStateChange callbacks from MultiProjectClient to drive 'Connected/Disconnecting' indicators. Show per-project status and overall relay connection health. Color code: green (connected), yellow (reconnecting), red (disconnected).","status":"closed","priority":2,"issue_type":"feature","created_at":"2025-12-24T11:51:32.74308+01:00","updated_at":"2025-12-24T11:52:22.778492+01:00","closed_at":"2025-12-24T11:52:22.778492+01:00"} {"id":"agent-relay-hvm","title":"Dashboard: Add ability to send messages to agents","description":"Add a message compose UI in the dashboard to send messages to any connected agent. Should include: (1) Agent selector dropdown, (2) Message input field, (3) Send button that dispatches via the relay protocol. This complements the existing message viewing capability.","status":"closed","priority":2,"issue_type":"feature","created_at":"2025-12-22T15:31:07.022117+01:00","updated_at":"2025-12-22T15:36:18.78037+01:00","closed_at":"2025-12-22T15:36:18.78037+01:00"} {"id":"agent-relay-i5f","title":"Fix rendering and display issues","description":"Relay messages display with garbled/overlapping text in terminal. Characters appear scattered and mixed with other terminal content. Example: message text like 'Ping me when you're ready' renders with letters displaced across the line. PR #10 added input detection (isInputClear, waitForClearInput, getCursorX) to prevent this, but it's not working effectively. Need to investigate why the input detection/wait logic isn't preventing message injection during active typing.","notes":"Parser improvements for continuation lines (bullets, numbered lists, box drawing chars for Gemini). Debug flag set to false.","status":"closed","priority":1,"issue_type":"bug","created_at":"2025-12-22T11:59:53.254169+01:00","updated_at":"2025-12-22T14:21:32.043374+01:00","closed_at":"2025-12-22T14:21:32.043374+01:00","labels":["review"]} +{"id":"agent-relay-i6gt","title":"Debug thinking block filter - tests fail despite correct patterns","notes":"Investigation notes:\n\nWhat was implemented:\n- Added THINKING_START and THINKING_END patterns to parser.ts (lines 69-71)\n- Added inThinkingBlock state variable (line 157)\n- Added detection logic in parsePassThrough (lines 445-465)\n- Added reset logic in flush() and reset() methods\n\nThe problem:\nTests show the thinking block content is NOT being filtered - it passes through to output.\nThe patterns match correctly when tested standalone in Node.\n\nWhat to investigate:\n1. The check is at lines 456-465 in parser.ts - why doesn't it match at runtime?\n2. Could be an issue with how the for loop flows or where the check is placed\n3. Try adding console.log in the detection code to trace execution\n4. The compiled code at dist/wrapper/parser.js lines 380-390 looks correct\n\nFiles modified:\n- src/wrapper/parser.ts - thinking block filter implementation\n- Tests were written but reverted (failing)","status":"open","priority":2,"issue_type":"bug","created_at":"2025-12-27T11:42:23.020797+01:00","updated_at":"2025-12-27T11:42:44.912268+01:00"} {"id":"agent-relay-i7s","title":"Add priority-based message queuing","description":"Implement message priority levels (urgent/high/normal/low) like AI Maestro. Urgent messages jump the queue and interrupt agents. Enables time-sensitive coordination in production scenarios.","status":"open","priority":3,"issue_type":"feature","created_at":"2025-12-23T17:04:48.031237+01:00","updated_at":"2025-12-23T17:04:48.031237+01:00"} -{"id":"agent-relay-j7y","title":"Implement composer toolbar buttons (bold, emoji)","description":"","status":"closed","priority":2,"issue_type":"bug","created_at":"2025-12-23T16:26:23.598781+01:00","updated_at":"2025-12-23T16:27:53.92751+01:00","closed_at":"2025-12-23T16:27:53.92751+01:00"} -{"id":"agent-relay-j9z","title":"Message injection corrupts human input in progress","description":"When a relay message arrives while the human is actively typing, the injection (Esc + Ctrl-U + message + Enter) destroys their partial input. Need to detect active input state before injecting. Options: (1) Check tmux pane input buffer, (2) Queue messages until prompt detected, (3) Use tmux display-message instead of send-keys for non-intrusive notification, (4) Add visual indicator that message is pending.","design":"Mitigation ideas: 1) Prefer tmux non-intrusive notify: tmux display-message (and always write to inbox/storage) instead of send-keys when user is typing. 2) Gate send-keys injection on 'safe' state: pane idle AND prompt detected AND no partial input; otherwise queue. 3) If queued, show pending count via display-message.","status":"open","priority":1,"issue_type":"bug","created_at":"2025-12-20T21:48:03.280298+01:00","updated_at":"2025-12-20T22:00:04.196161+01:00","dependencies":[{"issue_id":"agent-relay-j9z","depends_on_id":"agent-relay-6rz","type":"blocks","created_at":"2025-12-20T21:48:10.118963+01:00","created_by":"daemon"}]} +{"id":"agent-relay-iblw","title":"Allow agents to spawn other agents via agent-relay API","description":"Agents should be able to spawn new agent processes programmatically via agent-relay API. Enables dynamic team scaling and parallel agent coordination. Implement: (1) Agent spawn endpoint, (2) API wrapper for agent code, (3) Spawn coordination, (4) Resource limits, (5) Tests.","status":"open","priority":2,"issue_type":"feature","created_at":"2025-12-26T17:08:22.05611+01:00","updated_at":"2025-12-26T17:08:22.05611+01:00"} +{"id":"agent-relay-iic5","title":"Multi-line relay messages truncated in dashboard UI (regression)","description":"Multi-line relay messages are only displaying the first line in the dashboard UI, even though the parser is capturing all lines. Root cause investigation needed: (1) Dashboard rendering (may be truncating display), (2) WebSocket message transmission, (3) Message state management. Previously marked as fixed but still broken.","status":"closed","priority":1,"issue_type":"bug","created_at":"2025-12-26T17:05:33.146646+01:00","updated_at":"2025-12-26T17:20:58.611383+01:00","closed_at":"2025-12-26T17:20:58.611383+01:00"} +{"id":"agent-relay-j7y","title":"Implement composer toolbar buttons (bold, emoji)","status":"closed","priority":2,"issue_type":"bug","created_at":"2025-12-23T16:26:23.598781+01:00","updated_at":"2025-12-23T16:27:53.92751+01:00","closed_at":"2025-12-23T16:27:53.92751+01:00"} +{"id":"agent-relay-j9z","title":"Message injection corrupts human input in progress","description":"When a relay message arrives while the human is actively typing, the injection (Esc + Ctrl-U + message + Enter) destroys their partial input. Need to detect active input state before injecting. Options: (1) Check tmux pane input buffer, (2) Queue messages until prompt detected, (3) Use tmux display-message instead of send-keys for non-intrusive notification, (4) Add visual indicator that message is pending.","design":"Mitigation ideas: 1) Prefer tmux non-intrusive notify: tmux display-message (and always write to inbox/storage) instead of send-keys when user is typing. 2) Gate send-keys injection on 'safe' state: pane idle AND prompt detected AND no partial input; otherwise queue. 3) If queued, show pending count via display-message.","status":"open","priority":1,"issue_type":"bug","created_at":"2025-12-20T21:48:03.280298+01:00","updated_at":"2025-12-20T22:00:04.196161+01:00","dependencies":[{"issue_id":"agent-relay-j9z","depends_on_id":"agent-relay-6rz","type":"blocks","created_at":"2025-12-20T21:48:10.118963+01:00","created_by":"daemon","metadata":"{}"}]} +{"id":"agent-relay-jmwh","title":"Bundle tmux or provide fallback so users don't need to install separately","description":"User installation failed: tmux command not found. Currently agent-relay requires tmux to be pre-installed on the system. Need to bundle tmux or provide a fallback so installation works out-of-the-box.\n\nOptions to explore:\n1. NPM package that includes tmux binaries (npm tmux package or similar)\n2. Postinstall script that downloads/installs tmux\n3. Fallback to alternative shell mechanism if tmux unavailable\n4. Docker-based approach (out of scope but document)\n\nAcceptance: Installation should succeed and agent-relay should work without requiring manual tmux install","status":"closed","priority":0,"issue_type":"feature","assignee":"FullStack","created_at":"2025-12-28T18:51:04.556744-05:00","updated_at":"2025-12-28T19:32:54.921952-05:00","closed_at":"2025-12-28T19:32:54.921952-05:00","close_reason":"Implemented: postinstall script downloads tmux, tmux-resolver provides system→bundled fallback, all code uses resolved path, tests pass"} {"id":"agent-relay-k8t","title":"Consider backend-focused agent for daemon work","description":"Session retrospective: only had FE-Dev specialist, so backend work (permission detection, daemon issues) fell to Lead. Consider spawning a dedicated backend agent for daemon/server work in future sessions.","status":"open","priority":3,"issue_type":"task","created_at":"2025-12-23T23:10:24.756163+01:00","updated_at":"2025-12-23T23:10:24.756163+01:00"} {"id":"agent-relay-kaj","title":"Show project connection status in dashboard (Connected/Reconnecting/Disconnected)","description":"Use onProjectStateChange callbacks from MultiProjectClient to show connection status in dashboard. When onProjectStateChange(id, false) fires, show 'Disconnected' or 'Reconnecting...' indicator. When onProjectStateChange(id, true) fires, show 'Connected'. Could use a status badge in the sidebar or header.","status":"closed","priority":2,"issue_type":"feature","assignee":"FE-Dev","created_at":"2025-12-24T11:51:40.774105+01:00","updated_at":"2025-12-24T11:55:00.944949+01:00","closed_at":"2025-12-24T11:55:00.944949+01:00"} {"id":"agent-relay-kto","title":"Add topic-based pub/sub messaging","description":"Add topic subscription alongside direct addressing. Agents can subscribe to topics (e.g., 'errors', 'deploys', 'reviews') and receive all messages on those topics. Complements existing -\u003erelay:Name with -\u003erelay:#topic pattern. Pattern from Claude-Flow's message bus.","status":"open","priority":3,"issue_type":"feature","created_at":"2025-12-23T17:05:27.213842+01:00","updated_at":"2025-12-23T17:05:27.213842+01:00"} +{"id":"agent-relay-kw80","title":"Plan and execute Dashboard v1 → v2 migration","description":"## Dashboard v1 → v2 Migration Plan\n\n### Feature Parity Analysis (COMPLETED)\n\n**V1 Features in V2:** ✅ ALL FEATURES PORTED\n- Agent list with online/offline status\n- Message list with date dividers \n- Channel selection (general + DMs)\n- Command palette (Cmd+K)\n- Spawn agent modal\n- Thread support\n- Fleet view (multi-server)\n- Release agent button\n- Needs attention badge\n- ✅ @-mention autocomplete (agent-relay-ekk9 - DONE)\n\n**V2-Only Features (Enhancements):**\n- Theme support (light/dark/system)\n- Settings panel\n- Trajectory viewer\n- Decision queue\n- Enhanced broadcast composer with templates\n- Notification toasts\n\n### Migration Steps\n\n1. **[agent-relay-lls5]** Configure parallel running (v1:4280, v2:4281) - READY\n2. **[agent-relay-s8hg]** Add --dashboard=v1|v2 CLI flag - blocked by lls5\n3. ~~**[agent-relay-ekk9]** Fix @-mention autocomplete~~ ✅ DONE\n4. **[agent-relay-qkey]** Switch default to v2 - blocked by s8hg\n\n### Rollback Procedure\n\n1. Use `agent-relay up --dashboard=v1` to revert instantly\n2. V1 code remains in place until v2 is stable\n3. No data migration needed - both share same backend/storage","status":"open","priority":2,"issue_type":"feature","created_at":"2025-12-26T17:01:58.36924+01:00","updated_at":"2025-12-27T05:49:48.797956+01:00","dependencies":[{"issue_id":"agent-relay-kw80","depends_on_id":"agent-relay-cuer","type":"blocks","created_at":"2025-12-26T17:02:04.103774+01:00","created_by":"daemon","metadata":"{}"},{"issue_id":"agent-relay-kw80","depends_on_id":"agent-relay-ekk9","type":"blocks","created_at":"2025-12-27T05:36:37.645846+01:00","created_by":"daemon"}]} {"id":"agent-relay-kzw","title":"Project namespace uses /tmp which can be cleared on reboot","description":"In utils/project-namespace.ts:13, BASE_DIR is /tmp/agent-relay. On macOS/Linux, /tmp is cleared on reboot, losing all message history. Consider: (1) XDG_DATA_HOME fallback, (2) ~/.agent-relay option, (3) Per-project .agent-relay folder.","status":"open","priority":2,"issue_type":"task","created_at":"2025-12-20T00:18:33.224965+01:00","updated_at":"2025-12-20T00:18:33.224965+01:00"} -{"id":"agent-relay-l99","title":"Add output filtering for cleaner logs","description":"Filter noisy patterns from logs: thinking indicators [1/418], empty ANSI-only lines, etc. Add config.filterLogs option. See docs/TMUX_IMPROVEMENTS.md for implementation details.","status":"open","priority":4,"issue_type":"feature","created_at":"2025-12-20T21:28:51.199736+01:00","updated_at":"2025-12-20T21:28:51.199736+01:00","dependencies":[{"issue_id":"agent-relay-l99","depends_on_id":"agent-relay-1ek","type":"blocks","created_at":"2025-12-20T21:29:26.987526+01:00","created_by":"daemon"}]} -{"id":"agent-relay-lto","title":"Command palette channel/chat selection","description":"","status":"closed","priority":2,"issue_type":"feature","created_at":"2025-12-23T16:15:32.818842+01:00","updated_at":"2025-12-23T16:18:00.952354+01:00","closed_at":"2025-12-23T16:18:00.952354+01:00"} +{"id":"agent-relay-l99","title":"Add output filtering for cleaner logs","description":"Filter noisy patterns from logs: thinking indicators [1/418], empty ANSI-only lines, etc. Add config.filterLogs option. See docs/TMUX_IMPROVEMENTS.md for implementation details.","status":"open","priority":4,"issue_type":"feature","created_at":"2025-12-20T21:28:51.199736+01:00","updated_at":"2025-12-20T21:28:51.199736+01:00","dependencies":[{"issue_id":"agent-relay-l99","depends_on_id":"agent-relay-1ek","type":"blocks","created_at":"2025-12-20T21:29:26.987526+01:00","created_by":"daemon","metadata":"{}"}]} +{"id":"agent-relay-lfky","title":"Add 'agent thinking/processing' indicator to dashboard","description":"When an agent receives a message, the dashboard should show a visual indicator that the agent is processing/thinking. Similar to 'typing...' indicators in chat apps.\n\n**Requirements:**\n1. Backend: Track agent status (idle/processing/responding)\n2. Protocol: Agents report when they start/finish processing a message\n3. Dashboard UI: Show pulsing/animated indicator next to agent when processing\n4. Timeout: Auto-clear indicator if no response after X seconds\n\n**Implementation options:**\n- Agents send STATUS: PROCESSING / STATUS: IDLE messages\n- Or use heartbeat with embedded state\n- Or infer from message timestamps (less reliable)\n\nRequested by: Dashboard agent during v1→v2 migration planning","status":"closed","priority":2,"issue_type":"feature","created_at":"2025-12-27T05:38:19.022332+01:00","updated_at":"2025-12-27T10:59:41.070383+01:00","closed_at":"2025-12-27T10:59:41.070383+01:00","close_reason":"Processing indicator implementation complete with router tracking and dashboard color support"} +{"id":"agent-relay-lls5","title":"Configure daemon to serve v2 dashboard on alt port","description":"Add daemon configuration to serve v2 dashboard (Next.js) on port 4281 while keeping v1 on 4280. This enables parallel running during migration period.","status":"open","priority":2,"issue_type":"task","created_at":"2025-12-27T05:36:49.475705+01:00","updated_at":"2025-12-27T05:36:49.475705+01:00"} +{"id":"agent-relay-lto","title":"Command palette channel/chat selection","status":"closed","priority":2,"issue_type":"feature","created_at":"2025-12-23T16:15:32.818842+01:00","updated_at":"2025-12-23T16:18:00.952354+01:00","closed_at":"2025-12-23T16:18:00.952354+01:00"} {"id":"agent-relay-mcl","title":"Gemini agent cannot send messages properly","description":"Gemini agent cannot autonomously send relay messages. When Gemini outputs @relay: patterns, it enters shell mode instead of being intercepted by the relay wrapper. Manual typing works fine. This suggests a PTY parsing issue specific to how Gemini outputs the @relay: pattern - possibly timing, escaping, or output buffering differences compared to Claude.","status":"closed","priority":2,"issue_type":"bug","assignee":"LeadDev","created_at":"2025-12-20T00:29:15.797552+01:00","updated_at":"2025-12-20T21:56:07.20143+01:00","closed_at":"2025-12-20T21:56:07.20143+01:00"} {"id":"agent-relay-mkz","title":"Fix multi-line message display in dashboard","description":"Multi-line messages are not displaying properly in the dashboard UI. This is a critical UX issue affecting message readability. Need to fix the frontend rendering of message content to properly handle newlines, formatting, and multi-line text.","status":"closed","priority":1,"issue_type":"bug","assignee":"Frontend","created_at":"2025-12-25T14:11:25.663841+01:00","updated_at":"2025-12-26T11:51:07.14395+01:00","closed_at":"2025-12-26T11:51:07.14395+01:00"} -{"id":"agent-relay-n36","title":"Add metrics dashboard for system health monitoring","description":"","status":"closed","priority":2,"issue_type":"feature","created_at":"2025-12-25T14:07:36.499133+01:00","updated_at":"2025-12-26T11:41:20.33149+01:00","closed_at":"2025-12-26T11:41:20.33149+01:00"} -{"id":"agent-relay-nox","title":"Dashboard not showing live agents or messages","description":"","status":"closed","priority":1,"issue_type":"bug","created_at":"2025-12-23T12:11:48.614811+01:00","updated_at":"2025-12-23T12:26:51.322956+01:00","closed_at":"2025-12-23T12:26:51.322956+01:00"} +{"id":"agent-relay-mplo","title":"Parser: Fix overcapturing text in multi-line relay messages","description":"Recent parser improvements (agent-relay-6dl8 fix) are still overcapturing text in multi-line relay messages. Dashboard reports text is being included beyond message boundaries. Need to review parser.ts finishBlock() and handle parsing edge cases.","status":"closed","priority":1,"issue_type":"bug","assignee":"FullStack","created_at":"2025-12-26T21:06:42.006611+01:00","updated_at":"2025-12-27T05:43:25.432704+01:00","closed_at":"2025-12-27T05:43:25.432704+01:00","close_reason":"Fixed: Reverted parser to simpler logic - inline messages are single-line only, only indented lines continue (TUI wrapping). Multi-line messages should use [[RELAY]] block format."} +{"id":"agent-relay-n36","title":"Add metrics dashboard for system health monitoring","status":"closed","priority":2,"issue_type":"feature","created_at":"2025-12-25T14:07:36.499133+01:00","updated_at":"2025-12-26T11:41:20.33149+01:00","closed_at":"2025-12-26T11:41:20.33149+01:00"} +{"id":"agent-relay-nox","title":"Dashboard not showing live agents or messages","status":"closed","priority":1,"issue_type":"bug","created_at":"2025-12-23T12:11:48.614811+01:00","updated_at":"2025-12-23T12:26:51.322956+01:00","closed_at":"2025-12-23T12:26:51.322956+01:00"} {"id":"agent-relay-oiw","title":"BUG: No per-agent inbox files created","description":"Expected /tmp/agent-relay/\u003cproject\u003e/\u003cagent\u003e/inbox.md but only team/agents.json exists. Agents cannot read their inbox via file.","status":"closed","priority":2,"issue_type":"bug","created_at":"2025-12-20T21:46:24.826442+01:00","updated_at":"2025-12-22T14:29:46.830632+01:00","closed_at":"2025-12-22T14:29:46.830632+01:00"} {"id":"agent-relay-p0k","title":"Gemini agents cannot send relay messages","description":"Gem (Gemini CLI) can receive messages but cannot send them. Tried: -\u003erelay:, \u003e\u003erelay:, escaping with backticks, splitting lines. Messages don't get captured/sent. Possibly Gemini CLI output handling differs from Claude/Codex. Need to investigate how Gemini outputs text and whether the tmux capture-pane is picking it up correctly.","status":"closed","priority":1,"issue_type":"bug","created_at":"2025-12-22T13:52:43.407612+01:00","updated_at":"2025-12-22T13:55:11.068344+01:00","closed_at":"2025-12-22T13:55:11.068344+01:00"} -{"id":"agent-relay-pvx","title":"Dashboard agent detection debugging","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-23T17:14:36.376736+01:00","updated_at":"2025-12-23T17:16:13.115531+01:00","closed_at":"2025-12-23T17:16:13.115531+01:00"} +{"id":"agent-relay-pu6h","title":"Align metrics page styling with dashboard","description":"The metrics page styling is completely different from the main dashboard. Need to update it to match the dashboard's look and feel.","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-28T22:09:13.958899-05:00","updated_at":"2025-12-28T22:13:36.714948-05:00","closed_at":"2025-12-28T22:13:36.714948-05:00","close_reason":"Fixed by Frontend - updated colors, fonts, removed sci-fi effects to match dashboard"} +{"id":"agent-relay-pvx","title":"Dashboard agent detection debugging","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-23T17:14:36.376736+01:00","updated_at":"2025-12-23T17:16:13.115531+01:00","closed_at":"2025-12-23T17:16:13.115531+01:00"} +{"id":"agent-relay-qg4e","title":"Fix line break display in dashboard messages","description":"Line breaks in messages display as extra spaces instead of actual line breaks. Messages should render with proper newlines.","status":"closed","priority":1,"issue_type":"bug","created_at":"2025-12-28T22:09:53.92514-05:00","updated_at":"2025-12-28T22:11:31.098231-05:00","closed_at":"2025-12-28T22:11:31.098231-05:00","close_reason":"Fixed by Frontend - added normalization for literal \\n strings, \\r\\n, and \\r"} +{"id":"agent-relay-qkey","title":"Switch default dashboard to v2","description":"After feature parity is verified and parallel running period is complete, switch the default dashboard from v1 to v2. Update agent-relay up command to serve v2 by default.","status":"open","priority":3,"issue_type":"task","created_at":"2025-12-27T05:36:52.036031+01:00","updated_at":"2025-12-27T05:36:52.036031+01:00","dependencies":[{"issue_id":"agent-relay-qkey","depends_on_id":"agent-relay-s8hg","type":"blocks","created_at":"2025-12-27T05:37:00.746565+01:00","created_by":"daemon"},{"issue_id":"agent-relay-qkey","depends_on_id":"agent-relay-ekk9","type":"blocks","created_at":"2025-12-27T05:37:00.817799+01:00","created_by":"daemon"}]} {"id":"agent-relay-rm7","title":"Add scheduling strategies for task distribution","description":"Implement scheduling strategies for distributing tasks across agents. Start with round-robin, then add capability-based, least-loaded, and affinity strategies. Inspired by Claude-Flow's 4-strategy approach. Enables autonomous task assignment instead of manual coordination.","status":"open","priority":2,"issue_type":"feature","created_at":"2025-12-23T17:03:22.014068+01:00","updated_at":"2025-12-23T17:03:22.014068+01:00"} {"id":"agent-relay-ruv","title":"Add work stealing for load balancing","description":"Implement work stealing so idle agents can claim tasks from overloaded peers. Key production feature for better resource utilization. Pattern from Claude-Flow's WorkStealingCoordinator. Requires task queue visibility and agent load metrics.","status":"open","priority":2,"issue_type":"feature","created_at":"2025-12-23T17:03:28.153623+01:00","updated_at":"2025-12-23T17:03:28.153623+01:00"} +{"id":"agent-relay-s8hg","title":"Add dashboard version switch to CLI","description":"Add --dashboard=v1|v2 flag to agent-relay up command to choose which dashboard version to run. Default should remain v1 until migration is complete.","status":"open","priority":2,"issue_type":"task","created_at":"2025-12-27T05:36:51.0113+01:00","updated_at":"2025-12-27T05:36:51.0113+01:00","dependencies":[{"issue_id":"agent-relay-s8hg","depends_on_id":"agent-relay-lls5","type":"blocks","created_at":"2025-12-27T05:37:00.684918+01:00","created_by":"daemon"}]} {"id":"agent-relay-sio","title":"Add graceful degradation when relay daemon is unavailable","description":"In wrapper/tmux-wrapper.ts:195-197, daemon connection failures are silently caught. Consider: (1) Periodic reconnection attempts, (2) Queueing messages for later delivery, (3) Visual indicator in terminal showing connection status.","status":"closed","priority":2,"issue_type":"feature","assignee":"Implementer","created_at":"2025-12-20T00:17:32.600333+01:00","updated_at":"2025-12-22T17:12:29.892345+01:00","closed_at":"2025-12-22T17:12:29.892345+01:00"} -{"id":"agent-relay-t4b","title":"Add spawn agent UI to dashboard","description":"","status":"closed","priority":2,"issue_type":"feature","created_at":"2025-12-25T13:20:54.452959+01:00","updated_at":"2025-12-25T13:25:05.019339+01:00","closed_at":"2025-12-25T13:25:05.019339+01:00"} +{"id":"agent-relay-t4b","title":"Add spawn agent UI to dashboard","status":"closed","priority":2,"issue_type":"feature","created_at":"2025-12-25T13:20:54.452959+01:00","updated_at":"2025-12-25T13:25:05.019339+01:00","closed_at":"2025-12-25T13:25:05.019339+01:00"} {"id":"agent-relay-tun","title":"Debug output leaking to terminal during relay message send","description":"When sending relay messages (observed with relay:Gem), debug output is leaking to the terminal repeatedly. Output shows: 'relay:Gem] isInputClear: lastLine=\"\" ~/.../agent-relay (main*)' and 'ear=false' (truncated 'isInputClear=false'). Lines are split/corrupted and repeating in a loop. Likely issue in the tmux wrapper or message injection code where debug logging isn't being suppressed properly.","status":"closed","priority":1,"issue_type":"bug","assignee":"Lead","created_at":"2025-12-22T13:40:28.576361+01:00","updated_at":"2025-12-22T13:42:16.746166+01:00","closed_at":"2025-12-22T13:42:16.746166+01:00"} {"id":"agent-relay-tx9","title":"Add circuit breaker for unresponsive agents","description":"Implement circuit breaker pattern to handle unresponsive or failing agents. Auto-detect agents that stop responding, temporarily remove from routing, attempt recovery, alert on persistent failures. Prevents cascade failures in production. Pattern from Claude-Flow's CircuitBreakerManager.","status":"open","priority":2,"issue_type":"feature","created_at":"2025-12-23T17:03:48.162055+01:00","updated_at":"2025-12-23T17:03:48.162055+01:00"} {"id":"agent-relay-u2z","title":"Add @-mention autocomplete with Tab in dashboard chat input","description":"When a user types '@' in the dashboard chat input, show a dropdown of available/connected agents. Tab key should autocomplete the selected agent name. This enables easy @-mentioning of agents for direct messages.","status":"closed","priority":1,"issue_type":"feature","assignee":"FE-Dev","created_at":"2025-12-23T22:52:34.749062+01:00","updated_at":"2025-12-23T22:57:11.634365+01:00","closed_at":"2025-12-23T22:57:11.634365+01:00"} @@ -115,18 +134,19 @@ {"id":"agent-relay-ujq","title":"Add @-mention autocomplete in chat input","description":"When typing @ in the chat input, show autocomplete dropdown with available agent names. Tab to complete selection, arrow keys to navigate through options. Should filter as user types after @.","status":"closed","priority":1,"issue_type":"feature","assignee":"FE-Dev","created_at":"2025-12-24T08:01:03.793267+01:00","updated_at":"2025-12-24T08:02:51.638981+01:00","closed_at":"2025-12-24T08:02:51.638981+01:00"} {"id":"agent-relay-v57","title":"No message expiration/cleanup in SQLite storage","description":"SQLite adapter has no TTL or cleanup mechanism for old messages. Over time, the database will grow unbounded. Add: (1) Configurable message retention period, (2) Automatic cleanup job, (3) Index on ts column is there but no cleanup uses it.","status":"closed","priority":2,"issue_type":"task","assignee":"Lead","created_at":"2025-12-20T00:18:01.86766+01:00","updated_at":"2025-12-22T17:17:41.867951+01:00","closed_at":"2025-12-22T17:17:41.867951+01:00"} {"id":"agent-relay-v7f","title":"Add conflict detection and resolution","description":"Implement conflict detection when multiple agents work on same files/resources. Add resolution strategies: priority-based, timestamp-based, voting-based. Prevents race conditions in production. Pattern from Claude-Flow's ConflictResolver and OptimisticLockManager.","status":"open","priority":2,"issue_type":"feature","created_at":"2025-12-23T17:03:34.498546+01:00","updated_at":"2025-12-23T17:03:34.498546+01:00"} +{"id":"agent-relay-v9rc","title":"Fix light/dark mode toggle in settings","description":"The light mode / dark mode switch from the settings panel doesn't work","status":"closed","priority":2,"issue_type":"bug","created_at":"2025-12-28T22:05:41.332721-05:00","updated_at":"2025-12-28T22:08:21.904724-05:00","closed_at":"2025-12-28T22:08:21.904724-05:00","close_reason":"Fixed by Frontend - added light theme CSS variables and useEffect for data-theme attribute"} {"id":"agent-relay-vxc","title":"Navigation consistency between bridge and project views","description":"Ensure navigation patterns are consistent when switching between bridge and project dashboard views","status":"closed","priority":2,"issue_type":"task","assignee":"FE-Dev","created_at":"2025-12-24T11:47:46.62539+01:00","updated_at":"2025-12-24T12:00:15.805473+01:00","closed_at":"2025-12-24T12:00:15.805473+01:00"} -{"id":"agent-relay-wsd","title":"URGENT: Gemini interprets relay messages as shell commands","description":"","status":"closed","priority":0,"issue_type":"bug","created_at":"2025-12-22T14:23:05.989732+01:00","updated_at":"2025-12-22T14:24:18.714626+01:00","closed_at":"2025-12-22T14:24:18.714626+01:00"} +{"id":"agent-relay-wsd","title":"URGENT: Gemini interprets relay messages as shell commands","status":"closed","priority":0,"issue_type":"bug","created_at":"2025-12-22T14:23:05.989732+01:00","updated_at":"2025-12-22T14:24:18.714626+01:00","closed_at":"2025-12-22T14:24:18.714626+01:00"} {"id":"agent-relay-xn1","title":"Add remote access via Cloudflare tunnels","description":"Enable remote/mobile access to agent-relay dashboard and control. Use Cloudflare tunnels (like Maestro) for secure access without port forwarding. Include QR code generation for easy mobile connection. Major gap vs competitors - AI Maestro and Maestro both have this.","status":"open","priority":2,"issue_type":"feature","created_at":"2025-12-23T17:04:28.788302+01:00","updated_at":"2025-12-23T17:04:28.788302+01:00"} {"id":"agent-relay-ylf","title":"Add knowledge graph for persistent memory","description":"Implement knowledge graph storage (like Mimir's Neo4j approach) for persistent cross-session context. Store entities, relationships, and semantic embeddings. Enable agents to learn from past sessions and share knowledge. Could use SQLite with FTS5 or optional Neo4j adapter.","status":"open","priority":3,"issue_type":"feature","created_at":"2025-12-23T17:05:08.367875+01:00","updated_at":"2025-12-23T17:05:08.367875+01:00"} {"id":"agent-relay-ytj","title":"Filter chat view to show only messages with selected agent","description":"When clicking on a particular agent in the dashboard sidebar, the chat view should filter to only show messages between Dashboard and that specific agent. This provides a focused conversation view instead of showing all messages.","status":"closed","priority":2,"issue_type":"feature","assignee":"FE-Dev","created_at":"2025-12-23T22:53:31.325805+01:00","updated_at":"2025-12-23T22:58:41.407339+01:00","closed_at":"2025-12-23T22:58:41.407339+01:00"} {"id":"agent-relay-ytk","title":"Simplify agent-relay CLI interface","description":"The CLI has too many commands. Consolidate into a clean, simple interface:\n\n1. `relay start` should also kick off the dashboard automatically\n2. Merge redundant team-* commands\n3. Remove rarely-used commands or make them subcommands\n4. Target: 5-7 top-level commands max\n\nCurrent commands to evaluate:\n- start, stop, status (keep)\n- wrap (keep)\n- project (keep or merge into status)\n- send (keep)\n- team-setup, team-status, team-send, team-check, team-listen, team-start (consolidate)\n- msg-read (make subcommand or integrate)\n- dashboard (merge into start)","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-22T21:51:14.010849+01:00","updated_at":"2025-12-22T21:51:14.010849+01:00","closed_at":"2025-12-19T22:08:44.107992+01:00"} {"id":"agent-relay-ytl","title":"Add cross-project message syntax to parser","description":"Extend the parser (src/wrapper/parser.ts) to recognize @relay:project:agent syntax for cross-project messaging. Currently only @relay:agent is parsed. Need to add pattern matching for the project:agent format.","status":"closed","priority":1,"issue_type":"feature","created_at":"2025-12-21T10:45:00Z","updated_at":"2025-12-24T11:46:51.622328+01:00","closed_at":"2025-12-24T11:46:51.622328+01:00"} {"id":"agent-relay-ytm","title":"Enhance dashboard for multi-project bridge view","description":"Add a bridge view to the dashboard that shows all connected projects, their leads, workers, and cross-project message flow. Should be accessible at /bridge?projects=... See docs/DESIGN_BRIDGE_STAFFING.md for mockups.","status":"closed","priority":2,"issue_type":"feature","created_at":"2025-12-21T10:45:00Z","updated_at":"2025-12-22T22:04:18.511591+01:00","closed_at":"2025-12-22T22:04:18.511591+01:00"} -{"id":"agent-relay-ytn","title":"Shadow agent pairing: CLI support","description":"Add --shadow and --shadow-role CLI flags to spawn paired agents. Example: `agent-relay -n Lead --shadow Auditor claude` spawns Lead with an Auditor shadow that observes and provides feedback. The shadow receives all messages the primary sends/receives.","status":"open","priority":1,"issue_type":"feature","created_at":"2025-12-25T08:00:00Z","updated_at":"2025-12-25T08:00:00Z","labels":["shadow-agents"]} -{"id":"agent-relay-yto","title":"Shadow agent pairing: Config file support","description":"Support shadow agent configuration in .agent-relay.json. Define pairs (primary-\u003eshadow mappings) and roles (prompt templates like performance-auditor, integration-tester). Config format: { pairs: { Lead: { shadow: 'Auditor', shadowRole: 'performance-auditor' } }, roles: { 'performance-auditor': { prompt: '...', speakOn: ['SESSION_END'] } } }","status":"open","priority":2,"issue_type":"feature","created_at":"2025-12-25T08:00:00Z","updated_at":"2025-12-25T08:00:00Z","labels":["shadow-agents"],"dependencies":[{"issue_id":"agent-relay-yto","depends_on_id":"agent-relay-ytn","type":"blocks","created_at":"2025-12-25T08:00:00Z","created_by":"daemon"}]} -{"id":"agent-relay-ytp","title":"Shadow agent pairing: spawnWithShadow() API","description":"Programmatic API for spawning paired agents. Example: `const { primary, shadow } = await spawnWithShadow({ primary: { name: 'Lead', command: 'claude' }, shadow: { name: 'Auditor', role: 'performance-auditor', speakOn: ['SESSION_END'] } })`. Returns handles to both agents for orchestration.","status":"open","priority":2,"issue_type":"feature","created_at":"2025-12-25T08:00:00Z","updated_at":"2025-12-25T08:00:00Z","labels":["shadow-agents"],"dependencies":[{"issue_id":"agent-relay-ytp","depends_on_id":"agent-relay-ytn","type":"blocks","created_at":"2025-12-25T08:00:00Z","created_by":"daemon"}]} -{"id":"agent-relay-ytq","title":"Shadow agent pairing: Built-in roles","description":"Ship built-in shadow roles: (1) performance-auditor - observes silently, provides feedback at session end; (2) integration-tester - writes tests as primary writes code; (3) code-reviewer - reviews changes before commit; (4) security-auditor - flags security issues in real-time. Roles define prompts and speakOn triggers.","status":"open","priority":3,"issue_type":"feature","created_at":"2025-12-25T08:00:00Z","updated_at":"2025-12-25T08:00:00Z","labels":["shadow-agents"],"dependencies":[{"issue_id":"agent-relay-ytq","depends_on_id":"agent-relay-yto","type":"blocks","created_at":"2025-12-25T08:00:00Z","created_by":"daemon"}]} +{"id":"agent-relay-ytn","title":"Shadow agent pairing: CLI support","description":"Add --shadow and --shadow-role CLI flags to spawn paired agents. Example: `agent-relay -n Lead --shadow Auditor claude` spawns Lead with an Auditor shadow that observes and provides feedback. The shadow receives all messages the primary sends/receives.","status":"in_progress","priority":1,"issue_type":"feature","created_at":"2025-12-25T08:00:00Z","updated_at":"2025-12-26T20:27:51.517714+01:00","labels":["shadow-agents"]} +{"id":"agent-relay-yto","title":"Shadow agent pairing: Config file support","description":"Support shadow agent configuration in .agent-relay.json. Define pairs (primary-\u003eshadow mappings) and roles (prompt templates like performance-auditor, integration-tester). Config format: { pairs: { Lead: { shadow: 'Auditor', shadowRole: 'performance-auditor' } }, roles: { 'performance-auditor': { prompt: '...', speakOn: ['SESSION_END'] } } }","status":"open","priority":1,"issue_type":"feature","created_at":"2025-12-25T08:00:00Z","updated_at":"2025-12-27T10:56:12.797952+01:00","labels":["shadow-agents"],"dependencies":[{"issue_id":"agent-relay-yto","depends_on_id":"agent-relay-ytn","type":"blocks","created_at":"2025-12-25T08:00:00Z","created_by":"daemon","metadata":"{}"}]} +{"id":"agent-relay-ytp","title":"Shadow agent pairing: spawnWithShadow() API","description":"Programmatic API for spawning paired agents. Example: `const { primary, shadow } = await spawnWithShadow({ primary: { name: 'Lead', command: 'claude' }, shadow: { name: 'Auditor', role: 'performance-auditor', speakOn: ['SESSION_END'] } })`. Returns handles to both agents for orchestration.","status":"open","priority":1,"issue_type":"feature","created_at":"2025-12-25T08:00:00Z","updated_at":"2025-12-27T10:56:14.164476+01:00","labels":["shadow-agents"],"dependencies":[{"issue_id":"agent-relay-ytp","depends_on_id":"agent-relay-ytn","type":"blocks","created_at":"2025-12-25T08:00:00Z","created_by":"daemon","metadata":"{}"}]} +{"id":"agent-relay-ytq","title":"Shadow agent pairing: Built-in roles","description":"Ship built-in shadow roles: (1) performance-auditor - observes silently, provides feedback at session end; (2) integration-tester - writes tests as primary writes code; (3) code-reviewer - reviews changes before commit; (4) security-auditor - flags security issues in real-time. Roles define prompts and speakOn triggers.","status":"open","priority":3,"issue_type":"feature","created_at":"2025-12-25T08:00:00Z","updated_at":"2025-12-25T08:00:00Z","labels":["shadow-agents"],"dependencies":[{"issue_id":"agent-relay-ytq","depends_on_id":"agent-relay-yto","type":"blocks","created_at":"2025-12-25T08:00:00Z","created_by":"daemon","metadata":"{}"}]} {"id":"agent-relay-ytr","title":"Shadow agent pairing: Message routing","description":"Implement shadow message routing: shadows receive copies of all messages their primary sends/receives. Add 'shadow' subscription type in router. Shadows can be configured to speakOn specific triggers (SESSION_END, CODE_WRITTEN, REVIEW_REQUEST) or stay silent until asked.","status":"open","priority":1,"issue_type":"feature","created_at":"2025-12-25T08:00:00Z","updated_at":"2025-12-25T08:00:00Z","labels":["shadow-agents"]} {"id":"agent-relay-yts","title":"Wire up spawn/release command handling in lead mode","description":"Lead agents can output @relay:spawn and @relay:release commands, but the handler needs to be wired up to actually call the AgentSpawner. The spawner exists in src/bridge/spawner.ts, need to intercept these commands in the TmuxWrapper or add a message handler.","status":"closed","priority":1,"issue_type":"feature","created_at":"2025-12-21T10:45:00Z","updated_at":"2025-12-24T15:09:22.953849+01:00","closed_at":"2025-12-24T15:09:22.953849+01:00"} {"id":"agent-relay-ytt","title":"Implement threaded conversations (Slack-style)","description":"Add thread replies with parent_message_id tracking in storage, nested thread UI display in dashboard, thread notification badges, and expand/collapse thread views. Backend: add parent_message_id column, thread reply routing. Frontend: inline thread expansion, reply count badges, thread-specific compose.","status":"closed","priority":1,"issue_type":"feature","created_at":"2025-12-23T12:00:00Z","updated_at":"2025-12-23T16:37:22.987643+01:00","closed_at":"2025-12-23T16:37:22.987643+01:00"} diff --git a/.claude/agents/lead.md b/.claude/agents/lead.md index 0130cc7f0..41db6285f 100644 --- a/.claude/agents/lead.md +++ b/.claude/agents/lead.md @@ -40,34 +40,40 @@ When delegating, match tasks to roles: ## Communication Patterns +**Always use the fenced format** for reliable message delivery. + ### Assigning Work ``` -->relay:Implementer **TASK:** [Clear task name] +->relay:Implementer <<< +**TASK:** [Clear task name] **Files:** [Specific files to modify] **Requirements:** [Bullet points of what's needed] **Acceptance:** [How to know it's done] -**Claim:** `bd update --status=in_progress` +**Claim:** `bd update --status=in_progress`>>> ``` ### Status Checks ``` -->relay:Implementer Status check - how's [task] coming? +->relay:Implementer <<< +Status check - how's [task] coming?>>> ``` ### Priority Changes ``` -->relay:* **PRIORITY CHANGE:** [New priority] +->relay:* <<< +**PRIORITY CHANGE:** [New priority] Previous task: [What they were doing] New task: [What they should do now] -Reason: [Why the change] +Reason: [Why the change]>>> ``` ### Acknowledging Completion ``` -->relay:Implementer Confirmed. [Brief feedback]. Next task: [or "stand by"] +->relay:Implementer <<< +Confirmed. [Brief feedback]. Next task: [or "stand by"]>>> ``` ## Anti-Patterns (What NOT To Do) @@ -75,13 +81,15 @@ Reason: [Why the change] ### Don't Get Deep ``` ❌ BAD: "Let me read through this 500-line file and understand the architecture..." -✅ GOOD: "->relay:Implementer Read src/complex.ts and summarize the key functions." +✅ GOOD: "->relay:Implementer <<< +Read src/complex.ts and summarize the key functions.>>>" ``` ### Don't Implement ``` ❌ BAD: Writing code, editing files, running tests yourself -✅ GOOD: "->relay:Implementer Fix the failing test in parser.test.ts" +✅ GOOD: "->relay:Implementer <<< +Fix the failing test in parser.test.ts>>>" ``` ### Don't Over-Explain @@ -115,11 +123,12 @@ bd create --title="Add feature X" --type=feature --priority=P2 ### 4. Delegate ``` -->relay:Implementer **TASK:** Add feature X +->relay:Implementer <<< +**TASK:** Add feature X **Issue:** agent-relay-xxx **Requirements:** [2-3 bullet points] -**Claim:** `bd update agent-relay-xxx --status=in_progress` +**Claim:** `bd update agent-relay-xxx --status=in_progress`>>> ``` ### 5. Monitor & Unblock @@ -132,7 +141,8 @@ bd create --title="Add feature X" --type=feature --priority=P2 bd close agent-relay-xxx --reason "Feature complete" ``` ``` -->relay:Implementer Task closed. Next: [next task or "stand by"] +->relay:Implementer <<< +Task closed. Next: [next task or "stand by"]>>> ``` ## Decision Framework @@ -149,7 +159,8 @@ When facing a decision: Periodically broadcast status: ``` -->relay:* **STATUS UPDATE:** +->relay:* <<< +**STATUS UPDATE:** | Agent | Task | Status | |-------|------|--------| @@ -157,7 +168,7 @@ Periodically broadcast status: | Designer | Dashboard UI | ✅ Complete | **Blockers:** None -**Next:** [What's coming next] +**Next:** [What's coming next]>>> ``` ## Session Summary Pattern diff --git a/.claude/skills/using-agent-relay/SKILL.md b/.claude/skills/using-agent-relay/SKILL.md index 808a1ae14..4e7c1bf71 100644 --- a/.claude/skills/using-agent-relay/SKILL.md +++ b/.claude/skills/using-agent-relay/SKILL.md @@ -22,9 +22,8 @@ Real-time agent-to-agent messaging. Two modes: **tmux wrapper** (real-time, sub- | Pattern | Description | |---------|-------------| -| `->relay:Name message` | Direct message (output as text) | -| `->relay:Name <<<`...`>>>` | Multi-line message with blank lines/code | -| `->relay:* message` | Broadcast to all | +| `->relay:Name <<<`...`>>>` | **Default format** - always use fenced format | +| `->relay:* <<<`...`>>>` | Broadcast to all agents | | `[[RELAY]]{"to":"Name","body":"msg"}[[/RELAY]]` | Structured JSON | | `\->relay:` | Escape (literal output) | | `relay read ` | Read truncated message | @@ -51,16 +50,19 @@ relay team status # Show team ## Sending Messages (Tmux Mode) -**Output the pattern directly** - don't use bash commands: +**Output the pattern directly** - don't use bash commands. Always use the fenced format: ``` -->relay:BlueLake I've finished the API refactor. -->relay:* STATUS: Starting auth module. +->relay:BlueLake <<< +I've finished the API refactor.>>> + +->relay:* <<< +STATUS: Starting auth module.>>> ``` -### Multi-line Messages (Fenced Format) +### Fenced Format (Default) -For messages with blank lines, code blocks, or complex content: +The fenced format is the default for all messages: ``` ->relay:Reviewer <<< @@ -72,11 +74,10 @@ Please check: Key changes: 1. Added JWT validation -2. Fixed session expiry ->>> +2. Fixed session expiry>>> ``` -**CRITICAL:** Always end with `>>>` on its own line! The `<<<` opens, `>>>` closes. +**CRITICAL:** Always end with `>>>` at the end of the last line of content! The `<<<` opens, `>>>` closes. **Limits:** Fenced messages max 200 lines. For longer content, send summary with reference ID. @@ -113,20 +114,27 @@ relay read abc123 ``` # Task assignment -->relay:Developer TASK: Implement /api/register +->relay:Developer <<< +TASK: Implement /api/register>>> # Status broadcast -->relay:* STATUS: Starting auth module -->relay:* DONE: Auth complete +->relay:* <<< +STATUS: Starting auth module>>> + +->relay:* <<< +DONE: Auth complete>>> # Review request -->relay:Reviewer REVIEW: src/auth/*.ts +->relay:Reviewer <<< +REVIEW: src/auth/*.ts>>> # Question -->relay:Architect QUESTION: JWT or sessions? +->relay:Architect <<< +QUESTION: JWT or sessions?>>> # Blocked -->relay:* BLOCKED: Need DB credentials +->relay:* <<< +BLOCKED: Need DB credentials>>> ``` ## Spawning Agents diff --git a/.gitignore b/.gitignore index ebd36a280..870230b0e 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,5 @@ coverage/ .tmp-agent-relay-data .next + +src/dashboard/out diff --git a/AGENTS.md b/AGENTS.md index 803bb7797..d18332e21 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,24 +73,19 @@ Real-time agent-to-agent messaging. Output `->relay:` patterns to communicate. ## Sending Messages -``` -->relay:AgentName Your message here -->relay:* Broadcast to all agents -``` - -### Multi-line Messages - -For messages with blank lines or code: +**Always use the fenced format** for reliable message delivery: ``` ->relay:AgentName <<< -Your multi-line message here. +Your message here.>>> +``` -Can include blank lines and code. ->>> +``` +->relay:* <<< +Broadcast to all agents.>>> ``` -**CRITICAL:** Always end with `>>>` on its own line! +**CRITICAL:** Always end with `>>>` at the end of the last line of content! ## Receiving Messages @@ -116,11 +111,20 @@ Spawn workers to delegate tasks: ## Common Patterns ``` -->relay:* STATUS: Starting work on auth module -->relay:* DONE: Auth module complete -->relay:Developer TASK: Implement /api/register -->relay:Reviewer REVIEW: Please check src/auth/*.ts -->relay:Architect QUESTION: JWT or sessions? +->relay:* <<< +STATUS: Starting work on auth module>>> + +->relay:* <<< +DONE: Auth module complete>>> + +->relay:Developer <<< +TASK: Implement /api/register>>> + +->relay:Reviewer <<< +REVIEW: Please check src/auth/*.ts>>> + +->relay:Architect <<< +QUESTION: JWT or sessions?>>> ``` ## Rules diff --git a/README.md b/README.md index 4089eb797..8ad5ac2a7 100644 --- a/README.md +++ b/README.md @@ -23,11 +23,14 @@ agent-relay -n Alice claude agent-relay -n Bob codex ``` -Agents communicate by outputting `->relay:` patterns: +Agents communicate by outputting `->relay:` patterns. Always use the fenced format: ``` -->relay:Bob Hey, can you review my changes? -->relay:* Broadcasting to everyone +->relay:Bob <<< +Hey, can you review my changes?>>> + +->relay:* <<< +Broadcasting to everyone>>> ``` ## CLI @@ -68,13 +71,15 @@ Agents communicate by outputting `->relay:` patterns: ### Send Message ``` -->relay:AgentName Your message here +->relay:AgentName <<< +Your message here>>> ``` ### Broadcast ``` -->relay:* Message to all agents +->relay:* <<< +Message to all agents>>> ``` ### Receive @@ -136,8 +141,11 @@ agent-relay bridge ~/auth ~/frontend ~/api ### Cross-Project Messaging ``` -->relay:projectId:agent Message to specific agent -->relay:*:lead Broadcast to all project leads +->relay:projectId:agent <<< +Message to specific agent>>> + +->relay:*:lead <<< +Broadcast to all project leads>>> ``` ### Spawn Agents diff --git a/TESTING.md b/TESTING.md index 27756fa09..f6d974f30 100644 --- a/TESTING.md +++ b/TESTING.md @@ -35,6 +35,13 @@ Inside Alice's terminal, output these patterns: ->relay:spawn Dev1 claude "Write unit tests for the auth module" ``` +Or using the fenced format for messages: + +``` +->relay:Dev1 <<< +Write unit tests for the auth module>>> +``` + **Expected:** - New tmux window created in `relay-workers` session - Dev1 agent starts with the task injected @@ -122,14 +129,16 @@ agent-relay -n Bob claude From Alice (Project A): ``` -->relay:project-b:Bob Hey Bob, can you review my changes? +->relay:project-b:Bob <<< +Hey Bob, can you review my changes?>>> ``` **Expected:** Bob receives the message in Project B. From Bob (Project B): ``` -->relay:project-a:Alice Sure, sending review now. +->relay:project-a:Alice <<< +Sure, sending review now.>>> ``` **Expected:** Alice receives the message in Project A. @@ -138,7 +147,8 @@ From Bob (Project B): From the bridge/architect: ``` -->relay:*:lead Standup time - report your status +->relay:*:lead <<< +Standup time - report your status>>> ``` **Expected:** Both Alice and Bob receive the message. diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 5f814dc5d..0306e7e93 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -159,21 +159,20 @@ relay team status ### Sending Messages (Real-Time Mode) -**Inline format** (simple messages): -``` -->relay:AgentName Your message here -->relay:* Broadcast to all agents -``` +**Always use the fenced format** for reliable message delivery: -**Fenced format** (multi-line with blank lines/code): ``` ->relay:AgentName <<< -Multi-line message here. +Your message here.>>> +``` -Can include blank lines and code. ->>> +``` +->relay:* <<< +Broadcast to all agents.>>> ``` +**CRITICAL:** Always end with `>>>` at the end of the last line of content! + **Block format** (structured data): ``` [[RELAY]]{"to":"AgentName","type":"message","body":"Your message"}[[/RELAY]] @@ -200,11 +199,12 @@ Use this when you're wrapped with `relay wrap`. ### CRITICAL: How to Send Messages -**You (the AI agent) must OUTPUT the ->relay pattern as part of your response.** Do not wait for user input. The pattern is detected from your terminal output. +**You (the AI agent) must OUTPUT the ->relay pattern as part of your response.** Do not wait for user input. The pattern is detected from your terminal output. **Always use the fenced format.** **Correct - Output this directly:** ``` -->relay:PlayerO I've finished the API refactor. Ready for your review. +->relay:PlayerO <<< +I've finished the API refactor. Ready for your review.>>> ``` **Wrong - Don't use bash commands for real-time messaging:** @@ -218,25 +218,26 @@ relay team send -n MyName -t PlayerO -m "message" The `->relay:` pattern must appear on its own line. It can have common terminal/markdown prefixes: ``` -->relay:AgentName message Works - ->relay:AgentName message Works (leading whitespace OK) -> ->relay:AgentName message Works (input prompt OK) -$ ->relay:AgentName message Works (shell prompt OK) -- ->relay:AgentName message Works (list items OK) -* ->relay:AgentName message Works (asterisk lists OK) -Some text ->relay:AgentName msg Won't work (not at line start) +->relay:AgentName <<< Works + ->relay:AgentName <<< Works (leading whitespace OK) +> ->relay:AgentName <<< Works (input prompt OK) +$ ->relay:AgentName <<< Works (shell prompt OK) +- ->relay:AgentName <<< Works (list items OK) +Some text ->relay:AgentName <<< Won't work (not at line start) ``` ### Examples **Direct message:** ``` -->relay:PlayerO Your turn! I played X at center. +->relay:PlayerO <<< +Your turn! I played X at center.>>> ``` **Broadcast to all agents:** ``` -->relay:* I've completed the authentication module. Ready for review. +->relay:* <<< +I've completed the authentication module. Ready for review.>>> ``` **Structured data:** @@ -380,40 +381,52 @@ Just 4 commands: ### Task Handoff ``` -->relay:Developer TASK: Implement user registration endpoint +->relay:Developer <<< +TASK: Implement user registration endpoint Requirements: - POST /api/register - Validate email format - Hash password with bcrypt -- Return JWT token +- Return JWT token>>> ``` ### Status Updates ``` -->relay:* STATUS: Starting work on authentication module -->relay:* DONE: Authentication module complete, ready for review -->relay:Reviewer REVIEW: Please review src/auth/*.ts +->relay:* <<< +STATUS: Starting work on authentication module>>> + +->relay:* <<< +DONE: Authentication module complete, ready for review>>> + +->relay:Reviewer <<< +REVIEW: Please review src/auth/*.ts>>> ``` ### Requesting Help ``` -->relay:Architect QUESTION: Should we use JWT or session-based auth? -->relay:* BLOCKED: Need database credentials to proceed +->relay:Architect <<< +QUESTION: Should we use JWT or session-based auth?>>> + +->relay:* <<< +BLOCKED: Need database credentials to proceed>>> ``` ### Code Review Flow ``` # Developer requests review -->relay:Reviewer REVIEW: src/api/users.ts - Added pagination support +->relay:Reviewer <<< +REVIEW: src/api/users.ts - Added pagination support>>> # Reviewer provides feedback -->relay:Developer FEEDBACK: Line 45 - Consider using cursor-based pagination for better performance +->relay:Developer <<< +FEEDBACK: Line 45 - Consider using cursor-based pagination for better performance>>> # Developer confirms fix -->relay:Reviewer FIXED: Updated to cursor-based pagination, please re-review +->relay:Reviewer <<< +FIXED: Updated to cursor-based pagination, please re-review>>> ``` --- diff --git a/docs/INTEGRATION-GUIDE.md b/docs/INTEGRATION-GUIDE.md index 4bb9937a8..ef6fc16b3 100644 --- a/docs/INTEGRATION-GUIDE.md +++ b/docs/INTEGRATION-GUIDE.md @@ -17,7 +17,7 @@ How to integrate agent-relay, claude-mem, and agent-trajectories into a cohesive │ │ CLAUDE-MEM │ │ AGENT-RELAY │ │ │ │ (Observations) │ │ (Messaging) │ │ │ │ │ │ │ │ -│ │ • Tool calls │ │ • ->relay:Agent │ │ +│ │ • Tool calls │ │ • ->relay: <<< │ │ │ │ • Concepts │ │ • Broadcasting │ │ │ │ • Sessions │ │ • Persistence │ │ │ └────────┬────────┘ └────────┬────────┘ │ diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 2fcf61df1..452e1dd1e 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -279,26 +279,32 @@ Client should send fresh HELLO. ## 9. PTY Pattern Extractor -### Block Format (preferred) +### Fenced Format (default, recommended) ``` -[[RELAY]] -{"to": "*", "type": "message", "body": "Hello everyone"} -[[/RELAY]] +->relay:codex-1 <<< +Your turn to play>>> + +->relay:* <<< +Hello everyone>>> ``` -### Inline Format (single line only) +**Always use the fenced format** for reliable message delivery. The `<<<` opens, `>>>` closes at end of last line. + +### Block Format (structured data) ``` -->relay:codex-1 Your turn to play -->thinking:* Considering the Queen... +[[RELAY]] +{"to": "*", "type": "message", "body": "Hello everyone"} +[[/RELAY]] ``` ### Rules -1. Block: Only parse when `[[RELAY]]` at start of line -2. Inline: Only at start of line, not in code fences -3. Escape: `\->relay:` outputs literal `->relay:` +1. Fenced: `->relay:AgentName <<<` starts message, `>>>` ends it +2. Block: Only parse when `[[RELAY]]` at start of line +3. Pattern must be at start of line, not in code fences +4. Escape: `\->relay:` outputs literal `->relay:` --- diff --git a/docs/agent-relay-snippet.md b/docs/agent-relay-snippet.md index 1ef88f197..9e942d93f 100644 --- a/docs/agent-relay-snippet.md +++ b/docs/agent-relay-snippet.md @@ -4,24 +4,19 @@ Real-time agent-to-agent messaging. Output `->relay:` patterns to communicate. ## Sending Messages -``` -->relay:AgentName Your message here -->relay:* Broadcast to all agents -``` - -### Multi-line Messages - -For messages with blank lines or code: +**Always use the fenced format** for reliable message delivery: ``` ->relay:AgentName <<< -Your multi-line message here. +Your message here.>>> +``` -Can include blank lines and code. ->>> +``` +->relay:* <<< +Broadcast to all agents.>>> ``` -**CRITICAL:** Always end with `>>>` on its own line! +**CRITICAL:** Always end with `>>>` at the end of the last line of content! ## Receiving Messages @@ -47,11 +42,20 @@ Spawn workers to delegate tasks: ## Common Patterns ``` -->relay:* STATUS: Starting work on auth module -->relay:* DONE: Auth module complete -->relay:Developer TASK: Implement /api/register -->relay:Reviewer REVIEW: Please check src/auth/*.ts -->relay:Architect QUESTION: JWT or sessions? +->relay:* <<< +STATUS: Starting work on auth module>>> + +->relay:* <<< +DONE: Auth module complete>>> + +->relay:Developer <<< +TASK: Implement /api/register>>> + +->relay:Reviewer <<< +REVIEW: Please check src/auth/*.ts>>> + +->relay:Architect <<< +QUESTION: JWT or sessions?>>> ``` ## Rules diff --git a/examples/basic-chat/README.md b/examples/basic-chat/README.md index 21024b2ad..3c3146d24 100644 --- a/examples/basic-chat/README.md +++ b/examples/basic-chat/README.md @@ -23,7 +23,7 @@ npx agent-relay wrap -n Alice "claude" ``` Once Claude starts, you can tell it: -> "Your name is Alice. You're chatting with Bob via agent-relay. Say hello to Bob by typing: ->relay:Bob Hello Bob! I'm Alice." +> "Your name is Alice. You're chatting with Bob via agent-relay. Say hello to Bob using the fenced format." ### Terminal 3: Agent Bob @@ -41,9 +41,10 @@ Once Claude starts, you can tell it: - Sends matched messages through the daemon to other agents - Injects received messages into the agent's terminal -2. Messages use the inline format: +2. Messages use the fenced format (always recommended): ``` - ->relay:RecipientName Your message here + ->relay:RecipientName <<< + Your message here>>> ``` 3. Received messages appear as: @@ -71,6 +72,7 @@ agent-relay inbox-write -t Alice -f Bob -m "Hi Alice!" -d /tmp/chat ## Tips -- Use `->relay:*` to broadcast to all connected agents +- Always use the fenced format: `->relay:Name <<<` ... `>>>` +- Use `->relay:* <<<` to broadcast to all connected agents - Use `\->relay:` to output literal text without triggering the relay - Check daemon status with `npx agent-relay status` diff --git a/examples/collaborative-task/README.md b/examples/collaborative-task/README.md index 0dc712383..e163442af 100644 --- a/examples/collaborative-task/README.md +++ b/examples/collaborative-task/README.md @@ -27,7 +27,7 @@ npx agent-relay wrap -n Architect "claude" ``` Tell the agent: -> "You are the Architect. Your job is to design a solution for adding user authentication. Once you have a plan, message Developer with the design using ->relay:Developer" +> "You are the Architect. Your job is to design a solution for adding user authentication. Once you have a plan, message Developer with the design using the fenced format: ->relay:Developer <<< your message >>>" ### Terminal 3: Developer ```bash @@ -70,25 +70,30 @@ Architect Developer Reviewer ## Message Protocol -Agents use structured communication: +Agents use structured communication with the fenced format: ```bash # Architect assigns task -->relay:Developer TASK: Implement user registration endpoint. -Requirements: POST /api/register, validate email, hash password, return JWT. +->relay:Developer <<< +TASK: Implement user registration endpoint. +Requirements: POST /api/register, validate email, hash password, return JWT.>>> # Developer requests review -->relay:Reviewer REVIEW REQUEST: Please review src/api/register.ts +->relay:Reviewer <<< +REVIEW REQUEST: Please review src/api/register.ts>>> # Reviewer provides feedback -->relay:Developer FEEDBACK: Line 23: Use bcrypt instead of md5 for password hashing. +->relay:Developer <<< +FEEDBACK: Line 23: Use bcrypt instead of md5 for password hashing.>>> # Developer notifies completion -->relay:Architect DONE: Registration endpoint implemented and reviewed. +->relay:Architect <<< +DONE: Registration endpoint implemented and reviewed.>>> ``` ## Tips +- Always use the fenced format: `->relay:Name <<<` ... `>>>` +- End with `>>>` at the end of the last line of content - Use clear prefixes (TASK:, REVIEW:, FEEDBACK:, DONE:) for structured communication -- Broadcast status updates with `->relay:*` - Keep messages concise - agents can read files for details diff --git a/package-lock.json b/package-lock.json index 4725c0800..89aa3e8e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agent-relay", - "version": "1.0.10", + "version": "1.0.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agent-relay", - "version": "1.0.10", + "version": "1.0.11", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -16,6 +16,7 @@ "compare-versions": "^6.1.1", "dotenv": "^17.2.3", "express": "^5.2.1", + "http-proxy-middleware": "^3.0.5", "node-pty": "^1.0.0", "uuid": "^10.0.0", "ws": "^8.18.3" @@ -1294,11 +1295,18 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/http-proxy": { + "version": "1.17.17", + "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.17.tgz", + "integrity": "sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw==", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/node": { "version": "22.19.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz", "integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==", - "dev": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1935,6 +1943,17 @@ "balanced-match": "^1.0.0" } }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/buffer": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", @@ -2710,6 +2729,11 @@ "node": ">= 0.6" } }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" + }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -2839,6 +2863,17 @@ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", "license": "MIT" }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -2899,6 +2934,25 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -3232,6 +3286,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "dependencies": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -3245,6 +3312,22 @@ "node": ">= 14" } }, + "node_modules/http-proxy-middleware": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-3.0.5.tgz", + "integrity": "sha512-GLZZm1X38BPY4lkXA01jhwxvDoOkkXqjgVyUzVxiEK4iuRu03PZoYHhHRwxnfhQMDuaxi3vVri0YgSro/1oWqg==", + "dependencies": { + "@types/http-proxy": "^1.17.15", + "debug": "^4.3.6", + "http-proxy": "^1.18.1", + "is-glob": "^4.0.3", + "is-plain-object": "^5.0.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -3368,7 +3451,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -3388,7 +3470,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -3397,6 +3478,14 @@ "node": ">=0.10.0" } }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "engines": { + "node": ">=0.12.0" + } + }, "node_modules/is-path-inside": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", @@ -3407,6 +3496,14 @@ "node": ">=8" } }, + "node_modules/is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -3697,6 +3794,29 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -4275,6 +4395,11 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -4973,6 +5098,17 @@ "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", "dev": true }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -5087,8 +5223,7 @@ "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" }, "node_modules/unpipe": { "version": "1.0.0", diff --git a/package.json b/package.json index 58885926b..ac58d6c01 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-relay", - "version": "1.0.10", + "version": "1.0.12", "description": "Real-time agent-to-agent communication system", "type": "module", "main": "dist/index.js", @@ -22,16 +22,16 @@ }, "scripts": { "postinstall": "npm rebuild better-sqlite3 && node scripts/postinstall.js", - "build": "npm run clean && tsc && npm run build:frontend", - "build:frontend": "esbuild src/dashboard/frontend/app.ts --bundle --outfile=src/dashboard/public/js/app.js --format=esm --target=es2022 --minify --sourcemap", - "postbuild": "cp -r src/dashboard/public dist/dashboard/ && chmod +x dist/cli/index.js", + "build": "npm run clean && tsc && npm run build:dashboard", + "build:dashboard": "cd src/dashboard && npm run build", + "postbuild": "chmod +x dist/cli/index.js", "dev": "tsc -w", "dev:local": "npm run build && npm link && echo '✓ agent-relay linked globally'", "dev:unlink": "npm unlink -g agent-relay && echo '✓ agent-relay unlinked'", "dev:rebuild": "npm run build && echo '✓ Rebuilt (linked version updated)'", "start": "node dist/cli/index.js", "daemon": "node dist/daemon/server.js", - "dashboard": "node dist/dashboard/start.js", + "dashboard": "node dist/dashboard-server/start.js", "pretest": "npm run build", "test": "vitest run", "pretest:coverage": "npm run build", @@ -67,6 +67,7 @@ "compare-versions": "^6.1.1", "dotenv": "^17.2.3", "express": "^5.2.1", + "http-proxy-middleware": "^3.0.5", "node-pty": "^1.0.0", "uuid": "^10.0.0", "ws": "^8.18.3" diff --git a/prpm.json b/prpm.json index 40dc8e66a..db76437e4 100644 --- a/prpm.json +++ b/prpm.json @@ -10,7 +10,7 @@ "packages": [ { "name": "using-agent-relay", - "version": "1.0.0", + "version": "1.0.1", "description": "Skill for coordinating multiple AI agents - enables real-time inter-agent messaging via PTY wrapper or file-based inbox system", "format": "claude", "subtype": "skill", @@ -28,7 +28,7 @@ }, { "name": "agent-relay-snippet", - "version": "1.0.0", + "version": "1.0.1", "description": "AGENTS.md / CLAUDE.md snippet for agents on how to use agent-relay", "format": "generic", "subtype": "snippet", diff --git a/scripts/postinstall.js b/scripts/postinstall.js index 11e6f8a98..7e84ae808 100644 --- a/scripts/postinstall.js +++ b/scripts/postinstall.js @@ -198,11 +198,40 @@ async function installTmux() { } } +/** + * Install dashboard dependencies + */ +function installDashboardDeps() { + const dashboardDir = path.join(getPackageRoot(), 'src', 'dashboard'); + + if (!fs.existsSync(dashboardDir)) { + info('Dashboard directory not found, skipping'); + return; + } + + const dashboardNodeModules = path.join(dashboardDir, 'node_modules'); + if (fs.existsSync(dashboardNodeModules)) { + info('Dashboard dependencies already installed'); + return; + } + + info('Installing dashboard dependencies...'); + try { + execSync('npm install', { cwd: dashboardDir, stdio: 'inherit' }); + success('Dashboard dependencies installed'); + } catch (err) { + error(`Failed to install dashboard dependencies: ${err.message}`); + } +} + /** * Main postinstall routine */ async function main() { - // Skip in CI environments where tmux isn't needed + // Always install dashboard dependencies (needed for build) + installDashboardDeps(); + + // Skip tmux install in CI environments where tmux isn't needed if (process.env.CI === 'true') { info('Skipping tmux install in CI environment'); return; diff --git a/src/bridge/config.test.ts b/src/bridge/config.test.ts index 64030f79a..b2859230c 100644 --- a/src/bridge/config.test.ts +++ b/src/bridge/config.test.ts @@ -2,7 +2,7 @@ * Unit tests for Bridge Configuration */ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; diff --git a/src/bridge/config.ts b/src/bridge/config.ts index a92bc24ca..515836f68 100644 --- a/src/bridge/config.ts +++ b/src/bridge/config.ts @@ -7,7 +7,7 @@ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { getProjectPaths } from '../utils/project-namespace.js'; -import type { ProjectConfig, BridgeConfig } from './types.js'; +import type { ProjectConfig } from './types.js'; const CONFIG_PATHS = [ path.join(os.homedir(), '.agent-relay', 'bridge.json'), diff --git a/src/bridge/multi-project-client.ts b/src/bridge/multi-project-client.ts index e7b8c41de..5d74310ec 100644 --- a/src/bridge/multi-project-client.ts +++ b/src/bridge/multi-project-client.ts @@ -5,12 +5,10 @@ import net from 'node:net'; import fs from 'node:fs'; -import path from 'node:path'; import { v4 as uuid } from 'uuid'; import { type Envelope, type HelloPayload, - type WelcomePayload, type SendPayload, type DeliverEnvelope, PROTOCOL_VERSION, @@ -293,7 +291,7 @@ export class MultiProjectClient { * Broadcast to all agents in all projects */ broadcastAll(body: string): void { - for (const [projectId, conn] of this.connections) { + for (const [_projectId, conn] of this.connections) { if (conn.ready) { const envelope: Envelope = { v: PROTOCOL_VERSION, diff --git a/src/bridge/spawner.test.ts b/src/bridge/spawner.test.ts index 65074e6af..3e28056d9 100644 --- a/src/bridge/spawner.test.ts +++ b/src/bridge/spawner.test.ts @@ -1,25 +1,38 @@ /** - * Unit tests for AgentSpawner + * Unit tests for AgentSpawner (node-pty based) */ -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; import fs from 'node:fs'; -import { AgentSpawner } from './spawner.js'; -import { execAsync, sleep, escapeForTmux } from './utils.js'; +import { AgentSpawner, readWorkersMetadata, getWorkerLogsDir } from './spawner.js'; const PROJECT_ROOT = '/project/root'; -vi.mock('./utils.js', () => { - const execAsync = vi.fn(); - const sleep = vi.fn(); - const escapeForTmux = vi.fn((str: string) => `escaped:${str}`); +// Mock PtyWrapper +const mockPtyWrapper = { + start: vi.fn(), + stop: vi.fn(), + kill: vi.fn(), + write: vi.fn(), + getOutput: vi.fn(() => []), + getRawOutput: vi.fn(() => ''), + isRunning: true, + pid: 12345, + logPath: '/team/worker-logs/test.log', + name: 'TestWorker', +}; + +vi.mock('../wrapper/pty-wrapper.js', () => { return { - execAsync, - sleep, - escapeForTmux, + PtyWrapper: vi.fn().mockImplementation(() => mockPtyWrapper), }; }); +vi.mock('./utils.js', () => { + const sleep = vi.fn(); + return { sleep }; +}); + vi.mock('../utils/project-namespace.js', () => { return { getProjectPaths: vi.fn(() => ({ @@ -33,65 +46,31 @@ vi.mock('../utils/project-namespace.js', () => { }; }); -vi.mock('../utils/tmux-resolver.js', () => { - return { - getTmuxPath: vi.fn(() => 'tmux'), - }; -}); - -const execAsyncMock = vi.mocked(execAsync); -const sleepMock = vi.mocked(sleep); -const escapeForTmuxMock = vi.mocked(escapeForTmux); const existsSyncMock = vi.spyOn(fs, 'existsSync'); const readFileSyncMock = vi.spyOn(fs, 'readFileSync'); -let waitForAgentRegistrationMock: vi.SpyInstance; +const writeFileSyncMock = vi.spyOn(fs, 'writeFileSync'); +const mkdirSyncMock = vi.spyOn(fs, 'mkdirSync'); +let waitForAgentRegistrationMock: ReturnType; describe('AgentSpawner', () => { const projectRoot = PROJECT_ROOT; - const session = 'relay-workers'; beforeEach(() => { - vi.restoreAllMocks(); vi.clearAllMocks(); - execAsyncMock.mockReset(); - sleepMock.mockReset(); - escapeForTmuxMock.mockReset(); existsSyncMock.mockReturnValue(true); readFileSyncMock.mockReturnValue(JSON.stringify({ agents: [] })); - escapeForTmuxMock.mockImplementation((str: string) => `escaped:${str}`); + writeFileSyncMock.mockImplementation(() => {}); + mkdirSyncMock.mockImplementation(() => undefined); + mockPtyWrapper.start.mockResolvedValue(undefined); + mockPtyWrapper.isRunning = true; + mockPtyWrapper.pid = 12345; waitForAgentRegistrationMock = vi .spyOn(AgentSpawner.prototype as any, 'waitForAgentRegistration') .mockResolvedValue(true); }); - it('creates tmux session when missing', async () => { - execAsyncMock.mockRejectedValueOnce(new Error('missing')); - execAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }); - - const spawner = new AgentSpawner(projectRoot, session); - await spawner.ensureSession(); - - expect(execAsyncMock).toHaveBeenCalledTimes(2); - expect(execAsyncMock.mock.calls[0][0]).toBe(`"tmux" has-session -t ${session} 2>/dev/null`); - expect(execAsyncMock.mock.calls[1][0]).toBe(`"tmux" new-session -d -s ${session} -c "${projectRoot}"`); - }); - - it('does nothing when tmux session already exists', async () => { - execAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }); - - const spawner = new AgentSpawner(projectRoot, session); - await spawner.ensureSession(); - - expect(execAsyncMock).toHaveBeenCalledTimes(1); - expect(execAsyncMock).toHaveBeenCalledWith(`"tmux" has-session -t ${session} 2>/dev/null`); - }); - - it('spawns a worker and tracks it', async () => { - execAsyncMock.mockResolvedValue({ stdout: '/usr/local/bin/agent-relay', stderr: '' }); - sleepMock.mockResolvedValue(); - waitForAgentRegistrationMock.mockResolvedValue(true); - - const spawner = new AgentSpawner(projectRoot, session); + it('spawns a worker and tracks it with PID', async () => { + const spawner = new AgentSpawner(projectRoot); const result = await spawner.spawn({ name: 'Dev1', cli: 'claude', @@ -102,24 +81,18 @@ describe('AgentSpawner', () => { expect(result).toMatchObject({ success: true, name: 'Dev1', - window: `${session}:Dev1`, + pid: 12345, }); expect(spawner.hasWorker('Dev1')).toBe(true); - expect(execAsyncMock).toHaveBeenNthCalledWith(1, `"tmux" has-session -t ${session} 2>/dev/null`); - expect(execAsyncMock).toHaveBeenNthCalledWith(2, `"tmux" new-window -t ${session} -n Dev1 -c "${projectRoot}"`); - expect(execAsyncMock).toHaveBeenNthCalledWith(3, 'which agent-relay'); // Find full path - expect(execAsyncMock).toHaveBeenNthCalledWith(4, `"tmux" send-keys -t ${session}:Dev1 'unset TMUX && /usr/local/bin/agent-relay -n Dev1 -- claude --dangerously-skip-permissions' Enter`); - expect(execAsyncMock).toHaveBeenNthCalledWith(5, `"tmux" send-keys -t ${session}:Dev1 -l "escaped:Finish the report"`); - expect(execAsyncMock).toHaveBeenNthCalledWith(6, `"tmux" send-keys -t ${session}:Dev1 Enter`); - expect(sleepMock).toHaveBeenCalledWith(100); + expect(mockPtyWrapper.start).toHaveBeenCalled(); + expect(mockPtyWrapper.write).toHaveBeenCalledWith('Finish the report\r'); }); it('adds --dangerously-skip-permissions for Claude variants', async () => { - execAsyncMock.mockResolvedValue({ stdout: '/usr/local/bin/agent-relay', stderr: '' }); - sleepMock.mockResolvedValue(); - waitForAgentRegistrationMock.mockResolvedValue(true); + const { PtyWrapper } = await import('../wrapper/pty-wrapper.js'); + const PtyWrapperMock = PtyWrapper as Mock; - const spawner = new AgentSpawner(projectRoot, session); + const spawner = new AgentSpawner(projectRoot); await spawner.spawn({ name: 'Opus1', cli: 'claude:opus', @@ -127,19 +100,17 @@ describe('AgentSpawner', () => { requestedBy: 'Lead', }); - // Check that the command includes --dangerously-skip-permissions for claude:opus - expect(execAsyncMock).toHaveBeenNthCalledWith( - 4, - `"tmux" send-keys -t ${session}:Opus1 'unset TMUX && /usr/local/bin/agent-relay -n Opus1 -- claude:opus --dangerously-skip-permissions' Enter` - ); + // Check the PtyWrapper was constructed with --dangerously-skip-permissions + const constructorCall = PtyWrapperMock.mock.calls[0][0]; + expect(constructorCall.command).toBe('claude:opus'); + expect(constructorCall.args).toContain('--dangerously-skip-permissions'); }); it('does NOT add --dangerously-skip-permissions for non-Claude CLIs', async () => { - execAsyncMock.mockResolvedValue({ stdout: '/usr/local/bin/agent-relay', stderr: '' }); - sleepMock.mockResolvedValue(); - waitForAgentRegistrationMock.mockResolvedValue(true); + const { PtyWrapper } = await import('../wrapper/pty-wrapper.js'); + const PtyWrapperMock = PtyWrapper as Mock; - const spawner = new AgentSpawner(projectRoot, session); + const spawner = new AgentSpawner(projectRoot); await spawner.spawn({ name: 'Codex1', cli: 'codex', @@ -147,24 +118,23 @@ describe('AgentSpawner', () => { requestedBy: 'Lead', }); - // Check that the command does NOT include --dangerously-skip-permissions for codex - expect(execAsyncMock).toHaveBeenNthCalledWith( - 4, - `"tmux" send-keys -t ${session}:Codex1 'unset TMUX && /usr/local/bin/agent-relay -n Codex1 -- codex' Enter` - ); + // Check the PtyWrapper was constructed without --dangerously-skip-permissions + const constructorCall = PtyWrapperMock.mock.calls[0][0]; + expect(constructorCall.command).toBe('codex'); + expect(constructorCall.args).not.toContain('--dangerously-skip-permissions'); }); it('refuses to spawn a duplicate worker', async () => { - const spawner = new AgentSpawner(projectRoot, session); - spawner['activeWorkers'].set('Dev1', { + const spawner = new AgentSpawner(projectRoot); + // First spawn succeeds + await spawner.spawn({ name: 'Dev1', cli: 'claude', - task: 'Existing task', - spawnedBy: 'Lead', - spawnedAt: Date.now(), - window: `${session}:Dev1`, + task: 'First task', + requestedBy: 'Lead', }); + // Second spawn with same name should fail const result = await spawner.spawn({ name: 'Dev1', cli: 'claude', @@ -173,15 +143,13 @@ describe('AgentSpawner', () => { }); expect(result.success).toBe(false); - expect(execAsyncMock).not.toHaveBeenCalled(); + expect(result.error).toContain('already exists'); }); - it('returns failure when spawn command errors', async () => { - execAsyncMock - .mockResolvedValueOnce({ stdout: '', stderr: '' }) // has-session - .mockRejectedValueOnce(new Error('tmux new-window failed')); // new-window fails + it('returns failure when PtyWrapper.start() throws', async () => { + mockPtyWrapper.start.mockRejectedValueOnce(new Error('PTY spawn failed')); - const spawner = new AgentSpawner(projectRoot, session); + const spawner = new AgentSpawner(projectRoot); const result = await spawner.spawn({ name: 'Dev2', cli: 'claude', @@ -190,14 +158,14 @@ describe('AgentSpawner', () => { }); expect(result.success).toBe(false); + expect(result.error).toBe('PTY spawn failed'); expect(spawner.hasWorker('Dev2')).toBe(false); }); it('cleans up when agent does not register', async () => { - execAsyncMock.mockResolvedValue({ stdout: '/usr/local/bin/agent-relay', stderr: '' }); waitForAgentRegistrationMock.mockResolvedValue(false); - const spawner = new AgentSpawner(projectRoot, session); + const spawner = new AgentSpawner(projectRoot); const result = await spawner.spawn({ name: 'Late', cli: 'claude', @@ -207,91 +175,159 @@ describe('AgentSpawner', () => { expect(result.success).toBe(false); expect(result.error).toContain('failed to register'); - expect(execAsyncMock).toHaveBeenCalledWith(`"tmux" kill-window -t ${session}:Late`); + expect(mockPtyWrapper.kill).toHaveBeenCalled(); expect(spawner.hasWorker('Late')).toBe(false); }); it('releases a worker and removes tracking', async () => { - execAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }); - sleepMock.mockResolvedValue(); + const { sleep } = await import('./utils.js'); + const sleepMock = sleep as Mock; + sleepMock.mockResolvedValue(undefined); - const spawner = new AgentSpawner(projectRoot, session); - spawner['activeWorkers'].set('Worker', { + const spawner = new AgentSpawner(projectRoot); + await spawner.spawn({ name: 'Worker', cli: 'claude', task: 'Task', - spawnedBy: 'Lead', - spawnedAt: Date.now(), - window: `${session}:Worker`, + requestedBy: 'Lead', }); + mockPtyWrapper.isRunning = false; // Simulate graceful stop + const result = await spawner.release('Worker'); expect(result).toBe(true); expect(spawner.hasWorker('Worker')).toBe(false); - expect(execAsyncMock).toHaveBeenNthCalledWith(1, `"tmux" send-keys -t ${session}:Worker '/exit' Enter`); - expect(execAsyncMock).toHaveBeenNthCalledWith(2, `"tmux" kill-window -t ${session}:Worker`); - expect(sleepMock).toHaveBeenCalledWith(2000); + expect(mockPtyWrapper.stop).toHaveBeenCalled(); + }); + + it('force kills worker if still running after stop', async () => { + const { sleep } = await import('./utils.js'); + const sleepMock = sleep as Mock; + sleepMock.mockResolvedValue(undefined); + + const spawner = new AgentSpawner(projectRoot); + await spawner.spawn({ + name: 'Stubborn', + cli: 'claude', + task: 'Task', + requestedBy: 'Lead', + }); + + mockPtyWrapper.isRunning = true; // Still running after stop + + const result = await spawner.release('Stubborn'); + + expect(result).toBe(true); + expect(mockPtyWrapper.stop).toHaveBeenCalled(); + expect(mockPtyWrapper.kill).toHaveBeenCalled(); }); it('returns false when releasing a missing worker', async () => { - const spawner = new AgentSpawner(projectRoot, session); + const spawner = new AgentSpawner(projectRoot); const result = await spawner.release('Missing'); expect(result).toBe(false); - expect(execAsyncMock).not.toHaveBeenCalled(); }); - it('clears worker even when release fails', async () => { - execAsyncMock.mockRejectedValue(new Error('tmux error')); - sleepMock.mockResolvedValue(); + it('releases all workers', async () => { + const { sleep } = await import('./utils.js'); + const sleepMock = sleep as Mock; + sleepMock.mockResolvedValue(undefined); + + const spawner = new AgentSpawner(projectRoot); + await spawner.spawn({ name: 'A', cli: 'claude', task: 'Task A', requestedBy: 'Lead' }); + await spawner.spawn({ name: 'B', cli: 'claude', task: 'Task B', requestedBy: 'Lead' }); - const spawner = new AgentSpawner(projectRoot, session); - spawner['activeWorkers'].set('Failing', { - name: 'Failing', + mockPtyWrapper.isRunning = false; + + await spawner.releaseAll(); + + expect(spawner.getActiveWorkers()).toHaveLength(0); + }); + + it('saves workers metadata to disk', async () => { + const spawner = new AgentSpawner(projectRoot); + await spawner.spawn({ + name: 'Worker1', cli: 'claude', task: 'Task', - spawnedBy: 'Lead', - spawnedAt: Date.now(), - window: `${session}:Failing`, + requestedBy: 'Lead', }); - const result = await spawner.release('Failing'); + expect(writeFileSyncMock).toHaveBeenCalled(); + const [filePath, content] = writeFileSyncMock.mock.calls[0]; + expect(filePath).toBe('/team/workers.json'); + const parsed = JSON.parse(content as string); + expect(parsed.workers).toHaveLength(1); + expect(parsed.workers[0].name).toBe('Worker1'); + expect(parsed.workers[0].pid).toBe(12345); + }); - expect(result).toBe(true); - expect(spawner.hasWorker('Failing')).toBe(false); - expect(execAsyncMock).toHaveBeenCalledTimes(2); - expect(execAsyncMock.mock.calls[0][0]).toBe(`"tmux" send-keys -t ${session}:Failing '/exit' Enter`); - expect(execAsyncMock.mock.calls[1][0]).toBe(`"tmux" kill-window -t ${session}:Failing`); - expect(sleepMock).toHaveBeenCalledWith(2000); + it('getWorkerOutput returns output from PtyWrapper', async () => { + mockPtyWrapper.getOutput.mockReturnValue(['line1', 'line2', 'line3']); + + const spawner = new AgentSpawner(projectRoot); + await spawner.spawn({ name: 'Dev', cli: 'claude', task: '', requestedBy: 'Lead' }); + + const output = spawner.getWorkerOutput('Dev', 2); + + expect(output).toEqual(['line1', 'line2', 'line3']); + expect(mockPtyWrapper.getOutput).toHaveBeenCalledWith(2); }); - it('releases all workers', async () => { - execAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }); - sleepMock.mockResolvedValue(); + it('getWorkerOutput returns null for unknown worker', async () => { + const spawner = new AgentSpawner(projectRoot); + const output = spawner.getWorkerOutput('Unknown'); + expect(output).toBeNull(); + }); +}); - const spawner = new AgentSpawner(projectRoot, session); - spawner['activeWorkers'].set('A', { - name: 'A', - cli: 'claude', - task: 'Task A', - spawnedBy: 'Lead', - spawnedAt: Date.now(), - window: `${session}:A`, - }); - spawner['activeWorkers'].set('B', { - name: 'B', - cli: 'claude', - task: 'Task B', - spawnedBy: 'Lead', - spawnedAt: Date.now(), - window: `${session}:B`, - }); +describe('readWorkersMetadata', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); - await spawner.releaseAll(); + it('returns empty array when file does not exist', () => { + existsSyncMock.mockReturnValue(false); - expect(spawner.getActiveWorkers()).toHaveLength(0); - expect(execAsyncMock).toHaveBeenCalledTimes(4); // two send-keys and two kill-window + const workers = readWorkersMetadata(PROJECT_ROOT); + + expect(workers).toEqual([]); + }); + + it('returns workers from file', () => { + existsSyncMock.mockReturnValue(true); + readFileSyncMock.mockReturnValue( + JSON.stringify({ + workers: [ + { name: 'W1', cli: 'claude', pid: 123 }, + { name: 'W2', cli: 'codex', pid: 456 }, + ], + }) + ); + + const workers = readWorkersMetadata(PROJECT_ROOT); + + expect(workers).toHaveLength(2); + expect(workers[0].name).toBe('W1'); + expect(workers[1].name).toBe('W2'); + }); + + it('returns empty array on parse error', () => { + existsSyncMock.mockReturnValue(true); + readFileSyncMock.mockReturnValue('invalid json'); + + const workers = readWorkersMetadata(PROJECT_ROOT); + + expect(workers).toEqual([]); + }); +}); + +describe('getWorkerLogsDir', () => { + it('returns correct logs directory path', () => { + const logsDir = getWorkerLogsDir(PROJECT_ROOT); + expect(logsDir).toBe('/team/worker-logs'); }); }); diff --git a/src/bridge/spawner.ts b/src/bridge/spawner.ts index 977e11b9a..f6e864d66 100644 --- a/src/bridge/spawner.ts +++ b/src/bridge/spawner.ts @@ -1,54 +1,54 @@ /** * Agent Spawner - * Handles spawning and releasing worker agents via tmux. + * Handles spawning and releasing worker agents via node-pty. + * Workers run headlessly with output capture for logs. */ import fs from 'node:fs'; import path from 'node:path'; -import { execAsync, sleep, escapeForTmux } from './utils.js'; +import { sleep } from './utils.js'; import { getProjectPaths } from '../utils/project-namespace.js'; -import { getTmuxPath } from '../utils/tmux-resolver.js'; +import { PtyWrapper, type PtyWrapperConfig } from '../wrapper/pty-wrapper.js'; import type { SpawnRequest, SpawnResult, WorkerInfo } from './types.js'; +/** Worker metadata stored in workers.json */ +interface WorkerMeta { + name: string; + cli: string; + task: string; + spawnedBy: string; + spawnedAt: number; + pid?: number; + logFile?: string; +} + +interface ActiveWorker extends WorkerInfo { + pty: PtyWrapper; + logFile?: string; +} + export class AgentSpawner { - private activeWorkers: Map = new Map(); - private tmuxSession: string; + private activeWorkers: Map = new Map(); private agentsPath: string; private projectRoot: string; - private tmuxPath: string; // Resolved path to tmux binary + private socketPath?: string; + private logsDir: string; + private workersPath: string; - constructor( - projectRoot: string, - tmuxSession?: string - ) { + constructor(projectRoot: string, _tmuxSession?: string) { const paths = getProjectPaths(projectRoot); this.projectRoot = paths.projectRoot; this.agentsPath = path.join(paths.teamDir, 'agents.json'); + this.socketPath = paths.socketPath; + this.logsDir = path.join(paths.teamDir, 'worker-logs'); + this.workersPath = path.join(paths.teamDir, 'workers.json'); - // Resolve tmux path (will throw TmuxNotFoundError if tmux unavailable) - this.tmuxPath = getTmuxPath(); - - // Default session name based on project - this.tmuxSession = tmuxSession || 'relay-workers'; - } - - /** - * Ensure the worker tmux session exists - */ - async ensureSession(): Promise { - try { - await execAsync(`"${this.tmuxPath}" has-session -t ${this.tmuxSession} 2>/dev/null`); - } catch { - // Session doesn't exist, create it - await execAsync( - `"${this.tmuxPath}" new-session -d -s ${this.tmuxSession} -c "${this.projectRoot}"` - ); - console.log(`[spawner] Created session ${this.tmuxSession}`); - } + // Ensure logs directory exists + fs.mkdirSync(this.logsDir, { recursive: true }); } /** - * Spawn a new worker agent + * Spawn a new worker agent using node-pty */ async spawn(request: SpawnRequest): Promise { const { name, cli, task, requestedBy } = request; @@ -64,48 +64,67 @@ export class AgentSpawner { } try { - await this.ensureSession(); - if (debug) console.log(`[spawner:debug] Session ${this.tmuxSession} ready`); - - // Create new window for worker - const windowName = name; - const newWindowCmd = `"${this.tmuxPath}" new-window -t ${this.tmuxSession} -n ${windowName} -c "${this.projectRoot}"`; - if (debug) console.log(`[spawner:debug] Creating window: ${newWindowCmd}`); - await execAsync(newWindowCmd); - - // Build the agent-relay command - // Unset TMUX to allow Claude to run inside tmux (it refuses to nest by default) - // Use full path to agent-relay to avoid PATH issues with nvm/shell init - let agentRelayPath: string; - try { - const { stdout } = await execAsync('which agent-relay'); - agentRelayPath = stdout.trim(); - if (debug) console.log(`[spawner:debug] Found agent-relay at: ${agentRelayPath}`); - } catch { - // Fallback to npx if which fails - agentRelayPath = 'npx agent-relay'; - if (debug) console.log(`[spawner:debug] Using npx fallback`); + // Parse CLI command + const cliParts = cli.split(' '); + const command = cliParts[0]; + const args = cliParts.slice(1); + + // Add --dangerously-skip-permissions for Claude agents + const isClaudeCli = command.startsWith('claude'); + if (isClaudeCli && !args.includes('--dangerously-skip-permissions')) { + args.push('--dangerously-skip-permissions'); } - // Add --dangerously-skip-permissions for Claude agents to avoid permission dialogs - // Use -- to separate agent-relay options from the wrapped command - const isClaudeCli = cli.startsWith('claude'); - const cliWithFlags = isClaudeCli ? `${cli} --dangerously-skip-permissions` : cli; - const cmd = `unset TMUX && ${agentRelayPath} -n ${name} -- ${cliWithFlags}`; - if (debug) console.log(`[spawner:debug] Agent command: ${cmd}`); + // Add --dangerously-bypass-approvals-and-sandbox for Codex agents + const isCodexCli = command.startsWith('codex'); + if (isCodexCli && !args.includes('--dangerously-bypass-approvals-and-sandbox')) { + args.push('--dangerously-bypass-approvals-and-sandbox'); + } + + if (debug) console.log(`[spawner:debug] Spawning ${name} with: ${command} ${args.join(' ')}`); + + // Create PtyWrapper config + const ptyConfig: PtyWrapperConfig = { + name, + command, + args, + socketPath: this.socketPath, + cwd: this.projectRoot, + logsDir: this.logsDir, + onSpawn: async (workerName, workerCli, workerTask) => { + // Handle nested spawn requests + if (debug) console.log(`[spawner:debug] Nested spawn: ${workerName}`); + await this.spawn({ + name: workerName, + cli: workerCli, + task: workerTask, + requestedBy: name, + }); + }, + onRelease: async (workerName) => { + // Handle release requests from workers + if (debug) console.log(`[spawner:debug] Release request: ${workerName}`); + await this.release(workerName); + }, + onExit: (code) => { + if (debug) console.log(`[spawner:debug] Worker ${name} exited with code ${code}`); + this.activeWorkers.delete(name); + this.saveWorkersMetadata(); + }, + }; - // Send the command - const sendCmd = `"${this.tmuxPath}" send-keys -t ${this.tmuxSession}:${windowName} '${cmd}' Enter`; - if (debug) console.log(`[spawner:debug] Sending: ${sendCmd}`); - await execAsync(sendCmd); + // Create and start the pty wrapper + const pty = new PtyWrapper(ptyConfig); + await pty.start(); - // Wait for the agent to register with the daemon before injecting tasks + if (debug) console.log(`[spawner:debug] PTY started, pid: ${pty.pid}`); + + // Wait for the agent to register with the daemon const registered = await this.waitForAgentRegistration(name, 30_000, 500); if (!registered) { const error = `Worker ${name} failed to register within 30s`; console.error(`[spawner] ${error}`); - // Clean up the tmux window to avoid orphaned workers - await execAsync(`"${this.tmuxPath}" kill-window -t ${this.tmuxSession}:${windowName}`).catch(() => {}); + pty.kill(); return { success: false, name, @@ -115,34 +134,30 @@ export class AgentSpawner { // Inject the initial task if provided if (task && task.trim()) { - const escapedTask = escapeForTmux(task); - if (debug) console.log(`[spawner:debug] Injecting task: ${escapedTask.substring(0, 50)}...`); - await execAsync( - `"${this.tmuxPath}" send-keys -t ${this.tmuxSession}:${windowName} -l "${escapedTask}"` - ); - await sleep(100); - await execAsync( - `"${this.tmuxPath}" send-keys -t ${this.tmuxSession}:${windowName} Enter` - ); + if (debug) console.log(`[spawner:debug] Injecting task: ${task.substring(0, 50)}...`); + pty.write(task + '\r'); } // Track the worker - const workerInfo: WorkerInfo = { + const workerInfo: ActiveWorker = { name, cli, task, spawnedBy: requestedBy, spawnedAt: Date.now(), - window: `${this.tmuxSession}:${windowName}`, + pid: pty.pid, + pty, + logFile: pty.logPath, }; this.activeWorkers.set(name, workerInfo); + this.saveWorkersMetadata(); - console.log(`[spawner] Spawned ${name} (${cli}) for ${requestedBy}`); + console.log(`[spawner] Spawned ${name} (${cli}) for ${requestedBy} [pid: ${pty.pid}]`); return { success: true, name, - window: workerInfo.window, + pid: pty.pid, }; } catch (err: any) { console.error(`[spawner] Failed to spawn ${name}:`, err.message); @@ -166,20 +181,19 @@ export class AgentSpawner { } try { - // Send exit command gracefully - await execAsync( - `"${this.tmuxPath}" send-keys -t ${worker.window} '/exit' Enter` - ).catch(() => {}); + // Stop the pty process gracefully + worker.pty.stop(); - // Wait a bit for graceful shutdown + // Wait for graceful shutdown await sleep(2000); - // Kill the window - await execAsync( - `"${this.tmuxPath}" kill-window -t ${worker.window}` - ).catch(() => {}); + // Force kill if still running + if (worker.pty.isRunning) { + worker.pty.kill(); + } this.activeWorkers.delete(name); + this.saveWorkersMetadata(); console.log(`[spawner] Released ${name}`); return true; @@ -187,6 +201,7 @@ export class AgentSpawner { console.error(`[spawner] Failed to release ${name}:`, err.message); // Still remove from tracking this.activeWorkers.delete(name); + this.saveWorkersMetadata(); return false; } } @@ -202,10 +217,17 @@ export class AgentSpawner { } /** - * Get all active workers + * Get all active workers (returns WorkerInfo without pty reference) */ getActiveWorkers(): WorkerInfo[] { - return Array.from(this.activeWorkers.values()); + return Array.from(this.activeWorkers.values()).map((w) => ({ + name: w.name, + cli: w.cli, + task: w.task, + spawnedBy: w.spawnedBy, + spawnedAt: w.spawnedAt, + pid: w.pid, + })); } /** @@ -219,7 +241,34 @@ export class AgentSpawner { * Get worker info */ getWorker(name: string): WorkerInfo | undefined { - return this.activeWorkers.get(name); + const worker = this.activeWorkers.get(name); + if (!worker) return undefined; + return { + name: worker.name, + cli: worker.cli, + task: worker.task, + spawnedBy: worker.spawnedBy, + spawnedAt: worker.spawnedAt, + pid: worker.pid, + }; + } + + /** + * Get output logs from a worker + */ + getWorkerOutput(name: string, limit?: number): string[] | null { + const worker = this.activeWorkers.get(name); + if (!worker) return null; + return worker.pty.getOutput(limit); + } + + /** + * Get raw output from a worker + */ + getWorkerRawOutput(name: string): string | null { + const worker = this.activeWorkers.get(name); + if (!worker) return null; + return worker.pty.getRawOutput(); } /** @@ -261,4 +310,66 @@ export class AgentSpawner { return false; } } + + /** + * Save workers metadata to disk for CLI access + */ + private saveWorkersMetadata(): void { + try { + const workers: WorkerMeta[] = Array.from(this.activeWorkers.values()).map((w) => ({ + name: w.name, + cli: w.cli, + task: w.task, + spawnedBy: w.spawnedBy, + spawnedAt: w.spawnedAt, + pid: w.pid, + logFile: w.logFile, + })); + + fs.writeFileSync(this.workersPath, JSON.stringify({ workers }, null, 2)); + } catch (err: any) { + console.error('[spawner] Failed to save workers metadata:', err.message); + } + } + + /** + * Get path to logs directory + */ + getLogsDir(): string { + return this.logsDir; + } + + /** + * Get path to workers metadata file + */ + getWorkersPath(): string { + return this.workersPath; + } +} + +/** + * Read workers metadata from disk (for CLI use) + */ +export function readWorkersMetadata(projectRoot: string): WorkerMeta[] { + const paths = getProjectPaths(projectRoot); + const workersPath = path.join(paths.teamDir, 'workers.json'); + + if (!fs.existsSync(workersPath)) { + return []; + } + + try { + const raw = JSON.parse(fs.readFileSync(workersPath, 'utf-8')); + return Array.isArray(raw?.workers) ? raw.workers : []; + } catch { + return []; + } +} + +/** + * Get the worker logs directory path + */ +export function getWorkerLogsDir(projectRoot: string): string { + const paths = getProjectPaths(projectRoot); + return path.join(paths.teamDir, 'worker-logs'); } diff --git a/src/bridge/types.ts b/src/bridge/types.ts index d8772f176..90ee8eae8 100644 --- a/src/bridge/types.ts +++ b/src/bridge/types.ts @@ -46,7 +46,8 @@ export interface SpawnRequest { export interface SpawnResult { success: boolean; name: string; - window?: string; + /** PID of the spawned process (for pty-based workers) */ + pid?: number; error?: string; } @@ -56,5 +57,6 @@ export interface WorkerInfo { task: string; spawnedBy: string; spawnedAt: number; - window: string; + /** PID of the pty process */ + pid?: number; } diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index e81bc1692..22dc66f43 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -1,9 +1,7 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect } from 'vitest'; import { exec } from 'node:child_process'; import { promisify } from 'node:util'; import path from 'node:path'; -import fs from 'node:fs'; -import os from 'node:os'; const execAsync = promisify(exec); @@ -154,8 +152,8 @@ describe('CLI Helper Functions', () => { it('should parse duration strings', async () => { // These should not error const { code: code1 } = await runCli('history --since 1h'); - const { code: code2 } = await runCli('history --since 30m'); - const { code: code3 } = await runCli('history --since 7d'); + const { code: _code2 } = await runCli('history --since 30m'); + const { code: _code3 } = await runCli('history --since 7d'); // Commands should execute (might have no results, but shouldn't crash) expect([0, code1]).toContain(code1); }); diff --git a/src/cli/index.ts b/src/cli/index.ts index df2bdc5bb..e4817ffb6 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -17,6 +17,7 @@ import { Daemon } from '../daemon/server.js'; import { RelayClient } from '../wrapper/client.js'; import { generateAgentName } from '../utils/name-generator.js'; import { getTmuxPath } from '../utils/tmux-resolver.js'; +import { readWorkersMetadata, getWorkerLogsDir } from '../bridge/spawner.js'; import { checkForUpdatesInBackground, checkForUpdates } from '../utils/update-checker.js'; import fs from 'node:fs'; import path from 'node:path'; @@ -117,7 +118,7 @@ program requestedBy: agentName, }); if (result.success) { - console.error(`[${agentName}] ✓ Spawned ${workerName} in ${result.window}`); + console.error(`[${agentName}] ✓ Spawned ${workerName} [pid: ${result.pid}]`); } else { console.error(`[${agentName}] ✗ Failed to spawn ${workerName}: ${result.error}`); } @@ -142,117 +143,6 @@ program await wrapper.start(); }); -// Team config types -interface TeamAgent { - name: string; - cli: string; - role?: string; -} - -interface TeamConfig { - team?: string; - agents: TeamAgent[]; - autoSpawn?: boolean; -} - -// Load teams.json from project root or .agent-relay/ -function loadTeamConfig(projectRoot: string): TeamConfig | null { - const locations = [ - path.join(projectRoot, 'teams.json'), - path.join(projectRoot, '.agent-relay', 'teams.json'), - ]; - - for (const configPath of locations) { - if (fs.existsSync(configPath)) { - try { - const content = fs.readFileSync(configPath, 'utf-8'); - return JSON.parse(content) as TeamConfig; - } catch (err) { - console.error(`Failed to parse ${configPath}:`, err); - } - } - } - return null; -} - -// Spawn agents from team config using tmux -async function spawnTeamAgents( - agents: TeamAgent[], - socketPath: string, - dataDir: string, - projectRoot: string, - relayPrefix?: string -): Promise { - const { TmuxWrapper } = await import('../wrapper/tmux-wrapper.js'); - const { findAgentConfig, isClaudeCli, buildClaudeArgs } = await import('../utils/agent-config.js'); - const { AgentSpawner } = await import('../bridge/spawner.js'); - - // Create spawner so all team agents can spawn workers - const spawner = new AgentSpawner(projectRoot); - - for (const agent of agents) { - console.log(`Spawning agent: ${agent.name} (${agent.cli})`); - - // Parse CLI - handle "claude:opus" format - const [mainCommand, ...cliArgs] = agent.cli.split(/\s+/); - - // Auto-detect agent config and inject --model/--agent for Claude CLI - let finalArgs = cliArgs; - if (isClaudeCli(mainCommand)) { - const config = findAgentConfig(agent.name, projectRoot); - if (config) { - console.log(` Agent config: ${config.configPath}`); - if (config.model) { - console.log(` Model: ${config.model}`); - } - finalArgs = buildClaudeArgs(agent.name, cliArgs, projectRoot); - } - } - - const wrapper = new TmuxWrapper({ - name: agent.name, - command: mainCommand, - args: finalArgs, - socketPath, - debug: false, - relayPrefix, - useInbox: true, - inboxDir: dataDir, - // Wire up spawn/release callbacks so any agent can spawn workers - onSpawn: async (workerName: string, workerCli: string, task: string) => { - console.log(`[${agent.name}] Spawning ${workerName} (${workerCli})...`); - const result = await spawner.spawn({ - name: workerName, - cli: workerCli, - task, - requestedBy: agent.name, - }); - if (result.success) { - console.log(`[${agent.name}] ✓ Spawned ${workerName} in ${result.window}`); - } else { - console.error(`[${agent.name}] ✗ Failed to spawn ${workerName}: ${result.error}`); - } - }, - onRelease: async (workerName: string) => { - console.log(`[${agent.name}] Releasing ${workerName}...`); - const released = await spawner.release(workerName); - if (released) { - console.log(`[${agent.name}] ✓ Released ${workerName}`); - } else { - console.error(`[${agent.name}] ✗ Worker ${workerName} not found`); - } - }, - }); - - try { - await wrapper.start(); - console.log(` Started: ${agent.name}`); - } catch (err) { - console.error(` Failed to start ${agent.name}:`, err); - } - } -} - // up - Start daemon + dashboard program .command('up') @@ -260,7 +150,7 @@ program .option('--no-dashboard', 'Disable web dashboard') .option('--port ', 'Dashboard port', DEFAULT_DASHBOARD_PORT) .action(async (options) => { - const { getProjectPaths, ensureProjectDir } = await import('../utils/project-namespace.js'); + const { ensureProjectDir } = await import('../utils/project-namespace.js'); const paths = ensureProjectDir(); const socketPath = paths.socketPath; @@ -295,7 +185,7 @@ program // Dashboard starts by default (use --no-dashboard to disable) if (options.dashboard !== false) { const port = parseInt(options.port, 10); - const { startDashboard } = await import('../dashboard/server.js'); + const { startDashboard } = await import('../dashboard-server/server.js'); const actualPort = await startDashboard({ port, dataDir: paths.dataDir, @@ -372,10 +262,10 @@ program } }); -// agents - List connected agents (from registry file) +// agents - List connected agents (from registry file) and spawned workers program .command('agents') - .description('List connected agents') + .description('List connected agents and spawned workers') .option('--all', 'Include internal/CLI agents') .option('--json', 'Output as JSON') .action(async (options) => { @@ -383,31 +273,81 @@ program const paths = getProjectPaths(); const agentsPath = path.join(paths.teamDir, 'agents.json'); + // Load registered agents const allAgents = loadAgents(agentsPath); const agents = options.all ? allAgents : allAgents.filter(isVisibleAgent); + // Load spawned workers + const workers = readWorkersMetadata(paths.projectRoot); + + // Merge agents and workers + interface CombinedAgent { + name: string; + status: string; + cli: string; + lastSeen?: string; + spawnedBy?: string; + pid?: number; + } + + const combined: CombinedAgent[] = []; + + // Add registered agents + agents.forEach((agent) => { + const worker = workers.find(w => w.name === agent.name); + combined.push({ + name: agent.name ?? 'unknown', + status: getAgentStatus(agent), + cli: agent.cli ?? '-', + lastSeen: agent.lastSeen, + spawnedBy: worker?.spawnedBy, + pid: worker?.pid, + }); + }); + + // Add workers not in registry (orphaned or not yet registered) + workers.forEach((worker) => { + const existsInAgents = agents.some(a => a.name === worker.name); + if (!existsInAgents) { + combined.push({ + name: worker.name || 'unknown', + status: 'ONLINE', + cli: worker.cli || '-', + spawnedBy: worker.spawnedBy, + pid: worker.pid, + }); + } + }); + if (options.json) { - console.log(JSON.stringify(agents.map(a => ({ ...a, status: getAgentStatus(a) })), null, 2)); + console.log(JSON.stringify(combined, null, 2)); return; } - if (!agents.length) { + if (!combined.length) { const hint = options.all ? '' : ' (use --all to include internal/cli agents)'; console.log(`No agents found. Ensure the daemon is running and agents are connected${hint}.`); return; } - console.log('NAME STATUS CLI LAST SEEN'); - console.log('---------------------------------------------'); - agents.forEach((agent) => { - const name = (agent.name ?? 'unknown').padEnd(15); - const status = getAgentStatus(agent).padEnd(8); - const cli = (agent.cli ?? '-').padEnd(8); - const lastSeen = formatRelativeTime(agent.lastSeen); - console.log(`${name} ${status} ${cli} ${lastSeen}`); + console.log('NAME STATUS CLI PARENT'); + console.log('─'.repeat(50)); + combined.forEach((agent) => { + const name = agent.name.padEnd(15); + const status = agent.status.padEnd(8); + const cli = agent.cli.padEnd(9); + const parent = agent.spawnedBy ?? '-'; + console.log(`${name} ${status} ${cli} ${parent}`); }); + + if (workers.length > 0) { + console.log(''); + console.log('Commands:'); + console.log(' agent-relay agents:logs - View spawned agent output'); + console.log(' agent-relay agents:kill - Kill a spawned agent'); + } }); // who - Show currently active agents (online within last 30s) @@ -760,7 +700,7 @@ program try { await client.connect(); - } catch (err) { + } catch (_err) { console.error('Failed to connect to all projects'); writeBridgeState(); // Write final state before exit process.exit(1); @@ -1101,258 +1041,114 @@ function parseSince(input?: string): number | undefined { } // ============================================ -// Spawn/Worker debugging commands +// Spawned agent debugging commands // ============================================ -const WORKER_SESSION = 'relay-workers'; - -// workers - List spawned workers +// agents:logs - Show log file output for a spawned agent program - .command('workers') - .description('List spawned worker agents (from tmux)') - .option('--json', 'Output as JSON') - .action(async (options: { json?: boolean }) => { - try { - const tmuxPath = getTmuxPath(); - // Check if worker session exists - try { - await execAsync(`"${tmuxPath}" has-session -t ${WORKER_SESSION} 2>/dev/null`); - } catch { - if (options.json) { - console.log(JSON.stringify({ workers: [], session: null })); - } else { - console.log('No spawned workers (session does not exist)'); - } - return; - } - - // List windows in the worker session - const { stdout } = await execAsync( - `"${tmuxPath}" list-windows -t ${WORKER_SESSION} -F "#{window_index}|#{window_name}|#{pane_current_command}|#{window_activity}"` - ); - - const workers = stdout - .split('\n') - .filter(Boolean) - .map(line => { - const [index, name, command, activity] = line.split('|'); - const activityTs = parseInt(activity, 10) * 1000; - const lastActive = isNaN(activityTs) ? undefined : new Date(activityTs).toISOString(); - return { - index: parseInt(index, 10), - name, - command, - lastActive, - window: `${WORKER_SESSION}:${name}`, - }; - }) - // Filter out the default zsh window - .filter(w => w.name !== 'zsh' && w.command !== 'zsh'); - - if (options.json) { - console.log(JSON.stringify({ workers, session: WORKER_SESSION }, null, 2)); - return; - } - - if (!workers.length) { - console.log('No spawned workers'); - return; - } - - console.log('SPAWNED WORKERS'); - console.log('─'.repeat(50)); - console.log('NAME COMMAND WINDOW'); - console.log('─'.repeat(50)); - workers.forEach(w => { - const name = w.name.padEnd(15); - const cmd = (w.command || '-').padEnd(12); - console.log(`${name} ${cmd} ${w.window}`); - }); - console.log(''); - console.log('Commands:'); - console.log(' agent-relay workers:logs - View worker output'); - console.log(' agent-relay workers:attach - Attach to worker tmux'); - console.log(' agent-relay workers:kill - Kill a worker'); - } catch (err) { - console.error('Failed to list workers:', (err as Error).message); - } - }); - -// workers:logs - Show tmux pane output for a worker -program - .command('workers:logs') - .description('Show recent output from a spawned worker') - .argument('', 'Worker name') + .command('agents:logs') + .description('Show recent output from a spawned agent') + .argument('', 'Agent name') .option('-n, --lines ', 'Number of lines to show', '50') .option('-f, --follow', 'Follow output (like tail -f)') .action(async (name: string, options: { lines?: string; follow?: boolean }) => { - const tmuxPath = getTmuxPath(); - const window = `${WORKER_SESSION}:${name}`; + const { getProjectPaths } = await import('../utils/project-namespace.js'); + const paths = getProjectPaths(); + const logsDir = getWorkerLogsDir(paths.projectRoot); + const logFile = path.join(logsDir, `${name}.log`); - try { - // Check if window exists - await execAsync(`"${tmuxPath}" has-session -t ${window} 2>/dev/null`); - } catch { - console.error(`Worker "${name}" not found`); - console.log(`Run 'agent-relay workers' to see available workers`); + if (!fs.existsSync(logFile)) { + console.error(`No logs found for agent "${name}"`); + console.log(`Log file not found: ${logFile}`); + console.log(`Run 'agent-relay agents' to see available agents`); process.exit(1); } if (options.follow) { - console.log(`Following output from ${window} (Ctrl+C to stop)...`); + console.log(`Following logs for ${name} (Ctrl+C to stop)...`); console.log('─'.repeat(50)); - // Use a polling approach to follow - let lastContent = ''; - const poll = async () => { - try { - const { stdout } = await execAsync(`"${tmuxPath}" capture-pane -t ${window} -p -S -100`); - if (stdout !== lastContent) { - // Print only new lines - const newContent = stdout.replace(lastContent, ''); - if (newContent.trim()) { - process.stdout.write(newContent); - } - lastContent = stdout; - } - } catch { - console.error('\nWorker disconnected'); - process.exit(1); - } - }; + // Use tail -f approach + const { spawn } = await import('child_process'); + const child = spawn('tail', ['-f', logFile], { + stdio: ['ignore', 'inherit', 'inherit'], + }); - const interval = setInterval(poll, 500); process.on('SIGINT', () => { - clearInterval(interval); + child.kill(); console.log('\nStopped following'); process.exit(0); }); - await poll(); // Initial fetch - await new Promise(() => {}); // Keep running + child.on('exit', () => { + process.exit(0); + }); } else { try { const lines = parseInt(options.lines || '50', 10); - const { stdout } = await execAsync(`"${tmuxPath}" capture-pane -t ${window} -p -S -${lines}`); - console.log(`Output from ${window} (last ${lines} lines):`); + const { stdout } = await execAsync(`tail -n ${lines} "${logFile}"`); + console.log(`Logs for ${name} (last ${lines} lines):`); console.log('─'.repeat(50)); console.log(stdout || '(empty)'); } catch (err) { - console.error('Failed to capture output:', (err as Error).message); + console.error('Failed to read logs:', (err as Error).message); } } }); -// workers:attach - Attach to a worker's tmux window +// agents:kill - Kill a spawned agent by PID program - .command('workers:attach') - .description('Attach to a spawned worker tmux window') - .argument('', 'Worker name') - .action(async (name: string) => { - const tmuxPath = getTmuxPath(); - const window = `${WORKER_SESSION}:${name}`; - - try { - // Check if window exists - await execAsync(`"${tmuxPath}" has-session -t ${window} 2>/dev/null`); - } catch { - console.error(`Worker "${name}" not found`); - console.log(`Run 'agent-relay workers' to see available workers`); - process.exit(1); - } - - console.log(`Attaching to ${window}...`); - console.log('(Use Ctrl+B D to detach)'); - - // Spawn tmux attach as a child process with stdio inherited - const { spawn } = await import('child_process'); - const child = spawn(tmuxPath, ['attach-session', '-t', window], { - stdio: 'inherit', - }); - - child.on('exit', (code) => { - process.exit(code || 0); - }); - }); - -// workers:kill - Kill a spawned worker -program - .command('workers:kill') - .description('Kill a spawned worker') - .argument('', 'Worker name') + .command('agents:kill') + .description('Kill a spawned agent') + .argument('', 'Agent name') .option('--force', 'Skip graceful shutdown, kill immediately') .action(async (name: string, options: { force?: boolean }) => { - const tmuxPath = getTmuxPath(); - const window = `${WORKER_SESSION}:${name}`; + const { getProjectPaths } = await import('../utils/project-namespace.js'); + const paths = getProjectPaths(); + const workers = readWorkersMetadata(paths.projectRoot); + const worker = workers.find(w => w.name === name); - try { - // Check if window exists - await execAsync(`"${tmuxPath}" has-session -t ${window} 2>/dev/null`); - } catch { - console.error(`Worker "${name}" not found`); - console.log(`Run 'agent-relay workers' to see available workers`); + if (!worker) { + console.error(`Spawned agent "${name}" not found`); + console.log(`Run 'agent-relay agents' to see available agents`); process.exit(1); } - if (!options.force) { - // Try graceful shutdown first - console.log(`Sending /exit to ${name}...`); - try { - await execAsync(`"${tmuxPath}" send-keys -t ${window} '/exit' Enter`); - // Wait for graceful shutdown - await new Promise(r => setTimeout(r, 2000)); - } catch { - // Ignore errors, will force kill below - } - } - - // Kill the window - try { - await execAsync(`"${tmuxPath}" kill-window -t ${window}`); - console.log(`Killed worker: ${name}`); - } catch (err) { - console.error(`Failed to kill ${name}:`, (err as Error).message); + if (!worker.pid) { + console.error(`Agent "${name}" has no PID recorded`); process.exit(1); } - }); -// workers:session - Show tmux session info -program - .command('workers:session') - .description('Show worker tmux session details') - .action(async () => { try { - const tmuxPath = getTmuxPath(); - // Check if session exists - try { - await execAsync(`"${tmuxPath}" has-session -t ${WORKER_SESSION} 2>/dev/null`); - } catch { - console.log(`Session "${WORKER_SESSION}" does not exist`); - console.log('Spawn a worker to create it.'); - return; - } + if (!options.force) { + // Try graceful shutdown first (SIGTERM) + console.log(`Sending SIGTERM to ${name} (pid: ${worker.pid})...`); + process.kill(worker.pid, 'SIGTERM'); + // Wait for graceful shutdown + await new Promise(r => setTimeout(r, 2000)); - console.log(`Session: ${WORKER_SESSION}`); - console.log('─'.repeat(50)); + // Check if still running + try { + process.kill(worker.pid, 0); // Check if process exists + console.log(`Agent still running, sending SIGKILL...`); + process.kill(worker.pid, 'SIGKILL'); + } catch { + // Process no longer exists, graceful shutdown worked + } + } else { + // Force kill immediately + console.log(`Force killing ${name} (pid: ${worker.pid})...`); + process.kill(worker.pid, 'SIGKILL'); + } - // Get session info - const { stdout: sessionInfo } = await execAsync( - `"${tmuxPath}" display-message -t ${WORKER_SESSION} -p "Created: #{session_created_string}\\nWindows: #{session_windows}\\nAttached: #{?session_attached,yes,no}"` - ); - console.log(sessionInfo); - - // List windows - console.log('\nWindows:'); - const { stdout: windows } = await execAsync( - `"${tmuxPath}" list-windows -t ${WORKER_SESSION} -F " #{window_index}: #{window_name} (#{pane_current_command})"` - ); - console.log(windows || ' (none)'); - - console.log('\nQuick commands:'); - console.log(` tmux attach -t ${WORKER_SESSION} # Attach to session`); - console.log(` tmux kill-session -t ${WORKER_SESSION} # Kill entire session`); - } catch (err) { - console.error('Failed:', (err as Error).message); + console.log(`Killed agent: ${name}`); + } catch (err: any) { + if (err.code === 'ESRCH') { + console.log(`Agent ${name} is no longer running (pid: ${worker.pid})`); + } else { + console.error(`Failed to kill ${name}:`, err.message); + process.exit(1); + } } }); diff --git a/src/daemon/router.ts b/src/daemon/router.ts index d1629c275..f8e2d8c22 100644 --- a/src/daemon/router.ts +++ b/src/daemon/router.ts @@ -6,8 +6,6 @@ import { v4 as uuid } from 'uuid'; import { type Envelope, - type SendPayload, - type SendMeta, type SendEnvelope, type DeliverEnvelope, type AckPayload, diff --git a/src/dashboard/metrics.ts b/src/dashboard-server/metrics.ts similarity index 98% rename from src/dashboard/metrics.ts rename to src/dashboard-server/metrics.ts index 9c387ac0b..8f4058442 100644 --- a/src/dashboard/metrics.ts +++ b/src/dashboard-server/metrics.ts @@ -3,7 +3,8 @@ * Provides real-time metrics for monitoring: throughput, agent health, error rates. */ -import type { StorageAdapter, StoredMessage } from '../storage/adapter.js'; +// Storage types imported for documentation, not currently used in computation +// import type { StorageAdapter, StoredMessage } from '../storage/adapter.js'; export interface AgentMetrics { name: string; diff --git a/src/dashboard/needs-attention.test.ts b/src/dashboard-server/needs-attention.test.ts similarity index 100% rename from src/dashboard/needs-attention.test.ts rename to src/dashboard-server/needs-attention.test.ts diff --git a/src/dashboard/needs-attention.ts b/src/dashboard-server/needs-attention.ts similarity index 100% rename from src/dashboard/needs-attention.ts rename to src/dashboard-server/needs-attention.ts diff --git a/src/dashboard/server.ts b/src/dashboard-server/server.ts similarity index 95% rename from src/dashboard/server.ts rename to src/dashboard-server/server.ts index 150bc5be6..483f5693a 100644 --- a/src/dashboard/server.ts +++ b/src/dashboard-server/server.ts @@ -12,7 +12,7 @@ import { computeNeedsAttention } from './needs-attention.js'; import { computeSystemMetrics, formatPrometheusMetrics } from './metrics.js'; import { MultiProjectClient } from '../bridge/multi-project-client.js'; import { AgentSpawner } from '../bridge/spawner.js'; -import type { ProjectConfig, SpawnRequest, WorkerInfo } from '../bridge/types.js'; +import type { ProjectConfig, SpawnRequest } from '../bridge/types.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -96,23 +96,7 @@ export async function startDashboard( const { port, dataDir, teamDir, dbPath, enableSpawner, projectRoot, tmuxSession } = options; console.log('Starting dashboard...'); - console.log('__dirname:', __dirname); - const publicDir = path.join(__dirname, 'public'); - console.log('Public dir:', publicDir); - // Verify public directory exists and contains expected files - if (!fs.existsSync(publicDir)) { - console.error(`[dashboard] ERROR: Public directory not found: ${publicDir}`); - } else { - const files = fs.readdirSync(publicDir); - console.log('Public dir contents:', files.join(', ')); - if (!files.includes('metrics.html')) { - console.error('[dashboard] WARNING: metrics.html not found in public directory'); - } - if (!files.includes('bridge.html')) { - console.error('[dashboard] WARNING: bridge.html not found in public directory'); - } - } const storage: StorageAdapter | undefined = dbPath ? new SqliteStorageAdapter({ dbPath }) : undefined; @@ -180,10 +164,29 @@ export async function startDashboard( await storage.init(); } - // Serve static files from public directory - app.use(express.static(publicDir)); app.use(express.json()); + // Serve dashboard static files at root (built with `next build` in src/dashboard) + // __dirname is dist/dashboard-server, dashboard is at ../dashboard/out (relative to dist) + // But in source it's at ../dashboard/out (relative to src/dashboard-server) + const dashboardDistDir = path.join(__dirname, '..', 'dashboard', 'out'); + const dashboardSourceDir = path.join(__dirname, '..', '..', 'src', 'dashboard', 'out'); + + // Check which path exists (dist or src) + const dashboardDir = fs.existsSync(dashboardDistDir) ? dashboardDistDir : dashboardSourceDir; + if (fs.existsSync(dashboardDir)) { + console.log(`[dashboard] Serving from: ${dashboardDir}`); + // Serve Next.js static export with .html extension handling + app.use(express.static(dashboardDir, { extensions: ['html'] })); + + // Fallback for Next.js pages (e.g., /metrics -> /metrics.html) + app.get('/metrics', (req, res) => { + res.sendFile(path.join(dashboardDir, 'metrics.html')); + }); + } else { + console.error('[dashboard] Dashboard not found at:', dashboardDistDir, 'or', dashboardSourceDir); + } + // Relay client for sending messages from dashboard const socketPath = path.join(dataDir, 'relay.sock'); let relayClient: RelayClient | undefined; @@ -581,7 +584,7 @@ export async function startDashboard( agent.processingStartedAt = (state as { startedAt: number }).startedAt; } } - } catch (err) { + } catch (_err) { // Ignore errors reading processing state - it's optional } } @@ -901,28 +904,6 @@ export async function startDashboard( } }); - // Metrics view route - serves metrics.html - app.get('/metrics', (req, res) => { - const filePath = path.join(publicDir, 'metrics.html'); - res.sendFile(filePath, (err) => { - if (err) { - console.error(`[dashboard] Failed to serve metrics.html from ${filePath}:`, err.message); - res.status(404).send('Metrics page not found'); - } - }); - }); - - // Bridge view route - serves bridge.html - app.get('/bridge', (req, res) => { - const filePath = path.join(publicDir, 'bridge.html'); - res.sendFile(filePath, (err) => { - if (err) { - console.error(`[dashboard] Failed to serve bridge.html from ${filePath}:`, err.message); - res.status(404).send('Bridge page not found'); - } - }); - }); - // Bridge API endpoint - returns multi-project data // This is a placeholder that returns empty data when not in bridge mode // The actual bridge data comes from MultiProjectClient when running `agent-relay bridge` diff --git a/src/dashboard/start.ts b/src/dashboard-server/start.ts similarity index 100% rename from src/dashboard/start.ts rename to src/dashboard-server/start.ts diff --git a/src/dashboard-v2/app/globals.css b/src/dashboard/app/globals.css similarity index 80% rename from src/dashboard-v2/app/globals.css rename to src/dashboard/app/globals.css index f5ea86dcb..b7b07549d 100644 --- a/src/dashboard-v2/app/globals.css +++ b/src/dashboard/app/globals.css @@ -13,36 +13,41 @@ padding: 0; } -/* CSS Custom Properties */ +/* CSS Custom Properties - Default to Dark Mode */ :root { - /* Colors */ - --color-bg-primary: #f5f5f5; - --color-bg-secondary: #ffffff; - --color-bg-tertiary: #fafafa; - --color-bg-hover: #f0f0f0; - --color-bg-active: #e8e8e8; - - --color-text-primary: #1a1a1a; - --color-text-secondary: #666666; - --color-text-muted: #888888; - --color-text-inverse: #ffffff; - - --color-border: #e8e8e8; - --color-border-light: #f0f0f0; - --color-border-dark: #d0d0d0; + /* Colors - Dark Theme (default) */ + --color-bg-primary: #1a1d21; + --color-bg-secondary: #222529; + --color-bg-tertiary: #19171d; + --color-bg-hover: rgba(255, 255, 255, 0.06); + --color-bg-active: rgba(255, 255, 255, 0.1); + + --color-text-primary: #d1d2d3; + --color-text-secondary: #ababad; + --color-text-muted: #8d8d8e; + --color-text-inverse: #1a1d21; + + --color-border: rgba(255, 255, 255, 0.1); + --color-border-light: rgba(255, 255, 255, 0.06); + --color-border-dark: rgba(255, 255, 255, 0.15); --color-accent: #1264a3; --color-accent-hover: #0d4f82; - --color-accent-light: #e8f4fd; + --color-accent-light: rgba(18, 100, 163, 0.15); --color-success: #2bac76; - --color-success-light: #ecfdf5; + --color-success-light: rgba(43, 172, 118, 0.15); + + --color-warning: #e8a427; + --color-warning-light: rgba(232, 164, 39, 0.15); - --color-warning: #f59e0b; - --color-warning-light: #fffbeb; + --color-error: #e01e5a; + --color-error-light: rgba(224, 30, 90, 0.15); - --color-error: #ef4444; - --color-error-light: #fef2f2; + /* Sidebar colors */ + --sidebar-bg: #1a1a2e; + --sidebar-border: #2a2a3e; + --sidebar-hover: #3a3a4e; /* Typography */ --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; @@ -207,6 +212,68 @@ button:focus-visible { } } +/* ===================== + Light Theme + ===================== */ +[data-theme="light"] { + --color-bg-primary: #ffffff; + --color-bg-secondary: #f8f9fa; + --color-bg-tertiary: #f1f3f5; + --color-bg-hover: rgba(0, 0, 0, 0.04); + --color-bg-active: rgba(0, 0, 0, 0.08); + + --color-text-primary: #1a1d21; + --color-text-secondary: #616061; + --color-text-muted: #868686; + --color-text-inverse: #ffffff; + + --color-border: rgba(0, 0, 0, 0.1); + --color-border-light: rgba(0, 0, 0, 0.06); + --color-border-dark: rgba(0, 0, 0, 0.15); + + --color-accent: #1264a3; + --color-accent-hover: #0d4f82; + --color-accent-light: rgba(18, 100, 163, 0.1); + + --color-success: #2bac76; + --color-success-light: rgba(43, 172, 118, 0.1); + + --color-warning: #e8a427; + --color-warning-light: rgba(232, 164, 39, 0.1); + + --color-error: #e01e5a; + --color-error-light: rgba(224, 30, 90, 0.1); + + /* Sidebar colors - light theme */ + --sidebar-bg: #3f0e40; + --sidebar-border: #522653; + --sidebar-hover: #4a154b; +} + +/* Light theme sidebar text adjustments */ +[data-theme="light"] .sidebar { + background: var(--sidebar-bg); + border-right-color: var(--sidebar-border); +} + +[data-theme="light"] .sidebar-search { + background: rgba(255, 255, 255, 0.1); +} + +[data-theme="light"] .view-mode-toggle { + background: rgba(255, 255, 255, 0.1); +} + +[data-theme="light"] .toggle-btn.active { + background: rgba(255, 255, 255, 0.2); +} + +/* Light theme message list */ +[data-theme="light"] .message-badge.broadcast { + background: rgba(232, 164, 39, 0.15); + color: #b47e1a; +} + /* Dashboard Layout */ .dashboard-app { display: flex; @@ -322,6 +389,12 @@ button:focus-visible { outline: none; transition: border-color var(--transition-fast); box-sizing: border-box; + background: var(--color-bg-tertiary); + color: var(--color-text-primary); +} + +.composer-input::placeholder { + color: var(--color-text-muted); } .composer-input:focus { @@ -613,8 +686,8 @@ button:focus-visible { ===================== */ .header { height: 52px; - background: #ffffff; - border-bottom: 1px solid #e8e8e8; + background: var(--color-bg-secondary); + border-bottom: 1px solid var(--color-border); display: flex; align-items: center; justify-content: space-between; @@ -628,23 +701,23 @@ button:focus-visible { } .channel-prefix { - color: #888; + color: var(--color-text-muted); font-size: 16px; } .channel-name { font-weight: 600; font-size: 15px; - color: #1a1a1a; + color: var(--color-text-primary); } .channel-topic, .channel-breadcrumb { - color: #888; + color: var(--color-text-muted); font-size: 13px; margin-left: 8px; padding-left: 8px; - border-left: 1px solid #e8e8e8; + border-left: 1px solid var(--color-border); } .channel-info { @@ -671,8 +744,8 @@ button:focus-visible { } .agent-status-badge { - background: #f0f0f0; - color: #666; + background: var(--color-bg-hover); + color: var(--color-text-secondary); font-size: 11px; padding: 2px 8px; border-radius: 4px; @@ -690,27 +763,27 @@ button:focus-visible { align-items: center; gap: 6px; padding: 6px 12px; - background: #f5f5f5; - border: 1px solid #e8e8e8; + background: var(--color-bg-hover); + border: 1px solid var(--color-border); border-radius: 6px; - color: #666; + color: var(--color-text-secondary); font-size: 13px; cursor: pointer; transition: all 0.2s; } .header-btn:hover { - background: #ebebeb; - color: #333; + background: var(--color-bg-active); + color: var(--color-text-primary); } .header-btn kbd { - background: #fff; - border: 1px solid #ddd; + background: var(--color-bg-tertiary); + border: 1px solid var(--color-border); border-radius: 3px; padding: 1px 4px; font-size: 11px; - color: #888; + color: var(--color-text-muted); } .icon-btn { @@ -916,13 +989,13 @@ button:focus-visible { .agent-display-name { font-weight: 600; font-size: 14px; - color: #1a1a1a; + color: var(--color-text-primary); } .agent-full-name, .agent-breadcrumb { font-size: 12px; - color: #666; + color: var(--color-text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -944,18 +1017,18 @@ button:focus-visible { .agent-task { margin-top: 8px; padding: 8px; - background: rgba(0, 0, 0, 0.03); + background: var(--color-bg-hover); border-radius: 4px; font-size: 12px; } .task-label { - color: #666; + color: var(--color-text-secondary); margin-right: 4px; } .task-text { - color: #1a1a1a; + color: var(--color-text-primary); } .agent-card-footer { @@ -973,7 +1046,7 @@ button:focus-visible { } .agent-cli { - background: #f0f0f0; + background: var(--color-bg-hover); padding: 2px 6px; border-radius: 4px; } @@ -1062,7 +1135,7 @@ button:focus-visible { .message-list-empty h3 { margin: 0 0 8px; font-size: 16px; - color: #666; + color: var(--color-text-secondary); } .message-list-empty p { @@ -1079,12 +1152,12 @@ button:focus-visible { } .message-item:hover { - background: #f9f9f9; + background: var(--color-bg-hover); } .message-item.highlighted { - background: #fffbeb; - border-left: 3px solid #f59e0b; + background: var(--color-warning-light); + border-left: 3px solid var(--color-warning); padding-left: 9px; } @@ -1115,18 +1188,18 @@ button:focus-visible { .message-sender { font-weight: 600; font-size: 14px; - color: #1a1a1a; + color: var(--color-text-primary); } .message-arrow { - color: #888; + color: var(--color-text-muted); font-size: 12px; } .message-recipient { font-weight: 500; font-size: 13px; - color: #666; + color: var(--color-text-secondary); } .message-time { @@ -1151,7 +1224,7 @@ button:focus-visible { .message-body { font-size: 14px; line-height: 1.5; - color: #333; + color: var(--color-text-primary); white-space: pre-wrap; word-break: break-word; } @@ -1172,18 +1245,18 @@ button:focus-visible { margin-top: 8px; padding: 4px 8px; background: transparent; - border: 1px solid #e8e8e8; + border: 1px solid var(--color-border); border-radius: 4px; - color: #666; + color: var(--color-text-secondary); font-size: 12px; cursor: pointer; transition: all 0.15s; } .message-thread-btn:hover { - background: #f5f5f5; - border-color: #d0d0d0; - color: #333; + background: var(--color-bg-hover); + border-color: var(--color-border-dark); + color: var(--color-text-primary); } .message-thread-btn svg { @@ -1206,14 +1279,14 @@ button:focus-visible { } .command-palette { - background: #ffffff; + background: var(--color-bg-secondary); border-radius: 12px; width: 560px; max-width: 90vw; max-height: 60vh; display: flex; flex-direction: column; - box-shadow: 0 16px 70px rgba(0, 0, 0, 0.2); + box-shadow: 0 16px 70px rgba(0, 0, 0, 0.5); animation: slideDown 0.2s ease; } @@ -1222,11 +1295,11 @@ button:focus-visible { align-items: center; gap: 12px; padding: 16px; - border-bottom: 1px solid #e8e8e8; + border-bottom: 1px solid var(--color-border); } .command-palette-input-wrapper svg { - color: #888; + color: var(--color-text-muted); flex-shrink: 0; } @@ -1237,19 +1310,20 @@ button:focus-visible { font-family: inherit; outline: none; background: transparent; + color: var(--color-text-primary); } .command-palette-input::placeholder { - color: #aaa; + color: var(--color-text-muted); } .command-palette-kbd { - background: #f5f5f5; - border: 1px solid #e0e0e0; + background: var(--color-bg-hover); + border: 1px solid var(--color-border); border-radius: 4px; padding: 2px 6px; font-size: 11px; - color: #888; + color: var(--color-text-muted); font-family: inherit; } @@ -1296,11 +1370,11 @@ button:focus-visible { .command-palette-item:hover, .command-palette-item.selected { - background: #f5f5f5; + background: var(--color-bg-hover); } .command-palette-item.selected { - background: #e8f4fd; + background: var(--color-accent-light); } .command-palette-item-icon { @@ -1309,7 +1383,7 @@ button:focus-visible { justify-content: center; width: 28px; height: 28px; - color: #666; + color: var(--color-text-secondary); } .command-palette-agent-icon { @@ -1334,24 +1408,24 @@ button:focus-visible { .command-palette-item-label { font-size: 14px; font-weight: 500; - color: #1a1a1a; + color: var(--color-text-primary); } .command-palette-item-desc { font-size: 12px; - color: #888; + color: var(--color-text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .command-palette-item-shortcut { - background: #f0f0f0; - border: 1px solid #e0e0e0; + background: var(--color-bg-hover); + border: 1px solid var(--color-border); border-radius: 4px; padding: 2px 6px; font-size: 11px; - color: #666; + color: var(--color-text-secondary); font-family: inherit; } @@ -1370,13 +1444,13 @@ button:focus-visible { } .spawn-modal { - background: #ffffff; + background: var(--color-bg-secondary); border-radius: 12px; width: 480px; max-width: 90vw; max-height: 90vh; overflow-y: auto; - box-shadow: 0 16px 70px rgba(0, 0, 0, 0.2); + box-shadow: 0 16px 70px rgba(0, 0, 0, 0.5); animation: slideUp 0.2s ease; } @@ -1385,7 +1459,7 @@ button:focus-visible { align-items: center; justify-content: space-between; padding: 20px 24px; - border-bottom: 1px solid #e8e8e8; + border-bottom: 1px solid var(--color-border); } .spawn-modal-header h2 { @@ -1403,14 +1477,14 @@ button:focus-visible { background: transparent; border: none; border-radius: 6px; - color: #666; + color: var(--color-text-secondary); cursor: pointer; transition: all 0.15s; } .spawn-modal-close:hover { - background: #f5f5f5; - color: #333; + background: var(--color-bg-hover); + color: var(--color-text-primary); } .spawn-modal form { @@ -1425,13 +1499,13 @@ button:focus-visible { display: block; font-size: 13px; font-weight: 600; - color: #333; + color: var(--color-text-primary); margin-bottom: 8px; } .spawn-modal-optional { font-weight: 400; - color: #888; + color: var(--color-text-muted); } .spawn-modal-templates { @@ -1446,7 +1520,7 @@ button:focus-visible { align-items: center; gap: 4px; padding: 12px 8px; - background: #f9f9f9; + background: var(--color-bg-hover); border: 2px solid transparent; border-radius: 8px; cursor: pointer; @@ -1455,12 +1529,12 @@ button:focus-visible { } .spawn-modal-template:hover { - background: #f0f0f0; + background: var(--color-bg-active); } .spawn-modal-template.selected { - background: #e8f4fd; - border-color: #1264a3; + background: var(--color-accent-light); + border-color: var(--color-accent); } .spawn-modal-template-icon { @@ -1470,12 +1544,12 @@ button:focus-visible { .spawn-modal-template-name { font-size: 13px; font-weight: 600; - color: #333; + color: var(--color-text-primary); } .spawn-modal-template-desc { font-size: 11px; - color: #888; + color: var(--color-text-muted); text-align: center; } @@ -1500,21 +1574,23 @@ button:focus-visible { .spawn-modal-input { flex: 1; padding: 10px 14px; - border: 1px solid #e8e8e8; + border: 1px solid var(--color-border); border-radius: 6px; font-size: 14px; font-family: inherit; outline: none; transition: border-color 0.15s; + background: var(--color-bg-tertiary); + color: var(--color-text-primary); } .spawn-modal-input:focus { - border-color: #1264a3; + border-color: var(--color-accent); } .spawn-modal-input:disabled { - background: #f9f9f9; - color: #888; + background: var(--color-bg-hover); + color: var(--color-text-muted); } .spawn-modal-name-suggestions { @@ -1525,19 +1601,19 @@ button:focus-visible { .spawn-modal-suggestion { padding: 4px 8px; - background: #f5f5f5; - border: 1px solid #e8e8e8; + background: var(--color-bg-hover); + border: 1px solid var(--color-border); border-radius: 4px; font-size: 12px; - color: #666; + color: var(--color-text-secondary); cursor: pointer; font-family: inherit; transition: all 0.15s; } .spawn-modal-suggestion:hover { - background: #e8e8e8; - color: #333; + background: var(--color-bg-active); + color: var(--color-text-primary); } .spawn-modal-error { @@ -1562,7 +1638,7 @@ button:focus-visible { justify-content: flex-end; gap: 8px; padding-top: 8px; - border-top: 1px solid #e8e8e8; + border-top: 1px solid var(--color-border); } .spawn-modal-btn { @@ -1585,13 +1661,13 @@ button:focus-visible { } .spawn-modal-btn-secondary { - background: #f5f5f5; - color: #666; + background: var(--color-bg-hover); + color: var(--color-text-secondary); } .spawn-modal-btn-secondary:hover:not(:disabled) { - background: #e8e8e8; - color: #333; + background: var(--color-bg-active); + color: var(--color-text-primary); } .spawn-modal-btn-primary { @@ -1622,14 +1698,14 @@ button:focus-visible { } .settings-panel { - background: #ffffff; + background: var(--color-bg-secondary); border-radius: 12px; width: 500px; max-width: 90vw; max-height: 80vh; display: flex; flex-direction: column; - box-shadow: 0 16px 70px rgba(0, 0, 0, 0.2); + box-shadow: 0 16px 70px rgba(0, 0, 0, 0.5); animation: slideUp 0.2s ease; } @@ -1638,7 +1714,7 @@ button:focus-visible { align-items: center; justify-content: space-between; padding: 20px 24px; - border-bottom: 1px solid #e8e8e8; + border-bottom: 1px solid var(--color-border); } .settings-header h2 { @@ -1656,22 +1732,22 @@ button:focus-visible { background: transparent; border: none; border-radius: 6px; - color: #666; + color: var(--color-text-secondary); cursor: pointer; transition: all 0.15s; } .settings-close:hover { - background: #f5f5f5; - color: #333; + background: var(--color-bg-hover); + color: var(--color-text-primary); } .settings-tabs { display: flex; gap: 4px; padding: 12px 24px; - border-bottom: 1px solid #e8e8e8; - background: #fafafa; + border-bottom: 1px solid var(--color-border); + background: var(--color-bg-tertiary); } .settings-tab { @@ -1683,15 +1759,15 @@ button:focus-visible { border: none; border-radius: 6px; font-size: 13px; - color: #666; + color: var(--color-text-secondary); cursor: pointer; font-family: inherit; transition: all 0.15s; } .settings-tab:hover { - background: #f0f0f0; - color: #333; + background: var(--color-bg-hover); + color: var(--color-text-primary); } .settings-tab.active { @@ -1736,11 +1812,11 @@ button:focus-visible { align-items: center; gap: 6px; padding: 16px 20px; - background: #fafafa; + background: var(--color-bg-hover); border: 2px solid transparent; border-radius: 8px; font-size: 12px; - color: #666; + color: var(--color-text-secondary); cursor: pointer; font-family: inherit; transition: all 0.15s; @@ -1748,13 +1824,13 @@ button:focus-visible { } .settings-theme-btn:hover { - background: #f0f0f0; + background: var(--color-bg-active); } .settings-theme-btn.active { - background: #e8f4fd; - border-color: #1264a3; - color: #1264a3; + background: var(--color-accent-light); + border-color: var(--color-accent); + color: var(--color-accent); } .settings-toggle-option { @@ -1762,7 +1838,7 @@ button:focus-visible { align-items: center; justify-content: space-between; padding: 12px; - background: #fafafa; + background: var(--color-bg-hover); border-radius: 8px; } @@ -1779,12 +1855,12 @@ button:focus-visible { .settings-toggle-label { font-size: 14px; font-weight: 500; - color: #333; + color: var(--color-text-primary); } .settings-toggle-desc { font-size: 12px; - color: #888; + color: var(--color-text-muted); } .settings-toggle { @@ -1827,33 +1903,35 @@ button:focus-visible { flex-direction: column; gap: 6px; padding: 12px; - background: #fafafa; + background: var(--color-bg-hover); border-radius: 8px; } .settings-input-group label { font-size: 14px; font-weight: 500; - color: #333; + color: var(--color-text-primary); } .settings-input { padding: 8px 12px; - border: 1px solid #e8e8e8; + border: 1px solid var(--color-border); border-radius: 6px; font-size: 14px; font-family: inherit; outline: none; transition: border-color 0.15s; + background: var(--color-bg-tertiary); + color: var(--color-text-primary); } .settings-input:focus { - border-color: #1264a3; + border-color: var(--color-accent); } .settings-input:disabled { - background: #f5f5f5; - color: #888; + background: var(--color-bg-hover); + color: var(--color-text-muted); } .settings-footer { @@ -1861,24 +1939,24 @@ button:focus-visible { align-items: center; justify-content: space-between; padding: 16px 24px; - border-top: 1px solid #e8e8e8; + border-top: 1px solid var(--color-border); } .settings-reset-btn { padding: 8px 16px; background: transparent; - border: 1px solid #e8e8e8; + border: 1px solid var(--color-border); border-radius: 6px; font-size: 13px; - color: #666; + color: var(--color-text-secondary); cursor: pointer; font-family: inherit; transition: all 0.15s; } .settings-reset-btn:hover { - background: #f5f5f5; - color: #333; + background: var(--color-bg-hover); + color: var(--color-text-primary); } .settings-done-btn { diff --git a/src/dashboard-v2/app/layout.tsx b/src/dashboard/app/layout.tsx similarity index 100% rename from src/dashboard-v2/app/layout.tsx rename to src/dashboard/app/layout.tsx diff --git a/src/dashboard/app/metrics/page.tsx b/src/dashboard/app/metrics/page.tsx new file mode 100644 index 000000000..3c4a10ba0 --- /dev/null +++ b/src/dashboard/app/metrics/page.tsx @@ -0,0 +1,887 @@ +/** + * Dashboard V2 - Metrics Page + * + * System metrics view showing agent health, throughput, and session lifecycle. + */ + +'use client'; + +import React, { useState, useEffect } from 'react'; +import Link from 'next/link'; + +interface AgentMetric { + name: string; + messagesSent: number; + messagesReceived: number; + firstSeen: string; + lastSeen: string; + uptimeSeconds: number; + isOnline: boolean; +} + +interface SessionMetric { + agentName: string; + startedAt: string; + endedAt?: string; + messageCount: number; + closedBy?: 'agent' | 'disconnect' | 'error'; +} + +interface Metrics { + timestamp: string; + totalAgents: number; + onlineAgents: number; + offlineAgents: number; + totalMessages: number; + throughput: { + messagesLastMinute: number; + messagesLastHour: number; + messagesLast24Hours: number; + avgMessagesPerMinute: number; + }; + agents: AgentMetric[]; + sessions?: { + totalSessions: number; + activeSessions: number; + closedByAgent: number; + closedByDisconnect: number; + closedByError: number; + errorRate: number; + recentSessions: SessionMetric[]; + }; +} + +const COLORS = ['#4a9eff', '#b388ff', '#ff9e40', '#00e676', '#ff5c5c', '#00ffc8']; + +function getAvatarColor(name: string): string { + let hash = 0; + for (let i = 0; i < name.length; i++) { + hash = name.charCodeAt(i) + ((hash << 5) - hash); + } + return COLORS[Math.abs(hash) % COLORS.length]; +} + +function getInitials(name: string): string { + return name.slice(0, 2).toUpperCase(); +} + +function formatDuration(seconds: number): string { + if (seconds < 60) return `${seconds}s`; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m`; + if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`; + return `${Math.floor(seconds / 86400)}d ${Math.floor((seconds % 86400) / 3600)}h`; +} + +function formatTime(isoString: string): string { + return new Date(isoString).toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false + }); +} + +export default function MetricsPage() { + const [metrics, setMetrics] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchMetrics = async () => { + try { + const response = await fetch('/api/metrics'); + if (!response.ok) throw new Error('Failed to fetch metrics'); + const data = await response.json(); + setMetrics(data); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load metrics'); + } finally { + setLoading(false); + } + }; + + fetchMetrics(); + const interval = setInterval(fetchMetrics, 5000); + return () => clearInterval(interval); + }, []); + + if (loading) { + return ( +
+ +
+
+

Loading metrics...

+
+
+ ); + } + + if (error || !metrics) { + return ( +
+ +
+

{error || 'No metrics available'}

+ +
+
+ ); + } + + const errorRateClass = (metrics.sessions?.errorRate ?? 0) <= 1 ? 'healthy' : + (metrics.sessions?.errorRate ?? 0) <= 5 ? 'warning' : 'critical'; + + return ( +
+ + + {/* Header */} +
+
+
+ + + + + Dashboard + +
+
+ + + + + + +
+
Agent Metrics
+
+
+
+ + LIVE +
+
+
+ + {/* Main Content */} +
+ {/* Stats Overview */} +
+
+
Total Agents
+
{metrics.totalAgents}
+
{metrics.onlineAgents} online / {metrics.offlineAgents} offline
+
+
+
Online Now
+
{metrics.onlineAgents}
+
+ {metrics.totalAgents > 0 ? Math.round((metrics.onlineAgents / metrics.totalAgents) * 100) : 0}% availability +
+
+
+
Total Messages
+
{metrics.totalMessages.toLocaleString()}
+
all time
+
+
+
Avg. Throughput
+
{metrics.throughput.avgMessagesPerMinute}
+
messages / minute
+
+
+ + {/* Throughput Section */} +
+
+

Message Throughput

+
+
+
+ + + + +
+
+
+ + {/* Session Lifecycle Section */} + {metrics.sessions && ( +
+
+

Session Lifecycle

+ + {(metrics.sessions.errorRate || 0).toFixed(1)}% error rate + +
+
+
+
+
{metrics.sessions.totalSessions}
+
Total Sessions
+
+
+
{metrics.sessions.activeSessions}
+
Active
+
+
+
{metrics.sessions.closedByAgent}
+
Clean Close
+
+
+
{metrics.sessions.closedByDisconnect}
+
Disconnect
+
+
+
{metrics.sessions.closedByError}
+
Error
+
+
+ + {metrics.sessions.recentSessions && metrics.sessions.recentSessions.length > 0 && ( + + + + + + + + + + + + {metrics.sessions.recentSessions.slice(0, 5).map((session, i) => { + const started = new Date(session.startedAt); + const ended = session.endedAt ? new Date(session.endedAt) : new Date(); + const durationSec = Math.floor((ended.getTime() - started.getTime()) / 1000); + const closedClass = session.closedBy || 'active'; + const closedLabel = !session.closedBy ? 'Active' : + session.closedBy === 'agent' ? 'Clean' : + session.closedBy === 'disconnect' ? 'Disconnect' : 'Error'; + + return ( + + + + + + + + ); + })} + +
AgentStatusMessagesStartedDuration
+
+
+ {getInitials(session.agentName)} +
+ {session.agentName} +
+
+ {closedLabel} + {session.messageCount}{formatTime(session.startedAt)}{formatDuration(durationSec)}
+ )} +
+
+ )} + + {/* Agent Health Section */} +
+
+

Agent Health

+
+
+ {metrics.agents.length === 0 ? ( +
+ + + + +

No agents registered yet

+
+ ) : ( + + + + + + + + + + + + + {metrics.agents.map((agent) => ( + + + + + + + + + ))} + +
AgentStatusMessages SentMessages ReceivedUptimeLast Seen
+
+
+ {getInitials(agent.name)} +
+ {agent.name} +
+
+ + {agent.isOnline ? 'Online' : 'Offline'} + + {agent.messagesSent.toLocaleString()}{agent.messagesReceived.toLocaleString()}{formatDuration(agent.uptimeSeconds)}{formatTime(agent.lastSeen)}
+ )} +
+
+ +
+ Last updated: {formatTime(metrics.timestamp)} +
+
+
+ ); +} + +function ThroughputItem({ value, label, max }: { value: number; label: string; max: number }) { + const percentage = Math.min((value / max) * 100, 100); + return ( +
+
{value}
+
{label}
+
+
+
+
+ ); +} + +const styles = ` + .metrics-page { + min-height: 100vh; + background: #1a1d21; + color: #e8e8e8; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + } + + .header { + position: sticky; + top: 0; + z-index: 100; + background: #1a1a2e; + border-bottom: 1px solid #2a2a3e; + padding: 16px 32px; + } + + .header-content { + max-width: 1400px; + margin: 0 auto; + display: flex; + align-items: center; + justify-content: space-between; + } + + .header-left { + display: flex; + align-items: center; + gap: 16px; + } + + .back-link { + display: flex; + align-items: center; + gap: 8px; + color: #888; + text-decoration: none; + font-size: 14px; + font-weight: 500; + padding: 8px 12px; + border-radius: 6px; + transition: all 0.2s; + } + + .back-link:hover { + color: #4a9eff; + background: rgba(74, 158, 255, 0.1); + } + + .logo { + display: flex; + align-items: center; + gap: 12px; + } + + .logo-icon { + width: 32px; + height: 32px; + background: linear-gradient(135deg, #1e3a5f 0%, #2a4a6e 100%); + border: 1px solid #3a5a7e; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + } + + .logo-text { + font-size: 18px; + font-weight: 600; + letter-spacing: -0.3px; + } + + .logo-text span { + color: #4a9eff; + } + + .live-indicator { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + background: rgba(34, 197, 94, 0.1); + border: 1px solid rgba(34, 197, 94, 0.3); + border-radius: 20px; + font-family: 'IBM Plex Mono', monospace; + font-size: 12px; + font-weight: 500; + color: #22c55e; + } + + .live-dot { + width: 8px; + height: 8px; + background: #22c55e; + border-radius: 50%; + animation: pulse 2s ease-in-out infinite; + box-shadow: 0 0 10px rgba(34, 197, 94, 0.4); + } + + @keyframes pulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.5; transform: scale(0.9); } + } + + .main { + position: relative; + z-index: 1; + max-width: 1400px; + margin: 0 auto; + padding: 24px 32px 48px; + } + + .loading, .error-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100vh; + gap: 16px; + } + + .spinner { + width: 32px; + height: 32px; + border: 2px solid rgba(255, 255, 255, 0.1); + border-top-color: #4a9eff; + border-radius: 50%; + animation: spin 1s linear infinite; + } + + @keyframes spin { + to { transform: rotate(360deg); } + } + + .error-state button { + padding: 10px 20px; + background: #1264a3; + color: white; + border: none; + border-radius: 6px; + cursor: pointer; + } + + .stats-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 16px; + margin-bottom: 24px; + } + + .stat-card { + background: #222529; + border: 1px solid #2a2a3e; + border-radius: 8px; + padding: 20px; + transition: all 0.2s ease; + } + + .stat-card:hover { + border-color: #3a3a4e; + background: #282c30; + } + + .stat-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: #888; + margin-bottom: 8px; + } + + .stat-value { + font-family: 'IBM Plex Mono', monospace; + font-size: 32px; + font-weight: 700; + line-height: 1; + } + + .stat-value.accent-cyan { color: #4a9eff; } + .stat-value.accent-green { color: #22c55e; } + .stat-value.accent-orange { color: #f59e0b; } + .stat-value.accent-blue { color: #6366f1; } + + .stat-subtext { + font-size: 12px; + color: #666; + margin-top: 8px; + font-family: 'IBM Plex Mono', monospace; + } + + .section { + margin-bottom: 24px; + } + + .section-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; + } + + .section-title { + font-size: 13px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 1px; + color: #888; + display: flex; + align-items: center; + gap: 10px; + } + + .section-title::before { + content: ''; + width: 3px; + height: 14px; + background: #4a9eff; + border-radius: 2px; + } + + .throughput-panel, .lifecycle-panel { + background: #222529; + border: 1px solid #2a2a3e; + border-radius: 8px; + padding: 24px; + } + + .throughput-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 24px; + } + + .throughput-item { + text-align: center; + } + + .throughput-value { + font-family: 'IBM Plex Mono', monospace; + font-size: 40px; + font-weight: 700; + color: #4a9eff; + line-height: 1; + } + + .throughput-label { + font-size: 12px; + color: #888; + margin-top: 8px; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .throughput-bar { + height: 4px; + background: #2a2a3e; + border-radius: 2px; + margin-top: 12px; + overflow: hidden; + } + + .throughput-bar-fill { + height: 100%; + background: linear-gradient(90deg, #4a9eff, #6366f1); + border-radius: 2px; + transition: width 0.5s ease; + } + + .lifecycle-grid { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 20px; + } + + .lifecycle-item { + text-align: center; + } + + .lifecycle-value { + font-family: 'IBM Plex Mono', monospace; + font-size: 32px; + font-weight: 700; + line-height: 1; + } + + .lifecycle-value.accent-purple { color: #a78bfa; } + .lifecycle-value.accent-red { color: #ef4444; } + + .lifecycle-label { + font-size: 11px; + color: #888; + margin-top: 8px; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .error-rate-indicator { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 12px; + font-size: 12px; + font-weight: 600; + font-family: 'IBM Plex Mono', monospace; + } + + .error-rate-indicator.healthy { + background: rgba(34, 197, 94, 0.15); + color: #22c55e; + } + + .error-rate-indicator.warning { + background: rgba(245, 158, 11, 0.15); + color: #f59e0b; + } + + .error-rate-indicator.critical { + background: rgba(239, 68, 68, 0.15); + color: #ef4444; + } + + .sessions-table, .agents-table { + width: 100%; + border-collapse: collapse; + margin-top: 16px; + } + + .sessions-table th, .sessions-table td, + .agents-table th, .agents-table td { + padding: 12px 16px; + text-align: left; + } + + .sessions-table th, .agents-table th { + background: #1a1a2e; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: #888; + border-bottom: 1px solid #2a2a3e; + } + + .sessions-table tr, .agents-table tr { + border-bottom: 1px solid #2a2a3e; + } + + .sessions-table tr:last-child, .agents-table tr:last-child { + border-bottom: none; + } + + .agents-table tr:hover { + background: rgba(74, 158, 255, 0.03); + } + + .agents-table-container { + background: #222529; + border: 1px solid #2a2a3e; + border-radius: 8px; + overflow: hidden; + } + + .agent-name { + display: flex; + align-items: center; + gap: 12px; + } + + .agent-avatar { + width: 32px; + height: 32px; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; + font-size: 12px; + color: white; + } + + .agent-name-text { + font-weight: 600; + font-family: 'IBM Plex Mono', monospace; + } + + .status-badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 12px; + font-size: 12px; + font-weight: 500; + } + + .status-badge.online { + background: rgba(34, 197, 94, 0.15); + color: #22c55e; + } + + .status-badge.offline { + background: rgba(107, 114, 128, 0.15); + color: #6b7280; + } + + .status-badge::before { + content: ''; + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; + } + + .closed-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: 10px; + font-size: 11px; + font-weight: 500; + } + + .closed-badge.agent { + background: rgba(34, 197, 94, 0.15); + color: #22c55e; + } + + .closed-badge.disconnect { + background: rgba(245, 158, 11, 0.15); + color: #f59e0b; + } + + .closed-badge.error { + background: rgba(239, 68, 68, 0.15); + color: #ef4444; + } + + .closed-badge.active { + background: rgba(74, 158, 255, 0.15); + color: #4a9eff; + } + + .metric-cell { + font-family: 'IBM Plex Mono', monospace; + font-size: 14px; + } + + .metric-cell.sent { color: #4a9eff; } + .metric-cell.received { color: #a78bfa; } + + .uptime-cell { + font-family: 'IBM Plex Mono', monospace; + font-size: 13px; + color: #888; + } + + .empty-state { + padding: 48px 32px; + text-align: center; + } + + .empty-state-icon { + width: 48px; + height: 48px; + margin: 0 auto 16px; + color: #666; + opacity: 0.5; + } + + .empty-state-text { + color: #666; + font-size: 14px; + } + + .last-updated { + text-align: center; + padding: 20px; + font-size: 12px; + color: #666; + font-family: 'IBM Plex Mono', monospace; + } + + @media (max-width: 1200px) { + .stats-grid { + grid-template-columns: repeat(2, 1fr); + } + .throughput-grid { + grid-template-columns: repeat(2, 1fr); + } + .lifecycle-grid { + grid-template-columns: repeat(3, 1fr); + } + } + + @media (max-width: 768px) { + .header { + padding: 12px 16px; + } + .main { + padding: 16px; + } + .stats-grid { + grid-template-columns: 1fr; + } + .throughput-grid { + grid-template-columns: 1fr; + } + .lifecycle-grid { + grid-template-columns: repeat(2, 1fr); + } + } +`; diff --git a/src/dashboard-v2/app/page.tsx b/src/dashboard/app/page.tsx similarity index 100% rename from src/dashboard-v2/app/page.tsx rename to src/dashboard/app/page.tsx diff --git a/src/dashboard/frontend/app.ts b/src/dashboard/frontend/app.ts deleted file mode 100644 index a75fdf485..000000000 --- a/src/dashboard/frontend/app.ts +++ /dev/null @@ -1,397 +0,0 @@ -/** - * Dashboard Application Entry Point - */ - -import { subscribe, state, getViewMode } from './state.js'; -import { connect, sendMessage } from './websocket.js'; -import { - initElements, - getElements, - updateConnectionStatus, - renderAgents, - renderMessages, - selectChannel, - updateOnlineCount, - openCommandPalette, - closeCommandPalette, - filterPaletteResults, - handlePaletteKeydown, - initPaletteChannels, - closeThreadPanel, - renderThreadMessages, - showMentionAutocomplete, - hideMentionAutocomplete, - isMentionAutocompleteVisible, - navigateMentionAutocomplete, - completeMention, - getCurrentMentionQuery, - openSpawnModal, - closeSpawnModal, - spawnAgent, - fetchSpawnedAgents, - initFleetViewToggle, - updateFleetViewVisibility, - renderFleetAgents, - renderServers, -} from './components.js'; - -/** - * Initialize the dashboard application - */ -export function initApp(): void { - const elements = initElements(); - - // Subscribe to state changes - subscribe(() => { - updateConnectionStatus(); - // Render agents based on current view mode - if (getViewMode() === 'fleet') { - renderFleetAgents(); - renderServers(); - } else { - renderAgents(); - } - renderMessages(); - updateOnlineCount(); - // Update fleet toggle visibility based on available peer connections - updateFleetViewVisibility(); - }); - - // Set up event listeners - setupEventListeners(elements); - - // Initialize fleet view toggle handlers - initFleetViewToggle(); - - // Connect to WebSocket - connect(); - - // Fetch initial spawned agents list - fetchSpawnedAgents(); -} - -/** - * Set up all event listeners - */ -function setupEventListeners(elements: ReturnType): void { - // Channel clicks - elements.channelsList.querySelectorAll('.channel-item').forEach((item) => { - item.addEventListener('click', () => { - const channel = item.dataset.channel; - if (channel) { - selectChannel(channel); - } - }); - }); - - // Send button - elements.sendBtn.addEventListener('click', handleSend); - - // Keyboard shortcuts for composer - elements.messageInput.addEventListener('keydown', (e: KeyboardEvent) => { - // Handle mention autocomplete keys first - if (isMentionAutocompleteVisible()) { - if (e.key === 'Tab' || e.key === 'Enter') { - e.preventDefault(); - completeMention(); - return; - } - if (e.key === 'ArrowUp') { - e.preventDefault(); - navigateMentionAutocomplete('up'); - return; - } - if (e.key === 'ArrowDown') { - e.preventDefault(); - navigateMentionAutocomplete('down'); - return; - } - if (e.key === 'Escape') { - e.preventDefault(); - hideMentionAutocomplete(); - return; - } - } - - // Enter to send (Slack-style), Shift+Enter for newline - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - handleSend(); - } - // Shift+Enter allows default behavior (inserts newline) - }); - - // Auto-resize textarea and handle @-mention autocomplete - elements.messageInput.addEventListener('input', () => { - elements.messageInput.style.height = 'auto'; - elements.messageInput.style.height = - Math.min(elements.messageInput.scrollHeight, 200) + 'px'; - - // Check for @-mention at start of input - const query = getCurrentMentionQuery(); - if (query !== null) { - showMentionAutocomplete(query); - } else { - hideMentionAutocomplete(); - } - }); - - // Hide mention autocomplete when input loses focus (with delay to allow clicks) - elements.messageInput.addEventListener('blur', () => { - setTimeout(() => { - hideMentionAutocomplete(); - }, 150); - }); - - // Bold button - wrap selected text with ** or insert **bold** - elements.boldBtn.addEventListener('click', () => { - const input = elements.messageInput; - const start = input.selectionStart; - const end = input.selectionEnd; - const text = input.value; - - if (start === end) { - // No selection - insert **bold** placeholder - const before = text.substring(0, start); - const after = text.substring(end); - input.value = before + '**bold**' + after; - input.selectionStart = start + 2; - input.selectionEnd = start + 6; - } else { - // Wrap selection with ** - const before = text.substring(0, start); - const selected = text.substring(start, end); - const after = text.substring(end); - input.value = before + '**' + selected + '**' + after; - input.selectionStart = start; - input.selectionEnd = end + 4; - } - input.focus(); - }); - - // Emoji button - insert common emojis via simple picker - elements.emojiBtn.addEventListener('click', () => { - const emojis = ['👍', '👎', '✅', '❌', '🎉', '🔥', '💡', '⚠️', '📝', '🚀']; - const emoji = emojis[Math.floor(Math.random() * emojis.length)]; - const input = elements.messageInput; - const start = input.selectionStart; - const text = input.value; - input.value = text.substring(0, start) + emoji + text.substring(start); - input.selectionStart = input.selectionEnd = start + emoji.length; - input.focus(); - }); - - // Command palette - elements.searchTrigger.addEventListener('click', openCommandPalette); - - document.addEventListener('keydown', (e: KeyboardEvent) => { - if ((e.ctrlKey || e.metaKey) && e.key === 'k') { - e.preventDefault(); - if (elements.commandPaletteOverlay.classList.contains('visible')) { - closeCommandPalette(); - } else { - openCommandPalette(); - } - } - - if (e.key === 'Escape') { - closeCommandPalette(); - } - }); - - elements.commandPaletteOverlay.addEventListener('click', (e: MouseEvent) => { - if (e.target === elements.commandPaletteOverlay) { - closeCommandPalette(); - } - }); - - elements.paletteSearch.addEventListener('input', (e: Event) => { - const target = e.target as HTMLInputElement; - filterPaletteResults(target.value); - }); - - elements.paletteSearch.addEventListener('keydown', handlePaletteKeydown); - - // Command execution - document.querySelectorAll('.palette-item[data-command]').forEach((item) => { - item.addEventListener('click', () => { - const command = item.dataset.command; - - if (command === 'broadcast') { - // Pre-fill message input with @* for broadcast - elements.messageInput.value = '@* '; - elements.messageInput.focus(); - } else if (command === 'clear') { - elements.messagesList.innerHTML = ''; - } - - closeCommandPalette(); - }); - }); - - // Initialize palette channel click handlers - initPaletteChannels(); - - // Thread panel close button - elements.threadPanelClose.addEventListener('click', closeThreadPanel); - - // Thread panel send button - elements.threadSendBtn.addEventListener('click', handleThreadSend); - - // Thread message input keyboard shortcuts (Slack-style) - elements.threadMessageInput.addEventListener('keydown', (e: KeyboardEvent) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - handleThreadSend(); - } - // Shift+Enter allows default behavior (inserts newline) - }); - - // Close thread panel on Escape - document.addEventListener('keydown', (e: KeyboardEvent) => { - if (e.key === 'Escape' && elements.threadPanelOverlay.classList.contains('visible')) { - closeThreadPanel(); - } - }); - - // Spawn modal event listeners - elements.spawnBtn.addEventListener('click', openSpawnModal); - - elements.spawnModalClose.addEventListener('click', closeSpawnModal); - - // Cancel button in spawn modal - document.getElementById('spawn-cancel-btn')?.addEventListener('click', closeSpawnModal); - - // Close spawn modal on overlay click - elements.spawnModalOverlay.addEventListener('click', (e: MouseEvent) => { - if (e.target === elements.spawnModalOverlay) { - closeSpawnModal(); - } - }); - - // Close spawn modal on Escape - document.addEventListener('keydown', (e: KeyboardEvent) => { - if (e.key === 'Escape' && elements.spawnModalOverlay.classList.contains('visible')) { - closeSpawnModal(); - } - }); - - // Submit spawn form - elements.spawnSubmitBtn.addEventListener('click', spawnAgent); - - // Enter key in spawn name input triggers submit - elements.spawnNameInput.addEventListener('keydown', (e: KeyboardEvent) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - spawnAgent(); - } - }); -} - -/** - * Parse @mention from message text - * Formats: "@AgentName message" or "@* message" for broadcast - * Returns { to, message } or null if no valid mention found - */ -function parseMention(text: string): { to: string; message: string } | null { - const trimmed = text.trim(); - - // Match @mention at the start of the message - // @* for broadcast, @AgentName for direct message - const match = trimmed.match(/^@(\*|[^\s]+)\s+(.+)$/s); - - if (!match) { - return null; - } - - return { - to: match[1], - message: match[2].trim(), - }; -} - -/** - * Handle send button click - */ -async function handleSend(): Promise { - const elements = getElements(); - const rawMessage = elements.messageInput.value.trim(); - - if (!rawMessage) { - return; - } - - let to: string; - let message: string; - - // Check if we're in a DM (not general channel) - const isInDM = state.currentChannel !== 'general'; - - // Parse @mention from the message - const parsed = parseMention(rawMessage); - - if (parsed) { - // Message has explicit @mention - use it - to = parsed.to; - message = parsed.message; - } else if (isInDM) { - // In DM context - send to current channel without requiring @ - to = state.currentChannel; - message = rawMessage; - } else { - // In general channel without @mention - require it - alert('Message must start with @recipient (e.g., "@Lead hello" or "@* broadcast")'); - return; - } - - elements.sendBtn.disabled = true; - - const result = await sendMessage(to, message); - - if (result.success) { - elements.messageInput.value = ''; - elements.messageInput.style.height = 'auto'; - } else { - alert(result.error); - } - - elements.sendBtn.disabled = false; -} - -/** - * Handle thread panel send button click - */ -async function handleThreadSend(): Promise { - const elements = getElements(); - const message = elements.threadMessageInput.value.trim(); - const threadId = state.currentThread; - - if (!message || !threadId) { - return; - } - - // For thread replies, send to broadcast or use original recipient - // For now, send as broadcast with thread ID - elements.threadSendBtn.disabled = true; - - const result = await sendMessage('*', message, threadId); - - if (result.success) { - elements.threadMessageInput.value = ''; - // Re-render thread messages to show the new message - renderThreadMessages(threadId); - } else { - alert(result.error); - } - - elements.threadSendBtn.disabled = false; -} - -// Auto-initialize when DOM is ready -if (typeof document !== 'undefined') { - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', initApp); - } else { - initApp(); - } -} diff --git a/src/dashboard/frontend/components.ts b/src/dashboard/frontend/components.ts deleted file mode 100644 index 2c0fa7ffa..000000000 --- a/src/dashboard/frontend/components.ts +++ /dev/null @@ -1,1180 +0,0 @@ -/** - * Dashboard UI Components - */ - -import type { Agent, Message, DOMElements, ChannelType, SpawnedAgent, FleetAgent, PeerServer, ViewMode } from './types.js'; -import { state, getFilteredMessages, setCurrentChannel, setCurrentThread, getThreadMessages, getThreadReplyCount, setViewMode, getViewMode, getFleetData, isFleetAvailable, getAgentsForCurrentView } from './state.js'; -import { - escapeHtml, - formatTime, - formatDate, - getAvatarColor, - getInitials, - formatMessageBody, - isAgentOnline, -} from './utils.js'; - -// Track spawned agents -let spawnedAgents: SpawnedAgent[] = []; - -let elements: DOMElements; -let paletteSelectedIndex = -1; - -/** - * Initialize DOM element references - */ -export function initElements(): DOMElements { - elements = { - connectionDot: document.getElementById('connection-dot')!, - channelsList: document.getElementById('channels-list')!, - agentsList: document.getElementById('agents-list')!, - messagesList: document.getElementById('messages-list')!, - currentChannelName: document.getElementById('current-channel-name')!, - channelTopic: document.getElementById('channel-topic')!, - onlineCount: document.getElementById('online-count')!, - messageInput: document.getElementById('message-input') as HTMLTextAreaElement, - sendBtn: document.getElementById('send-btn') as HTMLButtonElement, - boldBtn: document.getElementById('bold-btn') as HTMLButtonElement, - emojiBtn: document.getElementById('emoji-btn') as HTMLButtonElement, - searchTrigger: document.getElementById('search-trigger')!, - commandPaletteOverlay: document.getElementById('command-palette-overlay')!, - paletteSearch: document.getElementById('palette-search') as HTMLInputElement, - paletteResults: document.getElementById('palette-results')!, - paletteChannelsSection: document.getElementById('palette-channels-section')!, - paletteAgentsSection: document.getElementById('palette-agents-section')!, - paletteMessagesSection: document.getElementById('palette-messages-section')!, - typingIndicator: document.getElementById('typing-indicator')!, - threadPanelOverlay: document.getElementById('thread-panel-overlay')!, - threadPanelId: document.getElementById('thread-panel-id')!, - threadPanelClose: document.getElementById('thread-panel-close') as HTMLButtonElement, - threadMessages: document.getElementById('thread-messages')!, - threadMessageInput: document.getElementById('thread-message-input') as HTMLTextAreaElement, - threadSendBtn: document.getElementById('thread-send-btn') as HTMLButtonElement, - mentionAutocomplete: document.getElementById('mention-autocomplete')!, - mentionAutocompleteList: document.getElementById('mention-autocomplete-list')!, - // Spawn modal elements - spawnBtn: document.getElementById('spawn-btn') as HTMLButtonElement, - spawnModalOverlay: document.getElementById('spawn-modal-overlay')!, - spawnModalClose: document.getElementById('spawn-modal-close') as HTMLButtonElement, - spawnNameInput: document.getElementById('spawn-name-input') as HTMLInputElement, - spawnCliInput: document.getElementById('spawn-cli-input') as HTMLInputElement, - spawnTaskInput: document.getElementById('spawn-task-input') as HTMLTextAreaElement, - spawnSubmitBtn: document.getElementById('spawn-submit-btn') as HTMLButtonElement, - spawnStatus: document.getElementById('spawn-status')!, - // Fleet view elements - viewToggle: document.getElementById('view-toggle')!, - viewToggleLocal: document.querySelector('[data-view="local"]') as HTMLButtonElement, - viewToggleFleet: document.querySelector('[data-view="fleet"]') as HTMLButtonElement, - peerCount: document.getElementById('peer-count')!, - serversSection: document.getElementById('servers-section')!, - serversList: document.getElementById('servers-list')!, - }; - return elements; -} - -/** - * Get DOM elements - */ -export function getElements(): DOMElements { - return elements; -} - -/** - * Update connection status indicator - */ -export function updateConnectionStatus(): void { - if (state.isConnected) { - elements.connectionDot.classList.remove('offline'); - } else { - elements.connectionDot.classList.add('offline'); - } -} - -/** - * Render agents list in sidebar - */ -export function renderAgents(): void { - console.log('[UI] renderAgents called, agents:', state.agents.length, state.agents.map(a => a.name)); - - // Create a set of spawned agent names for quick lookup - const spawnedNames = new Set(spawnedAgents.map(a => a.name)); - - const html = state.agents - .map((agent) => { - const online = isAgentOnline(agent.lastSeen || agent.lastActive); - const presenceClass = online ? 'online' : ''; - const isActive = state.currentChannel === agent.name; - const needsAttentionClass = agent.needsAttention ? 'needs-attention' : ''; - const isSpawned = spawnedNames.has(agent.name); - - // Spawned icon SVG (play/launch icon) - const spawnedIcon = isSpawned ? ` - - - - ` : ''; - - // Release button for spawned agents - const releaseBtn = isSpawned ? ` - - ` : ''; - - return ` -
  • -
    - ${getInitials(agent.name)} - -
    - ${escapeHtml(agent.name)} - ${spawnedIcon} - ${agent.needsAttention ? 'Needs Input' : ''} - ${releaseBtn} -
  • - `; - }) - .join(''); - - elements.agentsList.innerHTML = - html || - '
  • No agents connected
  • '; - - // Add click handlers for agent selection - elements.agentsList.querySelectorAll('.channel-item[data-agent]').forEach((item) => { - item.addEventListener('click', (e) => { - // Don't select channel if clicking release button - if ((e.target as HTMLElement).closest('.release-btn')) { - return; - } - const agentName = item.dataset.agent; - if (agentName) { - selectChannel(agentName); - } - }); - }); - - // Add release button click handlers - elements.agentsList.querySelectorAll('.release-btn[data-release]').forEach((btn) => { - btn.addEventListener('click', async (e) => { - e.stopPropagation(); - const agentName = btn.dataset.release; - if (agentName && confirm(`Release agent "${agentName}"? This will terminate the agent.`)) { - await releaseAgent(agentName); - } - }); - }); - - // Update command palette agents - updatePaletteAgents(); -} - -/** - * Render messages list - */ -export function renderMessages(): void { - const filtered = getFilteredMessages(); - - if (filtered.length === 0) { - elements.messagesList.innerHTML = ` -
    - - - -
    No messages yet
    -
    - ${ - state.currentChannel === 'general' - ? 'Messages between agents will appear here' - : `Messages with ${state.currentChannel} will appear here` - } -
    -
    - `; - return; - } - - let html = ''; - let lastDate: string | null = null; - - filtered.forEach((msg) => { - const msgDate = new Date(msg.timestamp).toDateString(); - - // Add date divider if needed - if (msgDate !== lastDate) { - html += ` -
    - ${formatDate(msg.timestamp)} -
    - `; - lastDate = msgDate; - } - - const isBroadcast = msg.to === '*'; - const avatarColor = getAvatarColor(msg.from); - const replyCount = getThreadReplyCount(msg.id); - - // Format: @From → @To: message (like Slack) - // For cross-project messages, show project badge before agent name - const recipientDisplay = isBroadcast - ? '@everyone' - : msg.project - ? `${escapeHtml(msg.project)}@${escapeHtml(msg.to)}` - : `@${escapeHtml(msg.to)}`; - - html += ` -
    -
    - ${getInitials(msg.from)} -
    -
    -
    - @${escapeHtml(msg.from)} - - → ${recipientDisplay} - - ${formatTime(msg.timestamp)} -
    -
    ${formatMessageBody(msg.content)}
    - ${ - msg.thread - ? ` -
    - - - - Thread: ${escapeHtml(msg.thread)} -
    - ` - : '' - } - ${ - replyCount > 0 - ? ` -
    - - - - ${replyCount} ${replyCount === 1 ? 'reply' : 'replies'} -
    - ` - : '' - } -
    -
    - - -
    -
    - `; - }); - - elements.messagesList.innerHTML = html; - - // Note: Auto-scroll removed - interferes with manual scrolling through history - - // Attach thread click handlers - attachThreadHandlers(); -} - -/** - * Select a channel and update UI - */ -export function selectChannel(channel: ChannelType): void { - setCurrentChannel(channel); - - // Update sidebar active states - elements.channelsList.querySelectorAll('.channel-item').forEach((item) => { - item.classList.toggle('active', item.dataset.channel === channel); - }); - elements.agentsList.querySelectorAll('.channel-item').forEach((item) => { - item.classList.toggle('active', item.dataset.agent === channel); - }); - - // Update header - const prefixEl = document.querySelector('.channel-header-name .prefix'); - if (channel === 'general') { - elements.currentChannelName.innerHTML = 'general'; - elements.channelTopic.textContent = 'All agent communications'; - if (prefixEl) prefixEl.textContent = '#'; - } else { - elements.currentChannelName.innerHTML = escapeHtml(channel); - const agent = state.agents.find((a) => a.name === channel); - elements.channelTopic.textContent = agent?.status || 'Direct messages'; - if (prefixEl) prefixEl.textContent = '@'; - } - - // Update composer placeholder - DM mode doesn't require @mention - elements.messageInput.placeholder = - channel === 'general' - ? '@AgentName message... (or @* to broadcast)' - : `Message ${channel}... (@ not required)`; - - // Re-render messages - renderMessages(); -} - -/** - * Update online count display - */ -export function updateOnlineCount(): void { - const online = state.agents.filter((a) => isAgentOnline(a.lastSeen || a.lastActive)).length; - elements.onlineCount.textContent = `${online} online`; -} - -/** - * Update agents in command palette - */ -export function updatePaletteAgents(): void { - const html = state.agents - .map((agent) => { - const online = isAgentOnline(agent.lastSeen || agent.lastActive); - return ` -
    -
    -
    - ${getInitials(agent.name)} - -
    -
    -
    -
    ${escapeHtml(agent.name)}
    -
    ${online ? 'Online' : 'Offline'}
    -
    -
    - `; - }) - .join(''); - - const section = elements.paletteAgentsSection; - const items = section.querySelectorAll('.palette-item'); - items.forEach((item) => item.remove()); - section.insertAdjacentHTML('beforeend', html); - - // Add click handlers - section.querySelectorAll('.palette-item[data-jump-agent]').forEach((item) => { - item.addEventListener('click', () => { - const agentName = item.dataset.jumpAgent; - if (agentName) { - selectChannel(agentName); - closeCommandPalette(); - } - }); - }); -} - -/** - * Initialize channel click handlers in command palette - */ -export function initPaletteChannels(): void { - elements.paletteChannelsSection - .querySelectorAll('.palette-item[data-jump-channel]') - .forEach((item) => { - item.addEventListener('click', () => { - const channelName = item.dataset.jumpChannel; - if (channelName) { - selectChannel(channelName); - closeCommandPalette(); - } - }); - }); -} - -/** - * Open command palette - */ -export function openCommandPalette(): void { - elements.commandPaletteOverlay.classList.add('visible'); - elements.paletteSearch.value = ''; - elements.paletteSearch.focus(); - paletteSelectedIndex = -1; - filterPaletteResults(''); -} - -/** - * Get all visible palette items - */ -export function getVisiblePaletteItems(): HTMLElement[] { - const allItems = Array.from( - elements.paletteResults.querySelectorAll('.palette-item') - ); - return allItems.filter((item) => item.style.display !== 'none'); -} - -/** - * Update the selected palette item visually - */ -export function updatePaletteSelection(): void { - const items = getVisiblePaletteItems(); - - // Remove selection from all items - items.forEach((item) => item.classList.remove('selected')); - - // Add selection to current item - if (paletteSelectedIndex >= 0 && paletteSelectedIndex < items.length) { - const selectedItem = items[paletteSelectedIndex]; - selectedItem.classList.add('selected'); - - // Scroll into view if needed - selectedItem.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); - } -} - -/** - * Handle keyboard navigation in command palette - */ -export function handlePaletteKeydown(e: KeyboardEvent): void { - const items = getVisiblePaletteItems(); - - if (items.length === 0) return; - - switch (e.key) { - case 'ArrowDown': - e.preventDefault(); - paletteSelectedIndex = paletteSelectedIndex < items.length - 1 - ? paletteSelectedIndex + 1 - : 0; - updatePaletteSelection(); - break; - - case 'ArrowUp': - e.preventDefault(); - paletteSelectedIndex = paletteSelectedIndex > 0 - ? paletteSelectedIndex - 1 - : items.length - 1; - updatePaletteSelection(); - break; - - case 'Enter': - e.preventDefault(); - if (paletteSelectedIndex >= 0 && paletteSelectedIndex < items.length) { - executePaletteItem(items[paletteSelectedIndex]); - } - break; - } -} - -/** - * Execute the action for a palette item - */ -export function executePaletteItem(item: HTMLElement): void { - // Check for command - const command = item.dataset.command; - if (command) { - if (command === 'broadcast') { - // Pre-fill message input with @* for broadcast - elements.messageInput.value = '@* '; - elements.messageInput.focus(); - } else if (command === 'clear') { - elements.messagesList.innerHTML = ''; - } - closeCommandPalette(); - return; - } - - // Check for channel jump - const channel = item.dataset.jumpChannel; - if (channel) { - selectChannel(channel); - closeCommandPalette(); - return; - } - - // Check for agent jump - const agent = item.dataset.jumpAgent; - if (agent) { - selectChannel(agent); - closeCommandPalette(); - return; - } - - // Check for message jump - const messageId = item.dataset.jumpMessage; - if (messageId) { - // Find and scroll to the message - const messageEl = elements.messagesList.querySelector(`[data-id="${messageId}"]`); - if (messageEl) { - messageEl.scrollIntoView({ behavior: 'smooth', block: 'center' }); - messageEl.classList.add('highlighted'); - setTimeout(() => messageEl.classList.remove('highlighted'), 2000); - } - closeCommandPalette(); - return; - } -} - -/** - * Close command palette - */ -export function closeCommandPalette(): void { - elements.commandPaletteOverlay.classList.remove('visible'); -} - -/** - * Filter command palette results based on query - */ -export function filterPaletteResults(query: string): void { - const q = query.toLowerCase(); - - // Reset selection when filtering - paletteSelectedIndex = -1; - - // Filter command items - document.querySelectorAll('.palette-item[data-command]').forEach((item) => { - const titleEl = item.querySelector('.palette-item-title'); - const title = titleEl?.textContent?.toLowerCase() || ''; - item.style.display = title.includes(q) ? 'flex' : 'none'; - }); - - // Filter channel items - document.querySelectorAll('.palette-item[data-jump-channel]').forEach((item) => { - const titleEl = item.querySelector('.palette-item-title'); - const title = titleEl?.textContent?.toLowerCase() || ''; - item.style.display = title.includes(q) ? 'flex' : 'none'; - }); - - // Filter agent items - document.querySelectorAll('.palette-item[data-jump-agent]').forEach((item) => { - const name = item.dataset.jumpAgent?.toLowerCase() || ''; - item.style.display = name.includes(q) ? 'flex' : 'none'; - }); - - // Show message search if query is long enough - if (q.length >= 2) { - const matches = state.messages.filter((m) => m.content.toLowerCase().includes(q)).slice(0, 5); - - if (matches.length > 0) { - elements.paletteMessagesSection.style.display = 'block'; - const items = matches - .map( - (m) => ` -
    -
    - - - -
    -
    -
    ${escapeHtml(m.from)}
    -
    ${escapeHtml(m.content.substring(0, 60))}${m.content.length > 60 ? '...' : ''}
    -
    -
    - ` - ) - .join(''); - - const existingItems = elements.paletteMessagesSection.querySelectorAll('.palette-item'); - existingItems.forEach((item) => item.remove()); - elements.paletteMessagesSection.insertAdjacentHTML('beforeend', items); - } else { - elements.paletteMessagesSection.style.display = 'none'; - } - } else { - elements.paletteMessagesSection.style.display = 'none'; - } -} - -/** - * Open thread panel for a specific thread - */ -export function openThreadPanel(threadId: string): void { - setCurrentThread(threadId); - elements.threadPanelId.textContent = threadId; - elements.threadPanelOverlay.classList.add('visible'); - elements.threadMessageInput.value = ''; - renderThreadMessages(threadId); - elements.threadMessageInput.focus(); -} - -/** - * Close thread panel - */ -export function closeThreadPanel(): void { - setCurrentThread(null); - elements.threadPanelOverlay.classList.remove('visible'); -} - -/** - * Render messages in thread panel - */ -export function renderThreadMessages(threadId: string): void { - const messages = getThreadMessages(threadId); - - if (messages.length === 0) { - elements.threadMessages.innerHTML = ` -
    -

    No messages in this thread yet.

    -

    Start the conversation below!

    -
    - `; - return; - } - - const html = messages - .map((msg) => ` -
    -
    -
    - ${getInitials(msg.from)} -
    - ${escapeHtml(msg.from)} - ${formatTime(msg.timestamp)} -
    -
    ${formatMessageBody(msg.content)}
    -
    - `) - .join(''); - - elements.threadMessages.innerHTML = html; - - // Scroll to bottom - elements.threadMessages.scrollTop = elements.threadMessages.scrollHeight; -} - -/** - * Attach thread click handlers to messages (call after renderMessages) - */ -export function attachThreadHandlers(): void { - // Thread indicator clicks - elements.messagesList.querySelectorAll('.thread-indicator').forEach((el) => { - el.style.cursor = 'pointer'; - el.addEventListener('click', (e) => { - e.stopPropagation(); - const threadId = el.dataset.thread; - if (threadId) { - openThreadPanel(threadId); - } - }); - }); - - // Reply count badge clicks - elements.messagesList.querySelectorAll('.reply-count-badge').forEach((el) => { - el.addEventListener('click', (e) => { - e.stopPropagation(); - const threadId = el.dataset.thread; - if (threadId) { - openThreadPanel(threadId); - } - }); - }); - - // Reply in thread button clicks - elements.messagesList.querySelectorAll('.message-action-btn[data-action="reply"]').forEach((el) => { - el.addEventListener('click', (e) => { - e.stopPropagation(); - const messageId = el.closest('.message')?.getAttribute('data-id'); - if (messageId) { - // Use message ID as thread ID for new threads - openThreadPanel(messageId); - } - }); - }); -} - -/** - * @-Mention Autocomplete State - */ -let mentionSelectedIndex = 0; -let mentionFilteredAgents: typeof state.agents = []; - -/** - * Show mention autocomplete dropdown with filtered agents - */ -export function showMentionAutocomplete(filter: string): void { - const filterLower = filter.toLowerCase(); - - // Filter agents by name, include broadcast option - mentionFilteredAgents = state.agents.filter(agent => - agent.name.toLowerCase().includes(filterLower) - ); - - // Reset selection - mentionSelectedIndex = 0; - - // Build HTML for agent list - let html = ''; - - // Add broadcast option if filter matches - if ('*'.includes(filterLower) || 'everyone'.includes(filterLower) || 'all'.includes(filterLower) || 'broadcast'.includes(filterLower)) { - html += ` -
    -
    *
    - @everyone - Broadcast to all -
    - `; - } - - // Add agents - mentionFilteredAgents.forEach((agent, index) => { - const isSelected = index === mentionSelectedIndex; - html += ` -
    -
    - ${getInitials(agent.name)} -
    - @${escapeHtml(agent.name)} - ${escapeHtml(agent.role || 'Agent')} -
    - `; - }); - - if (html === '') { - html = '
    No matching agents
    '; - } - - elements.mentionAutocompleteList.innerHTML = html; - elements.mentionAutocomplete.classList.add('visible'); - - // Add click handlers to items - elements.mentionAutocompleteList.querySelectorAll('.mention-autocomplete-item[data-mention]').forEach((item) => { - item.addEventListener('click', () => { - const mention = item.dataset.mention; - if (mention) { - completeMention(mention); - } - }); - }); -} - -/** - * Hide mention autocomplete dropdown - */ -export function hideMentionAutocomplete(): void { - elements.mentionAutocomplete.classList.remove('visible'); - mentionFilteredAgents = []; - mentionSelectedIndex = 0; -} - -/** - * Check if mention autocomplete is visible - */ -export function isMentionAutocompleteVisible(): boolean { - return elements.mentionAutocomplete.classList.contains('visible'); -} - -/** - * Navigate mention autocomplete selection - */ -export function navigateMentionAutocomplete(direction: 'up' | 'down'): void { - const items = elements.mentionAutocompleteList.querySelectorAll('.mention-autocomplete-item[data-mention]'); - if (items.length === 0) return; - - // Remove current selection - items[mentionSelectedIndex]?.classList.remove('selected'); - - // Update index - if (direction === 'down') { - mentionSelectedIndex = (mentionSelectedIndex + 1) % items.length; - } else { - mentionSelectedIndex = (mentionSelectedIndex - 1 + items.length) % items.length; - } - - // Add new selection - items[mentionSelectedIndex]?.classList.add('selected'); - items[mentionSelectedIndex]?.scrollIntoView({ block: 'nearest' }); -} - -/** - * Complete the current mention selection - */ -export function completeMention(mention?: string): void { - const items = elements.mentionAutocompleteList.querySelectorAll('.mention-autocomplete-item[data-mention]'); - - // Use provided mention or get from selected item - let selectedMention = mention; - if (!selectedMention && items.length > 0) { - selectedMention = items[mentionSelectedIndex]?.dataset.mention; - } - - if (!selectedMention) { - hideMentionAutocomplete(); - return; - } - - // Replace the @... text with the completed mention - const input = elements.messageInput; - const value = input.value; - - // Find the @ position (should be at start or after whitespace) - const atMatch = value.match(/^@\S*/); - if (atMatch) { - // Replace the @partial with @CompletedName - const completedText = `@${selectedMention} `; - input.value = completedText + value.substring(atMatch[0].length); - input.selectionStart = input.selectionEnd = completedText.length; - } - - hideMentionAutocomplete(); - input.focus(); -} - -/** - * Get the current @mention being typed (if any) - */ -export function getCurrentMentionQuery(): string | null { - const input = elements.messageInput; - const value = input.value; - const cursorPos = input.selectionStart; - - // Check if cursor is within an @mention at the start - const atMatch = value.match(/^@(\S*)/); - if (atMatch && cursorPos <= atMatch[0].length) { - return atMatch[1]; // Return the text after @ - } - - return null; -} - -// ======================================== -// Spawn Modal Functions -// ======================================== - -/** - * Open the spawn agent modal - */ -export function openSpawnModal(): void { - elements.spawnModalOverlay.classList.add('visible'); - elements.spawnNameInput.value = ''; - elements.spawnCliInput.value = 'claude'; - elements.spawnTaskInput.value = ''; - elements.spawnStatus.textContent = ''; - elements.spawnStatus.className = 'spawn-status'; - elements.spawnNameInput.focus(); -} - -/** - * Close the spawn agent modal - */ -export function closeSpawnModal(): void { - elements.spawnModalOverlay.classList.remove('visible'); -} - -/** - * Spawn a new agent via the API - */ -export async function spawnAgent(): Promise<{ success: boolean; error?: string }> { - const name = elements.spawnNameInput.value.trim(); - const cli = elements.spawnCliInput.value.trim() || 'claude'; - const task = elements.spawnTaskInput.value.trim(); - - if (!name) { - elements.spawnStatus.textContent = 'Agent name is required'; - elements.spawnStatus.className = 'spawn-status error'; - return { success: false, error: 'Agent name is required' }; - } - - elements.spawnSubmitBtn.disabled = true; - elements.spawnStatus.textContent = 'Spawning agent...'; - elements.spawnStatus.className = 'spawn-status loading'; - - try { - const response = await fetch('/api/spawn', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, cli, task }), - }); - - const result = await response.json(); - - if (response.ok && result.success) { - elements.spawnStatus.textContent = `Agent "${name}" spawned successfully!`; - elements.spawnStatus.className = 'spawn-status success'; - - // Refresh spawned agents list - await fetchSpawnedAgents(); - - // Close modal after brief delay - setTimeout(() => { - closeSpawnModal(); - }, 1000); - - return { success: true }; - } else { - throw new Error(result.error || 'Failed to spawn agent'); - } - } catch (err: any) { - elements.spawnStatus.textContent = err.message || 'Failed to spawn agent'; - elements.spawnStatus.className = 'spawn-status error'; - return { success: false, error: err.message }; - } finally { - elements.spawnSubmitBtn.disabled = false; - } -} - -/** - * Fetch list of spawned agents from API - */ -export async function fetchSpawnedAgents(): Promise { - try { - const response = await fetch('/api/spawned'); - const result = await response.json(); - - if (result.success && Array.isArray(result.agents)) { - spawnedAgents = result.agents; - // Re-render agents to show spawned status - renderAgents(); - } - } catch (err) { - console.error('[UI] Failed to fetch spawned agents:', err); - } -} - -/** - * Release a spawned agent - */ -export async function releaseAgent(name: string): Promise { - try { - const response = await fetch(`/api/spawned/${encodeURIComponent(name)}`, { - method: 'DELETE', - }); - - const result = await response.json(); - - if (result.success) { - // Refresh the list - await fetchSpawnedAgents(); - } else { - console.error('[UI] Failed to release agent:', result.error); - } - } catch (err) { - console.error('[UI] Failed to release agent:', err); - } -} - -/** - * Get spawned agents list - */ -export function getSpawnedAgents(): SpawnedAgent[] { - return spawnedAgents; -} - -// ======================================== -// Fleet View Functions -// ======================================== - -/** - * Initialize fleet view toggle handlers - */ -export function initFleetViewToggle(): void { - elements.viewToggleLocal?.addEventListener('click', () => { - switchViewMode('local'); - }); - - elements.viewToggleFleet?.addEventListener('click', () => { - switchViewMode('fleet'); - }); -} - -/** - * Switch between local and fleet view - */ -export function switchViewMode(mode: ViewMode): void { - setViewMode(mode); - - // Update toggle button states - elements.viewToggleLocal?.classList.toggle('active', mode === 'local'); - elements.viewToggleFleet?.classList.toggle('active', mode === 'fleet'); - - // Show/hide servers section - if (elements.serversSection) { - elements.serversSection.style.display = mode === 'fleet' ? 'block' : 'none'; - } - - // Re-render agents list - renderAgents(); - - // Re-render servers if in fleet mode - if (mode === 'fleet') { - renderServers(); - } -} - -/** - * Update fleet view visibility based on available peer connections - */ -export function updateFleetViewVisibility(): void { - const fleetData = getFleetData(); - const hasFleet = fleetData && fleetData.servers.length > 0; - - // Show toggle only if fleet is available - if (elements.viewToggle) { - elements.viewToggle.style.display = hasFleet ? 'flex' : 'none'; - } - - // Update peer count - if (elements.peerCount && fleetData) { - elements.peerCount.textContent = String(fleetData.servers.length); - } - - // If fleet became unavailable while in fleet mode, switch to local - if (!hasFleet && getViewMode() === 'fleet') { - switchViewMode('local'); - } -} - -/** - * Render peer servers list in sidebar - */ -export function renderServers(): void { - const fleetData = getFleetData(); - - if (!fleetData || fleetData.servers.length === 0) { - if (elements.serversList) { - elements.serversList.innerHTML = '
  • No peer servers connected
  • '; - } - return; - } - - const html = fleetData.servers.map(server => { - const isLocal = server.id === fleetData.localServerId; - const statusClass = server.connected ? '' : 'offline'; - - return ` -
  • -
    - - - - - - -
    - ${escapeHtml(server.name)}${isLocal ? ' (local)' : ''} - ${server.agentCount} -
  • - `; - }).join(''); - - if (elements.serversList) { - elements.serversList.innerHTML = html; - } -} - -/** - * Render agents list with fleet support - * Updates renderAgents to show server badges in fleet mode - */ -export function renderFleetAgents(): void { - const viewMode = getViewMode(); - const fleetData = getFleetData(); - - if (viewMode !== 'fleet' || !fleetData) { - renderAgents(); - return; - } - - // Get fleet agents - const agents = fleetData.agents; - const spawnedNames = new Set(spawnedAgents.map(a => a.name)); - - const html = agents.map((agent: FleetAgent) => { - const online = isAgentOnline(agent.lastSeen || agent.lastActive); - const presenceClass = online ? 'online' : ''; - const isActive = state.currentChannel === agent.name; - const needsAttentionClass = agent.needsAttention ? 'needs-attention' : ''; - const isSpawned = spawnedNames.has(agent.name); - const isLocal = agent.isLocal; - - // Server badge for fleet agents - const serverBadge = ` - - - ${escapeHtml(agent.serverName || agent.server)} - - `; - - // Server indicator on avatar - const serverIndicator = !isLocal ? ` - - ` : ''; - - // Spawned icon SVG - const spawnedIcon = isSpawned ? ` - - - - ` : ''; - - // Release button for spawned agents - const releaseBtn = isSpawned ? ` - - ` : ''; - - return ` -
  • -
    - ${getInitials(agent.name)} - - ${serverIndicator} -
    - ${serverBadge} - ${escapeHtml(agent.name)} - ${spawnedIcon} - ${agent.needsAttention ? 'Needs Input' : ''} - ${releaseBtn} -
  • - `; - }).join(''); - - elements.agentsList.innerHTML = html || '
  • No agents in fleet
  • '; - - // Add click handlers - attachAgentClickHandlers(); - - // Update command palette agents - updatePaletteAgents(); -} - -/** - * Attach click handlers to agent list items - */ -function attachAgentClickHandlers(): void { - elements.agentsList.querySelectorAll('.channel-item[data-agent]').forEach((item) => { - item.addEventListener('click', (e) => { - if ((e.target as HTMLElement).closest('.release-btn')) { - return; - } - const agentName = item.dataset.agent; - if (agentName) { - selectChannel(agentName); - } - }); - }); - - elements.agentsList.querySelectorAll('.release-btn[data-release]').forEach((btn) => { - btn.addEventListener('click', async (e) => { - e.stopPropagation(); - const agentName = btn.dataset.release; - if (agentName && confirm(`Release agent "${agentName}"? This will terminate the agent.`)) { - await releaseAgent(agentName); - } - }); - }); -} - -/** - * Check if an agent is a fleet agent (has server info) - */ -function isFleetAgent(agent: Agent | FleetAgent): agent is FleetAgent { - return 'server' in agent && 'serverName' in agent; -} diff --git a/src/dashboard/frontend/index.ts b/src/dashboard/frontend/index.ts deleted file mode 100644 index 9bd7a2f01..000000000 --- a/src/dashboard/frontend/index.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * Dashboard Frontend Module - * - * A Slack-like real-time dashboard for Agent Relay communications. - */ - -// Re-export all types -export type { - Agent, - Message, - DashboardData, - SessionInfo, - AgentSummary, - ChannelType, - AppState, - DOMElements, -} from './types.js'; - -// Re-export state management -export { - state, - subscribe, - setAgents, - setMessages, - setCurrentChannel, - setConnectionStatus, - getFilteredMessages, -} from './state.js'; - -// Re-export utilities -export { - STALE_THRESHOLD_MS, - isAgentOnline, - escapeHtml, - formatTime, - formatDate, - getAvatarColor, - getInitials, - formatMessageBody, -} from './utils.js'; - -// Re-export WebSocket functionality -export { connect, sendMessage, onData } from './websocket.js'; - -// Re-export UI components -export { - initElements, - getElements, - updateConnectionStatus, - renderAgents, - renderMessages, - selectChannel, - updateOnlineCount, - openCommandPalette, - closeCommandPalette, - filterPaletteResults, -} from './components.js'; - -// Re-export app initialization -export { initApp } from './app.js'; diff --git a/src/dashboard/frontend/state.test.ts b/src/dashboard/frontend/state.test.ts deleted file mode 100644 index d8ab9270a..000000000 --- a/src/dashboard/frontend/state.test.ts +++ /dev/null @@ -1,227 +0,0 @@ -/** - * Tests for Dashboard State Management - */ - -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { - state, - subscribe, - setAgents, - setMessages, - setCurrentChannel, - setConnectionStatus, - setWebSocket, - incrementReconnectAttempts, - getFilteredMessages, -} from './state.js'; -import type { Agent, Message } from './types.js'; - -describe('state', () => { - // Reset state before each test - beforeEach(() => { - state.agents = []; - state.messages = []; - state.currentChannel = 'general'; - state.isConnected = false; - state.ws = null; - state.reconnectAttempts = 0; - }); - - describe('initial state', () => { - it('should have empty agents array', () => { - expect(state.agents).toEqual([]); - }); - - it('should have empty messages array', () => { - expect(state.messages).toEqual([]); - }); - - it('should have general as default channel', () => { - expect(state.currentChannel).toBe('general'); - }); - - it('should be disconnected by default', () => { - expect(state.isConnected).toBe(false); - }); - - it('should have null websocket', () => { - expect(state.ws).toBeNull(); - }); - - it('should have zero reconnect attempts', () => { - expect(state.reconnectAttempts).toBe(0); - }); - }); - - describe('subscribe', () => { - it('should call listener when state changes', () => { - const listener = vi.fn(); - subscribe(listener); - - setAgents([{ name: 'Alice' } as Agent]); - - expect(listener).toHaveBeenCalled(); - }); - - it('should return unsubscribe function', () => { - const listener = vi.fn(); - const unsubscribe = subscribe(listener); - - unsubscribe(); - setAgents([{ name: 'Bob' } as Agent]); - - expect(listener).not.toHaveBeenCalled(); - }); - - it('should support multiple listeners', () => { - const listener1 = vi.fn(); - const listener2 = vi.fn(); - - subscribe(listener1); - subscribe(listener2); - - setAgents([{ name: 'Charlie' } as Agent]); - - expect(listener1).toHaveBeenCalled(); - expect(listener2).toHaveBeenCalled(); - }); - }); - - describe('setAgents', () => { - it('should update agents array', () => { - const agents: Agent[] = [ - { name: 'Alice', role: 'Developer', cli: 'claude', messageCount: 5 }, - { name: 'Bob', role: 'Reviewer', cli: 'gemini', messageCount: 3 }, - ]; - - setAgents(agents); - - expect(state.agents).toEqual(agents); - expect(state.agents).toHaveLength(2); - }); - - it('should replace existing agents', () => { - setAgents([{ name: 'Alice' } as Agent]); - setAgents([{ name: 'Bob' } as Agent]); - - expect(state.agents).toHaveLength(1); - expect(state.agents[0].name).toBe('Bob'); - }); - }); - - describe('setMessages', () => { - it('should update messages array', () => { - const messages: Message[] = [ - { id: '1', from: 'Alice', to: 'Bob', content: 'Hello', timestamp: '2025-01-15T12:00:00Z' }, - ]; - - setMessages(messages); - - expect(state.messages).toEqual(messages); - }); - - it('should replace existing messages', () => { - setMessages([{ id: '1' } as Message]); - setMessages([{ id: '2' } as Message]); - - expect(state.messages).toHaveLength(1); - expect(state.messages[0].id).toBe('2'); - }); - }); - - describe('setCurrentChannel', () => { - it('should update current channel', () => { - setCurrentChannel('Alice'); - expect(state.currentChannel).toBe('Alice'); - }); - - it('should accept agent names as channels', () => { - setCurrentChannel('Bob'); - expect(state.currentChannel).toBe('Bob'); - }); - }); - - describe('setConnectionStatus', () => { - it('should set connected status', () => { - setConnectionStatus(true); - expect(state.isConnected).toBe(true); - }); - - it('should set disconnected status', () => { - state.isConnected = true; - setConnectionStatus(false); - expect(state.isConnected).toBe(false); - }); - - it('should reset reconnect attempts when connected', () => { - state.reconnectAttempts = 5; - setConnectionStatus(true); - expect(state.reconnectAttempts).toBe(0); - }); - - it('should not reset reconnect attempts when disconnected', () => { - state.reconnectAttempts = 5; - setConnectionStatus(false); - expect(state.reconnectAttempts).toBe(5); - }); - }); - - describe('incrementReconnectAttempts', () => { - it('should increment reconnect attempts', () => { - expect(state.reconnectAttempts).toBe(0); - - incrementReconnectAttempts(); - expect(state.reconnectAttempts).toBe(1); - - incrementReconnectAttempts(); - expect(state.reconnectAttempts).toBe(2); - }); - }); - - describe('setWebSocket', () => { - it('should set websocket instance', () => { - const mockWs = {} as WebSocket; - setWebSocket(mockWs); - expect(state.ws).toBe(mockWs); - }); - - it('should allow setting to null', () => { - state.ws = {} as WebSocket; - setWebSocket(null); - expect(state.ws).toBeNull(); - }); - }); - - describe('getFilteredMessages', () => { - const messages: Message[] = [ - { id: '1', from: 'Alice', to: 'Bob', content: 'Direct message', timestamp: '2025-01-15T12:00:00Z' }, - { id: '2', from: 'Bob', to: '*', content: 'Broadcast', timestamp: '2025-01-15T12:01:00Z' }, - { id: '3', from: 'Charlie', to: 'Alice', content: 'To Alice', timestamp: '2025-01-15T12:02:00Z' }, - { id: '4', from: 'Alice', to: '*', content: 'Alice broadcast', timestamp: '2025-01-15T12:03:00Z' }, - ]; - - beforeEach(() => { - setMessages(messages); - }); - - it('should return all messages for general channel', () => { - setCurrentChannel('general'); - const filtered = getFilteredMessages(); - expect(filtered).toHaveLength(4); - }); - - it('should return messages to/from agent for agent channel', () => { - setCurrentChannel('Alice'); - const filtered = getFilteredMessages(); - // Messages where Alice is sender or recipient - expect(filtered).toHaveLength(3); - expect(filtered.every((m) => m.from === 'Alice' || m.to === 'Alice')).toBe(true); - }); - - it('should return empty array when no matching messages', () => { - setCurrentChannel('Unknown'); - const filtered = getFilteredMessages(); - expect(filtered).toHaveLength(0); - }); - }); -}); diff --git a/src/dashboard/frontend/state.ts b/src/dashboard/frontend/state.ts deleted file mode 100644 index 20e3781de..000000000 --- a/src/dashboard/frontend/state.ts +++ /dev/null @@ -1,184 +0,0 @@ -/** - * Dashboard State Management - */ - -import type { Agent, Message, AppState, ChannelType, ViewMode, FleetData, FleetAgent, PeerServer } from './types.js'; - -/** - * Global application state - */ -export const state: AppState = { - agents: [], - messages: [], - currentChannel: 'general', - currentThread: null, - isConnected: false, - ws: null, - reconnectAttempts: 0, - viewMode: 'local', - fleetData: null, -}; - -/** - * State update callbacks - */ -type StateListener = () => void; -const listeners: StateListener[] = []; - -/** - * Subscribe to state changes - */ -export function subscribe(listener: StateListener): () => void { - listeners.push(listener); - return () => { - const index = listeners.indexOf(listener); - if (index > -1) { - listeners.splice(index, 1); - } - }; -} - -/** - * Notify all listeners of state change - */ -function notifyListeners(): void { - listeners.forEach((listener) => listener()); -} - -/** - * Update agents in state - */ -export function setAgents(agents: Agent[]): void { - state.agents = agents; - notifyListeners(); -} - -/** - * Update messages in state - */ -export function setMessages(messages: Message[]): void { - state.messages = messages; - notifyListeners(); -} - -/** - * Set current channel/conversation - */ -export function setCurrentChannel(channel: ChannelType): void { - state.currentChannel = channel; - notifyListeners(); -} - -/** - * Update connection status - */ -export function setConnectionStatus(connected: boolean): void { - state.isConnected = connected; - if (connected) { - state.reconnectAttempts = 0; - } - notifyListeners(); -} - -/** - * Increment reconnect attempts - */ -export function incrementReconnectAttempts(): void { - state.reconnectAttempts++; -} - -/** - * Set WebSocket instance - */ -export function setWebSocket(ws: WebSocket | null): void { - state.ws = ws; -} - -/** - * Filter messages based on current channel - */ -export function getFilteredMessages(): Message[] { - const { messages, currentChannel } = state; - - if (currentChannel === 'general') { - return messages; - } - - // Filter for specific agent - show messages to/from that agent - return messages.filter( - (m) => m.from === currentChannel || m.to === currentChannel - ); -} - -/** - * Set current thread for thread panel - */ -export function setCurrentThread(thread: string | null): void { - state.currentThread = thread; -} - -/** - * Get messages for a specific thread - */ -export function getThreadMessages(threadId: string): Message[] { - return state.messages.filter((m) => m.thread === threadId); -} - -/** - * Get reply count for a thread - */ -export function getThreadReplyCount(threadId: string): number { - return state.messages.filter((m) => m.thread === threadId).length; -} - -// ======================================== -// Fleet View State Management -// ======================================== - -/** - * Set view mode (local or fleet) - */ -export function setViewMode(mode: ViewMode): void { - state.viewMode = mode; - notifyListeners(); -} - -/** - * Get current view mode - */ -export function getViewMode(): ViewMode { - return state.viewMode; -} - -/** - * Update fleet data - */ -export function setFleetData(data: FleetData | null): void { - state.fleetData = data; - notifyListeners(); -} - -/** - * Get fleet data - */ -export function getFleetData(): FleetData | null { - return state.fleetData; -} - -/** - * Get agents for current view mode - * Returns local agents or fleet agents depending on view mode - */ -export function getAgentsForCurrentView(): (Agent | FleetAgent)[] { - if (state.viewMode === 'fleet' && state.fleetData) { - return state.fleetData.agents; - } - return state.agents; -} - -/** - * Check if fleet view is available (has peer connections) - */ -export function isFleetAvailable(): boolean { - return state.fleetData !== null && state.fleetData.servers.length > 0; -} diff --git a/src/dashboard/frontend/tsconfig.json b/src/dashboard/frontend/tsconfig.json deleted file mode 100644 index e48731ac7..000000000 --- a/src/dashboard/frontend/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "extends": "../../../tsconfig.json", - "compilerOptions": { - "lib": ["ES2022", "DOM", "DOM.Iterable"], - "outDir": "../../../dist/dashboard/frontend", - "rootDir": ".", - "module": "ES2022", - "moduleResolution": "bundler", - "target": "ES2022", - "declaration": false, - "declarationMap": false, - "noEmit": true - }, - "include": ["./**/*.ts"], - "exclude": ["./**/*.test.ts"] -} diff --git a/src/dashboard/frontend/types.ts b/src/dashboard/frontend/types.ts deleted file mode 100644 index d7a5b2a6b..000000000 --- a/src/dashboard/frontend/types.ts +++ /dev/null @@ -1,148 +0,0 @@ -/** - * Dashboard Frontend Types - */ - -export interface Agent { - name: string; - role: string; - cli: string; - messageCount: number; - status?: string; - lastActive?: string; - lastSeen?: string; - needsAttention?: boolean; -} - -export interface Message { - id: string; - from: string; - to: string; - content: string; - timestamp: string; - thread?: string; - project?: string; // For cross-project messages -} - -export interface DashboardData { - agents: Agent[]; - messages: Message[]; - activity: Message[]; - sessions: SessionInfo[]; - summaries: AgentSummary[]; -} - -export interface SessionInfo { - id: string; - agentName: string; - cli?: string; - startedAt: string; - endedAt?: string; - duration?: string; - messageCount: number; - summary?: string; - isActive: boolean; - closedBy?: 'agent' | 'disconnect' | 'error'; -} - -export interface AgentSummary { - agentName: string; - lastUpdated: string; - currentTask?: string; - completedTasks?: string[]; - context?: string; -} - -export type ChannelType = 'general' | string; - -export interface AppState { - agents: Agent[]; - messages: Message[]; - currentChannel: ChannelType; - currentThread: string | null; - isConnected: boolean; - ws: WebSocket | null; - reconnectAttempts: number; - // Fleet view state - viewMode: ViewMode; - fleetData: FleetData | null; -} - -export interface DOMElements { - connectionDot: HTMLElement; - channelsList: HTMLElement; - agentsList: HTMLElement; - messagesList: HTMLElement; - currentChannelName: HTMLElement; - channelTopic: HTMLElement; - onlineCount: HTMLElement; - messageInput: HTMLTextAreaElement; - sendBtn: HTMLButtonElement; - boldBtn: HTMLButtonElement; - emojiBtn: HTMLButtonElement; - searchTrigger: HTMLElement; - commandPaletteOverlay: HTMLElement; - paletteSearch: HTMLInputElement; - paletteResults: HTMLElement; - paletteChannelsSection: HTMLElement; - paletteAgentsSection: HTMLElement; - paletteMessagesSection: HTMLElement; - typingIndicator: HTMLElement; - threadPanelOverlay: HTMLElement; - threadPanelId: HTMLElement; - threadPanelClose: HTMLButtonElement; - threadMessages: HTMLElement; - threadMessageInput: HTMLTextAreaElement; - threadSendBtn: HTMLButtonElement; - mentionAutocomplete: HTMLElement; - mentionAutocompleteList: HTMLElement; - // Spawn modal elements - spawnBtn: HTMLButtonElement; - spawnModalOverlay: HTMLElement; - spawnModalClose: HTMLButtonElement; - spawnNameInput: HTMLInputElement; - spawnCliInput: HTMLInputElement; - spawnTaskInput: HTMLTextAreaElement; - spawnSubmitBtn: HTMLButtonElement; - spawnStatus: HTMLElement; - // Fleet view elements - viewToggle: HTMLElement; - viewToggleLocal: HTMLButtonElement; - viewToggleFleet: HTMLButtonElement; - peerCount: HTMLElement; - serversSection: HTMLElement; - serversList: HTMLElement; -} - -export interface SpawnedAgent { - name: string; - cli: string; - task: string; - spawnedBy: string; - spawnedAt: number; - window: string; -} - -// Fleet view types -export type ViewMode = 'local' | 'fleet'; - -export interface PeerServer { - id: string; - name: string; - host: string; - port: number; - connected: boolean; - lastSeen?: string; - agentCount: number; -} - -export interface FleetAgent extends Agent { - server: string; // Server ID where agent is running - serverName: string; // Human-readable server name - isLocal: boolean; // true if on local server -} - -export interface FleetData { - servers: PeerServer[]; - agents: FleetAgent[]; - localServerId: string; -} diff --git a/src/dashboard/frontend/utils.test.ts b/src/dashboard/frontend/utils.test.ts deleted file mode 100644 index 151964bc6..000000000 --- a/src/dashboard/frontend/utils.test.ts +++ /dev/null @@ -1,256 +0,0 @@ -/** - * Tests for Dashboard Utility Functions - */ - -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { - STALE_THRESHOLD_MS, - isAgentOnline, - escapeHtml, - formatTime, - formatDate, - getAvatarColor, - getInitials, - formatMessageBody, -} from './utils.js'; - -describe('utils', () => { - describe('STALE_THRESHOLD_MS', () => { - it('should be 45 seconds (1.5x heartbeat timeout for flickering prevention)', () => { - expect(STALE_THRESHOLD_MS).toBe(45000); - }); - }); - - describe('isAgentOnline', () => { - beforeEach(() => { - vi.useFakeTimers(); - vi.setSystemTime(new Date('2025-01-15T12:00:00.000Z')); - }); - - it('should return false for undefined lastSeen', () => { - expect(isAgentOnline(undefined)).toBe(false); - }); - - it('should return false for empty string', () => { - expect(isAgentOnline('')).toBe(false); - }); - - it('should return false for invalid date string', () => { - expect(isAgentOnline('not-a-date')).toBe(false); - }); - - it('should return true for recent timestamp (within 30s)', () => { - const recentTime = new Date(Date.now() - 10000).toISOString(); // 10 seconds ago - expect(isAgentOnline(recentTime)).toBe(true); - }); - - it('should return false for stale timestamp (over 30s)', () => { - const staleTime = new Date(Date.now() - 60000).toISOString(); // 60 seconds ago - expect(isAgentOnline(staleTime)).toBe(false); - }); - - it('should return true for exactly at threshold', () => { - const atThreshold = new Date(Date.now() - STALE_THRESHOLD_MS + 1).toISOString(); - expect(isAgentOnline(atThreshold)).toBe(true); - }); - - it('should return false for exactly past threshold', () => { - const pastThreshold = new Date(Date.now() - STALE_THRESHOLD_MS - 1).toISOString(); - expect(isAgentOnline(pastThreshold)).toBe(false); - }); - }); - - describe('escapeHtml', () => { - it('should return empty string for undefined', () => { - expect(escapeHtml(undefined)).toBe(''); - }); - - it('should return empty string for empty string', () => { - expect(escapeHtml('')).toBe(''); - }); - - it('should escape HTML special characters', () => { - expect(escapeHtml('')).toBe( - '<script>alert("xss")</script>' - ); - }); - - it('should escape ampersands', () => { - expect(escapeHtml('foo & bar')).toBe('foo & bar'); - }); - - it('should escape quotes', () => { - expect(escapeHtml('say "hello"')).toBe('say "hello"'); - }); - - it('should handle mixed content', () => { - expect(escapeHtml('
    Hello & goodbye
    ')).toBe( - '<div class="test">Hello & goodbye</div>' - ); - }); - - it('should preserve normal text', () => { - expect(escapeHtml('Hello World')).toBe('Hello World'); - }); - }); - - describe('formatTime', () => { - it('should format timestamp to locale time', () => { - const timestamp = '2025-01-15T14:30:00.000Z'; - const result = formatTime(timestamp); - // Result depends on locale, but should contain hour and minute - expect(result).toMatch(/\d{1,2}:\d{2}/); - }); - - it('should handle midnight', () => { - const timestamp = '2025-01-15T00:00:00.000Z'; - const result = formatTime(timestamp); - expect(result).toBeTruthy(); - }); - }); - - describe('formatDate', () => { - beforeEach(() => { - vi.useFakeTimers(); - vi.setSystemTime(new Date('2025-01-15T12:00:00.000Z')); - }); - - it('should return "Today" for today\'s date', () => { - const today = '2025-01-15T10:00:00.000Z'; - expect(formatDate(today)).toBe('Today'); - }); - - it('should return "Yesterday" for yesterday\'s date', () => { - const yesterday = '2025-01-14T10:00:00.000Z'; - expect(formatDate(yesterday)).toBe('Yesterday'); - }); - - it('should return formatted date for older dates', () => { - const oldDate = '2025-01-10T10:00:00.000Z'; - const result = formatDate(oldDate); - // Should include weekday, month, and day - expect(result).toContain('January'); - expect(result).toContain('10'); - }); - }); - - describe('getAvatarColor', () => { - it('should return a valid hex color', () => { - const color = getAvatarColor('Alice'); - expect(color).toMatch(/^#[0-9a-f]{6}$/i); - }); - - it('should return consistent color for same name', () => { - const color1 = getAvatarColor('Bob'); - const color2 = getAvatarColor('Bob'); - expect(color1).toBe(color2); - }); - - it('should return different colors for different names', () => { - const color1 = getAvatarColor('Alice'); - const color2 = getAvatarColor('Charlie'); - // Not guaranteed to be different, but likely - expect(typeof color1).toBe('string'); - expect(typeof color2).toBe('string'); - }); - - it('should handle empty string', () => { - const color = getAvatarColor(''); - expect(color).toMatch(/^#[0-9a-f]{6}$/i); - }); - - it('should handle special characters', () => { - const color = getAvatarColor('Agent-123_test'); - expect(color).toMatch(/^#[0-9a-f]{6}$/i); - }); - }); - - describe('getInitials', () => { - it('should return first 2 characters uppercase', () => { - expect(getInitials('Alice')).toBe('AL'); - }); - - it('should handle lowercase names', () => { - expect(getInitials('bob')).toBe('BO'); - }); - - it('should handle single character names', () => { - expect(getInitials('A')).toBe('A'); - }); - - it('should handle long names', () => { - expect(getInitials('Alexander')).toBe('AL'); - }); - - it('should handle names with numbers', () => { - expect(getInitials('Agent123')).toBe('AG'); - }); - }); - - describe('formatMessageBody', () => { - it('should return empty string for undefined', () => { - expect(formatMessageBody(undefined)).toBe(''); - }); - - it('should return empty string for empty string', () => { - expect(formatMessageBody('')).toBe(''); - }); - - it('should escape HTML', () => { - expect(formatMessageBody('')).toContain('<script>'); - }); - - it('should convert inline code with backticks', () => { - const result = formatMessageBody('Use `console.log()` for debugging'); - expect(result).toContain('console.log()'); - }); - - it('should convert code blocks with triple backticks', () => { - const result = formatMessageBody('```\nconst x = 1;\n```'); - expect(result).toContain('
    ');
    -      expect(result).toContain('const x = 1;');
    -      expect(result).toContain('
    '); - }); - - it('should handle code blocks with language specifier', () => { - const result = formatMessageBody('```js\nconst x = 1;\n```'); - expect(result).toContain('
    ');
    -      expect(result).toContain('const x = 1;');
    -    });
    -
    -    it('should handle mixed content', () => {
    -      const result = formatMessageBody('Hello `world` and ```code block```');
    -      expect(result).toContain('world');
    -      expect(result).toContain('
    code block
    '); - }); - - it('should preserve plain text', () => { - expect(formatMessageBody('Hello World')).toBe('Hello World'); - }); - - it('should preserve newlines (CSS white-space: pre-wrap handles display)', () => { - // Newlines are preserved as-is since CSS handles multi-line display - expect(formatMessageBody('Hello\nWorld')).toBe('Hello\nWorld'); - }); - - it('should handle multiple newlines', () => { - // Newlines preserved, CSS handles display - expect(formatMessageBody('Line1\nLine2\nLine3')).toBe('Line1\nLine2\nLine3'); - }); - - it('should handle newlines with other content', () => { - const result = formatMessageBody('Check this:\n- Item 1\n- Item 2'); - // Newlines preserved - expect(result).toBe('Check this:\n- Item 1\n- Item 2'); - }); - - it('should convert bold markdown', () => { - expect(formatMessageBody('This is **bold** text')).toContain('bold'); - expect(formatMessageBody('This is __also bold__ text')).toContain('also bold'); - }); - - it('should convert italic markdown', () => { - expect(formatMessageBody('This is *italic* text')).toContain('italic'); - }); - }); -}); diff --git a/src/dashboard/frontend/utils.ts b/src/dashboard/frontend/utils.ts deleted file mode 100644 index ceb639667..000000000 --- a/src/dashboard/frontend/utils.ts +++ /dev/null @@ -1,123 +0,0 @@ -/** - * Dashboard Utility Functions - */ - -/** Threshold for considering an agent offline (45 seconds) - * This is intentionally 1.5x the heartbeat timeout (30s) to prevent flickering - * when heartbeats are slightly delayed */ -export const STALE_THRESHOLD_MS = 45000; - -/** - * Check if an agent is online based on last seen timestamp - */ -export function isAgentOnline(lastSeen: string | undefined): boolean { - if (!lastSeen) return false; - const ts = Date.parse(lastSeen); - if (Number.isNaN(ts)) return false; - return Date.now() - ts < STALE_THRESHOLD_MS; -} - -/** - * Escape HTML to prevent XSS - */ -export function escapeHtml(text: string | undefined): string { - if (!text) return ''; - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; -} - -/** - * Format timestamp to locale time string - */ -export function formatTime(timestamp: string): string { - const date = new Date(timestamp); - return date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); -} - -/** - * Format timestamp to human-readable date - */ -export function formatDate(timestamp: string): string { - const date = new Date(timestamp); - const today = new Date(); - const yesterday = new Date(today); - yesterday.setDate(yesterday.getDate() - 1); - - if (date.toDateString() === today.toDateString()) { - return 'Today'; - } else if (date.toDateString() === yesterday.toDateString()) { - return 'Yesterday'; - } else { - return date.toLocaleDateString([], { - weekday: 'long', - month: 'long', - day: 'numeric', - }); - } -} - -/** - * Generate a consistent color for an agent based on their name - */ -export function getAvatarColor(name: string): string { - const colors = [ - '#e01e5a', - '#2bac76', - '#e8a427', - '#1264a3', - '#7c3aed', - '#0d9488', - '#dc2626', - '#9333ea', - '#ea580c', - '#0891b2', - ]; - let hash = 0; - for (let i = 0; i < name.length; i++) { - hash = name.charCodeAt(i) + ((hash << 5) - hash); - } - return colors[Math.abs(hash) % colors.length]; -} - -/** - * Get initials from a name (first 2 characters, uppercase) - */ -export function getInitials(name: string): string { - return name.substring(0, 2).toUpperCase(); -} - -/** - * Format message body with basic markdown-like formatting - * Note: CSS uses white-space: pre-wrap to handle newlines naturally - */ -export function formatMessageBody(content: string | undefined): string { - if (!content) return ''; - - let escaped = escapeHtml(content); - - // Simple code block detection (must process before inline code) - // Handle code blocks with optional language specifier: ```js\n... ``` or ```...\n``` - // The language specifier must be followed by a newline to be recognized - escaped = escaped.replace(/```(\w+)?\n([\s\S]*?)```/g, (_match, _lang, code) => { - return `
    ${code.trim()}
    `; - }); - - // Handle inline code blocks without newlines: ```code here``` - escaped = escaped.replace(/```([^`\n]+)```/g, '
    $1
    '); - - // Inline code: `code` - escaped = escaped.replace(/`([^`]+)`/g, '$1'); - - // Bold: **text** or __text__ - escaped = escaped.replace(/\*\*([^*]+)\*\*/g, '$1'); - escaped = escaped.replace(/__([^_]+)__/g, '$1'); - - // Italic: *text* or _text_ (not inside bold) - escaped = escaped.replace(/(?$1'); - - // Don't convert newlines to
    - CSS white-space: pre-wrap handles this - // This was causing double line breaks before - - return escaped; -} diff --git a/src/dashboard/frontend/websocket.ts b/src/dashboard/frontend/websocket.ts deleted file mode 100644 index 992ee6cf4..000000000 --- a/src/dashboard/frontend/websocket.ts +++ /dev/null @@ -1,125 +0,0 @@ -/** - * WebSocket Connection Handler - */ - -import type { DashboardData, FleetData } from './types.js'; -import { - state, - setAgents, - setMessages, - setConnectionStatus, - setWebSocket, - incrementReconnectAttempts, - setFleetData, -} from './state.js'; - -type DataHandler = (data: DashboardData) => void; - -let dataHandler: DataHandler | null = null; - -/** - * Set the handler for incoming data - */ -export function onData(handler: DataHandler): void { - dataHandler = handler; -} - -/** - * Connect to the WebSocket server - */ -export function connect(): void { - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const ws = new WebSocket(`${protocol}//${window.location.host}/ws`); - - ws.onopen = (): void => { - setConnectionStatus(true); - }; - - ws.onclose = (): void => { - setConnectionStatus(false); - // Reconnect with exponential backoff - const delay = Math.min(1000 * Math.pow(2, state.reconnectAttempts), 30000); - incrementReconnectAttempts(); - setTimeout(connect, delay); - }; - - ws.onerror = (error): void => { - console.error('WebSocket error:', error); - }; - - ws.onmessage = (event: MessageEvent): void => { - try { - const data: DashboardData = JSON.parse(event.data as string); - handleData(data); - } catch (e) { - console.error('Failed to parse message:', e); - } - }; - - setWebSocket(ws); -} - -/** - * Extended dashboard data with fleet support - */ -interface ExtendedDashboardData extends DashboardData { - fleet?: FleetData; -} - -/** - * Handle incoming dashboard data - */ -function handleData(data: ExtendedDashboardData): void { - console.log('[WS] Received data:', { agentCount: data.agents?.length, messageCount: data.messages?.length, hasFleet: !!data.fleet }); - - if (data.agents) { - console.log('[WS] Setting agents:', data.agents.map(a => a.name)); - setAgents(data.agents); - } - - if (data.messages) { - setMessages(data.messages); - } - - // Handle fleet data for multi-server view - if (data.fleet) { - console.log('[WS] Setting fleet data:', { servers: data.fleet.servers?.length, agents: data.fleet.agents?.length }); - setFleetData(data.fleet); - } - - if (dataHandler) { - dataHandler(data); - } -} - -/** - * Send a message via the REST API - */ -export async function sendMessage( - to: string, - message: string, - thread?: string -): Promise<{ success: boolean; error?: string }> { - try { - const body: { to: string; message: string; thread?: string } = { to, message }; - if (thread) { - body.thread = thread; - } - - const response = await fetch('/api/send', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - - const result = await response.json(); - - if (response.ok && result.success) { - return { success: true }; - } else { - return { success: false, error: result.error || 'Failed to send message' }; - } - } catch (err) { - return { success: false, error: 'Network error - could not send message' }; - } -} diff --git a/src/dashboard-v2/index.ts b/src/dashboard/index.ts similarity index 100% rename from src/dashboard-v2/index.ts rename to src/dashboard/index.ts diff --git a/src/dashboard-v2/lib/api.ts b/src/dashboard/lib/api.ts similarity index 100% rename from src/dashboard-v2/lib/api.ts rename to src/dashboard/lib/api.ts diff --git a/src/dashboard-v2/lib/colors.test.ts b/src/dashboard/lib/colors.test.ts similarity index 100% rename from src/dashboard-v2/lib/colors.test.ts rename to src/dashboard/lib/colors.test.ts diff --git a/src/dashboard-v2/lib/colors.ts b/src/dashboard/lib/colors.ts similarity index 100% rename from src/dashboard-v2/lib/colors.ts rename to src/dashboard/lib/colors.ts diff --git a/src/dashboard-v2/lib/hierarchy.ts b/src/dashboard/lib/hierarchy.ts similarity index 100% rename from src/dashboard-v2/lib/hierarchy.ts rename to src/dashboard/lib/hierarchy.ts diff --git a/src/dashboard-v2/next-env.d.ts b/src/dashboard/next-env.d.ts similarity index 100% rename from src/dashboard-v2/next-env.d.ts rename to src/dashboard/next-env.d.ts diff --git a/src/dashboard-v2/next.config.js b/src/dashboard/next.config.js similarity index 78% rename from src/dashboard-v2/next.config.js rename to src/dashboard/next.config.js index 192a01005..dbec7d422 100644 --- a/src/dashboard-v2/next.config.js +++ b/src/dashboard/next.config.js @@ -1,13 +1,14 @@ /** @type {import('next').NextConfig} */ const nextConfig = { - // Output as standalone for easier deployment - output: 'standalone', + // Static export - generates HTML/JS/CSS that can be served by any server + output: 'export', + distDir: 'out', // Disable strict mode for now during development reactStrictMode: true, - // Configure for dashboard subdirectory - basePath: '/v2', + // V2 is now the default dashboard at root path + // Legacy v1 dashboard is available at /v1 // Proxy API requests to the main dashboard server async rewrites() { diff --git a/src/dashboard-v2/package-lock.json b/src/dashboard/package-lock.json similarity index 100% rename from src/dashboard-v2/package-lock.json rename to src/dashboard/package-lock.json diff --git a/src/dashboard-v2/package.json b/src/dashboard/package.json similarity index 100% rename from src/dashboard-v2/package.json rename to src/dashboard/package.json diff --git a/src/dashboard/public/bridge.html b/src/dashboard/public/bridge.html deleted file mode 100644 index 1f55accd9..000000000 --- a/src/dashboard/public/bridge.html +++ /dev/null @@ -1,1272 +0,0 @@ - - - - - - Agent Relay - Bridge - - - - - - -
    - - - - -
    -
    -
    - # - All Projects -
    -
    -
    - 0 - projects -
    -
    - 0 - agents -
    -
    -
    - - -
    -
    -
    - - - -
    No Projects Connected
    -
    Start the bridge to orchestrate multiple projects
    -
    -
    -
    - - - - - - -
    -
    - - -
    -
    -
    -
    - - - - - - - Spawn New Agent -
    - -
    -
    -
    - - -
    -
    - - -
    -
    - - - The AI CLI tool to wrap -
    -
    - - -
    -
    -
    - -
    -
    - - - - diff --git a/src/dashboard/public/index.html b/src/dashboard/public/index.html deleted file mode 100644 index 07ad18c3d..000000000 --- a/src/dashboard/public/index.html +++ /dev/null @@ -1,2262 +0,0 @@ - - - - - - Agent Relay - - - - - - -
    - - - - -
    -
    -
    - # - general -
    -
    -
    All agent communications
    -
    -
    - - 0 online -
    -
    -
    - -
    -
    - -
    -
    -
    - - - -
    - Someone is typing... -
    -
    - -
    -
    - -
    -
    Agents
    -
    - -
    -
    - Tab or Enter to select - ↑↓ to navigate - Esc to close -
    -
    -
    -
    - - -
    - -
    - -
    -
    -
    -
    - Ctrl + Enter to send -
    -
    -
    -
    - - -
    -
    - -
    -
    -
    Commands
    -
    -
    - - - - - -
    -
    -
    /broadcast
    -
    Send message to all agents
    -
    -
    -
    -
    - - - - -
    -
    -
    /status
    -
    Set your status message
    -
    -
    -
    -
    - - - - -
    -
    -
    /clear
    -
    Clear message view
    -
    -
    -
    -
    -
    Channels
    -
    -
    - - - - - - -
    -
    -
    #general
    -
    All agent communications
    -
    -
    -
    -
    -
    Jump to Agent
    - -
    - -
    -
    -
    - - -
    -
    -
    -
    - - - - Thread - -
    - -
    -
    - -
    -
    - - -
    -
    -
    - - -
    -
    -
    -
    - - - - - - - Spawn New Agent -
    - -
    -
    -
    - - -
    -
    - - - The AI CLI tool to wrap -
    -
    - - - This will be injected into the agent's terminal -
    -
    -
    - -
    -
    - - - - - diff --git a/src/dashboard/public/js/app.js b/src/dashboard/public/js/app.js deleted file mode 100644 index 71982d0df..000000000 --- a/src/dashboard/public/js/app.js +++ /dev/null @@ -1,184 +0,0 @@ -var i={agents:[],messages:[],currentChannel:"general",currentThread:null,isConnected:!1,ws:null,reconnectAttempts:0,viewMode:"local",fleetData:null},S=[];function Y(t){return S.push(t),()=>{let e=S.indexOf(t);e>-1&&S.splice(e,1)}}function h(){S.forEach(t=>t())}function G(t){i.agents=t,h()}function X(t){i.messages=t,h()}function Z(t){i.currentChannel=t,h()}function k(t){i.isConnected=t,t&&(i.reconnectAttempts=0),h()}function ee(){i.reconnectAttempts++}function te(t){i.ws=t}function ne(){let{messages:t,currentChannel:e}=i;return e==="general"?t:t.filter(n=>n.from===e||n.to===e)}function $(t){i.currentThread=t}function se(t){return i.messages.filter(e=>e.thread===t)}function ae(t){return i.messages.filter(e=>e.thread===t).length}function oe(t){i.viewMode=t,h()}function L(){return i.viewMode}function re(t){i.fleetData=t,h()}function x(){return i.fleetData}var ie=null;function B(){let t=window.location.protocol==="https:"?"wss:":"ws:",e=new WebSocket(`${t}//${window.location.host}/ws`);e.onopen=()=>{k(!0)},e.onclose=()=>{k(!1);let n=Math.min(1e3*Math.pow(2,i.reconnectAttempts),3e4);ee(),setTimeout(B,n)},e.onerror=n=>{console.error("WebSocket error:",n)},e.onmessage=n=>{try{let s=JSON.parse(n.data);Be(s)}catch(s){console.error("Failed to parse message:",s)}},te(e)}function Be(t){console.log("[WS] Received data:",{agentCount:t.agents?.length,messageCount:t.messages?.length,hasFleet:!!t.fleet}),t.agents&&(console.log("[WS] Setting agents:",t.agents.map(e=>e.name)),G(t.agents)),t.messages&&X(t.messages),t.fleet&&(console.log("[WS] Setting fleet data:",{servers:t.fleet.servers?.length,agents:t.fleet.agents?.length}),re(t.fleet)),ie&&ie(t)}async function D(t,e,n){try{let s={to:t,message:e};n&&(s.thread=n);let o=await fetch("/api/send",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(s)}),r=await o.json();return o.ok&&r.success?{success:!0}:{success:!1,error:r.error||"Failed to send message"}}catch{return{success:!1,error:"Network error - could not send message"}}}function M(t){if(!t)return!1;let e=Date.parse(t);return Number.isNaN(e)?!1:Date.now()-e<45e3}function l(t){if(!t)return"";let e=document.createElement("div");return e.textContent=t,e.innerHTML}function H(t){return new Date(t).toLocaleTimeString([],{hour:"numeric",minute:"2-digit"})}function le(t){let e=new Date(t),n=new Date,s=new Date(n);return s.setDate(s.getDate()-1),e.toDateString()===n.toDateString()?"Today":e.toDateString()===s.toDateString()?"Yesterday":e.toLocaleDateString([],{weekday:"long",month:"long",day:"numeric"})}function v(t){let e=["#e01e5a","#2bac76","#e8a427","#1264a3","#7c3aed","#0d9488","#dc2626","#9333ea","#ea580c","#0891b2"],n=0;for(let s=0;s`
    ${o.trim()}
    `),e=e.replace(/```([^`\n]+)```/g,"
    $1
    "),e=e.replace(/`([^`]+)`/g,"$1"),e=e.replace(/\*\*([^*]+)\*\*/g,"$1"),e=e.replace(/__([^_]+)__/g,"$1"),e=e.replace(/(?$1"),e}var j=[],a,d=-1;function de(){return a={connectionDot:document.getElementById("connection-dot"),channelsList:document.getElementById("channels-list"),agentsList:document.getElementById("agents-list"),messagesList:document.getElementById("messages-list"),currentChannelName:document.getElementById("current-channel-name"),channelTopic:document.getElementById("channel-topic"),onlineCount:document.getElementById("online-count"),messageInput:document.getElementById("message-input"),sendBtn:document.getElementById("send-btn"),boldBtn:document.getElementById("bold-btn"),emojiBtn:document.getElementById("emoji-btn"),searchTrigger:document.getElementById("search-trigger"),commandPaletteOverlay:document.getElementById("command-palette-overlay"),paletteSearch:document.getElementById("palette-search"),paletteResults:document.getElementById("palette-results"),paletteChannelsSection:document.getElementById("palette-channels-section"),paletteAgentsSection:document.getElementById("palette-agents-section"),paletteMessagesSection:document.getElementById("palette-messages-section"),typingIndicator:document.getElementById("typing-indicator"),threadPanelOverlay:document.getElementById("thread-panel-overlay"),threadPanelId:document.getElementById("thread-panel-id"),threadPanelClose:document.getElementById("thread-panel-close"),threadMessages:document.getElementById("thread-messages"),threadMessageInput:document.getElementById("thread-message-input"),threadSendBtn:document.getElementById("thread-send-btn"),mentionAutocomplete:document.getElementById("mention-autocomplete"),mentionAutocompleteList:document.getElementById("mention-autocomplete-list"),spawnBtn:document.getElementById("spawn-btn"),spawnModalOverlay:document.getElementById("spawn-modal-overlay"),spawnModalClose:document.getElementById("spawn-modal-close"),spawnNameInput:document.getElementById("spawn-name-input"),spawnCliInput:document.getElementById("spawn-cli-input"),spawnTaskInput:document.getElementById("spawn-task-input"),spawnSubmitBtn:document.getElementById("spawn-submit-btn"),spawnStatus:document.getElementById("spawn-status"),viewToggle:document.getElementById("view-toggle"),viewToggleLocal:document.querySelector('[data-view="local"]'),viewToggleFleet:document.querySelector('[data-view="fleet"]'),peerCount:document.getElementById("peer-count"),serversSection:document.getElementById("servers-section"),serversList:document.getElementById("servers-list")},a}function O(){return a}function ue(){i.isConnected?a.connectionDot.classList.remove("offline"):a.connectionDot.classList.add("offline")}function b(){console.log("[UI] renderAgents called, agents:",i.agents.length,i.agents.map(n=>n.name));let t=new Set(j.map(n=>n.name)),e=i.agents.map(n=>{let o=M(n.lastSeen||n.lastActive)?"online":"",r=i.currentChannel===n.name,c=n.needsAttention?"needs-attention":"",u=t.has(n.name),E=u?` - - - - `:"",C=u?` - - `:"";return` -
  • -
    - ${f(n.name)} - -
    - ${l(n.name)} - ${E} - ${n.needsAttention?'Needs Input':""} - ${C} -
  • - `}).join("");a.agentsList.innerHTML=e||'
  • No agents connected
  • ',a.agentsList.querySelectorAll(".channel-item[data-agent]").forEach(n=>{n.addEventListener("click",s=>{if(s.target.closest(".release-btn"))return;let o=n.dataset.agent;o&&g(o)})}),a.agentsList.querySelectorAll(".release-btn[data-release]").forEach(n=>{n.addEventListener("click",async s=>{s.stopPropagation();let o=n.dataset.release;o&&confirm(`Release agent "${o}"? This will terminate the agent.`)&&await Le(o)})}),me()}function q(){let t=ne();if(t.length===0){a.messagesList.innerHTML=` -
    - - - -
    No messages yet
    -
    - ${i.currentChannel==="general"?"Messages between agents will appear here":`Messages with ${i.currentChannel} will appear here`} -
    -
    - `;return}let e="",n=null;t.forEach(s=>{let o=new Date(s.timestamp).toDateString();o!==n&&(e+=` -
    - ${le(s.timestamp)} -
    - `,n=o);let r=s.to==="*",c=v(s.from),u=ae(s.id),E=r?"@everyone":s.project?`${l(s.project)}@${l(s.to)}`:`@${l(s.to)}`;e+=` -
    -
    - ${f(s.from)} -
    -
    -
    - @${l(s.from)} - - \u2192 ${E} - - ${H(s.timestamp)} -
    -
    ${P(s.content)}
    - ${s.thread?` -
    - - - - Thread: ${l(s.thread)} -
    - `:""} - ${u>0?` -
    - - - - ${u} ${u===1?"reply":"replies"} -
    - `:""} -
    -
    - - -
    -
    - `}),a.messagesList.innerHTML=e,He()}function g(t){Z(t),a.channelsList.querySelectorAll(".channel-item").forEach(n=>{n.classList.toggle("active",n.dataset.channel===t)}),a.agentsList.querySelectorAll(".channel-item").forEach(n=>{n.classList.toggle("active",n.dataset.agent===t)});let e=document.querySelector(".channel-header-name .prefix");if(t==="general")a.currentChannelName.innerHTML="general",a.channelTopic.textContent="All agent communications",e&&(e.textContent="#");else{a.currentChannelName.innerHTML=l(t);let n=i.agents.find(s=>s.name===t);a.channelTopic.textContent=n?.status||"Direct messages",e&&(e.textContent="@")}a.messageInput.placeholder=t==="general"?"@AgentName message... (or @* to broadcast)":`Message ${t}... (@ not required)`,q()}function pe(){let t=i.agents.filter(e=>M(e.lastSeen||e.lastActive)).length;a.onlineCount.textContent=`${t} online`}function me(){let t=i.agents.map(s=>{let o=M(s.lastSeen||s.lastActive);return` -
    -
    -
    - ${f(s.name)} - -
    -
    -
    -
    ${l(s.name)}
    -
    ${o?"Online":"Offline"}
    -
    -
    - `}).join(""),e=a.paletteAgentsSection;e.querySelectorAll(".palette-item").forEach(s=>s.remove()),e.insertAdjacentHTML("beforeend",t),e.querySelectorAll(".palette-item[data-jump-agent]").forEach(s=>{s.addEventListener("click",()=>{let o=s.dataset.jumpAgent;o&&(g(o),m())})})}function ge(){a.paletteChannelsSection.querySelectorAll(".palette-item[data-jump-channel]").forEach(t=>{t.addEventListener("click",()=>{let e=t.dataset.jumpChannel;e&&(g(e),m())})})}function V(){a.commandPaletteOverlay.classList.add("visible"),a.paletteSearch.value="",a.paletteSearch.focus(),d=-1,R("")}function ve(){return Array.from(a.paletteResults.querySelectorAll(".palette-item")).filter(e=>e.style.display!=="none")}function ce(){let t=ve();if(t.forEach(e=>e.classList.remove("selected")),d>=0&&d0?d-1:e.length-1,ce();break;case"Enter":t.preventDefault(),d>=0&&dr.classList.remove("highlighted"),2e3)),m();return}}function m(){a.commandPaletteOverlay.classList.remove("visible")}function R(t){let e=t.toLowerCase();if(d=-1,document.querySelectorAll(".palette-item[data-command]").forEach(n=>{let o=n.querySelector(".palette-item-title")?.textContent?.toLowerCase()||"";n.style.display=o.includes(e)?"flex":"none"}),document.querySelectorAll(".palette-item[data-jump-channel]").forEach(n=>{let o=n.querySelector(".palette-item-title")?.textContent?.toLowerCase()||"";n.style.display=o.includes(e)?"flex":"none"}),document.querySelectorAll(".palette-item[data-jump-agent]").forEach(n=>{let s=n.dataset.jumpAgent?.toLowerCase()||"";n.style.display=s.includes(e)?"flex":"none"}),e.length>=2){let n=i.messages.filter(s=>s.content.toLowerCase().includes(e)).slice(0,5);if(n.length>0){a.paletteMessagesSection.style.display="block";let s=n.map(r=>` -
    -
    - - - -
    -
    -
    ${l(r.from)}
    -
    ${l(r.content.substring(0,60))}${r.content.length>60?"...":""}
    -
    -
    - `).join("");a.paletteMessagesSection.querySelectorAll(".palette-item").forEach(r=>r.remove()),a.paletteMessagesSection.insertAdjacentHTML("beforeend",s)}else a.paletteMessagesSection.style.display="none"}else a.paletteMessagesSection.style.display="none"}function F(t){$(t),a.threadPanelId.textContent=t,a.threadPanelOverlay.classList.add("visible"),a.threadMessageInput.value="",_(t),a.threadMessageInput.focus()}function K(){$(null),a.threadPanelOverlay.classList.remove("visible")}function _(t){let e=se(t);if(e.length===0){a.threadMessages.innerHTML=` -
    -

    No messages in this thread yet.

    -

    Start the conversation below!

    -
    - `;return}let n=e.map(s=>` -
    -
    -
    - ${f(s.from)} -
    - ${l(s.from)} - ${H(s.timestamp)} -
    -
    ${P(s.content)}
    -
    - `).join("");a.threadMessages.innerHTML=n,a.threadMessages.scrollTop=a.threadMessages.scrollHeight}function He(){a.messagesList.querySelectorAll(".thread-indicator").forEach(t=>{t.style.cursor="pointer",t.addEventListener("click",e=>{e.stopPropagation();let n=t.dataset.thread;n&&F(n)})}),a.messagesList.querySelectorAll(".reply-count-badge").forEach(t=>{t.addEventListener("click",e=>{e.stopPropagation();let n=t.dataset.thread;n&&F(n)})}),a.messagesList.querySelectorAll('.message-action-btn[data-action="reply"]').forEach(t=>{t.addEventListener("click",e=>{e.stopPropagation();let n=t.closest(".message")?.getAttribute("data-id");n&&F(n)})})}var p=0,A=[];function he(t){let e=t.toLowerCase();A=i.agents.filter(s=>s.name.toLowerCase().includes(e)),p=0;let n="";("*".includes(e)||"everyone".includes(e)||"all".includes(e)||"broadcast".includes(e))&&(n+=` -
    -
    *
    - @everyone - Broadcast to all -
    - `),A.forEach((s,o)=>{n+=` -
    -
    - ${f(s.name)} -
    - @${l(s.name)} - ${l(s.role||"Agent")} -
    - `}),n===""&&(n='
    No matching agents
    '),a.mentionAutocompleteList.innerHTML=n,a.mentionAutocomplete.classList.add("visible"),a.mentionAutocompleteList.querySelectorAll(".mention-autocomplete-item[data-mention]").forEach(s=>{s.addEventListener("click",()=>{let o=s.dataset.mention;o&&W(o)})})}function y(){a.mentionAutocomplete.classList.remove("visible"),A=[],p=0}function ye(){return a.mentionAutocomplete.classList.contains("visible")}function U(t){let e=a.mentionAutocompleteList.querySelectorAll(".mention-autocomplete-item[data-mention]");e.length!==0&&(e[p]?.classList.remove("selected"),t==="down"?p=(p+1)%e.length:p=(p-1+e.length)%e.length,e[p]?.classList.add("selected"),e[p]?.scrollIntoView({block:"nearest"}))}function W(t){let e=a.mentionAutocompleteList.querySelectorAll(".mention-autocomplete-item[data-mention]"),n=t;if(!n&&e.length>0&&(n=e[p]?.dataset.mention),!n){y();return}let s=a.messageInput,o=s.value,r=o.match(/^@\S*/);if(r){let c=`@${n} `;s.value=c+o.substring(r[0].length),s.selectionStart=s.selectionEnd=c.length}y(),s.focus()}function we(){let t=a.messageInput,e=t.value,n=t.selectionStart,s=e.match(/^@(\S*)/);return s&&n<=s[0].length?s[1]:null}function Ee(){a.spawnModalOverlay.classList.add("visible"),a.spawnNameInput.value="",a.spawnCliInput.value="claude",a.spawnTaskInput.value="",a.spawnStatus.textContent="",a.spawnStatus.className="spawn-status",a.spawnNameInput.focus()}function w(){a.spawnModalOverlay.classList.remove("visible")}async function z(){let t=a.spawnNameInput.value.trim(),e=a.spawnCliInput.value.trim()||"claude",n=a.spawnTaskInput.value.trim();if(!t)return a.spawnStatus.textContent="Agent name is required",a.spawnStatus.className="spawn-status error",{success:!1,error:"Agent name is required"};a.spawnSubmitBtn.disabled=!0,a.spawnStatus.textContent="Spawning agent...",a.spawnStatus.className="spawn-status loading";try{let s=await fetch("/api/spawn",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:t,cli:e,task:n})}),o=await s.json();if(s.ok&&o.success)return a.spawnStatus.textContent=`Agent "${t}" spawned successfully!`,a.spawnStatus.className="spawn-status success",await T(),setTimeout(()=>{w()},1e3),{success:!0};throw new Error(o.error||"Failed to spawn agent")}catch(s){return a.spawnStatus.textContent=s.message||"Failed to spawn agent",a.spawnStatus.className="spawn-status error",{success:!1,error:s.message}}finally{a.spawnSubmitBtn.disabled=!1}}async function T(){try{let e=await(await fetch("/api/spawned")).json();e.success&&Array.isArray(e.agents)&&(j=e.agents,b())}catch(t){console.error("[UI] Failed to fetch spawned agents:",t)}}async function Le(t){try{let n=await(await fetch(`/api/spawned/${encodeURIComponent(t)}`,{method:"DELETE"})).json();n.success?await T():console.error("[UI] Failed to release agent:",n.error)}catch(e){console.error("[UI] Failed to release agent:",e)}}function Me(){a.viewToggleLocal?.addEventListener("click",()=>{N("local")}),a.viewToggleFleet?.addEventListener("click",()=>{N("fleet")})}function N(t){oe(t),a.viewToggleLocal?.classList.toggle("active",t==="local"),a.viewToggleFleet?.classList.toggle("active",t==="fleet"),a.serversSection&&(a.serversSection.style.display=t==="fleet"?"block":"none"),b(),t==="fleet"&&J()}function be(){let t=x(),e=t&&t.servers.length>0;a.viewToggle&&(a.viewToggle.style.display=e?"flex":"none"),a.peerCount&&t&&(a.peerCount.textContent=String(t.servers.length)),!e&&L()==="fleet"&&N("local")}function J(){let t=x();if(!t||t.servers.length===0){a.serversList&&(a.serversList.innerHTML='
  • No peer servers connected
  • ');return}let e=t.servers.map(n=>{let s=n.id===t.localServerId,o=n.connected?"":"offline";return` -
  • -
    - - - - - - -
    - ${l(n.name)}${s?" (local)":""} - ${n.agentCount} -
  • - `}).join("");a.serversList&&(a.serversList.innerHTML=e)}function Se(){let t=L(),e=x();if(t!=="fleet"||!e){b();return}let n=e.agents,s=new Set(j.map(r=>r.name)),o=n.map(r=>{let c=M(r.lastSeen||r.lastActive),u=c?"online":"",E=i.currentChannel===r.name,C=r.needsAttention?"needs-attention":"",I=s.has(r.name),Q=r.isLocal,Ce=` - - - ${l(r.serverName||r.server)} - - `,Ie=Q?"":` - - `,ke=I?` - - - - `:"",$e=I?` - - `:"";return` -
  • -
    - ${f(r.name)} - - ${Ie} -
    - ${Ce} - ${l(r.name)} - ${ke} - ${r.needsAttention?'Needs Input':""} - ${$e} -
  • - `}).join("");a.agentsList.innerHTML=o||'
  • No agents in fleet
  • ',Pe(),me()}function Pe(){a.agentsList.querySelectorAll(".channel-item[data-agent]").forEach(t=>{t.addEventListener("click",e=>{if(e.target.closest(".release-btn"))return;let n=t.dataset.agent;n&&g(n)})}),a.agentsList.querySelectorAll(".release-btn[data-release]").forEach(t=>{t.addEventListener("click",async e=>{e.stopPropagation();let n=t.dataset.release;n&&confirm(`Release agent "${n}"? This will terminate the agent.`)&&await Le(n)})})}function xe(){let t=de();Y(()=>{ue(),L()==="fleet"?(Se(),J()):b(),q(),pe(),be()}),Fe(t),Me(),B(),T()}function Fe(t){t.channelsList.querySelectorAll(".channel-item").forEach(e=>{e.addEventListener("click",()=>{let n=e.dataset.channel;n&&g(n)})}),t.sendBtn.addEventListener("click",Ae),t.messageInput.addEventListener("keydown",e=>{if(ye()){if(e.key==="Tab"||e.key==="Enter"){e.preventDefault(),W();return}if(e.key==="ArrowUp"){e.preventDefault(),U("up");return}if(e.key==="ArrowDown"){e.preventDefault(),U("down");return}if(e.key==="Escape"){e.preventDefault(),y();return}}e.key==="Enter"&&!e.shiftKey&&(e.preventDefault(),Ae())}),t.messageInput.addEventListener("input",()=>{t.messageInput.style.height="auto",t.messageInput.style.height=Math.min(t.messageInput.scrollHeight,200)+"px";let e=we();e!==null?he(e):y()}),t.messageInput.addEventListener("blur",()=>{setTimeout(()=>{y()},150)}),t.boldBtn.addEventListener("click",()=>{let e=t.messageInput,n=e.selectionStart,s=e.selectionEnd,o=e.value;if(n===s){let r=o.substring(0,n),c=o.substring(s);e.value=r+"**bold**"+c,e.selectionStart=n+2,e.selectionEnd=n+6}else{let r=o.substring(0,n),c=o.substring(n,s),u=o.substring(s);e.value=r+"**"+c+"**"+u,e.selectionStart=n,e.selectionEnd=s+4}e.focus()}),t.emojiBtn.addEventListener("click",()=>{let e=["\u{1F44D}","\u{1F44E}","\u2705","\u274C","\u{1F389}","\u{1F525}","\u{1F4A1}","\u26A0\uFE0F","\u{1F4DD}","\u{1F680}"],n=e[Math.floor(Math.random()*e.length)],s=t.messageInput,o=s.selectionStart,r=s.value;s.value=r.substring(0,o)+n+r.substring(o),s.selectionStart=s.selectionEnd=o+n.length,s.focus()}),t.searchTrigger.addEventListener("click",V),document.addEventListener("keydown",e=>{(e.ctrlKey||e.metaKey)&&e.key==="k"&&(e.preventDefault(),t.commandPaletteOverlay.classList.contains("visible")?m():V()),e.key==="Escape"&&m()}),t.commandPaletteOverlay.addEventListener("click",e=>{e.target===t.commandPaletteOverlay&&m()}),t.paletteSearch.addEventListener("input",e=>{let n=e.target;R(n.value)}),t.paletteSearch.addEventListener("keydown",fe),document.querySelectorAll(".palette-item[data-command]").forEach(e=>{e.addEventListener("click",()=>{let n=e.dataset.command;n==="broadcast"?(t.messageInput.value="@* ",t.messageInput.focus()):n==="clear"&&(t.messagesList.innerHTML=""),m()})}),ge(),t.threadPanelClose.addEventListener("click",K),t.threadSendBtn.addEventListener("click",Te),t.threadMessageInput.addEventListener("keydown",e=>{e.key==="Enter"&&!e.shiftKey&&(e.preventDefault(),Te())}),document.addEventListener("keydown",e=>{e.key==="Escape"&&t.threadPanelOverlay.classList.contains("visible")&&K()}),t.spawnBtn.addEventListener("click",Ee),t.spawnModalClose.addEventListener("click",w),document.getElementById("spawn-cancel-btn")?.addEventListener("click",w),t.spawnModalOverlay.addEventListener("click",e=>{e.target===t.spawnModalOverlay&&w()}),document.addEventListener("keydown",e=>{e.key==="Escape"&&t.spawnModalOverlay.classList.contains("visible")&&w()}),t.spawnSubmitBtn.addEventListener("click",z),t.spawnNameInput.addEventListener("keydown",e=>{e.key==="Enter"&&!e.shiftKey&&(e.preventDefault(),z())})}function Ne(t){let n=t.trim().match(/^@(\*|[^\s]+)\s+(.+)$/s);return n?{to:n[1],message:n[2].trim()}:null}async function Ae(){let t=O(),e=t.messageInput.value.trim();if(!e)return;let n,s,o=i.currentChannel!=="general",r=Ne(e);if(r)n=r.to,s=r.message;else if(o)n=i.currentChannel,s=e;else{alert('Message must start with @recipient (e.g., "@Lead hello" or "@* broadcast")');return}t.sendBtn.disabled=!0;let c=await D(n,s);c.success?(t.messageInput.value="",t.messageInput.style.height="auto"):alert(c.error),t.sendBtn.disabled=!1}async function Te(){let t=O(),e=t.threadMessageInput.value.trim(),n=i.currentThread;if(!e||!n)return;t.threadSendBtn.disabled=!0;let s=await D("*",e,n);s.success?(t.threadMessageInput.value="",_(n)):alert(s.error),t.threadSendBtn.disabled=!1}typeof document<"u"&&(document.readyState==="loading"?document.addEventListener("DOMContentLoaded",xe):xe());export{xe as initApp}; -//# sourceMappingURL=app.js.map diff --git a/src/dashboard/public/js/app.js.map b/src/dashboard/public/js/app.js.map deleted file mode 100644 index dde2da813..000000000 --- a/src/dashboard/public/js/app.js.map +++ /dev/null @@ -1,7 +0,0 @@ -{ - "version": 3, - "sources": ["../../frontend/state.ts", "../../frontend/websocket.ts", "../../frontend/utils.ts", "../../frontend/components.ts", "../../frontend/app.ts"], - "sourcesContent": ["/**\n * Dashboard State Management\n */\n\nimport type { Agent, Message, AppState, ChannelType, ViewMode, FleetData, FleetAgent, PeerServer } from './types.js';\n\n/**\n * Global application state\n */\nexport const state: AppState = {\n agents: [],\n messages: [],\n currentChannel: 'general',\n currentThread: null,\n isConnected: false,\n ws: null,\n reconnectAttempts: 0,\n viewMode: 'local',\n fleetData: null,\n};\n\n/**\n * State update callbacks\n */\ntype StateListener = () => void;\nconst listeners: StateListener[] = [];\n\n/**\n * Subscribe to state changes\n */\nexport function subscribe(listener: StateListener): () => void {\n listeners.push(listener);\n return () => {\n const index = listeners.indexOf(listener);\n if (index > -1) {\n listeners.splice(index, 1);\n }\n };\n}\n\n/**\n * Notify all listeners of state change\n */\nfunction notifyListeners(): void {\n listeners.forEach((listener) => listener());\n}\n\n/**\n * Update agents in state\n */\nexport function setAgents(agents: Agent[]): void {\n state.agents = agents;\n notifyListeners();\n}\n\n/**\n * Update messages in state\n */\nexport function setMessages(messages: Message[]): void {\n state.messages = messages;\n notifyListeners();\n}\n\n/**\n * Set current channel/conversation\n */\nexport function setCurrentChannel(channel: ChannelType): void {\n state.currentChannel = channel;\n notifyListeners();\n}\n\n/**\n * Update connection status\n */\nexport function setConnectionStatus(connected: boolean): void {\n state.isConnected = connected;\n if (connected) {\n state.reconnectAttempts = 0;\n }\n notifyListeners();\n}\n\n/**\n * Increment reconnect attempts\n */\nexport function incrementReconnectAttempts(): void {\n state.reconnectAttempts++;\n}\n\n/**\n * Set WebSocket instance\n */\nexport function setWebSocket(ws: WebSocket | null): void {\n state.ws = ws;\n}\n\n/**\n * Filter messages based on current channel\n */\nexport function getFilteredMessages(): Message[] {\n const { messages, currentChannel } = state;\n\n if (currentChannel === 'general') {\n return messages;\n }\n\n // Filter for specific agent - show messages to/from that agent\n return messages.filter(\n (m) => m.from === currentChannel || m.to === currentChannel\n );\n}\n\n/**\n * Set current thread for thread panel\n */\nexport function setCurrentThread(thread: string | null): void {\n state.currentThread = thread;\n}\n\n/**\n * Get messages for a specific thread\n */\nexport function getThreadMessages(threadId: string): Message[] {\n return state.messages.filter((m) => m.thread === threadId);\n}\n\n/**\n * Get reply count for a thread\n */\nexport function getThreadReplyCount(threadId: string): number {\n return state.messages.filter((m) => m.thread === threadId).length;\n}\n\n// ========================================\n// Fleet View State Management\n// ========================================\n\n/**\n * Set view mode (local or fleet)\n */\nexport function setViewMode(mode: ViewMode): void {\n state.viewMode = mode;\n notifyListeners();\n}\n\n/**\n * Get current view mode\n */\nexport function getViewMode(): ViewMode {\n return state.viewMode;\n}\n\n/**\n * Update fleet data\n */\nexport function setFleetData(data: FleetData | null): void {\n state.fleetData = data;\n notifyListeners();\n}\n\n/**\n * Get fleet data\n */\nexport function getFleetData(): FleetData | null {\n return state.fleetData;\n}\n\n/**\n * Get agents for current view mode\n * Returns local agents or fleet agents depending on view mode\n */\nexport function getAgentsForCurrentView(): (Agent | FleetAgent)[] {\n if (state.viewMode === 'fleet' && state.fleetData) {\n return state.fleetData.agents;\n }\n return state.agents;\n}\n\n/**\n * Check if fleet view is available (has peer connections)\n */\nexport function isFleetAvailable(): boolean {\n return state.fleetData !== null && state.fleetData.servers.length > 0;\n}\n", "/**\n * WebSocket Connection Handler\n */\n\nimport type { DashboardData, FleetData } from './types.js';\nimport {\n state,\n setAgents,\n setMessages,\n setConnectionStatus,\n setWebSocket,\n incrementReconnectAttempts,\n setFleetData,\n} from './state.js';\n\ntype DataHandler = (data: DashboardData) => void;\n\nlet dataHandler: DataHandler | null = null;\n\n/**\n * Set the handler for incoming data\n */\nexport function onData(handler: DataHandler): void {\n dataHandler = handler;\n}\n\n/**\n * Connect to the WebSocket server\n */\nexport function connect(): void {\n const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';\n const ws = new WebSocket(`${protocol}//${window.location.host}/ws`);\n\n ws.onopen = (): void => {\n setConnectionStatus(true);\n };\n\n ws.onclose = (): void => {\n setConnectionStatus(false);\n // Reconnect with exponential backoff\n const delay = Math.min(1000 * Math.pow(2, state.reconnectAttempts), 30000);\n incrementReconnectAttempts();\n setTimeout(connect, delay);\n };\n\n ws.onerror = (error): void => {\n console.error('WebSocket error:', error);\n };\n\n ws.onmessage = (event: MessageEvent): void => {\n try {\n const data: DashboardData = JSON.parse(event.data as string);\n handleData(data);\n } catch (e) {\n console.error('Failed to parse message:', e);\n }\n };\n\n setWebSocket(ws);\n}\n\n/**\n * Extended dashboard data with fleet support\n */\ninterface ExtendedDashboardData extends DashboardData {\n fleet?: FleetData;\n}\n\n/**\n * Handle incoming dashboard data\n */\nfunction handleData(data: ExtendedDashboardData): void {\n console.log('[WS] Received data:', { agentCount: data.agents?.length, messageCount: data.messages?.length, hasFleet: !!data.fleet });\n\n if (data.agents) {\n console.log('[WS] Setting agents:', data.agents.map(a => a.name));\n setAgents(data.agents);\n }\n\n if (data.messages) {\n setMessages(data.messages);\n }\n\n // Handle fleet data for multi-server view\n if (data.fleet) {\n console.log('[WS] Setting fleet data:', { servers: data.fleet.servers?.length, agents: data.fleet.agents?.length });\n setFleetData(data.fleet);\n }\n\n if (dataHandler) {\n dataHandler(data);\n }\n}\n\n/**\n * Send a message via the REST API\n */\nexport async function sendMessage(\n to: string,\n message: string,\n thread?: string\n): Promise<{ success: boolean; error?: string }> {\n try {\n const body: { to: string; message: string; thread?: string } = { to, message };\n if (thread) {\n body.thread = thread;\n }\n\n const response = await fetch('/api/send', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(body),\n });\n\n const result = await response.json();\n\n if (response.ok && result.success) {\n return { success: true };\n } else {\n return { success: false, error: result.error || 'Failed to send message' };\n }\n } catch (err) {\n return { success: false, error: 'Network error - could not send message' };\n }\n}\n", "/**\n * Dashboard Utility Functions\n */\n\n/** Threshold for considering an agent offline (45 seconds)\n * This is intentionally 1.5x the heartbeat timeout (30s) to prevent flickering\n * when heartbeats are slightly delayed */\nexport const STALE_THRESHOLD_MS = 45000;\n\n/**\n * Check if an agent is online based on last seen timestamp\n */\nexport function isAgentOnline(lastSeen: string | undefined): boolean {\n if (!lastSeen) return false;\n const ts = Date.parse(lastSeen);\n if (Number.isNaN(ts)) return false;\n return Date.now() - ts < STALE_THRESHOLD_MS;\n}\n\n/**\n * Escape HTML to prevent XSS\n */\nexport function escapeHtml(text: string | undefined): string {\n if (!text) return '';\n const div = document.createElement('div');\n div.textContent = text;\n return div.innerHTML;\n}\n\n/**\n * Format timestamp to locale time string\n */\nexport function formatTime(timestamp: string): string {\n const date = new Date(timestamp);\n return date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });\n}\n\n/**\n * Format timestamp to human-readable date\n */\nexport function formatDate(timestamp: string): string {\n const date = new Date(timestamp);\n const today = new Date();\n const yesterday = new Date(today);\n yesterday.setDate(yesterday.getDate() - 1);\n\n if (date.toDateString() === today.toDateString()) {\n return 'Today';\n } else if (date.toDateString() === yesterday.toDateString()) {\n return 'Yesterday';\n } else {\n return date.toLocaleDateString([], {\n weekday: 'long',\n month: 'long',\n day: 'numeric',\n });\n }\n}\n\n/**\n * Generate a consistent color for an agent based on their name\n */\nexport function getAvatarColor(name: string): string {\n const colors = [\n '#e01e5a',\n '#2bac76',\n '#e8a427',\n '#1264a3',\n '#7c3aed',\n '#0d9488',\n '#dc2626',\n '#9333ea',\n '#ea580c',\n '#0891b2',\n ];\n let hash = 0;\n for (let i = 0; i < name.length; i++) {\n hash = name.charCodeAt(i) + ((hash << 5) - hash);\n }\n return colors[Math.abs(hash) % colors.length];\n}\n\n/**\n * Get initials from a name (first 2 characters, uppercase)\n */\nexport function getInitials(name: string): string {\n return name.substring(0, 2).toUpperCase();\n}\n\n/**\n * Format message body with basic markdown-like formatting\n * Note: CSS uses white-space: pre-wrap to handle newlines naturally\n */\nexport function formatMessageBody(content: string | undefined): string {\n if (!content) return '';\n\n let escaped = escapeHtml(content);\n\n // Simple code block detection (must process before inline code)\n // Handle code blocks with optional language specifier: ```js\\n... ``` or ```...\\n```\n // The language specifier must be followed by a newline to be recognized\n escaped = escaped.replace(/```(\\w+)?\\n([\\s\\S]*?)```/g, (_match, _lang, code) => {\n return `
    ${code.trim()}
    `;\n });\n\n // Handle inline code blocks without newlines: ```code here```\n escaped = escaped.replace(/```([^`\\n]+)```/g, '
    $1
    ');\n\n // Inline code: `code`\n escaped = escaped.replace(/`([^`]+)`/g, '$1');\n\n // Bold: **text** or __text__\n escaped = escaped.replace(/\\*\\*([^*]+)\\*\\*/g, '$1');\n escaped = escaped.replace(/__([^_]+)__/g, '$1');\n\n // Italic: *text* or _text_ (not inside bold)\n escaped = escaped.replace(/(?$1');\n\n // Don't convert newlines to
    - CSS white-space: pre-wrap handles this\n // This was causing double line breaks before\n\n return escaped;\n}\n", "/**\n * Dashboard UI Components\n */\n\nimport type { Agent, Message, DOMElements, ChannelType, SpawnedAgent, FleetAgent, PeerServer, ViewMode } from './types.js';\nimport { state, getFilteredMessages, setCurrentChannel, setCurrentThread, getThreadMessages, getThreadReplyCount, setViewMode, getViewMode, getFleetData, isFleetAvailable, getAgentsForCurrentView } from './state.js';\nimport {\n escapeHtml,\n formatTime,\n formatDate,\n getAvatarColor,\n getInitials,\n formatMessageBody,\n isAgentOnline,\n} from './utils.js';\n\n// Track spawned agents\nlet spawnedAgents: SpawnedAgent[] = [];\n\nlet elements: DOMElements;\nlet paletteSelectedIndex = -1;\n\n/**\n * Initialize DOM element references\n */\nexport function initElements(): DOMElements {\n elements = {\n connectionDot: document.getElementById('connection-dot')!,\n channelsList: document.getElementById('channels-list')!,\n agentsList: document.getElementById('agents-list')!,\n messagesList: document.getElementById('messages-list')!,\n currentChannelName: document.getElementById('current-channel-name')!,\n channelTopic: document.getElementById('channel-topic')!,\n onlineCount: document.getElementById('online-count')!,\n messageInput: document.getElementById('message-input') as HTMLTextAreaElement,\n sendBtn: document.getElementById('send-btn') as HTMLButtonElement,\n boldBtn: document.getElementById('bold-btn') as HTMLButtonElement,\n emojiBtn: document.getElementById('emoji-btn') as HTMLButtonElement,\n searchTrigger: document.getElementById('search-trigger')!,\n commandPaletteOverlay: document.getElementById('command-palette-overlay')!,\n paletteSearch: document.getElementById('palette-search') as HTMLInputElement,\n paletteResults: document.getElementById('palette-results')!,\n paletteChannelsSection: document.getElementById('palette-channels-section')!,\n paletteAgentsSection: document.getElementById('palette-agents-section')!,\n paletteMessagesSection: document.getElementById('palette-messages-section')!,\n typingIndicator: document.getElementById('typing-indicator')!,\n threadPanelOverlay: document.getElementById('thread-panel-overlay')!,\n threadPanelId: document.getElementById('thread-panel-id')!,\n threadPanelClose: document.getElementById('thread-panel-close') as HTMLButtonElement,\n threadMessages: document.getElementById('thread-messages')!,\n threadMessageInput: document.getElementById('thread-message-input') as HTMLTextAreaElement,\n threadSendBtn: document.getElementById('thread-send-btn') as HTMLButtonElement,\n mentionAutocomplete: document.getElementById('mention-autocomplete')!,\n mentionAutocompleteList: document.getElementById('mention-autocomplete-list')!,\n // Spawn modal elements\n spawnBtn: document.getElementById('spawn-btn') as HTMLButtonElement,\n spawnModalOverlay: document.getElementById('spawn-modal-overlay')!,\n spawnModalClose: document.getElementById('spawn-modal-close') as HTMLButtonElement,\n spawnNameInput: document.getElementById('spawn-name-input') as HTMLInputElement,\n spawnCliInput: document.getElementById('spawn-cli-input') as HTMLInputElement,\n spawnTaskInput: document.getElementById('spawn-task-input') as HTMLTextAreaElement,\n spawnSubmitBtn: document.getElementById('spawn-submit-btn') as HTMLButtonElement,\n spawnStatus: document.getElementById('spawn-status')!,\n // Fleet view elements\n viewToggle: document.getElementById('view-toggle')!,\n viewToggleLocal: document.querySelector('[data-view=\"local\"]') as HTMLButtonElement,\n viewToggleFleet: document.querySelector('[data-view=\"fleet\"]') as HTMLButtonElement,\n peerCount: document.getElementById('peer-count')!,\n serversSection: document.getElementById('servers-section')!,\n serversList: document.getElementById('servers-list')!,\n };\n return elements;\n}\n\n/**\n * Get DOM elements\n */\nexport function getElements(): DOMElements {\n return elements;\n}\n\n/**\n * Update connection status indicator\n */\nexport function updateConnectionStatus(): void {\n if (state.isConnected) {\n elements.connectionDot.classList.remove('offline');\n } else {\n elements.connectionDot.classList.add('offline');\n }\n}\n\n/**\n * Render agents list in sidebar\n */\nexport function renderAgents(): void {\n console.log('[UI] renderAgents called, agents:', state.agents.length, state.agents.map(a => a.name));\n\n // Create a set of spawned agent names for quick lookup\n const spawnedNames = new Set(spawnedAgents.map(a => a.name));\n\n const html = state.agents\n .map((agent) => {\n const online = isAgentOnline(agent.lastSeen || agent.lastActive);\n const presenceClass = online ? 'online' : '';\n const isActive = state.currentChannel === agent.name;\n const needsAttentionClass = agent.needsAttention ? 'needs-attention' : '';\n const isSpawned = spawnedNames.has(agent.name);\n\n // Spawned icon SVG (play/launch icon)\n const spawnedIcon = isSpawned ? `\n \n \n \n ` : '';\n\n // Release button for spawned agents\n const releaseBtn = isSpawned ? `\n \n ` : '';\n\n return `\n
  • \n
    \n ${getInitials(agent.name)}\n \n
    \n ${escapeHtml(agent.name)}\n ${spawnedIcon}\n ${agent.needsAttention ? 'Needs Input' : ''}\n ${releaseBtn}\n
  • \n `;\n })\n .join('');\n\n elements.agentsList.innerHTML =\n html ||\n '
  • No agents connected
  • ';\n\n // Add click handlers for agent selection\n elements.agentsList.querySelectorAll('.channel-item[data-agent]').forEach((item) => {\n item.addEventListener('click', (e) => {\n // Don't select channel if clicking release button\n if ((e.target as HTMLElement).closest('.release-btn')) {\n return;\n }\n const agentName = item.dataset.agent;\n if (agentName) {\n selectChannel(agentName);\n }\n });\n });\n\n // Add release button click handlers\n elements.agentsList.querySelectorAll('.release-btn[data-release]').forEach((btn) => {\n btn.addEventListener('click', async (e) => {\n e.stopPropagation();\n const agentName = btn.dataset.release;\n if (agentName && confirm(`Release agent \"${agentName}\"? This will terminate the agent.`)) {\n await releaseAgent(agentName);\n }\n });\n });\n\n // Update command palette agents\n updatePaletteAgents();\n}\n\n/**\n * Render messages list\n */\nexport function renderMessages(): void {\n const filtered = getFilteredMessages();\n\n if (filtered.length === 0) {\n elements.messagesList.innerHTML = `\n
    \n \n \n \n
    No messages yet
    \n
    \n ${\n state.currentChannel === 'general'\n ? 'Messages between agents will appear here'\n : `Messages with ${state.currentChannel} will appear here`\n }\n
    \n
    \n `;\n return;\n }\n\n let html = '';\n let lastDate: string | null = null;\n\n filtered.forEach((msg) => {\n const msgDate = new Date(msg.timestamp).toDateString();\n\n // Add date divider if needed\n if (msgDate !== lastDate) {\n html += `\n
    \n ${formatDate(msg.timestamp)}\n
    \n `;\n lastDate = msgDate;\n }\n\n const isBroadcast = msg.to === '*';\n const avatarColor = getAvatarColor(msg.from);\n const replyCount = getThreadReplyCount(msg.id);\n\n // Format: @From \u2192 @To: message (like Slack)\n // For cross-project messages, show project badge before agent name\n const recipientDisplay = isBroadcast\n ? '@everyone'\n : msg.project\n ? `${escapeHtml(msg.project)}@${escapeHtml(msg.to)}`\n : `@${escapeHtml(msg.to)}`;\n\n html += `\n
    \n
    \n ${getInitials(msg.from)}\n
    \n
    \n
    \n @${escapeHtml(msg.from)}\n \n \u2192 ${recipientDisplay}\n \n ${formatTime(msg.timestamp)}\n
    \n
    ${formatMessageBody(msg.content)}
    \n ${\n msg.thread\n ? `\n
    \n \n \n \n Thread: ${escapeHtml(msg.thread)}\n
    \n `\n : ''\n }\n ${\n replyCount > 0\n ? `\n
    \n \n \n \n ${replyCount} ${replyCount === 1 ? 'reply' : 'replies'}\n
    \n `\n : ''\n }\n
    \n
    \n \n \n
    \n
    \n `;\n });\n\n elements.messagesList.innerHTML = html;\n\n // Note: Auto-scroll removed - interferes with manual scrolling through history\n\n // Attach thread click handlers\n attachThreadHandlers();\n}\n\n/**\n * Select a channel and update UI\n */\nexport function selectChannel(channel: ChannelType): void {\n setCurrentChannel(channel);\n\n // Update sidebar active states\n elements.channelsList.querySelectorAll('.channel-item').forEach((item) => {\n item.classList.toggle('active', item.dataset.channel === channel);\n });\n elements.agentsList.querySelectorAll('.channel-item').forEach((item) => {\n item.classList.toggle('active', item.dataset.agent === channel);\n });\n\n // Update header\n const prefixEl = document.querySelector('.channel-header-name .prefix');\n if (channel === 'general') {\n elements.currentChannelName.innerHTML = 'general';\n elements.channelTopic.textContent = 'All agent communications';\n if (prefixEl) prefixEl.textContent = '#';\n } else {\n elements.currentChannelName.innerHTML = escapeHtml(channel);\n const agent = state.agents.find((a) => a.name === channel);\n elements.channelTopic.textContent = agent?.status || 'Direct messages';\n if (prefixEl) prefixEl.textContent = '@';\n }\n\n // Update composer placeholder - DM mode doesn't require @mention\n elements.messageInput.placeholder =\n channel === 'general'\n ? '@AgentName message... (or @* to broadcast)'\n : `Message ${channel}... (@ not required)`;\n\n // Re-render messages\n renderMessages();\n}\n\n/**\n * Update online count display\n */\nexport function updateOnlineCount(): void {\n const online = state.agents.filter((a) => isAgentOnline(a.lastSeen || a.lastActive)).length;\n elements.onlineCount.textContent = `${online} online`;\n}\n\n/**\n * Update agents in command palette\n */\nexport function updatePaletteAgents(): void {\n const html = state.agents\n .map((agent) => {\n const online = isAgentOnline(agent.lastSeen || agent.lastActive);\n return `\n
    \n
    \n
    \n ${getInitials(agent.name)}\n \n
    \n
    \n
    \n
    ${escapeHtml(agent.name)}
    \n
    ${online ? 'Online' : 'Offline'}
    \n
    \n
    \n `;\n })\n .join('');\n\n const section = elements.paletteAgentsSection;\n const items = section.querySelectorAll('.palette-item');\n items.forEach((item) => item.remove());\n section.insertAdjacentHTML('beforeend', html);\n\n // Add click handlers\n section.querySelectorAll('.palette-item[data-jump-agent]').forEach((item) => {\n item.addEventListener('click', () => {\n const agentName = item.dataset.jumpAgent;\n if (agentName) {\n selectChannel(agentName);\n closeCommandPalette();\n }\n });\n });\n}\n\n/**\n * Initialize channel click handlers in command palette\n */\nexport function initPaletteChannels(): void {\n elements.paletteChannelsSection\n .querySelectorAll('.palette-item[data-jump-channel]')\n .forEach((item) => {\n item.addEventListener('click', () => {\n const channelName = item.dataset.jumpChannel;\n if (channelName) {\n selectChannel(channelName);\n closeCommandPalette();\n }\n });\n });\n}\n\n/**\n * Open command palette\n */\nexport function openCommandPalette(): void {\n elements.commandPaletteOverlay.classList.add('visible');\n elements.paletteSearch.value = '';\n elements.paletteSearch.focus();\n paletteSelectedIndex = -1;\n filterPaletteResults('');\n}\n\n/**\n * Get all visible palette items\n */\nexport function getVisiblePaletteItems(): HTMLElement[] {\n const allItems = Array.from(\n elements.paletteResults.querySelectorAll('.palette-item')\n );\n return allItems.filter((item) => item.style.display !== 'none');\n}\n\n/**\n * Update the selected palette item visually\n */\nexport function updatePaletteSelection(): void {\n const items = getVisiblePaletteItems();\n\n // Remove selection from all items\n items.forEach((item) => item.classList.remove('selected'));\n\n // Add selection to current item\n if (paletteSelectedIndex >= 0 && paletteSelectedIndex < items.length) {\n const selectedItem = items[paletteSelectedIndex];\n selectedItem.classList.add('selected');\n\n // Scroll into view if needed\n selectedItem.scrollIntoView({ block: 'nearest', behavior: 'smooth' });\n }\n}\n\n/**\n * Handle keyboard navigation in command palette\n */\nexport function handlePaletteKeydown(e: KeyboardEvent): void {\n const items = getVisiblePaletteItems();\n\n if (items.length === 0) return;\n\n switch (e.key) {\n case 'ArrowDown':\n e.preventDefault();\n paletteSelectedIndex = paletteSelectedIndex < items.length - 1\n ? paletteSelectedIndex + 1\n : 0;\n updatePaletteSelection();\n break;\n\n case 'ArrowUp':\n e.preventDefault();\n paletteSelectedIndex = paletteSelectedIndex > 0\n ? paletteSelectedIndex - 1\n : items.length - 1;\n updatePaletteSelection();\n break;\n\n case 'Enter':\n e.preventDefault();\n if (paletteSelectedIndex >= 0 && paletteSelectedIndex < items.length) {\n executePaletteItem(items[paletteSelectedIndex]);\n }\n break;\n }\n}\n\n/**\n * Execute the action for a palette item\n */\nexport function executePaletteItem(item: HTMLElement): void {\n // Check for command\n const command = item.dataset.command;\n if (command) {\n if (command === 'broadcast') {\n // Pre-fill message input with @* for broadcast\n elements.messageInput.value = '@* ';\n elements.messageInput.focus();\n } else if (command === 'clear') {\n elements.messagesList.innerHTML = '';\n }\n closeCommandPalette();\n return;\n }\n\n // Check for channel jump\n const channel = item.dataset.jumpChannel;\n if (channel) {\n selectChannel(channel);\n closeCommandPalette();\n return;\n }\n\n // Check for agent jump\n const agent = item.dataset.jumpAgent;\n if (agent) {\n selectChannel(agent);\n closeCommandPalette();\n return;\n }\n\n // Check for message jump\n const messageId = item.dataset.jumpMessage;\n if (messageId) {\n // Find and scroll to the message\n const messageEl = elements.messagesList.querySelector(`[data-id=\"${messageId}\"]`);\n if (messageEl) {\n messageEl.scrollIntoView({ behavior: 'smooth', block: 'center' });\n messageEl.classList.add('highlighted');\n setTimeout(() => messageEl.classList.remove('highlighted'), 2000);\n }\n closeCommandPalette();\n return;\n }\n}\n\n/**\n * Close command palette\n */\nexport function closeCommandPalette(): void {\n elements.commandPaletteOverlay.classList.remove('visible');\n}\n\n/**\n * Filter command palette results based on query\n */\nexport function filterPaletteResults(query: string): void {\n const q = query.toLowerCase();\n\n // Reset selection when filtering\n paletteSelectedIndex = -1;\n\n // Filter command items\n document.querySelectorAll('.palette-item[data-command]').forEach((item) => {\n const titleEl = item.querySelector('.palette-item-title');\n const title = titleEl?.textContent?.toLowerCase() || '';\n item.style.display = title.includes(q) ? 'flex' : 'none';\n });\n\n // Filter channel items\n document.querySelectorAll('.palette-item[data-jump-channel]').forEach((item) => {\n const titleEl = item.querySelector('.palette-item-title');\n const title = titleEl?.textContent?.toLowerCase() || '';\n item.style.display = title.includes(q) ? 'flex' : 'none';\n });\n\n // Filter agent items\n document.querySelectorAll('.palette-item[data-jump-agent]').forEach((item) => {\n const name = item.dataset.jumpAgent?.toLowerCase() || '';\n item.style.display = name.includes(q) ? 'flex' : 'none';\n });\n\n // Show message search if query is long enough\n if (q.length >= 2) {\n const matches = state.messages.filter((m) => m.content.toLowerCase().includes(q)).slice(0, 5);\n\n if (matches.length > 0) {\n elements.paletteMessagesSection.style.display = 'block';\n const items = matches\n .map(\n (m) => `\n
    \n
    \n \n \n \n
    \n
    \n
    ${escapeHtml(m.from)}
    \n
    ${escapeHtml(m.content.substring(0, 60))}${m.content.length > 60 ? '...' : ''}
    \n
    \n
    \n `\n )\n .join('');\n\n const existingItems = elements.paletteMessagesSection.querySelectorAll('.palette-item');\n existingItems.forEach((item) => item.remove());\n elements.paletteMessagesSection.insertAdjacentHTML('beforeend', items);\n } else {\n elements.paletteMessagesSection.style.display = 'none';\n }\n } else {\n elements.paletteMessagesSection.style.display = 'none';\n }\n}\n\n/**\n * Open thread panel for a specific thread\n */\nexport function openThreadPanel(threadId: string): void {\n setCurrentThread(threadId);\n elements.threadPanelId.textContent = threadId;\n elements.threadPanelOverlay.classList.add('visible');\n elements.threadMessageInput.value = '';\n renderThreadMessages(threadId);\n elements.threadMessageInput.focus();\n}\n\n/**\n * Close thread panel\n */\nexport function closeThreadPanel(): void {\n setCurrentThread(null);\n elements.threadPanelOverlay.classList.remove('visible');\n}\n\n/**\n * Render messages in thread panel\n */\nexport function renderThreadMessages(threadId: string): void {\n const messages = getThreadMessages(threadId);\n\n if (messages.length === 0) {\n elements.threadMessages.innerHTML = `\n
    \n

    No messages in this thread yet.

    \n

    Start the conversation below!

    \n
    \n `;\n return;\n }\n\n const html = messages\n .map((msg) => `\n
    \n
    \n
    \n ${getInitials(msg.from)}\n
    \n ${escapeHtml(msg.from)}\n ${formatTime(msg.timestamp)}\n
    \n
    ${formatMessageBody(msg.content)}
    \n
    \n `)\n .join('');\n\n elements.threadMessages.innerHTML = html;\n\n // Scroll to bottom\n elements.threadMessages.scrollTop = elements.threadMessages.scrollHeight;\n}\n\n/**\n * Attach thread click handlers to messages (call after renderMessages)\n */\nexport function attachThreadHandlers(): void {\n // Thread indicator clicks\n elements.messagesList.querySelectorAll('.thread-indicator').forEach((el) => {\n el.style.cursor = 'pointer';\n el.addEventListener('click', (e) => {\n e.stopPropagation();\n const threadId = el.dataset.thread;\n if (threadId) {\n openThreadPanel(threadId);\n }\n });\n });\n\n // Reply count badge clicks\n elements.messagesList.querySelectorAll('.reply-count-badge').forEach((el) => {\n el.addEventListener('click', (e) => {\n e.stopPropagation();\n const threadId = el.dataset.thread;\n if (threadId) {\n openThreadPanel(threadId);\n }\n });\n });\n\n // Reply in thread button clicks\n elements.messagesList.querySelectorAll('.message-action-btn[data-action=\"reply\"]').forEach((el) => {\n el.addEventListener('click', (e) => {\n e.stopPropagation();\n const messageId = el.closest('.message')?.getAttribute('data-id');\n if (messageId) {\n // Use message ID as thread ID for new threads\n openThreadPanel(messageId);\n }\n });\n });\n}\n\n/**\n * @-Mention Autocomplete State\n */\nlet mentionSelectedIndex = 0;\nlet mentionFilteredAgents: typeof state.agents = [];\n\n/**\n * Show mention autocomplete dropdown with filtered agents\n */\nexport function showMentionAutocomplete(filter: string): void {\n const filterLower = filter.toLowerCase();\n\n // Filter agents by name, include broadcast option\n mentionFilteredAgents = state.agents.filter(agent =>\n agent.name.toLowerCase().includes(filterLower)\n );\n\n // Reset selection\n mentionSelectedIndex = 0;\n\n // Build HTML for agent list\n let html = '';\n\n // Add broadcast option if filter matches\n if ('*'.includes(filterLower) || 'everyone'.includes(filterLower) || 'all'.includes(filterLower) || 'broadcast'.includes(filterLower)) {\n html += `\n
    \n
    *
    \n @everyone\n Broadcast to all\n
    \n `;\n }\n\n // Add agents\n mentionFilteredAgents.forEach((agent, index) => {\n const isSelected = index === mentionSelectedIndex;\n html += `\n
    \n
    \n ${getInitials(agent.name)}\n
    \n @${escapeHtml(agent.name)}\n ${escapeHtml(agent.role || 'Agent')}\n
    \n `;\n });\n\n if (html === '') {\n html = '
    No matching agents
    ';\n }\n\n elements.mentionAutocompleteList.innerHTML = html;\n elements.mentionAutocomplete.classList.add('visible');\n\n // Add click handlers to items\n elements.mentionAutocompleteList.querySelectorAll('.mention-autocomplete-item[data-mention]').forEach((item) => {\n item.addEventListener('click', () => {\n const mention = item.dataset.mention;\n if (mention) {\n completeMention(mention);\n }\n });\n });\n}\n\n/**\n * Hide mention autocomplete dropdown\n */\nexport function hideMentionAutocomplete(): void {\n elements.mentionAutocomplete.classList.remove('visible');\n mentionFilteredAgents = [];\n mentionSelectedIndex = 0;\n}\n\n/**\n * Check if mention autocomplete is visible\n */\nexport function isMentionAutocompleteVisible(): boolean {\n return elements.mentionAutocomplete.classList.contains('visible');\n}\n\n/**\n * Navigate mention autocomplete selection\n */\nexport function navigateMentionAutocomplete(direction: 'up' | 'down'): void {\n const items = elements.mentionAutocompleteList.querySelectorAll('.mention-autocomplete-item[data-mention]');\n if (items.length === 0) return;\n\n // Remove current selection\n items[mentionSelectedIndex]?.classList.remove('selected');\n\n // Update index\n if (direction === 'down') {\n mentionSelectedIndex = (mentionSelectedIndex + 1) % items.length;\n } else {\n mentionSelectedIndex = (mentionSelectedIndex - 1 + items.length) % items.length;\n }\n\n // Add new selection\n items[mentionSelectedIndex]?.classList.add('selected');\n items[mentionSelectedIndex]?.scrollIntoView({ block: 'nearest' });\n}\n\n/**\n * Complete the current mention selection\n */\nexport function completeMention(mention?: string): void {\n const items = elements.mentionAutocompleteList.querySelectorAll('.mention-autocomplete-item[data-mention]');\n\n // Use provided mention or get from selected item\n let selectedMention = mention;\n if (!selectedMention && items.length > 0) {\n selectedMention = items[mentionSelectedIndex]?.dataset.mention;\n }\n\n if (!selectedMention) {\n hideMentionAutocomplete();\n return;\n }\n\n // Replace the @... text with the completed mention\n const input = elements.messageInput;\n const value = input.value;\n\n // Find the @ position (should be at start or after whitespace)\n const atMatch = value.match(/^@\\S*/);\n if (atMatch) {\n // Replace the @partial with @CompletedName\n const completedText = `@${selectedMention} `;\n input.value = completedText + value.substring(atMatch[0].length);\n input.selectionStart = input.selectionEnd = completedText.length;\n }\n\n hideMentionAutocomplete();\n input.focus();\n}\n\n/**\n * Get the current @mention being typed (if any)\n */\nexport function getCurrentMentionQuery(): string | null {\n const input = elements.messageInput;\n const value = input.value;\n const cursorPos = input.selectionStart;\n\n // Check if cursor is within an @mention at the start\n const atMatch = value.match(/^@(\\S*)/);\n if (atMatch && cursorPos <= atMatch[0].length) {\n return atMatch[1]; // Return the text after @\n }\n\n return null;\n}\n\n// ========================================\n// Spawn Modal Functions\n// ========================================\n\n/**\n * Open the spawn agent modal\n */\nexport function openSpawnModal(): void {\n elements.spawnModalOverlay.classList.add('visible');\n elements.spawnNameInput.value = '';\n elements.spawnCliInput.value = 'claude';\n elements.spawnTaskInput.value = '';\n elements.spawnStatus.textContent = '';\n elements.spawnStatus.className = 'spawn-status';\n elements.spawnNameInput.focus();\n}\n\n/**\n * Close the spawn agent modal\n */\nexport function closeSpawnModal(): void {\n elements.spawnModalOverlay.classList.remove('visible');\n}\n\n/**\n * Spawn a new agent via the API\n */\nexport async function spawnAgent(): Promise<{ success: boolean; error?: string }> {\n const name = elements.spawnNameInput.value.trim();\n const cli = elements.spawnCliInput.value.trim() || 'claude';\n const task = elements.spawnTaskInput.value.trim();\n\n if (!name) {\n elements.spawnStatus.textContent = 'Agent name is required';\n elements.spawnStatus.className = 'spawn-status error';\n return { success: false, error: 'Agent name is required' };\n }\n\n elements.spawnSubmitBtn.disabled = true;\n elements.spawnStatus.textContent = 'Spawning agent...';\n elements.spawnStatus.className = 'spawn-status loading';\n\n try {\n const response = await fetch('/api/spawn', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ name, cli, task }),\n });\n\n const result = await response.json();\n\n if (response.ok && result.success) {\n elements.spawnStatus.textContent = `Agent \"${name}\" spawned successfully!`;\n elements.spawnStatus.className = 'spawn-status success';\n\n // Refresh spawned agents list\n await fetchSpawnedAgents();\n\n // Close modal after brief delay\n setTimeout(() => {\n closeSpawnModal();\n }, 1000);\n\n return { success: true };\n } else {\n throw new Error(result.error || 'Failed to spawn agent');\n }\n } catch (err: any) {\n elements.spawnStatus.textContent = err.message || 'Failed to spawn agent';\n elements.spawnStatus.className = 'spawn-status error';\n return { success: false, error: err.message };\n } finally {\n elements.spawnSubmitBtn.disabled = false;\n }\n}\n\n/**\n * Fetch list of spawned agents from API\n */\nexport async function fetchSpawnedAgents(): Promise {\n try {\n const response = await fetch('/api/spawned');\n const result = await response.json();\n\n if (result.success && Array.isArray(result.agents)) {\n spawnedAgents = result.agents;\n // Re-render agents to show spawned status\n renderAgents();\n }\n } catch (err) {\n console.error('[UI] Failed to fetch spawned agents:', err);\n }\n}\n\n/**\n * Release a spawned agent\n */\nexport async function releaseAgent(name: string): Promise {\n try {\n const response = await fetch(`/api/spawned/${encodeURIComponent(name)}`, {\n method: 'DELETE',\n });\n\n const result = await response.json();\n\n if (result.success) {\n // Refresh the list\n await fetchSpawnedAgents();\n } else {\n console.error('[UI] Failed to release agent:', result.error);\n }\n } catch (err) {\n console.error('[UI] Failed to release agent:', err);\n }\n}\n\n/**\n * Get spawned agents list\n */\nexport function getSpawnedAgents(): SpawnedAgent[] {\n return spawnedAgents;\n}\n\n// ========================================\n// Fleet View Functions\n// ========================================\n\n/**\n * Initialize fleet view toggle handlers\n */\nexport function initFleetViewToggle(): void {\n elements.viewToggleLocal?.addEventListener('click', () => {\n switchViewMode('local');\n });\n\n elements.viewToggleFleet?.addEventListener('click', () => {\n switchViewMode('fleet');\n });\n}\n\n/**\n * Switch between local and fleet view\n */\nexport function switchViewMode(mode: ViewMode): void {\n setViewMode(mode);\n\n // Update toggle button states\n elements.viewToggleLocal?.classList.toggle('active', mode === 'local');\n elements.viewToggleFleet?.classList.toggle('active', mode === 'fleet');\n\n // Show/hide servers section\n if (elements.serversSection) {\n elements.serversSection.style.display = mode === 'fleet' ? 'block' : 'none';\n }\n\n // Re-render agents list\n renderAgents();\n\n // Re-render servers if in fleet mode\n if (mode === 'fleet') {\n renderServers();\n }\n}\n\n/**\n * Update fleet view visibility based on available peer connections\n */\nexport function updateFleetViewVisibility(): void {\n const fleetData = getFleetData();\n const hasFleet = fleetData && fleetData.servers.length > 0;\n\n // Show toggle only if fleet is available\n if (elements.viewToggle) {\n elements.viewToggle.style.display = hasFleet ? 'flex' : 'none';\n }\n\n // Update peer count\n if (elements.peerCount && fleetData) {\n elements.peerCount.textContent = String(fleetData.servers.length);\n }\n\n // If fleet became unavailable while in fleet mode, switch to local\n if (!hasFleet && getViewMode() === 'fleet') {\n switchViewMode('local');\n }\n}\n\n/**\n * Render peer servers list in sidebar\n */\nexport function renderServers(): void {\n const fleetData = getFleetData();\n\n if (!fleetData || fleetData.servers.length === 0) {\n if (elements.serversList) {\n elements.serversList.innerHTML = '
  • No peer servers connected
  • ';\n }\n return;\n }\n\n const html = fleetData.servers.map(server => {\n const isLocal = server.id === fleetData.localServerId;\n const statusClass = server.connected ? '' : 'offline';\n\n return `\n
  • \n
    \n \n \n \n \n \n \n
    \n ${escapeHtml(server.name)}${isLocal ? ' (local)' : ''}\n ${server.agentCount}\n
  • \n `;\n }).join('');\n\n if (elements.serversList) {\n elements.serversList.innerHTML = html;\n }\n}\n\n/**\n * Render agents list with fleet support\n * Updates renderAgents to show server badges in fleet mode\n */\nexport function renderFleetAgents(): void {\n const viewMode = getViewMode();\n const fleetData = getFleetData();\n\n if (viewMode !== 'fleet' || !fleetData) {\n renderAgents();\n return;\n }\n\n // Get fleet agents\n const agents = fleetData.agents;\n const spawnedNames = new Set(spawnedAgents.map(a => a.name));\n\n const html = agents.map((agent: FleetAgent) => {\n const online = isAgentOnline(agent.lastSeen || agent.lastActive);\n const presenceClass = online ? 'online' : '';\n const isActive = state.currentChannel === agent.name;\n const needsAttentionClass = agent.needsAttention ? 'needs-attention' : '';\n const isSpawned = spawnedNames.has(agent.name);\n const isLocal = agent.isLocal;\n\n // Server badge for fleet agents\n const serverBadge = `\n \n \n ${escapeHtml(agent.serverName || agent.server)}\n \n `;\n\n // Server indicator on avatar\n const serverIndicator = !isLocal ? `\n \n ` : '';\n\n // Spawned icon SVG\n const spawnedIcon = isSpawned ? `\n \n \n \n ` : '';\n\n // Release button for spawned agents\n const releaseBtn = isSpawned ? `\n \n ` : '';\n\n return `\n
  • \n
    \n ${getInitials(agent.name)}\n \n ${serverIndicator}\n
    \n ${serverBadge}\n ${escapeHtml(agent.name)}\n ${spawnedIcon}\n ${agent.needsAttention ? 'Needs Input' : ''}\n ${releaseBtn}\n
  • \n `;\n }).join('');\n\n elements.agentsList.innerHTML = html || '
  • No agents in fleet
  • ';\n\n // Add click handlers\n attachAgentClickHandlers();\n\n // Update command palette agents\n updatePaletteAgents();\n}\n\n/**\n * Attach click handlers to agent list items\n */\nfunction attachAgentClickHandlers(): void {\n elements.agentsList.querySelectorAll('.channel-item[data-agent]').forEach((item) => {\n item.addEventListener('click', (e) => {\n if ((e.target as HTMLElement).closest('.release-btn')) {\n return;\n }\n const agentName = item.dataset.agent;\n if (agentName) {\n selectChannel(agentName);\n }\n });\n });\n\n elements.agentsList.querySelectorAll('.release-btn[data-release]').forEach((btn) => {\n btn.addEventListener('click', async (e) => {\n e.stopPropagation();\n const agentName = btn.dataset.release;\n if (agentName && confirm(`Release agent \"${agentName}\"? This will terminate the agent.`)) {\n await releaseAgent(agentName);\n }\n });\n });\n}\n\n/**\n * Check if an agent is a fleet agent (has server info)\n */\nfunction isFleetAgent(agent: Agent | FleetAgent): agent is FleetAgent {\n return 'server' in agent && 'serverName' in agent;\n}\n", "/**\n * Dashboard Application Entry Point\n */\n\nimport { subscribe, state, getViewMode } from './state.js';\nimport { connect, sendMessage } from './websocket.js';\nimport {\n initElements,\n getElements,\n updateConnectionStatus,\n renderAgents,\n renderMessages,\n selectChannel,\n updateOnlineCount,\n openCommandPalette,\n closeCommandPalette,\n filterPaletteResults,\n handlePaletteKeydown,\n initPaletteChannels,\n closeThreadPanel,\n renderThreadMessages,\n showMentionAutocomplete,\n hideMentionAutocomplete,\n isMentionAutocompleteVisible,\n navigateMentionAutocomplete,\n completeMention,\n getCurrentMentionQuery,\n openSpawnModal,\n closeSpawnModal,\n spawnAgent,\n fetchSpawnedAgents,\n initFleetViewToggle,\n updateFleetViewVisibility,\n renderFleetAgents,\n renderServers,\n} from './components.js';\n\n/**\n * Initialize the dashboard application\n */\nexport function initApp(): void {\n const elements = initElements();\n\n // Subscribe to state changes\n subscribe(() => {\n updateConnectionStatus();\n // Render agents based on current view mode\n if (getViewMode() === 'fleet') {\n renderFleetAgents();\n renderServers();\n } else {\n renderAgents();\n }\n renderMessages();\n updateOnlineCount();\n // Update fleet toggle visibility based on available peer connections\n updateFleetViewVisibility();\n });\n\n // Set up event listeners\n setupEventListeners(elements);\n\n // Initialize fleet view toggle handlers\n initFleetViewToggle();\n\n // Connect to WebSocket\n connect();\n\n // Fetch initial spawned agents list\n fetchSpawnedAgents();\n}\n\n/**\n * Set up all event listeners\n */\nfunction setupEventListeners(elements: ReturnType): void {\n // Channel clicks\n elements.channelsList.querySelectorAll('.channel-item').forEach((item) => {\n item.addEventListener('click', () => {\n const channel = item.dataset.channel;\n if (channel) {\n selectChannel(channel);\n }\n });\n });\n\n // Send button\n elements.sendBtn.addEventListener('click', handleSend);\n\n // Keyboard shortcuts for composer\n elements.messageInput.addEventListener('keydown', (e: KeyboardEvent) => {\n // Handle mention autocomplete keys first\n if (isMentionAutocompleteVisible()) {\n if (e.key === 'Tab' || e.key === 'Enter') {\n e.preventDefault();\n completeMention();\n return;\n }\n if (e.key === 'ArrowUp') {\n e.preventDefault();\n navigateMentionAutocomplete('up');\n return;\n }\n if (e.key === 'ArrowDown') {\n e.preventDefault();\n navigateMentionAutocomplete('down');\n return;\n }\n if (e.key === 'Escape') {\n e.preventDefault();\n hideMentionAutocomplete();\n return;\n }\n }\n\n // Enter to send (Slack-style), Shift+Enter for newline\n if (e.key === 'Enter' && !e.shiftKey) {\n e.preventDefault();\n handleSend();\n }\n // Shift+Enter allows default behavior (inserts newline)\n });\n\n // Auto-resize textarea and handle @-mention autocomplete\n elements.messageInput.addEventListener('input', () => {\n elements.messageInput.style.height = 'auto';\n elements.messageInput.style.height =\n Math.min(elements.messageInput.scrollHeight, 200) + 'px';\n\n // Check for @-mention at start of input\n const query = getCurrentMentionQuery();\n if (query !== null) {\n showMentionAutocomplete(query);\n } else {\n hideMentionAutocomplete();\n }\n });\n\n // Hide mention autocomplete when input loses focus (with delay to allow clicks)\n elements.messageInput.addEventListener('blur', () => {\n setTimeout(() => {\n hideMentionAutocomplete();\n }, 150);\n });\n\n // Bold button - wrap selected text with ** or insert **bold**\n elements.boldBtn.addEventListener('click', () => {\n const input = elements.messageInput;\n const start = input.selectionStart;\n const end = input.selectionEnd;\n const text = input.value;\n\n if (start === end) {\n // No selection - insert **bold** placeholder\n const before = text.substring(0, start);\n const after = text.substring(end);\n input.value = before + '**bold**' + after;\n input.selectionStart = start + 2;\n input.selectionEnd = start + 6;\n } else {\n // Wrap selection with **\n const before = text.substring(0, start);\n const selected = text.substring(start, end);\n const after = text.substring(end);\n input.value = before + '**' + selected + '**' + after;\n input.selectionStart = start;\n input.selectionEnd = end + 4;\n }\n input.focus();\n });\n\n // Emoji button - insert common emojis via simple picker\n elements.emojiBtn.addEventListener('click', () => {\n const emojis = ['\uD83D\uDC4D', '\uD83D\uDC4E', '\u2705', '\u274C', '\uD83C\uDF89', '\uD83D\uDD25', '\uD83D\uDCA1', '\u26A0\uFE0F', '\uD83D\uDCDD', '\uD83D\uDE80'];\n const emoji = emojis[Math.floor(Math.random() * emojis.length)];\n const input = elements.messageInput;\n const start = input.selectionStart;\n const text = input.value;\n input.value = text.substring(0, start) + emoji + text.substring(start);\n input.selectionStart = input.selectionEnd = start + emoji.length;\n input.focus();\n });\n\n // Command palette\n elements.searchTrigger.addEventListener('click', openCommandPalette);\n\n document.addEventListener('keydown', (e: KeyboardEvent) => {\n if ((e.ctrlKey || e.metaKey) && e.key === 'k') {\n e.preventDefault();\n if (elements.commandPaletteOverlay.classList.contains('visible')) {\n closeCommandPalette();\n } else {\n openCommandPalette();\n }\n }\n\n if (e.key === 'Escape') {\n closeCommandPalette();\n }\n });\n\n elements.commandPaletteOverlay.addEventListener('click', (e: MouseEvent) => {\n if (e.target === elements.commandPaletteOverlay) {\n closeCommandPalette();\n }\n });\n\n elements.paletteSearch.addEventListener('input', (e: Event) => {\n const target = e.target as HTMLInputElement;\n filterPaletteResults(target.value);\n });\n\n elements.paletteSearch.addEventListener('keydown', handlePaletteKeydown);\n\n // Command execution\n document.querySelectorAll('.palette-item[data-command]').forEach((item) => {\n item.addEventListener('click', () => {\n const command = item.dataset.command;\n\n if (command === 'broadcast') {\n // Pre-fill message input with @* for broadcast\n elements.messageInput.value = '@* ';\n elements.messageInput.focus();\n } else if (command === 'clear') {\n elements.messagesList.innerHTML = '';\n }\n\n closeCommandPalette();\n });\n });\n\n // Initialize palette channel click handlers\n initPaletteChannels();\n\n // Thread panel close button\n elements.threadPanelClose.addEventListener('click', closeThreadPanel);\n\n // Thread panel send button\n elements.threadSendBtn.addEventListener('click', handleThreadSend);\n\n // Thread message input keyboard shortcuts (Slack-style)\n elements.threadMessageInput.addEventListener('keydown', (e: KeyboardEvent) => {\n if (e.key === 'Enter' && !e.shiftKey) {\n e.preventDefault();\n handleThreadSend();\n }\n // Shift+Enter allows default behavior (inserts newline)\n });\n\n // Close thread panel on Escape\n document.addEventListener('keydown', (e: KeyboardEvent) => {\n if (e.key === 'Escape' && elements.threadPanelOverlay.classList.contains('visible')) {\n closeThreadPanel();\n }\n });\n\n // Spawn modal event listeners\n elements.spawnBtn.addEventListener('click', openSpawnModal);\n\n elements.spawnModalClose.addEventListener('click', closeSpawnModal);\n\n // Cancel button in spawn modal\n document.getElementById('spawn-cancel-btn')?.addEventListener('click', closeSpawnModal);\n\n // Close spawn modal on overlay click\n elements.spawnModalOverlay.addEventListener('click', (e: MouseEvent) => {\n if (e.target === elements.spawnModalOverlay) {\n closeSpawnModal();\n }\n });\n\n // Close spawn modal on Escape\n document.addEventListener('keydown', (e: KeyboardEvent) => {\n if (e.key === 'Escape' && elements.spawnModalOverlay.classList.contains('visible')) {\n closeSpawnModal();\n }\n });\n\n // Submit spawn form\n elements.spawnSubmitBtn.addEventListener('click', spawnAgent);\n\n // Enter key in spawn name input triggers submit\n elements.spawnNameInput.addEventListener('keydown', (e: KeyboardEvent) => {\n if (e.key === 'Enter' && !e.shiftKey) {\n e.preventDefault();\n spawnAgent();\n }\n });\n}\n\n/**\n * Parse @mention from message text\n * Formats: \"@AgentName message\" or \"@* message\" for broadcast\n * Returns { to, message } or null if no valid mention found\n */\nfunction parseMention(text: string): { to: string; message: string } | null {\n const trimmed = text.trim();\n\n // Match @mention at the start of the message\n // @* for broadcast, @AgentName for direct message\n const match = trimmed.match(/^@(\\*|[^\\s]+)\\s+(.+)$/s);\n\n if (!match) {\n return null;\n }\n\n return {\n to: match[1],\n message: match[2].trim(),\n };\n}\n\n/**\n * Handle send button click\n */\nasync function handleSend(): Promise {\n const elements = getElements();\n const rawMessage = elements.messageInput.value.trim();\n\n if (!rawMessage) {\n return;\n }\n\n let to: string;\n let message: string;\n\n // Check if we're in a DM (not general channel)\n const isInDM = state.currentChannel !== 'general';\n\n // Parse @mention from the message\n const parsed = parseMention(rawMessage);\n\n if (parsed) {\n // Message has explicit @mention - use it\n to = parsed.to;\n message = parsed.message;\n } else if (isInDM) {\n // In DM context - send to current channel without requiring @\n to = state.currentChannel;\n message = rawMessage;\n } else {\n // In general channel without @mention - require it\n alert('Message must start with @recipient (e.g., \"@Lead hello\" or \"@* broadcast\")');\n return;\n }\n\n elements.sendBtn.disabled = true;\n\n const result = await sendMessage(to, message);\n\n if (result.success) {\n elements.messageInput.value = '';\n elements.messageInput.style.height = 'auto';\n } else {\n alert(result.error);\n }\n\n elements.sendBtn.disabled = false;\n}\n\n/**\n * Handle thread panel send button click\n */\nasync function handleThreadSend(): Promise {\n const elements = getElements();\n const message = elements.threadMessageInput.value.trim();\n const threadId = state.currentThread;\n\n if (!message || !threadId) {\n return;\n }\n\n // For thread replies, send to broadcast or use original recipient\n // For now, send as broadcast with thread ID\n elements.threadSendBtn.disabled = true;\n\n const result = await sendMessage('*', message, threadId);\n\n if (result.success) {\n elements.threadMessageInput.value = '';\n // Re-render thread messages to show the new message\n renderThreadMessages(threadId);\n } else {\n alert(result.error);\n }\n\n elements.threadSendBtn.disabled = false;\n}\n\n// Auto-initialize when DOM is ready\nif (typeof document !== 'undefined') {\n if (document.readyState === 'loading') {\n document.addEventListener('DOMContentLoaded', initApp);\n } else {\n initApp();\n }\n}\n"], - "mappings": "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", - "names": ["state", "listeners", "subscribe", "listener", "index", "notifyListeners", "setAgents", "agents", "setMessages", "messages", "setCurrentChannel", "channel", "setConnectionStatus", "connected", "incrementReconnectAttempts", "setWebSocket", "ws", "getFilteredMessages", "currentChannel", "m", "setCurrentThread", "thread", "getThreadMessages", "threadId", "getThreadReplyCount", "setViewMode", "mode", "getViewMode", "setFleetData", "data", "getFleetData", "dataHandler", "connect", "protocol", "ws", "setConnectionStatus", "delay", "state", "incrementReconnectAttempts", "error", "event", "data", "handleData", "e", "setWebSocket", "a", "setAgents", "setMessages", "setFleetData", "dataHandler", "sendMessage", "to", "message", "thread", "body", "response", "result", "isAgentOnline", "lastSeen", "ts", "escapeHtml", "text", "div", "formatTime", "timestamp", "formatDate", "date", "today", "yesterday", "getAvatarColor", "name", "colors", "hash", "i", "getInitials", "formatMessageBody", "content", "escaped", "_match", "_lang", "code", "spawnedAgents", "elements", "paletteSelectedIndex", "initElements", "getElements", "updateConnectionStatus", "state", "renderAgents", "a", "spawnedNames", "html", "agent", "presenceClass", "isAgentOnline", "isActive", "needsAttentionClass", "isSpawned", "spawnedIcon", "releaseBtn", "escapeHtml", "getAvatarColor", "getInitials", "item", "e", "agentName", "selectChannel", "btn", "releaseAgent", "updatePaletteAgents", "renderMessages", "filtered", "getFilteredMessages", "lastDate", "msg", "msgDate", "formatDate", "isBroadcast", "avatarColor", "replyCount", "getThreadReplyCount", "recipientDisplay", "formatTime", "formatMessageBody", "attachThreadHandlers", "channel", "setCurrentChannel", "prefixEl", "updateOnlineCount", "online", "section", "closeCommandPalette", "initPaletteChannels", "channelName", "openCommandPalette", "filterPaletteResults", "getVisiblePaletteItems", "updatePaletteSelection", "items", "selectedItem", "handlePaletteKeydown", "executePaletteItem", "command", "messageId", "messageEl", "query", "q", "title", "name", "matches", "m", "openThreadPanel", "threadId", "setCurrentThread", "renderThreadMessages", "closeThreadPanel", "messages", "getThreadMessages", "el", "mentionSelectedIndex", "mentionFilteredAgents", "showMentionAutocomplete", "filter", "filterLower", "index", "mention", "completeMention", "hideMentionAutocomplete", "isMentionAutocompleteVisible", "navigateMentionAutocomplete", "direction", "selectedMention", "input", "value", "atMatch", "completedText", "getCurrentMentionQuery", "cursorPos", "openSpawnModal", "closeSpawnModal", "spawnAgent", "cli", "task", "response", "result", "fetchSpawnedAgents", "err", "initFleetViewToggle", "elements", "switchViewMode", "mode", "setViewMode", "renderAgents", "renderServers", "updateFleetViewVisibility", "fleetData", "getFleetData", "hasFleet", "getViewMode", "html", "server", "isLocal", "statusClass", "escapeHtml", "renderFleetAgents", "viewMode", "agents", "spawnedNames", "spawnedAgents", "a", "agent", "online", "isAgentOnline", "presenceClass", "isActive", "state", "needsAttentionClass", "isSpawned", "serverBadge", "serverIndicator", "spawnedIcon", "releaseBtn", "getAvatarColor", "getInitials", "attachAgentClickHandlers", "updatePaletteAgents", "item", "agentName", "selectChannel", "btn", "releaseAgent", "initApp", "elements", "initElements", "subscribe", "updateConnectionStatus", "getViewMode", "renderFleetAgents", "renderServers", "renderAgents", "renderMessages", "updateOnlineCount", "updateFleetViewVisibility", "setupEventListeners", "initFleetViewToggle", "connect", "fetchSpawnedAgents", "item", "channel", "selectChannel", "handleSend", "isMentionAutocompleteVisible", "completeMention", "navigateMentionAutocomplete", "hideMentionAutocomplete", "query", "getCurrentMentionQuery", "showMentionAutocomplete", "input", "start", "end", "text", "before", "after", "selected", "emojis", "emoji", "openCommandPalette", "closeCommandPalette", "target", "filterPaletteResults", "handlePaletteKeydown", "command", "initPaletteChannels", "closeThreadPanel", "handleThreadSend", "openSpawnModal", "closeSpawnModal", "spawnAgent", "parseMention", "match", "getElements", "rawMessage", "to", "message", "isInDM", "state", "parsed", "result", "sendMessage", "threadId", "renderThreadMessages"] -} diff --git a/src/dashboard/public/metrics.html b/src/dashboard/public/metrics.html deleted file mode 100644 index 3572bb596..000000000 --- a/src/dashboard/public/metrics.html +++ /dev/null @@ -1,999 +0,0 @@ - - - - - - Metrics | Agent Relay - - - - - - -
    -
    -
    - - - - - Dashboard - - -
    -
    - - LIVE -
    -
    -
    - -
    -
    -
    -
    -
    -
    -
    - - - - diff --git a/src/dashboard-v2/react-components/AgentCard.tsx b/src/dashboard/react-components/AgentCard.tsx similarity index 68% rename from src/dashboard-v2/react-components/AgentCard.tsx rename to src/dashboard/react-components/AgentCard.tsx index 8d5fd9cc4..97a4a8413 100644 --- a/src/dashboard-v2/react-components/AgentCard.tsx +++ b/src/dashboard/react-components/AgentCard.tsx @@ -23,6 +23,7 @@ export interface AgentCardProps { compact?: boolean; onClick?: (agent: Agent) => void; onMessageClick?: (agent: Agent) => void; + onReleaseClick?: (agent: Agent) => void; } export function AgentCard({ @@ -32,6 +33,7 @@ export function AgentCard({ compact = false, onClick, onMessageClick, + onReleaseClick, }: AgentCardProps) { const colors = getAgentColor(agent.name); const initials = getAgentInitials(agent.name); @@ -47,6 +49,11 @@ export function AgentCard({ onMessageClick?.(agent); }; + const handleReleaseClick = (e: React.MouseEvent) => { + e.stopPropagation(); + onReleaseClick?.(agent); + }; + if (compact) { return (
    {initials}
    - {agent.name} - {agent.isProcessing ? ( - - ) : ( -
    - )} - {agent.needsAttention &&
    } +
    + {displayName} + {getAgentBreadcrumb(agent.name)} +
    +
    + {agent.isSpawned && onReleaseClick && ( + + )} + {agent.isProcessing ? ( + + ) : ( +
    + )} + {agent.needsAttention &&
    } +
    ); } @@ -126,12 +147,20 @@ export function AgentCard({ {agent.messageCount !== undefined && agent.messageCount > 0 && ( {agent.messageCount} msgs )} + {agent.isSpawned && spawned} +
    +
    + {agent.isSpawned && onReleaseClick && ( + + )} + {onMessageClick && ( + + )}
    - {onMessageClick && ( - - )}
    ); @@ -157,6 +186,28 @@ function MessageIcon() { ); } +/** + * Release/kill icon SVG (X in circle) + */ +function ReleaseIcon() { + return ( + + + + + + ); +} + /** * CSS styles for the component (can be moved to a CSS file) */ @@ -322,38 +373,116 @@ export const agentCardStyles = ` opacity: 0.9; } +.agent-actions { + display: flex; + gap: 6px; +} + +.release-btn { + background: var(--color-error, #e01e5a); + color: white; + border: none; + border-radius: 4px; + padding: 4px 8px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: opacity 0.2s; +} + +.release-btn:hover { + opacity: 0.9; +} + +.agent-spawned-badge { + background: var(--color-accent-light, rgba(18, 100, 163, 0.15)); + color: var(--color-accent, #1264a3); + font-size: 10px; + padding: 2px 6px; + border-radius: 3px; + text-transform: uppercase; + font-weight: 500; +} + /* Compact variant */ .agent-card-compact { display: flex; align-items: center; gap: 8px; padding: 8px 12px; - border-radius: 4px; + border-radius: 6px; cursor: pointer; transition: background 0.2s; } .agent-card-compact:hover { - background: var(--agent-light); + background: rgba(74, 158, 255, 0.08); } .agent-card-compact.selected { - background: var(--agent-light); + background: rgba(74, 158, 255, 0.12); border-left: 3px solid var(--agent-primary); } -.agent-card-compact .agent-name { +.agent-compact-info { flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.agent-card-compact .agent-name { font-size: 13px; + font-weight: 600; + color: #e8e8e8; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.agent-breadcrumb-compact { + font-size: 10px; + color: #888; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.agent-compact-actions { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + +.release-btn-compact { + background: transparent; + border: none; + color: #666; + padding: 4px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + transition: all 0.2s; + opacity: 0; +} + +.agent-card-compact:hover .release-btn-compact { + opacity: 1; +} + +.release-btn-compact:hover { + background: rgba(239, 68, 68, 0.15); + color: #ef4444; +} + .agent-card-compact .attention-badge { width: 8px; height: 8px; - margin-left: auto; } /* Thinking indicator section */ diff --git a/src/dashboard-v2/react-components/AgentList.tsx b/src/dashboard/react-components/AgentList.tsx similarity index 97% rename from src/dashboard-v2/react-components/AgentList.tsx rename to src/dashboard/react-components/AgentList.tsx index 30ce676e6..92c14b389 100644 --- a/src/dashboard-v2/react-components/AgentList.tsx +++ b/src/dashboard/react-components/AgentList.tsx @@ -17,6 +17,7 @@ export interface AgentListProps { searchQuery?: string; onAgentSelect?: (agent: Agent) => void; onAgentMessage?: (agent: Agent) => void; + onReleaseClick?: (agent: Agent) => void; compact?: boolean; showGroupStats?: boolean; } @@ -27,6 +28,7 @@ export function AgentList({ searchQuery = '', onAgentSelect, onAgentMessage, + onReleaseClick, compact = false, showGroupStats = true, }: AgentListProps) { @@ -112,6 +114,7 @@ export function AgentList({ onToggle={() => toggleGroup(group.prefix)} onAgentSelect={onAgentSelect} onAgentMessage={onAgentMessage} + onReleaseClick={onReleaseClick} /> ))}
    @@ -127,6 +130,7 @@ interface AgentGroupComponentProps { onToggle: () => void; onAgentSelect?: (agent: Agent) => void; onAgentMessage?: (agent: Agent) => void; + onReleaseClick?: (agent: Agent) => void; } function AgentGroupComponent({ @@ -138,6 +142,7 @@ function AgentGroupComponent({ onToggle, onAgentSelect, onAgentMessage, + onReleaseClick, }: AgentGroupComponentProps) { const stats = showStats ? getGroupStats(group.agents) : null; @@ -184,6 +189,7 @@ function AgentGroupComponent({ compact={compact} onClick={onAgentSelect} onMessageClick={onAgentMessage} + onReleaseClick={onReleaseClick} /> ))}
    @@ -331,7 +337,7 @@ export const agentListStyles = ` .group-name { font-weight: 600; - color: #1a1a1a; + color: #e8e8e8; } .group-count { diff --git a/src/dashboard-v2/react-components/App.tsx b/src/dashboard/react-components/App.tsx similarity index 76% rename from src/dashboard-v2/react-components/App.tsx rename to src/dashboard/react-components/App.tsx index ec69ec2f5..9764014c4 100644 --- a/src/dashboard-v2/react-components/App.tsx +++ b/src/dashboard/react-components/App.tsx @@ -111,11 +111,56 @@ export function App({ wsUrl }: AppProps) { } }, []); + // Handle release/kill agent + const handleReleaseAgent = useCallback(async (agent: Agent) => { + if (!agent.isSpawned) return; + + const confirmed = window.confirm(`Are you sure you want to release agent "${agent.name}"?`); + if (!confirmed) return; + + try { + const result = await api.releaseAgent(agent.name); + if (!result.success) { + console.error('Failed to release agent:', result.error); + } + } catch (err) { + console.error('Failed to release agent:', err); + } + }, []); + // Handle command palette const handleCommandPaletteOpen = useCallback(() => { setIsCommandPaletteOpen(true); }, []); + // Apply theme to document + React.useEffect(() => { + const applyTheme = (theme: 'light' | 'dark' | 'system') => { + let effectiveTheme: 'light' | 'dark'; + + if (theme === 'system') { + // Check system preference + const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; + effectiveTheme = prefersDark ? 'dark' : 'light'; + } else { + effectiveTheme = theme; + } + + // Apply theme to document root + document.documentElement.setAttribute('data-theme', effectiveTheme); + }; + + applyTheme(settings.theme); + + // Listen for system theme changes when in 'system' mode + if (settings.theme === 'system') { + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + const handleChange = () => applyTheme('system'); + mediaQuery.addEventListener('change', handleChange); + return () => mediaQuery.removeEventListener('change', handleChange); + } + }, [settings.theme]); + // Keyboard shortcuts React.useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -125,6 +170,12 @@ export function App({ wsUrl }: AppProps) { setIsCommandPaletteOpen(true); } + // Cmd/Ctrl + Shift + S for spawn agent + if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 's') { + e.preventDefault(); + handleSpawnClick(); + } + // Escape to close modals if (e.key === 'Escape') { setIsCommandPaletteOpen(false); @@ -134,7 +185,7 @@ export function App({ wsUrl }: AppProps) { window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, []); + }, [handleSpawnClick]); return (
    @@ -148,6 +199,7 @@ export function App({ wsUrl }: AppProps) { onAgentSelect={handleAgentSelect} onViewModeChange={setViewMode} onSpawnClick={handleSpawnClick} + onReleaseClick={handleReleaseAgent} /> {/* Main Content */} @@ -207,6 +259,10 @@ export function App({ wsUrl }: AppProps) { agents={agents} onAgentSelect={handleAgentSelect} onSpawnClick={handleSpawnClick} + onGeneralClick={() => { + selectAgent(null); + setCurrentChannel('general'); + }} /> {/* Spawn Modal */} @@ -278,7 +334,32 @@ function MessageComposer({ recipient, agents, onSend, isSending, error }: Messag e.preventDefault(); if (!message.trim() || isSending) return; - const success = await onSend(recipient, message); + // Parse message to determine target + // If message starts with @AgentName, extract the target and message content + const mentionMatch = message.match(/^@(\S+)\s*([\s\S]*)/); + let target: string; + let content: string; + + if (mentionMatch) { + // User explicitly mentioned someone - route to that agent + const mentionedName = mentionMatch[1]; + content = mentionMatch[2] || ''; + + // Check if it's a broadcast mention (@everyone, @*, @all) + if (mentionedName === '*' || mentionedName.toLowerCase() === 'everyone' || mentionedName.toLowerCase() === 'all') { + target = '*'; + } else { + target = mentionedName; + } + } else { + // No @mention - use context-aware routing + // If in general channel, broadcast to everyone + // If in a DM, stay in that DM + target = recipient; + content = message; + } + + const success = await onSend(target, content || message); if (success) { setMessage(''); setShowMentions(false); @@ -347,13 +428,13 @@ function ErrorIcon() { } /** - * CSS styles for the main app + * CSS styles for the main app - Dark mode styling matching v1 dashboard */ export const appStyles = ` .dashboard-app { display: flex; height: 100vh; - background: #f5f5f5; + background: #1a1d21; } .dashboard-main { @@ -361,12 +442,17 @@ export const appStyles = ` display: flex; flex-direction: column; min-width: 0; + background: #222529; } .dashboard-content { flex: 1; overflow-y: auto; - padding: 20px; + padding: 0; +} + +.messages-container { + height: 100%; } .loading-state, @@ -376,13 +462,14 @@ export const appStyles = ` align-items: center; justify-content: center; height: 100%; - color: #666; + color: #8d8d8e; text-align: center; } .loading-state .spinner { animation: spin 1s linear infinite; margin-bottom: 16px; + color: #00ffc8; } @keyframes spin { @@ -391,13 +478,17 @@ export const appStyles = ` } .error-state svg { - color: #ef4444; + color: #e01e5a; margin-bottom: 16px; } .error-state h2 { margin: 0 0 8px; - color: #1a1a1a; + color: #d1d2d3; +} + +.error-state p { + color: #8d8d8e; } .error-state button { @@ -408,31 +499,37 @@ export const appStyles = ` border: none; border-radius: 4px; cursor: pointer; + transition: background 0.2s; +} + +.error-state button:hover { + background: #0d4f82; } .messages-placeholder { - background: white; + background: #1a1d21; border-radius: 8px; padding: 20px; - box-shadow: 0 1px 3px rgba(0,0,0,0.1); + border: 1px solid rgba(255, 255, 255, 0.06); } .agent-summary { margin-top: 16px; padding: 12px; - background: #f9f9f9; + background: rgba(255, 255, 255, 0.04); border-radius: 4px; } .agent-summary p { margin: 4px 0; font-size: 13px; + color: #ababad; } .message-composer { - padding: 16px; - background: white; - border-top: 1px solid #e8e8e8; + padding: 16px 20px; + background: #222529; + border-top: 1px solid rgba(255, 255, 255, 0.06); } .composer-form { @@ -449,18 +546,25 @@ export const appStyles = ` .composer-input { width: 100%; padding: 10px 14px; - border: 1px solid #e8e8e8; + background: #222529; + border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 6px; font-size: 14px; + color: #d1d2d3; outline: none; box-sizing: border-box; + transition: border-color 0.2s; +} + +.composer-input::placeholder { + color: #8d8d8e; } .composer-input:focus { border-color: #1264a3; } -/* Mention Autocomplete Styles */ +/* Mention Autocomplete Styles - Dark mode */ .mention-autocomplete { position: absolute; bottom: 100%; @@ -468,10 +572,10 @@ export const appStyles = ` right: 0; max-height: 200px; overflow-y: auto; - background: #ffffff; - border: 1px solid #e5e7eb; + background: #1a1d21; + border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 8px; - box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.1); + box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.4); z-index: 100; margin-bottom: 4px; } @@ -487,7 +591,7 @@ export const appStyles = ` .mention-item:hover, .mention-item.selected { - background: #f3f4f6; + background: rgba(255, 255, 255, 0.08); } .mention-avatar { @@ -511,12 +615,12 @@ export const appStyles = ` .mention-name { font-size: 14px; font-weight: 500; - color: #1f2937; + color: #d1d2d3; } .mention-description { font-size: 12px; - color: #6b7280; + color: #8d8d8e; } .composer-send { @@ -540,14 +644,14 @@ export const appStyles = ` } .composer-error { - color: #ef4444; + color: #e01e5a; font-size: 12px; } .modal-overlay { position: fixed; inset: 0; - background: rgba(0, 0, 0, 0.5); + background: rgba(0, 0, 0, 0.6); display: flex; align-items: center; justify-content: center; @@ -555,19 +659,23 @@ export const appStyles = ` } .modal { - background: white; + background: #1a1d21; + border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 8px; padding: 24px; min-width: 400px; max-width: 90vw; + box-shadow: 0 18px 50px rgba(0, 0, 0, 0.6); } .modal h2 { margin: 0 0 16px; + color: #d1d2d3; } .command-palette { - background: white; + background: #1a1d21; + border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 8px; padding: 8px; width: 500px; @@ -577,8 +685,14 @@ export const appStyles = ` .command-palette input { width: 100%; padding: 12px 16px; + background: transparent; border: none; font-size: 16px; + color: #d1d2d3; outline: none; } + +.command-palette input::placeholder { + color: #8d8d8e; +} `; diff --git a/src/dashboard-v2/react-components/BroadcastComposer.tsx b/src/dashboard/react-components/BroadcastComposer.tsx similarity index 100% rename from src/dashboard-v2/react-components/BroadcastComposer.tsx rename to src/dashboard/react-components/BroadcastComposer.tsx diff --git a/src/dashboard-v2/react-components/CommandPalette.tsx b/src/dashboard/react-components/CommandPalette.tsx similarity index 94% rename from src/dashboard-v2/react-components/CommandPalette.tsx rename to src/dashboard/react-components/CommandPalette.tsx index a9a962499..fba2623be 100644 --- a/src/dashboard-v2/react-components/CommandPalette.tsx +++ b/src/dashboard/react-components/CommandPalette.tsx @@ -26,6 +26,7 @@ export interface CommandPaletteProps { onAgentSelect: (agent: Agent) => void; onSpawnClick: () => void; onSettingsClick?: () => void; + onGeneralClick?: () => void; customCommands?: Command[]; } @@ -36,6 +37,7 @@ export function CommandPalette({ onAgentSelect, onSpawnClick, onSettingsClick, + onGeneralClick, customCommands = [], }: CommandPaletteProps) { const [query, setQuery] = useState(''); @@ -65,7 +67,7 @@ export function CommandPalette({ description: 'Launch a new agent instance', category: 'actions', icon: , - shortcut: '⌘N', + shortcut: '⌘⇧S', action: () => { onSpawnClick(); onClose(); @@ -90,6 +92,7 @@ export function CommandPalette({ category: 'navigation', icon: , action: () => { + onGeneralClick?.(); onClose(); }, }, @@ -114,7 +117,7 @@ export function CommandPalette({ ...customCommands, ]; return cmds; - }, [agents, onAgentSelect, onSpawnClick, onSettingsClick, onClose, customCommands]); + }, [agents, onAgentSelect, onSpawnClick, onSettingsClick, onGeneralClick, onClose, customCommands]); // Filter commands based on query const filteredCommands = useMemo(() => { @@ -331,13 +334,13 @@ function SettingsIcon() { } /** - * CSS styles for the command palette + * CSS styles for the command palette - Dark mode */ export const commandPaletteStyles = ` .command-palette-overlay { position: fixed; inset: 0; - background: rgba(0, 0, 0, 0.5); + background: rgba(0, 0, 0, 0.6); display: flex; align-items: flex-start; justify-content: center; @@ -347,14 +350,15 @@ export const commandPaletteStyles = ` } .command-palette { - background: #ffffff; + background: #1a1a2e; + border: 1px solid #2a2a3e; border-radius: 12px; width: 560px; max-width: 90vw; max-height: 60vh; display: flex; flex-direction: column; - box-shadow: 0 16px 70px rgba(0, 0, 0, 0.2); + box-shadow: 0 16px 70px rgba(0, 0, 0, 0.5); animation: slideDown 0.2s ease; } @@ -363,7 +367,7 @@ export const commandPaletteStyles = ` align-items: center; gap: 12px; padding: 16px; - border-bottom: 1px solid #e8e8e8; + border-bottom: 1px solid #2a2a3e; } .command-palette-input-wrapper svg { @@ -378,15 +382,16 @@ export const commandPaletteStyles = ` font-family: inherit; outline: none; background: transparent; + color: #e8e8e8; } .command-palette-input::placeholder { - color: #aaa; + color: #666; } .command-palette-kbd { - background: #f5f5f5; - border: 1px solid #e0e0e0; + background: #2a2a3e; + border: 1px solid #3a3a4e; border-radius: 4px; padding: 2px 6px; font-size: 11px; @@ -435,13 +440,13 @@ export const commandPaletteStyles = ` transition: background 0.1s; } -.command-palette-item:hover, -.command-palette-item.selected { - background: #f5f5f5; +.command-palette-item:hover { + background: #2a2a3e; } .command-palette-item.selected { - background: #e8f4fd; + background: rgba(74, 158, 255, 0.15); + border: 1px solid rgba(74, 158, 255, 0.3); } .command-palette-item-icon { @@ -450,7 +455,7 @@ export const commandPaletteStyles = ` justify-content: center; width: 28px; height: 28px; - color: #666; + color: #888; } .command-palette-agent-icon { @@ -475,7 +480,7 @@ export const commandPaletteStyles = ` .command-palette-item-label { font-size: 14px; font-weight: 500; - color: #1a1a1a; + color: #e8e8e8; } .command-palette-item-desc { @@ -487,12 +492,12 @@ export const commandPaletteStyles = ` } .command-palette-item-shortcut { - background: #f0f0f0; - border: 1px solid #e0e0e0; + background: #2a2a3e; + border: 1px solid #3a3a4e; border-radius: 4px; padding: 2px 6px; font-size: 11px; - color: #666; + color: #888; font-family: inherit; } diff --git a/src/dashboard-v2/react-components/DecisionQueue.tsx b/src/dashboard/react-components/DecisionQueue.tsx similarity index 100% rename from src/dashboard-v2/react-components/DecisionQueue.tsx rename to src/dashboard/react-components/DecisionQueue.tsx diff --git a/src/dashboard-v2/react-components/FleetOverview.tsx b/src/dashboard/react-components/FleetOverview.tsx similarity index 100% rename from src/dashboard-v2/react-components/FleetOverview.tsx rename to src/dashboard/react-components/FleetOverview.tsx diff --git a/src/dashboard-v2/react-components/MentionAutocomplete.tsx b/src/dashboard/react-components/MentionAutocomplete.tsx similarity index 100% rename from src/dashboard-v2/react-components/MentionAutocomplete.tsx rename to src/dashboard/react-components/MentionAutocomplete.tsx diff --git a/src/dashboard-v2/react-components/MessageList.tsx b/src/dashboard/react-components/MessageList.tsx similarity index 82% rename from src/dashboard-v2/react-components/MessageList.tsx rename to src/dashboard/react-components/MessageList.tsx index cbe0b2d5c..a72921a73 100644 --- a/src/dashboard-v2/react-components/MessageList.tsx +++ b/src/dashboard/react-components/MessageList.tsx @@ -98,7 +98,7 @@ function MessageItem({ message, isHighlighted, onThreadClick }: MessageItemProps )} {timestamp} - {message.isBroadcast && ( + {message.to === '*' && ( broadcast )}
    @@ -125,8 +125,14 @@ function MessageItem({ message, isHighlighted, onThreadClick }: MessageItemProps * Format message body with newline preservation and link detection */ function formatMessageBody(content: string): React.ReactNode { + // Normalize line endings: handle \r\n, literal \\n strings, and \n + let normalizedContent = content + .replace(/\\n/g, '\n') // Convert literal \n strings to actual newlines + .replace(/\r\n/g, '\n') // Normalize Windows line endings + .replace(/\r/g, '\n'); // Handle standalone \r + // Split by newlines and render each line - const lines = content.split('\n'); + const lines = normalizedContent.split('\n'); return lines.map((line, i) => ( @@ -206,7 +212,7 @@ function ThreadIcon() { } /** - * CSS styles for the message list + * CSS styles for the message list - Dark mode styling matching v1 dashboard */ export const messageListStyles = ` .message-list { @@ -214,6 +220,7 @@ export const messageListStyles = ` flex-direction: column; gap: 4px; padding: 16px; + background: #222529; } .message-list-empty { @@ -222,24 +229,26 @@ export const messageListStyles = ` align-items: center; justify-content: center; height: 100%; - color: #888; + color: #8d8d8e; text-align: center; } .message-list-empty svg { margin-bottom: 16px; opacity: 0.5; + color: #5f6368; } .message-list-empty h3 { margin: 0 0 8px; font-size: 16px; - color: #666; + color: #ababad; } .message-list-empty p { margin: 0; font-size: 13px; + color: #8d8d8e; } .message-item { @@ -251,12 +260,12 @@ export const messageListStyles = ` } .message-item:hover { - background: #f9f9f9; + background: rgba(255, 255, 255, 0.03); } .message-item.highlighted { - background: #fffbeb; - border-left: 3px solid #f59e0b; + background: rgba(232, 164, 39, 0.1); + border-left: 3px solid #e8a427; padding-left: 9px; } @@ -270,6 +279,7 @@ export const messageListStyles = ` justify-content: center; font-weight: 600; font-size: 12px; + color: white; } .message-content { @@ -287,22 +297,22 @@ export const messageListStyles = ` .message-sender { font-weight: 600; font-size: 14px; - color: #1a1a1a; + color: #d1d2d3; } .message-arrow { - color: #888; + color: #8d8d8e; font-size: 12px; } .message-recipient { font-weight: 500; font-size: 13px; - color: #666; + color: #1d9bd1; } .message-time { - color: #888; + color: #8d8d8e; font-size: 11px; margin-left: auto; } @@ -316,25 +326,47 @@ export const messageListStyles = ` } .message-badge.broadcast { - background: #dbeafe; - color: #1d4ed8; + background: rgba(232, 164, 39, 0.15); + color: #e8a427; } .message-body { font-size: 14px; line-height: 1.5; - color: #333; + color: #d1d2d3; white-space: pre-wrap; word-break: break-word; } +.message-body code { + font-family: 'JetBrains Mono', ui-monospace, monospace; + font-size: 13px; + background: rgba(255, 255, 255, 0.06); + padding: 2px 4px; + border-radius: 3px; + color: #00ffc8; +} + +.message-body pre { + font-family: 'JetBrains Mono', ui-monospace, monospace; + font-size: 13px; + background: rgba(0, 0, 0, 0.3); + padding: 12px; + border-radius: 4px; + margin: 8px 0; + overflow-x: auto; + white-space: pre-wrap; + word-wrap: break-word; +} + .message-link { - color: #1264a3; + color: #1d9bd1; text-decoration: none; } .message-link:hover { text-decoration: underline; + color: #4ab8e8; } .message-thread-btn { @@ -343,19 +375,19 @@ export const messageListStyles = ` gap: 4px; margin-top: 8px; padding: 4px 8px; - background: transparent; - border: 1px solid #e8e8e8; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 4px; - color: #666; + color: #1d9bd1; font-size: 12px; cursor: pointer; transition: all 0.15s; } .message-thread-btn:hover { - background: #f5f5f5; - border-color: #d0d0d0; - color: #333; + background: rgba(255, 255, 255, 0.08); + border-color: rgba(255, 255, 255, 0.2); + color: #4ab8e8; } .message-thread-btn svg { diff --git a/src/dashboard-v2/react-components/NotificationToast.tsx b/src/dashboard/react-components/NotificationToast.tsx similarity index 100% rename from src/dashboard-v2/react-components/NotificationToast.tsx rename to src/dashboard/react-components/NotificationToast.tsx diff --git a/src/dashboard-v2/react-components/ServerCard.tsx b/src/dashboard/react-components/ServerCard.tsx similarity index 100% rename from src/dashboard-v2/react-components/ServerCard.tsx rename to src/dashboard/react-components/ServerCard.tsx diff --git a/src/dashboard-v2/react-components/SettingsPanel.tsx b/src/dashboard/react-components/SettingsPanel.tsx similarity index 98% rename from src/dashboard-v2/react-components/SettingsPanel.tsx rename to src/dashboard/react-components/SettingsPanel.tsx index 088fc7541..3b51aee1e 100644 --- a/src/dashboard-v2/react-components/SettingsPanel.tsx +++ b/src/dashboard/react-components/SettingsPanel.tsx @@ -67,15 +67,16 @@ export function SettingsPanel({ const [activeTab, setActiveTab] = useState<'appearance' | 'notifications' | 'connection'>('appearance'); const updateSetting = useCallback( - ( - category: K, - key: keyof Settings[K], - value: Settings[K][keyof Settings[K]] + ( + category: 'notifications' | 'display' | 'connection', + key: string, + value: boolean | number ) => { + const categorySettings = settings[category]; onSettingsChange({ ...settings, [category]: { - ...settings[category], + ...categorySettings, [key]: value, }, }); diff --git a/src/dashboard-v2/react-components/SpawnModal.tsx b/src/dashboard/react-components/SpawnModal.tsx similarity index 100% rename from src/dashboard-v2/react-components/SpawnModal.tsx rename to src/dashboard/react-components/SpawnModal.tsx diff --git a/src/dashboard-v2/react-components/ThemeProvider.tsx b/src/dashboard/react-components/ThemeProvider.tsx similarity index 100% rename from src/dashboard-v2/react-components/ThemeProvider.tsx rename to src/dashboard/react-components/ThemeProvider.tsx diff --git a/src/dashboard-v2/react-components/ThinkingIndicator.tsx b/src/dashboard/react-components/ThinkingIndicator.tsx similarity index 100% rename from src/dashboard-v2/react-components/ThinkingIndicator.tsx rename to src/dashboard/react-components/ThinkingIndicator.tsx diff --git a/src/dashboard-v2/react-components/TrajectoryViewer.tsx b/src/dashboard/react-components/TrajectoryViewer.tsx similarity index 100% rename from src/dashboard-v2/react-components/TrajectoryViewer.tsx rename to src/dashboard/react-components/TrajectoryViewer.tsx diff --git a/src/dashboard-v2/react-components/hooks/index.ts b/src/dashboard/react-components/hooks/index.ts similarity index 100% rename from src/dashboard-v2/react-components/hooks/index.ts rename to src/dashboard/react-components/hooks/index.ts diff --git a/src/dashboard-v2/react-components/hooks/useAgents.ts b/src/dashboard/react-components/hooks/useAgents.ts similarity index 100% rename from src/dashboard-v2/react-components/hooks/useAgents.ts rename to src/dashboard/react-components/hooks/useAgents.ts diff --git a/src/dashboard-v2/react-components/hooks/useMessages.ts b/src/dashboard/react-components/hooks/useMessages.ts similarity index 99% rename from src/dashboard-v2/react-components/hooks/useMessages.ts rename to src/dashboard/react-components/hooks/useMessages.ts index 3d31f9251..b3c651bab 100644 --- a/src/dashboard-v2/react-components/hooks/useMessages.ts +++ b/src/dashboard/react-components/hooks/useMessages.ts @@ -95,7 +95,7 @@ export function useMessages({ setSendError(result.error || 'Failed to send message'); return false; - } catch (error) { + } catch (_error) { setSendError('Network error'); return false; } finally { diff --git a/src/dashboard-v2/react-components/hooks/useWebSocket.ts b/src/dashboard/react-components/hooks/useWebSocket.ts similarity index 100% rename from src/dashboard-v2/react-components/hooks/useWebSocket.ts rename to src/dashboard/react-components/hooks/useWebSocket.ts diff --git a/src/dashboard-v2/react-components/index.ts b/src/dashboard/react-components/index.ts similarity index 100% rename from src/dashboard-v2/react-components/index.ts rename to src/dashboard/react-components/index.ts diff --git a/src/dashboard-v2/react-components/layout/Header.tsx b/src/dashboard/react-components/layout/Header.tsx similarity index 100% rename from src/dashboard-v2/react-components/layout/Header.tsx rename to src/dashboard/react-components/layout/Header.tsx diff --git a/src/dashboard-v2/react-components/layout/Sidebar.tsx b/src/dashboard/react-components/layout/Sidebar.tsx similarity index 79% rename from src/dashboard-v2/react-components/layout/Sidebar.tsx rename to src/dashboard/react-components/layout/Sidebar.tsx index 6121bd53b..3b9b77a71 100644 --- a/src/dashboard-v2/react-components/layout/Sidebar.tsx +++ b/src/dashboard/react-components/layout/Sidebar.tsx @@ -18,6 +18,7 @@ export interface SidebarProps { onAgentSelect?: (agent: Agent) => void; onViewModeChange?: (mode: 'local' | 'fleet') => void; onSpawnClick?: () => void; + onReleaseClick?: (agent: Agent) => void; } export function Sidebar({ @@ -29,6 +30,7 @@ export function Sidebar({ onAgentSelect, onViewModeChange, onSpawnClick, + onReleaseClick, }: SidebarProps) { const [searchQuery, setSearchQuery] = useState(''); @@ -83,6 +85,7 @@ export function Sidebar({ selectedAgent={selectedAgent} searchQuery={searchQuery} onAgentSelect={onAgentSelect} + onReleaseClick={onReleaseClick} compact={true} showGroupStats={true} /> @@ -90,6 +93,10 @@ export function Sidebar({ {/* Footer Actions */}
    + + + Metrics +