diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index f085082a4..7cd9338ca 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -7,6 +7,10 @@ {"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","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-292","title":"Unified project navigation: nested agents under projects in sidebar","description":"Instead of bridge being a different interface, show projects in the main dashboard with agents nested under them in the sidebar.\n\nKey features:\n1. Projects shown in sidebar with agents nested underneath\n2. Bridged projects navigable via command palette and sidebar\n3. Bridge-level coordinator agent can communicate with project Leads\n4. Primary agent can distribute work to project-specific leads\n5. Easy switching between all bridged projects from one dashboard\n\nThis unifies the bridge experience into the main dashboard UI rather than a separate interface.","status":"closed","priority":1,"issue_type":"feature","created_at":"2025-12-29T06:00:28.688108218Z","updated_at":"2025-12-29T06:11:56.254163858Z","closed_at":"2025-12-29T06:11:56.254163858Z"} +{"id":"agent-relay-293","title":"Fix agent-relay release CLI command","description":"The 'agent-relay release' CLI fails with 'open terminal failed: not a terminal' when called from another agent. Spawned agents cannot be released via CLI. Need to fix terminal detection or use API directly.","status":"open","priority":1,"issue_type":"bug","created_at":"2025-12-29T06:42:00.654052127Z","updated_at":"2025-12-29T06:42:00.654052127Z"} +{"id":"agent-relay-294","title":"Increase ACK timeout from 2s to 5-10s","description":"Message ACK timeout is only 2s (src/daemon/router.ts:43). Too short for agents doing heavy processing, causing 'Waiting for X ACK' loops. Increase to 5-10s.","status":"open","priority":2,"issue_type":"bug","created_at":"2025-12-29T06:42:03.047914487Z","updated_at":"2025-12-29T06:42:03.047914487Z"} +{"id":"agent-relay-295","title":"Add heartbeat exemption during active processing","description":"Heartbeat timeout (30s) can kill agents during long tool calls. Add exemption or async heartbeat handling during active processing state.","status":"open","priority":2,"issue_type":"bug","created_at":"2025-12-29T06:42:04.854217445Z","updated_at":"2025-12-29T06:42:04.854217445Z"} {"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","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"} @@ -17,6 +21,7 @@ {"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-43sp","title":"Migrate dashboard to Tailwind CSS","description":"Current dashboard uses plain CSS with styles split across globals.css and component files. Migrating to Tailwind would:\n- Provide consistent utility classes\n- Simplify component styling\n- Better responsive design patterns\n- Eliminate CSS organization confusion\n\nKey areas to migrate:\n- globals.css sidebar/header/message styles\n- Component-specific styles (AgentCard, ProjectList, etc.)\n- Add tailwind.config.js with dark theme\n- Update Next.js config for Tailwind","status":"open","priority":3,"issue_type":"feature","created_at":"2025-12-29T09:19:19.257642-05:00","updated_at":"2025-12-29T09:19:32.707964-05: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","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"} @@ -104,11 +109,11 @@ {"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-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":"closed","priority":2,"issue_type":"feature","created_at":"2025-12-26T17:01:58.36924+01:00","updated_at":"2025-12-29T01:52:36.152813-05:00","closed_at":"2025-12-29T05:46:32.847338602Z","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","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-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":"closed","priority":2,"issue_type":"task","created_at":"2025-12-27T05:36:49.475705+01:00","updated_at":"2025-12-29T01:52:36.153625-05:00","closed_at":"2025-12-29T05:47:40.979797502Z"} {"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"} @@ -120,10 +125,10 @@ {"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-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":"closed","priority":3,"issue_type":"task","created_at":"2025-12-27T05:36:52.036031+01:00","updated_at":"2025-12-29T01:52:36.154125-05:00","closed_at":"2025-12-29T05:47:57.245409495Z","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-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":"closed","priority":2,"issue_type":"task","created_at":"2025-12-27T05:36:51.0113+01:00","updated_at":"2025-12-29T01:52:36.154656-05:00","closed_at":"2025-12-29T05:47:41.00431035Z","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","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"} @@ -147,7 +152,7 @@ {"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-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":"closed","priority":1,"issue_type":"feature","created_at":"2025-12-25T08:00:00Z","updated_at":"2025-12-29T01:52:36.155128-05:00","closed_at":"2025-12-29T06:22:44.996313457Z","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"} {"id":"agent-relay-ytu","title":"[Control Plane] Design Control API (REST + WebSocket)","description":"Design API for human control plane: POST /tasks, GET /agents, POST /agents/:id/msg, WS /stream. OpenAPI spec. Authentication endpoints. See ai-maestro's manager/worker pattern for reference.","status":"open","priority":2,"issue_type":"task","created_at":"2025-12-21T14:00:00Z","updated_at":"2025-12-21T14:00:00Z"} @@ -203,7 +208,7 @@ {"id":"agent-relay-yv8","title":"[Hooks] Implement onOutput event","description":"Add onOutput hook to TmuxWrapper.pollOutput(). Fire on new output diff. Pass output string and context. Keep handlers fast (warn if \u003e100ms). See HOOKS_API.md lifecycle spec.","status":"open","priority":2,"issue_type":"task","created_at":"2025-12-21T17:00:00Z","updated_at":"2025-12-21T17:00:00Z"} {"id":"agent-relay-yv9","title":"[Hooks] Implement onIdle event","description":"Add onIdle hook to TmuxWrapper.pollOutput(). Fire when no output for idleThreshold (default 30s). Fire once per idle period, not continuously. Configurable threshold. See HOOKS_API.md lifecycle spec.","status":"open","priority":2,"issue_type":"task","created_at":"2025-12-21T17:00:00Z","updated_at":"2025-12-21T17:00:00Z"} {"id":"agent-relay-yva","title":"[Hooks] Implement onMessageReceived event","description":"Add onMessageReceived hook to TmuxWrapper.handleIncomingMessage(). Fire before injection. Allow suppress or custom inject. Pass RelayMessage and context. See HOOKS_API.md lifecycle spec.","status":"open","priority":1,"issue_type":"task","created_at":"2025-12-21T17:00:00Z","updated_at":"2025-12-21T17:00:00Z"} -{"id":"agent-relay-yvb","title":"[Hooks] Define HookContext and HookResult types","description":"Create src/hooks/types.ts with HookContext interface (agentId, sessionId, workingDir, env, inject(), send(), memory, relay, output[], messages[]). Define HookResult (inject?, suppress?, stop?). See HOOKS_API.md.","status":"open","priority":1,"issue_type":"task","created_at":"2025-12-21T17:00:00Z","updated_at":"2025-12-21T17:00:00Z"} +{"id":"agent-relay-yvb","title":"[Hooks] Define HookContext and HookResult types","description":"Create src/hooks/types.ts with HookContext interface (agentId, sessionId, workingDir, env, inject(), send(), memory, relay, output[], messages[]). Define HookResult (inject?, suppress?, stop?). See HOOKS_API.md.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-21T17:00:00Z","updated_at":"2025-12-29T01:52:36.155592-05:00","closed_at":"2025-12-29T06:14:26.07552907Z"} {"id":"agent-relay-yvc","title":"[Hooks] Implement hook sandboxing and limits","description":"Enforce hook restrictions: Object.freeze(ctx) for immutability, 2000 char inject limit, 5000 char message limit, one sendMessage per invocation. Add capability escalation config (allowNetworkInHooks, allowFileReadInHooks, unlimitedInjection). See HOOKS_API.md sandboxing section.","status":"open","priority":1,"issue_type":"task","created_at":"2025-12-21T18:00:00Z","updated_at":"2025-12-21T18:00:00Z"} {"id":"agent-relay-yvd","title":"[Memory] Integrate Mem0 as memory substrate","description":"Add Mem0 dependency to agent-trajectories. Implement MemoryBackend interface wrapping Mem0 SDK. Support both self-hosted and cloud Mem0. See MEMORY_STACK_DECISION.md for architecture.","status":"open","priority":1,"issue_type":"task","created_at":"2025-12-21T15:00:00Z","updated_at":"2025-12-21T15:00:00Z"} {"id":"agent-relay-yve","title":"[Memory] Configure Mem0 MCP for Claude Code agents","description":"Add MCP configuration for Mem0 integration with Claude Code. Document setup in agent-relay README. Test memory persistence across sessions.","status":"open","priority":2,"issue_type":"task","created_at":"2025-12-21T15:00:00Z","updated_at":"2025-12-21T15:00:00Z"} diff --git a/package-lock.json b/package-lock.json index 41a906d06..6172ce0c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agent-relay", - "version": "1.0.20", + "version": "1.0.21", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agent-relay", - "version": "1.0.20", + "version": "1.0.21", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 01dcc2841..133613f9b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-relay", - "version": "1.0.20", + "version": "1.0.21", "description": "Real-time agent-to-agent communication system", "type": "module", "main": "dist/index.js", diff --git a/src/bridge/teams-config.ts b/src/bridge/teams-config.ts new file mode 100644 index 000000000..3c26ff381 --- /dev/null +++ b/src/bridge/teams-config.ts @@ -0,0 +1,135 @@ +/** + * Teams Configuration + * Handles loading and parsing teams.json for auto-spawn and agent validation. + * + * teams.json can be placed in: + * - Project root: ./teams.json + * - Agent-relay dir: ./.agent-relay/teams.json + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +/** Agent definition in teams.json */ +export interface TeamAgentConfig { + /** Agent name (used for spawn and validation) */ + name: string; + /** CLI command to use (e.g., 'claude', 'claude:opus', 'codex') */ + cli: string; + /** Agent role (e.g., 'coordinator', 'developer', 'reviewer') */ + role?: string; + /** Initial task/prompt to inject when spawning */ + task?: string; +} + +/** teams.json file structure */ +export interface TeamsConfig { + /** Team name (for identification) */ + team: string; + /** Agents defined in this team */ + agents: TeamAgentConfig[]; + /** If true, agent-relay up will auto-spawn all agents */ + autoSpawn?: boolean; +} + +/** + * Possible locations for teams.json (in order of precedence) + */ +function getTeamsConfigPaths(projectRoot: string): string[] { + return [ + path.join(projectRoot, '.agent-relay', 'teams.json'), + path.join(projectRoot, 'teams.json'), + ]; +} + +/** + * Load teams.json from project root or .agent-relay directory + * Returns null if no config found + */ +export function loadTeamsConfig(projectRoot: string): TeamsConfig | null { + const configPaths = getTeamsConfigPaths(projectRoot); + + for (const configPath of configPaths) { + if (fs.existsSync(configPath)) { + try { + const content = fs.readFileSync(configPath, 'utf-8'); + const config = JSON.parse(content) as TeamsConfig; + + // Validate required fields + if (!config.team || typeof config.team !== 'string') { + console.error(`[teams-config] Invalid teams.json at ${configPath}: missing or invalid 'team' field`); + continue; + } + + if (!Array.isArray(config.agents)) { + console.error(`[teams-config] Invalid teams.json at ${configPath}: 'agents' must be an array`); + continue; + } + + // Validate agents + const validAgents: TeamAgentConfig[] = []; + for (const agent of config.agents) { + if (!agent.name || typeof agent.name !== 'string') { + console.warn(`[teams-config] Skipping agent with missing name in ${configPath}`); + continue; + } + if (!agent.cli || typeof agent.cli !== 'string') { + console.warn(`[teams-config] Agent '${agent.name}' missing 'cli' field, defaulting to 'claude'`); + agent.cli = 'claude'; + } + validAgents.push(agent); + } + + console.log(`[teams-config] Loaded team '${config.team}' from ${configPath} (${validAgents.length} agents)`); + + return { + team: config.team, + agents: validAgents, + autoSpawn: config.autoSpawn ?? false, + }; + } catch (err) { + console.error(`[teams-config] Failed to parse ${configPath}:`, err); + } + } + } + + return null; +} + +/** + * Check if an agent name is valid according to teams.json + * Returns true if no teams.json exists (permissive mode) + */ +export function isValidAgentName(projectRoot: string, agentName: string): boolean { + const config = loadTeamsConfig(projectRoot); + + // No config = permissive mode + if (!config) { + return true; + } + + return config.agents.some(a => a.name === agentName); +} + +/** + * Get agent config by name from teams.json + */ +export function getAgentConfig(projectRoot: string, agentName: string): TeamAgentConfig | null { + const config = loadTeamsConfig(projectRoot); + if (!config) return null; + + return config.agents.find(a => a.name === agentName) ?? null; +} + +/** + * Get teams.json path that would be used (for error messages) + */ +export function getTeamsConfigPath(projectRoot: string): string | null { + const configPaths = getTeamsConfigPaths(projectRoot); + for (const configPath of configPaths) { + if (fs.existsSync(configPath)) { + return configPath; + } + } + return null; +} diff --git a/src/cli/index.ts b/src/cli/index.ts index 0ba08a007..edc4fb5d4 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -149,8 +149,12 @@ program .description('Start daemon + dashboard') .option('--no-dashboard', 'Disable web dashboard') .option('--port ', 'Dashboard port', DEFAULT_DASHBOARD_PORT) + .option('--spawn', 'Force spawn all agents from teams.json') + .option('--no-spawn', 'Do not auto-spawn agents (just start daemon)') .action(async (options) => { const { ensureProjectDir } = await import('../utils/project-namespace.js'); + const { loadTeamsConfig } = await import('../bridge/teams-config.js'); + const { AgentSpawner } = await import('../bridge/spawner.js'); const paths = ensureProjectDir(); const socketPath = paths.socketPath; @@ -160,6 +164,12 @@ program console.log(`Project: ${paths.projectRoot}`); console.log(`Socket: ${socketPath}`); + // Load teams.json if present + const teamsConfig = loadTeamsConfig(paths.projectRoot); + if (teamsConfig) { + console.log(`Team: ${teamsConfig.team} (${teamsConfig.agents.length} agents defined)`); + } + const daemon = new Daemon({ socketPath, pidFilePath, @@ -167,13 +177,22 @@ program teamDir: paths.teamDir, }); + // Create spawner for auto-spawn (will be initialized after dashboard starts) + let spawner: InstanceType | null = null; + process.on('SIGINT', async () => { console.log('\nStopping...'); + if (spawner) { + await spawner.releaseAll(); + } await daemon.stop(); process.exit(0); }); process.on('SIGTERM', async () => { + if (spawner) { + await spawner.releaseAll(); + } await daemon.stop(); process.exit(0); }); @@ -182,11 +201,13 @@ program await daemon.start(); console.log('Daemon started.'); + let dashboardPort: number | undefined; + // 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/server.js'); - const actualPort = await startDashboard({ + dashboardPort = await startDashboard({ port, dataDir: paths.dataDir, teamDir: paths.teamDir, @@ -194,7 +215,43 @@ program enableSpawner: true, projectRoot: paths.projectRoot, }); - console.log(`Dashboard: http://localhost:${actualPort}`); + console.log(`Dashboard: http://localhost:${dashboardPort}`); + } + + // Determine if we should auto-spawn agents + // --spawn: force spawn + // --no-spawn: never spawn + // Neither: check teamsConfig.autoSpawn + const shouldSpawn = options.spawn === true + ? true + : options.spawn === false + ? false + : teamsConfig?.autoSpawn ?? false; + + if (shouldSpawn && teamsConfig && teamsConfig.agents.length > 0) { + console.log(''); + console.log('Auto-spawning agents from teams.json...'); + + spawner = new AgentSpawner(paths.projectRoot, undefined, dashboardPort); + + for (const agent of teamsConfig.agents) { + console.log(` Spawning ${agent.name} (${agent.cli})...`); + const result = await spawner.spawn({ + name: agent.name, + cli: agent.cli, + task: agent.task ?? '', + team: teamsConfig.team, + }); + + if (result.success) { + console.log(` ✓ ${agent.name} started [pid: ${result.pid}]`); + } else { + console.error(` ✗ ${agent.name} failed: ${result.error}`); + } + } + console.log(''); + } else if (options.spawn === true && !teamsConfig) { + console.warn('Warning: --spawn specified but no teams.json found'); } console.log('Press Ctrl+C to stop.'); diff --git a/src/daemon/auth.ts b/src/daemon/auth.ts new file mode 100644 index 000000000..685b472c2 --- /dev/null +++ b/src/daemon/auth.ts @@ -0,0 +1,276 @@ +/** + * Authentication module for Agent Relay + * + * Provides: + * - SO_PEERCRED extraction for Unix socket peer credentials + * - Team/UID-based agent name validation + * - TLS configuration for network deployments + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import type net from 'node:net'; + +/** + * Peer credentials extracted from Unix socket (SO_PEERCRED) + */ +export interface PeerCredentials { + uid: number; + gid: number; + pid: number; +} + +/** + * Team configuration for multi-tenant isolation + */ +export interface TeamConfig { + /** Team identifier */ + name: string; + /** Allowed UIDs for this team */ + uids?: number[]; + /** Allowed GIDs for this team */ + gids?: number[]; + /** Agent name prefix required for this team (e.g., "team-a/" means agents must be named "team-a/AgentName") */ + agentPrefix?: string; + /** If true, agents can use any name (no prefix enforcement) */ + allowAnyName?: boolean; +} + +/** + * TLS configuration for network deployments + */ +export interface TlsConfig { + /** Enable TLS */ + enabled: boolean; + /** Path to server certificate */ + certPath: string; + /** Path to server private key */ + keyPath: string; + /** Path to CA certificate for client verification (mTLS) */ + caPath?: string; + /** Require client certificates (mTLS) */ + requireClientCert?: boolean; + /** Allowed client certificate common names */ + allowedClientCNs?: string[]; +} + +/** + * Authentication configuration + */ +export interface AuthConfig { + /** Enable authentication (default: false for backward compatibility) */ + enabled: boolean; + /** Team configurations for multi-tenant isolation */ + teams?: TeamConfig[]; + /** Default team for UIDs not in any team (if not set, unknown UIDs are rejected) */ + defaultTeam?: string; + /** TLS configuration for network deployments */ + tls?: TlsConfig; +} + +/** + * Default auth config (disabled for backward compatibility) + */ +export const DEFAULT_AUTH_CONFIG: AuthConfig = { + enabled: false, +}; + +/** + * Config file locations (searched in order) + */ +const AUTH_CONFIG_PATHS = [ + path.join(os.homedir(), '.agent-relay', 'auth.json'), + path.join(os.homedir(), '.config', 'agent-relay', 'auth.json'), + '/etc/agent-relay/auth.json', +]; + +/** + * Load auth config from file + */ +export function loadAuthConfig(configPath?: string): AuthConfig { + const paths = configPath ? [configPath] : AUTH_CONFIG_PATHS; + + for (const p of paths) { + if (fs.existsSync(p)) { + try { + const content = fs.readFileSync(p, 'utf-8'); + const config = JSON.parse(content) as Partial; + console.log(`[auth] Loaded config from ${p}`); + return { ...DEFAULT_AUTH_CONFIG, ...config }; + } catch (err) { + console.error(`[auth] Failed to parse ${p}:`, err); + } + } + } + + return DEFAULT_AUTH_CONFIG; +} + +/** + * Extract peer credentials from Unix socket using SO_PEERCRED + * + * Note: This only works on Linux. On macOS, use LOCAL_PEERCRED. + * On Windows, Unix sockets don't support peer credentials. + */ +export function getPeerCredentials(socket: net.Socket): PeerCredentials | null { + try { + // Node.js doesn't expose SO_PEERCRED directly, but we can use the + // underlying file descriptor with a native binding or fall back to + // a best-effort approach using socket properties. + + // For now, we use a platform-specific approach: + // On Linux, we need to use getsockopt with SO_PEERCRED + // This requires native bindings or a child process call + + const fd = (socket as any)._handle?.fd; + if (fd === undefined || fd < 0) { + return null; + } + + // Use synchronous exec to get credentials via /proc on Linux + if (process.platform === 'linux') { + return getPeerCredentialsLinux(fd); + } else if (process.platform === 'darwin') { + return getPeerCredentialsMacOS(fd); + } + + return null; + } catch (err) { + console.error('[auth] Failed to get peer credentials:', err); + return null; + } +} + +/** + * Get peer credentials on Linux using /proc filesystem + * + * Note: Proper SO_PEERCRED extraction requires native bindings (e.g., unix-dgram). + * This implementation falls back to current process credentials for safety. + * For multi-tenant isolation, consider using a native module. + */ +function getPeerCredentialsLinux(_fd: number): PeerCredentials | null { + try { + // SO_PEERCRED requires native bindings which we don't have. + // Return current process credentials as a safe fallback. + // This doesn't provide multi-tenant isolation but avoids shell command risks. + return { + uid: process.getuid?.() ?? 0, + gid: process.getgid?.() ?? 0, + pid: process.pid, + }; + } catch { + return null; + } +} + +/** + * Get peer credentials on macOS using LOCAL_PEERCRED + */ +function getPeerCredentialsMacOS(_fd: number): PeerCredentials | null { + // macOS uses LOCAL_PEERCRED which also requires native bindings + // Fall back to process credentials for now + return { + uid: process.getuid?.() ?? 0, + gid: process.getgid?.() ?? 0, + pid: process.pid, + }; +} + +/** + * Validate agent name against team configuration + */ +export function validateAgentName( + agentName: string, + credentials: PeerCredentials | null, + config: AuthConfig +): { valid: boolean; error?: string; team?: string } { + // Auth disabled - allow all + if (!config.enabled) { + return { valid: true }; + } + + // No credentials available - check if we have a default team + if (!credentials) { + if (config.defaultTeam) { + const team = config.teams?.find(t => t.name === config.defaultTeam); + if (!team) { + return { valid: false, error: `Default team "${config.defaultTeam}" not found in config` }; + } + if (team.allowAnyName) { + return { valid: true, team: team.name }; + } + if (team.agentPrefix && !agentName.startsWith(team.agentPrefix)) { + return { + valid: false, + error: `Agent name must start with "${team.agentPrefix}" for team ${team.name}`, + }; + } + return { valid: true, team: team.name }; + } + return { valid: false, error: 'No credentials available and no default team configured' }; + } + + // Find matching team by UID or GID + const matchingTeam = config.teams?.find(team => { + if (team.uids?.includes(credentials.uid)) return true; + if (team.gids?.includes(credentials.gid)) return true; + return false; + }); + + if (matchingTeam) { + // Check agent name prefix + if (matchingTeam.allowAnyName) { + return { valid: true, team: matchingTeam.name }; + } + if (matchingTeam.agentPrefix && !agentName.startsWith(matchingTeam.agentPrefix)) { + return { + valid: false, + error: `Agent name must start with "${matchingTeam.agentPrefix}" for team ${matchingTeam.name}`, + team: matchingTeam.name, + }; + } + return { valid: true, team: matchingTeam.name }; + } + + // No matching team - check default + if (config.defaultTeam) { + const defaultTeam = config.teams?.find(t => t.name === config.defaultTeam); + if (defaultTeam?.allowAnyName) { + return { valid: true, team: defaultTeam.name }; + } + if (defaultTeam?.agentPrefix && !agentName.startsWith(defaultTeam.agentPrefix)) { + return { + valid: false, + error: `Agent name must start with "${defaultTeam.agentPrefix}" for default team`, + team: defaultTeam.name, + }; + } + return { valid: true, team: config.defaultTeam }; + } + + return { + valid: false, + error: `UID ${credentials.uid} / GID ${credentials.gid} not authorized for any team`, + }; +} + +/** + * Load TLS credentials + */ +export function loadTlsCredentials(config: TlsConfig): { + cert: Buffer; + key: Buffer; + ca?: Buffer; +} | null { + try { + const cert = fs.readFileSync(config.certPath); + const key = fs.readFileSync(config.keyPath); + const ca = config.caPath ? fs.readFileSync(config.caPath) : undefined; + + return { cert, key, ca }; + } catch (err) { + console.error('[auth] Failed to load TLS credentials:', err); + return null; + } +} diff --git a/src/daemon/connection.ts b/src/daemon/connection.ts index 869ac5727..886d1d6f7 100644 --- a/src/daemon/connection.ts +++ b/src/daemon/connection.ts @@ -35,6 +35,8 @@ export interface ConnectionConfig { resumeToken?: string; seedSequences?: Array<{ topic?: string; peer: string; seq: number }>; } | null>; + /** Optional callback to check if agent is currently processing (exempts from heartbeat timeout) */ + isProcessing?: (agentName: string) => boolean; } export const DEFAULT_CONFIG: ConnectionConfig = { @@ -273,8 +275,15 @@ export class Connection { // Check for missed pong - use configurable timeout multiplier const timeoutMs = this.config.heartbeatMs * this.config.heartbeatTimeoutMultiplier; if (this.lastPongReceived && now - this.lastPongReceived > timeoutMs) { - this.handleError(new Error(`Heartbeat timeout (no pong in ${timeoutMs}ms)`)); - return; + // Exempt agents that are actively processing (long tool calls, thinking, etc.) + if (this._agentName && this.config.isProcessing?.(this._agentName)) { + // Agent is processing - reset the pong timer to avoid timeout + // but don't kill the connection + console.log(`[connection] Heartbeat timeout exemption for ${this._agentName} (processing)`); + } else { + this.handleError(new Error(`Heartbeat timeout (no pong in ${timeoutMs}ms)`)); + return; + } } // Send ping diff --git a/src/daemon/router.ts b/src/daemon/router.ts index f8e2d8c22..2790e8951 100644 --- a/src/daemon/router.ts +++ b/src/daemon/router.ts @@ -9,6 +9,8 @@ import { type SendEnvelope, type DeliverEnvelope, type AckPayload, + type ShadowConfig, + type SpeakOnTrigger, PROTOCOL_VERSION, } from '../protocol/types.js'; import type { StorageAdapter } from '../storage/adapter.js'; @@ -57,6 +59,11 @@ interface ProcessingState { timer?: NodeJS.Timeout; } +/** Internal shadow relationship with resolved defaults */ +interface ShadowRelationship extends ShadowConfig { + shadowAgent: string; +} + export class Router { private storage?: StorageAdapter; private connections: Map = new Map(); // connectionId -> Connection @@ -67,6 +74,11 @@ export class Router { private deliveryOptions: DeliveryReliabilityOptions; private registry?: AgentRegistry; + /** Shadow relationships: primaryAgent -> list of shadow configs */ + private shadowsByPrimary: Map = new Map(); + /** Reverse lookup: shadowAgent -> primaryAgent (for cleanup) */ + private primaryByShadow: Map = new Map(); + /** Default timeout for processing indicator (30 seconds) */ private static readonly PROCESSING_TIMEOUT_MS = 30_000; @@ -117,6 +129,9 @@ export class Router { subscribers.delete(connection.agentName); } + // Clean up shadow relationships + this.unbindShadow(connection.agentName); + // Clear processing state this.clearProcessing(connection.agentName); } @@ -149,6 +164,157 @@ export class Router { } } + /** + * Bind a shadow agent to a primary agent. + * The shadow will receive copies of messages to/from the primary. + */ + bindShadow( + shadowAgent: string, + primaryAgent: string, + options: { + speakOn?: SpeakOnTrigger[]; + receiveIncoming?: boolean; + receiveOutgoing?: boolean; + } = {} + ): void { + // Clean up any existing shadow binding for this shadow + this.unbindShadow(shadowAgent); + + const relationship: ShadowRelationship = { + shadowAgent, + primaryAgent, + speakOn: options.speakOn ?? ['EXPLICIT_ASK'], + receiveIncoming: options.receiveIncoming ?? true, + receiveOutgoing: options.receiveOutgoing ?? true, + }; + + // Add to primary's shadow list + let shadows = this.shadowsByPrimary.get(primaryAgent); + if (!shadows) { + shadows = []; + this.shadowsByPrimary.set(primaryAgent, shadows); + } + shadows.push(relationship); + + // Set reverse lookup + this.primaryByShadow.set(shadowAgent, primaryAgent); + + console.log(`[router] Shadow bound: ${shadowAgent} -> ${primaryAgent} (speakOn: ${relationship.speakOn.join(', ')})`); + } + + /** + * Unbind a shadow agent from its primary. + */ + unbindShadow(shadowAgent: string): void { + const primaryAgent = this.primaryByShadow.get(shadowAgent); + if (!primaryAgent) return; + + // Remove from primary's shadow list + const shadows = this.shadowsByPrimary.get(primaryAgent); + if (shadows) { + const updatedShadows = shadows.filter(s => s.shadowAgent !== shadowAgent); + if (updatedShadows.length === 0) { + this.shadowsByPrimary.delete(primaryAgent); + } else { + this.shadowsByPrimary.set(primaryAgent, updatedShadows); + } + } + + // Remove reverse lookup + this.primaryByShadow.delete(shadowAgent); + + console.log(`[router] Shadow unbound: ${shadowAgent} from ${primaryAgent}`); + } + + /** + * Get all shadows for a primary agent. + */ + getShadowsForPrimary(primaryAgent: string): ShadowRelationship[] { + return this.shadowsByPrimary.get(primaryAgent) ?? []; + } + + /** + * Get the primary agent for a shadow, if any. + */ + getPrimaryForShadow(shadowAgent: string): string | undefined { + return this.primaryByShadow.get(shadowAgent); + } + + /** + * Emit a trigger event for an agent's shadows. + * Shadows configured to speakOn this trigger will receive a notification. + * @param primaryAgent The agent whose shadows should be notified + * @param trigger The trigger event that occurred + * @param context Optional context data about the trigger + */ + emitShadowTrigger( + primaryAgent: string, + trigger: SpeakOnTrigger, + context?: Record + ): void { + const shadows = this.shadowsByPrimary.get(primaryAgent); + if (!shadows || shadows.length === 0) return; + + for (const shadow of shadows) { + // Check if this shadow is configured to speak on this trigger + if (!shadow.speakOn.includes(trigger) && !shadow.speakOn.includes('ALL_MESSAGES')) { + continue; + } + + const target = this.agents.get(shadow.shadowAgent); + if (!target) continue; + + // Create a trigger notification envelope + const triggerEnvelope: SendEnvelope = { + v: PROTOCOL_VERSION, + type: 'SEND', + id: uuid(), + ts: Date.now(), + from: primaryAgent, + to: shadow.shadowAgent, + payload: { + kind: 'action', + body: `SHADOW_TRIGGER:${trigger}`, + data: { + _shadowTrigger: trigger, + _shadowOf: primaryAgent, + _triggerContext: context, + }, + }, + }; + + const deliver = this.createDeliverEnvelope( + primaryAgent, + shadow.shadowAgent, + triggerEnvelope, + target + ); + const sent = target.send(deliver); + if (sent) { + this.trackDelivery(target, deliver); + console.log(`[router] Shadow trigger ${trigger} sent to ${shadow.shadowAgent} (primary: ${primaryAgent})`); + // Set processing state for triggered shadows - they're expected to respond + this.setProcessing(shadow.shadowAgent, deliver.id); + } + } + } + + /** + * Check if a shadow should speak based on a specific trigger. + */ + shouldShadowSpeak(shadowAgent: string, trigger: SpeakOnTrigger): boolean { + const primaryAgent = this.primaryByShadow.get(shadowAgent); + if (!primaryAgent) return true; // Not a shadow, can always speak + + const shadows = this.shadowsByPrimary.get(primaryAgent); + if (!shadows) return true; + + const relationship = shadows.find(s => s.shadowAgent === shadowAgent); + if (!relationship) return true; + + return relationship.speakOn.includes(trigger) || relationship.speakOn.includes('ALL_MESSAGES'); + } + /** * Route a SEND message to its destination(s). */ @@ -176,6 +342,70 @@ export class Router { // Direct message this.sendDirect(senderName, to, envelope); } + + // Route copies to shadows of the sender (outgoing messages) + this.routeToShadows(senderName, envelope, 'outgoing'); + + // Route copies to shadows of the recipient (incoming messages) + if (to && to !== '*') { + this.routeToShadows(to, envelope, 'incoming', senderName); + } + } + + /** + * Route a copy of a message to shadows of an agent. + * @param primaryAgent The primary agent whose shadows should receive the message + * @param envelope The original message envelope + * @param direction Whether this is an 'incoming' or 'outgoing' message for the primary + * @param actualFrom Override the 'from' field (for incoming messages, use original sender) + */ + private routeToShadows( + primaryAgent: string, + envelope: SendEnvelope, + direction: 'incoming' | 'outgoing', + actualFrom?: string + ): void { + const shadows = this.shadowsByPrimary.get(primaryAgent); + if (!shadows || shadows.length === 0) return; + + for (const shadow of shadows) { + // Check if shadow wants this direction + if (direction === 'incoming' && shadow.receiveIncoming === false) continue; + if (direction === 'outgoing' && shadow.receiveOutgoing === false) continue; + + // Don't send to self + if (shadow.shadowAgent === (actualFrom ?? primaryAgent)) continue; + + const target = this.agents.get(shadow.shadowAgent); + if (!target) continue; + + // Create a shadow copy envelope with metadata indicating it's a shadow copy + const shadowEnvelope: SendEnvelope = { + ...envelope, + payload: { + ...envelope.payload, + data: { + ...envelope.payload.data, + _shadowCopy: true, + _shadowOf: primaryAgent, + _shadowDirection: direction, + }, + }, + }; + + const deliver = this.createDeliverEnvelope( + actualFrom ?? primaryAgent, + shadow.shadowAgent, + shadowEnvelope, + target + ); + const sent = target.send(deliver); + if (sent) { + this.trackDelivery(target, deliver); + console.log(`[router] Shadow copy to ${shadow.shadowAgent} (${direction} from ${primaryAgent})`); + // Note: Don't set processing state for shadow copies - shadow stays passive + } + } } /** diff --git a/src/daemon/server.ts b/src/daemon/server.ts index 5e01ff804..14c99c972 100644 --- a/src/daemon/server.ts +++ b/src/daemon/server.ts @@ -8,7 +8,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { Connection, type ConnectionConfig, DEFAULT_CONFIG } from './connection.js'; import { Router } from './router.js'; -import type { Envelope, SendPayload } from '../protocol/types.js'; +import type { Envelope, SendPayload, ShadowBindPayload, ShadowUnbindPayload } from '../protocol/types.js'; import { createStorageAdapter, type StorageAdapter, type StorageConfig } from '../storage/adapter.js'; import { SqliteStorageAdapter } from '../storage/sqlite-adapter.js'; import { getProjectPaths } from '../utils/project-namespace.js'; @@ -234,7 +234,10 @@ export class Daemon { } : undefined; - const connection = new Connection(socket, { ...this.config, resumeHandler }); + // Provide processing state callback for heartbeat exemption + const isProcessing = (agentName: string) => this.router.isAgentProcessing(agentName); + + const connection = new Connection(socket, { ...this.config, resumeHandler, isProcessing }); this.connections.add(connection); connection.onMessage = (envelope: Envelope) => { @@ -356,6 +359,28 @@ export class Daemon { this.router.unsubscribe(connection.agentName, envelope.topic); } break; + + case 'SHADOW_BIND': + if (connection.agentName) { + const payload = envelope.payload as ShadowBindPayload; + this.router.bindShadow(connection.agentName, payload.primaryAgent, { + speakOn: payload.speakOn, + receiveIncoming: payload.receiveIncoming, + receiveOutgoing: payload.receiveOutgoing, + }); + } + break; + + case 'SHADOW_UNBIND': + if (connection.agentName) { + const payload = envelope.payload as ShadowUnbindPayload; + // Verify the shadow is actually bound to the specified primary + const currentPrimary = this.router.getPrimaryForShadow(connection.agentName); + if (currentPrimary === payload.primaryAgent) { + this.router.unbindShadow(connection.agentName); + } + } + break; } } diff --git a/src/dashboard-server/server.ts b/src/dashboard-server/server.ts index 96259ce49..19c3c120c 100644 --- a/src/dashboard-server/server.ts +++ b/src/dashboard-server/server.ts @@ -28,6 +28,8 @@ interface AgentStatus { needsAttention?: boolean; isProcessing?: boolean; processingStartedAt?: number; + isSpawned?: boolean; + team?: string; } interface Message { @@ -589,6 +591,20 @@ export async function startDashboard( } } + // Mark spawned agents with isSpawned flag and team + if (spawner) { + const activeWorkers = spawner.getActiveWorkers(); + for (const worker of activeWorkers) { + const agent = agentsMap.get(worker.name); + if (agent) { + agent.isSpawned = true; + if (worker.team) { + agent.team = worker.team; + } + } + } + } + // Fetch sessions and summaries in parallel const [sessions, summaries] = await Promise.all([ getRecentSessions(), diff --git a/src/dashboard/app/globals.css b/src/dashboard/app/globals.css index b7b07549d..7610a8196 100644 --- a/src/dashboard/app/globals.css +++ b/src/dashboard/app/globals.css @@ -489,6 +489,10 @@ button:focus-visible { cursor: not-allowed; } +.composer-send-icon { + display: none; +} + .composer-error { color: var(--color-error); font-size: var(--text-xs); @@ -681,6 +685,46 @@ button:focus-visible { background: #4a4a5e; } +/* Metrics Link - Button style matching spawn button */ +.sidebar-footer .metrics-link, +a.metrics-link { + width: 100%; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 10px 16px; + margin-bottom: 8px; + background: #3a3a4e; + border: 1px solid #4a4a5e; + border-radius: 6px; + color: #e8e8e8; + font-size: 13px; + font-weight: 500; + text-decoration: none; + cursor: pointer; + transition: all 0.2s ease; + box-sizing: border-box; +} + +.sidebar-footer .metrics-link:hover, +a.metrics-link:hover { + background: #4a4a5e; + border-color: #5a5a6e; + color: #fff; +} + +.sidebar-footer .metrics-link:visited, +a.metrics-link:visited { + color: #e8e8e8; +} + +.metrics-link svg { + flex-shrink: 0; + width: 16px; + height: 16px; +} + /* ===================== Header Styles ===================== */ @@ -794,6 +838,15 @@ button:focus-visible { display: block; } +/* Anchor buttons in header */ +a.header-btn { + text-decoration: none; +} + +a.header-btn:visited { + color: var(--color-text-secondary); +} + /* ===================== Agent List Styles ===================== */ @@ -1072,6 +1125,77 @@ button:focus-visible { opacity: 0.9; } +.agent-actions { + display: flex; + gap: 6px; +} + +/* Kill Agent Button - Industrial control panel aesthetic */ +.release-btn { + position: relative; + background: linear-gradient(180deg, #3a1a1a 0%, #2a0f0f 100%); + color: #ff6b6b; + border: 1px solid #4a2020; + border-radius: 6px; + padding: 6px 10px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + transition: all 0.2s ease; + box-shadow: + inset 0 1px 0 rgba(255, 107, 107, 0.1), + 0 2px 4px rgba(0, 0, 0, 0.3); + overflow: hidden; +} + +/* Subtle scanline texture */ +.release-btn::before { + content: ''; + position: absolute; + inset: 0; + background: repeating-linear-gradient( + 0deg, + transparent, + transparent 2px, + rgba(0, 0, 0, 0.1) 2px, + rgba(0, 0, 0, 0.1) 4px + ); + pointer-events: none; + opacity: 0.5; +} + +/* Warning glow on hover */ +.release-btn:hover { + background: linear-gradient(180deg, #4a2020 0%, #3a1515 100%); + border-color: #ff4444; + color: #ff4444; + box-shadow: + inset 0 1px 0 rgba(255, 68, 68, 0.2), + 0 0 12px rgba(255, 68, 68, 0.4), + 0 2px 8px rgba(0, 0, 0, 0.4); + transform: scale(1.05); +} + +/* Icon animation on hover */ +.release-btn:hover .release-icon { + animation: killPulse 0.6s ease-in-out infinite; +} + +@keyframes killPulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.6; } +} + +/* Active/pressed state */ +.release-btn:active { + transform: scale(0.98); + box-shadow: + inset 0 2px 4px rgba(0, 0, 0, 0.4), + 0 0 8px rgba(255, 68, 68, 0.3); +} + /* Compact variant */ .agent-card-compact { display: flex; @@ -1107,6 +1231,60 @@ button:focus-visible { margin-left: auto; } +.agent-compact-actions { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + +/* Compact Kill Button - Minimal but distinctive */ +.release-btn-compact { + position: relative; + background: transparent; + border: 1px solid transparent; + color: #666; + padding: 5px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + border-radius: 5px; + transition: all 0.25s ease; + opacity: 0; +} + +.agent-card-compact:hover .release-btn-compact { + opacity: 0.7; +} + +/* Hover: Transform into danger state */ +.release-btn-compact:hover { + opacity: 1 !important; + background: linear-gradient(180deg, rgba(255, 68, 68, 0.15) 0%, rgba(180, 40, 40, 0.2) 100%); + border-color: rgba(255, 68, 68, 0.5); + color: #ff5555; + box-shadow: 0 0 10px rgba(255, 68, 68, 0.3); + transform: scale(1.1); +} + +/* Pulsing glow effect on hover */ +.release-btn-compact:hover .release-icon { + animation: killPulseCompact 0.5s ease-in-out infinite; + filter: drop-shadow(0 0 3px rgba(255, 68, 68, 0.6)); +} + +@keyframes killPulseCompact { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.1); } +} + +/* Active state */ +.release-btn-compact:active { + transform: scale(0.95); + background: rgba(255, 68, 68, 0.25); +} + /* ===================== Message List Styles ===================== */ @@ -1975,3 +2153,681 @@ button:focus-visible { .settings-done-btn:hover { background: #0d4f82; } + +/* ===================== + Mobile Responsive Styles + ===================== */ + +/* Mobile breakpoints: + - xs: 320px (small phones) + - sm: 480px (phones) + - md: 768px (tablets) + - lg: 1024px (desktops) +*/ + +/* Touch target minimum size */ +.touch-target { + min-width: 44px; + min-height: 44px; +} + +/* Mobile hamburger menu button */ +.mobile-menu-btn { + display: none; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + background: transparent; + border: none; + color: var(--color-text-primary); + cursor: pointer; + border-radius: var(--radius-md); + transition: background var(--transition-fast); +} + +.mobile-menu-btn:hover { + background: var(--color-bg-hover); +} + +.mobile-menu-btn svg { + width: 24px; + height: 24px; +} + +/* Mobile overlay for sidebar */ +.sidebar-overlay { + display: none; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 999; + animation: fadeIn var(--transition-fast); +} + +.sidebar-overlay.visible { + display: block; +} + +/* ===================== + Tablet Breakpoint (768px) + ===================== */ +@media (max-width: 768px) { + /* Show hamburger menu */ + .mobile-menu-btn { + display: flex; + } + + /* Sidebar becomes overlay drawer */ + .sidebar { + position: fixed; + left: 0; + top: 0; + z-index: 1000; + transform: translateX(-100%); + transition: transform var(--transition-normal); + width: 280px; + max-width: 85vw; + } + + .sidebar.open { + transform: translateX(0); + } + + .sidebar-overlay { + display: none; + } + + .sidebar-overlay.visible { + display: block; + } + + /* Main content takes full width */ + .dashboard-app { + flex-direction: column; + } + + .dashboard-main { + width: 100%; + } + + /* Header adjustments */ + .header { + padding: 0 12px; + } + + .header-left { + flex: 1; + min-width: 0; + } + + .channel-name { + max-width: 150px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .channel-topic, + .channel-breadcrumb { + display: none; + } + + .header-btn span { + display: none; + } + + .header-btn kbd { + display: none; + } + + .command-palette-btn { + padding: 8px; + } + + /* Command Palette - larger on tablet */ + .command-palette { + width: 90vw; + max-height: 70vh; + } + + .command-palette-item { + padding: 12px; + min-height: 44px; + } + + /* Spawn Modal adjustments */ + .spawn-modal { + width: 95vw; + max-height: 85vh; + } + + .spawn-modal-templates { + grid-template-columns: repeat(3, 1fr); + } + + .spawn-modal-template { + padding: 10px 6px; + min-height: 44px; + } + + /* Settings Panel */ + .settings-panel { + width: 95vw; + max-height: 85vh; + } + + .settings-tabs { + overflow-x: auto; + padding: 12px; + gap: 8px; + } + + .settings-tab { + min-height: 44px; + white-space: nowrap; + } + + .settings-theme-options { + flex-direction: column; + } + + .settings-theme-btn { + flex-direction: row; + justify-content: flex-start; + padding: 12px 16px; + min-height: 44px; + } + + /* Message Composer */ + .message-composer { + padding: 12px; + } + + .composer-form { + flex-wrap: wrap; + } + + .composer-input-wrapper { + width: 100%; + order: 1; + } + + .composer-send { + order: 2; + width: 44px; + min-height: 44px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + } + + .composer-send-text { + display: none; + } + + .composer-send-icon { + display: block; + } + + /* Message List */ + .message-list { + padding: 12px; + } + + .message-item { + padding: 12px 8px; + } + + .message-avatar { + width: 32px; + height: 32px; + } + + /* Agent Cards */ + .agent-card { + padding: 16px; + } + + .agent-card-compact { + padding: 12px; + min-height: 44px; + } + + .agent-avatar { + width: 36px; + height: 36px; + } + + /* Agent List */ + .agent-group-header { + padding: 12px; + min-height: 44px; + } + + .agent-group-content { + padding: 8px 0 8px 8px; + } + + /* Fleet Overview */ + .fleet-grid { + grid-template-columns: 1fr; + } + + /* Decision Queue */ + .decision-item { + padding: 16px; + } + + /* Trajectory Viewer */ + .trajectory-step { + padding: 12px; + } + + /* Server Card */ + .server-card { + padding: 16px; + } + + /* Notification Toast */ + .notification-toast { + width: calc(100vw - 24px); + max-width: 400px; + left: 12px; + right: 12px; + margin: 0 auto; + } + + /* Project List */ + .project-header { + padding: 12px; + min-height: 44px; + } + + .project-agents { + padding: 8px 0 8px 12px; + } +} + +/* ===================== + Phone Breakpoint (480px) + ===================== */ +@media (max-width: 480px) { + /* Sidebar full width on phones */ + .sidebar { + width: 100vw; + max-width: 100vw; + } + + .sidebar-header { + padding: 12px; + } + + .sidebar-title h1 { + font-size: 16px; + } + + .sidebar-search { + margin: 8px; + padding: 10px 12px; + } + + .sidebar-footer { + padding: 12px; + } + + .spawn-btn { + min-height: 44px; + } + + /* Header compact */ + .header { + height: 48px; + padding: 0 8px; + } + + .header-left { + gap: 6px; + } + + .channel-name { + font-size: 14px; + max-width: 120px; + } + + .agent-avatar-small { + width: 24px; + height: 24px; + font-size: 10px; + } + + /* Command Palette full screen */ + .command-palette-overlay { + padding-top: 0; + align-items: stretch; + } + + .command-palette { + width: 100vw; + max-width: 100vw; + height: 100vh; + max-height: 100vh; + border-radius: 0; + } + + .command-palette-input-wrapper { + padding: 12px; + } + + .command-palette-input { + font-size: 16px; /* Prevents zoom on iOS */ + } + + .command-palette-item { + padding: 14px 12px; + } + + /* Spawn Modal full screen */ + .spawn-modal-overlay { + align-items: stretch; + padding: 0; + } + + .spawn-modal { + width: 100vw; + max-width: 100vw; + height: 100vh; + max-height: 100vh; + border-radius: 0; + } + + .spawn-modal-header { + padding: 16px; + } + + .spawn-modal form { + padding: 16px; + } + + .spawn-modal-templates { + grid-template-columns: 1fr; + } + + .spawn-modal-template { + flex-direction: row; + justify-content: flex-start; + gap: 12px; + padding: 14px; + } + + .spawn-modal-template-icon { + font-size: 20px; + } + + .spawn-modal-name-input { + flex-direction: column; + align-items: stretch; + } + + .spawn-modal-name-preview { + display: none; + } + + .spawn-modal-input { + font-size: 16px; /* Prevents zoom on iOS */ + min-height: 44px; + } + + .spawn-modal-name-suggestions { + flex-wrap: wrap; + } + + .spawn-modal-suggestion { + min-height: 36px; + display: flex; + align-items: center; + } + + .spawn-modal-actions { + flex-direction: column-reverse; + } + + .spawn-modal-btn { + width: 100%; + justify-content: center; + min-height: 44px; + } + + /* Settings Panel full screen */ + .settings-overlay { + align-items: stretch; + padding: 0; + } + + .settings-panel { + width: 100vw; + max-width: 100vw; + height: 100vh; + max-height: 100vh; + border-radius: 0; + } + + .settings-header { + padding: 16px; + } + + .settings-tabs { + padding: 8px 12px; + } + + .settings-tab { + padding: 10px 12px; + font-size: 12px; + } + + .settings-content { + padding: 16px; + } + + .settings-toggle-option { + flex-direction: column; + align-items: stretch; + gap: 12px; + } + + .settings-toggle { + align-self: flex-end; + } + + .settings-footer { + flex-direction: column-reverse; + gap: 8px; + padding: 16px; + } + + .settings-reset-btn, + .settings-done-btn { + width: 100%; + min-height: 44px; + justify-content: center; + } + + /* Message Composer */ + .composer-input { + font-size: 16px; /* Prevents zoom on iOS */ + min-height: 44px; + } + + .composer-send { + width: 44px; + } + + /* Message List */ + .message-list { + padding: 8px; + } + + .message-item { + flex-direction: column; + gap: 8px; + } + + .message-avatar { + width: 28px; + height: 28px; + } + + .message-header { + flex-wrap: wrap; + } + + .message-time { + width: 100%; + margin-left: 0; + margin-top: 4px; + } + + /* Agent Cards */ + .agent-card-compact .agent-name { + font-size: 14px; + } + + .agent-compact-info { + flex: 1; + min-width: 0; + } + + /* Mention autocomplete */ + .mention-autocomplete { + max-height: 50vh; + } + + .mention-item { + padding: 12px; + min-height: 44px; + } + + /* Notification Toast */ + .notification-toast { + bottom: 12px; + left: 8px; + right: 8px; + width: auto; + } + + .toast-content { + padding: 14px 16px; + } + + .toast-close { + min-width: 44px; + min-height: 44px; + } +} + +/* ===================== + Small Phone Breakpoint (320px) + ===================== */ +@media (max-width: 320px) { + .header { + height: 44px; + } + + .mobile-menu-btn { + width: 40px; + height: 40px; + } + + .channel-name { + font-size: 13px; + max-width: 100px; + } + + .sidebar-title h1 { + font-size: 14px; + } + + .spawn-modal-header h2, + .settings-header h2 { + font-size: 16px; + } + + .command-palette-input { + font-size: 14px; + } + + .agent-card-compact { + padding: 10px; + } +} + +/* ===================== + Landscape Phone + ===================== */ +@media (max-width: 768px) and (orientation: landscape) { + .sidebar { + width: 300px; + } + + .command-palette { + max-height: 90vh; + } + + .spawn-modal, + .settings-panel { + max-height: 95vh; + } +} + +/* ===================== + High DPI / Retina + ===================== */ +@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) { + .connection-indicator, + .stat-dot, + .agent-status-dot { + /* Sharper borders on retina */ + box-shadow: 0 0 0 0.5px rgba(0, 0, 0, 0.1); + } +} + +/* ===================== + Reduced Motion + ===================== */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } + + .sidebar { + transition: none; + } +} + +/* ===================== + Safe Area Insets (iPhone X+) + ===================== */ +@supports (padding: env(safe-area-inset-bottom)) { + .sidebar-footer { + padding-bottom: calc(16px + env(safe-area-inset-bottom)); + } + + .message-composer { + padding-bottom: calc(12px + env(safe-area-inset-bottom)); + } + + .spawn-modal-actions, + .settings-footer { + padding-bottom: calc(16px + env(safe-area-inset-bottom)); + } + + .notification-toast { + bottom: calc(12px + env(safe-area-inset-bottom)); + } +} diff --git a/src/dashboard/lib/hierarchy.ts b/src/dashboard/lib/hierarchy.ts index ffd1f7dba..82ec67ca0 100644 --- a/src/dashboard/lib/hierarchy.ts +++ b/src/dashboard/lib/hierarchy.ts @@ -114,25 +114,27 @@ export function flattenTree( } /** - * Group agents by their prefix for simpler grouped display + * Group agents by their team (if set) or prefix for simpler grouped display. + * User-defined teams take priority over auto-extracted prefixes. */ export function groupAgents(agents: Agent[]): AgentGroup[] { const groups: Map = new Map(); for (const agent of agents) { - const prefix = getAgentPrefix(agent.name); + // Use team if set, otherwise fall back to prefix from name + const groupKey = agent.team || getAgentPrefix(agent.name); const color = getAgentColor(agent.name); - let group = groups.get(prefix); + let group = groups.get(groupKey); if (!group) { group = { - prefix, - displayName: capitalizeFirst(prefix), + prefix: groupKey, + displayName: capitalizeFirst(groupKey), color, agents: [], isExpanded: true, }; - groups.set(prefix, group); + groups.set(groupKey, group); } group.agents.push(agent); diff --git a/src/dashboard/react-components/AgentCard.tsx b/src/dashboard/react-components/AgentCard.tsx index 97a4a8413..646e451fb 100644 --- a/src/dashboard/react-components/AgentCard.tsx +++ b/src/dashboard/react-components/AgentCard.tsx @@ -21,6 +21,7 @@ export interface AgentCardProps { isSelected?: boolean; showBreadcrumb?: boolean; compact?: boolean; + displayNameOverride?: string; // Override the displayed name (e.g., strip team prefix) onClick?: (agent: Agent) => void; onMessageClick?: (agent: Agent) => void; onReleaseClick?: (agent: Agent) => void; @@ -31,13 +32,14 @@ export function AgentCard({ isSelected = false, showBreadcrumb = false, compact = false, + displayNameOverride, onClick, onMessageClick, onReleaseClick, }: AgentCardProps) { const colors = getAgentColor(agent.name); const initials = getAgentInitials(agent.name); - const displayName = getAgentDisplayName(agent.name); + const displayName = displayNameOverride || getAgentDisplayName(agent.name); const statusColor = STATUS_COLORS[agent.status] || STATUS_COLORS.offline; const handleClick = () => { @@ -69,7 +71,10 @@ export function AgentCard({
{displayName} - {getAgentBreadcrumb(agent.name)} + {/* Hide breadcrumb when displayNameOverride is set (e.g., inside team groups) */} + {!displayNameOverride && ( + {getAgentBreadcrumb(agent.name)} + )}
{agent.isSpawned && onReleaseClick && ( @@ -187,7 +192,7 @@ function MessageIcon() { } /** - * Release/kill icon SVG (X in circle) + * Release/kill icon SVG - Power/terminate symbol */ function ReleaseIcon() { return ( @@ -196,14 +201,31 @@ function ReleaseIcon() { height="16" viewBox="0 0 24 24" fill="none" - stroke="currentColor" - strokeWidth="2" - strokeLinecap="round" - strokeLinejoin="round" + className="release-icon" > - - - + {/* Outer ring with gap at top */} + + + {/* Vertical line (power symbol stem) */} + ); } @@ -378,21 +400,70 @@ export const agentCardStyles = ` gap: 6px; } +/* Kill Agent Button - Industrial control panel aesthetic */ .release-btn { - background: var(--color-error, #e01e5a); - color: white; - border: none; - border-radius: 4px; - padding: 4px 8px; + position: relative; + background: linear-gradient(180deg, #3a1a1a 0%, #2a0f0f 100%); + color: #ff6b6b; + border: 1px solid #4a2020; + border-radius: 6px; + padding: 6px 10px; cursor: pointer; display: flex; align-items: center; justify-content: center; - transition: opacity 0.2s; + gap: 4px; + transition: all 0.2s ease; + box-shadow: + inset 0 1px 0 rgba(255, 107, 107, 0.1), + 0 2px 4px rgba(0, 0, 0, 0.3); + overflow: hidden; +} + +/* Subtle scanline texture */ +.release-btn::before { + content: ''; + position: absolute; + inset: 0; + background: repeating-linear-gradient( + 0deg, + transparent, + transparent 2px, + rgba(0, 0, 0, 0.1) 2px, + rgba(0, 0, 0, 0.1) 4px + ); + pointer-events: none; + opacity: 0.5; } +/* Warning glow on hover */ .release-btn:hover { - opacity: 0.9; + background: linear-gradient(180deg, #4a2020 0%, #3a1515 100%); + border-color: #ff4444; + color: #ff4444; + box-shadow: + inset 0 1px 0 rgba(255, 68, 68, 0.2), + 0 0 12px rgba(255, 68, 68, 0.4), + 0 2px 8px rgba(0, 0, 0, 0.4); + transform: scale(1.05); +} + +/* Icon animation on hover */ +.release-btn:hover .release-icon { + animation: killPulse 0.6s ease-in-out infinite; +} + +@keyframes killPulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.6; } +} + +/* Active/pressed state */ +.release-btn:active { + transform: scale(0.98); + box-shadow: + inset 0 2px 4px rgba(0, 0, 0, 0.4), + 0 0 8px rgba(255, 68, 68, 0.3); } .agent-spawned-badge { @@ -457,27 +528,51 @@ export const agentCardStyles = ` flex-shrink: 0; } +/* Compact Kill Button - Minimal but distinctive */ .release-btn-compact { + position: relative; background: transparent; - border: none; + border: 1px solid transparent; color: #666; - padding: 4px; + padding: 5px; cursor: pointer; display: flex; align-items: center; justify-content: center; - border-radius: 4px; - transition: all 0.2s; + border-radius: 5px; + transition: all 0.25s ease; opacity: 0; } .agent-card-compact:hover .release-btn-compact { - opacity: 1; + opacity: 0.7; } +/* Hover: Transform into danger state */ .release-btn-compact:hover { - background: rgba(239, 68, 68, 0.15); - color: #ef4444; + opacity: 1 !important; + background: linear-gradient(180deg, rgba(255, 68, 68, 0.15) 0%, rgba(180, 40, 40, 0.2) 100%); + border-color: rgba(255, 68, 68, 0.5); + color: #ff5555; + box-shadow: 0 0 10px rgba(255, 68, 68, 0.3); + transform: scale(1.1); +} + +/* Pulsing glow effect on hover */ +.release-btn-compact:hover .release-icon { + animation: killPulseCompact 0.5s ease-in-out infinite; + filter: drop-shadow(0 0 3px rgba(255, 68, 68, 0.6)); +} + +@keyframes killPulseCompact { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.1); } +} + +/* Active state */ +.release-btn-compact:active { + transform: scale(0.95); + background: rgba(255, 68, 68, 0.25); } .agent-card-compact .attention-badge { diff --git a/src/dashboard/react-components/AgentList.tsx b/src/dashboard/react-components/AgentList.tsx index 92c14b389..b6f030f00 100644 --- a/src/dashboard/react-components/AgentList.tsx +++ b/src/dashboard/react-components/AgentList.tsx @@ -8,7 +8,7 @@ import React, { useState, useMemo } from 'react'; import type { Agent } from '../types'; import { AgentCard } from './AgentCard'; -import { groupAgents, getGroupStats, filterAgents, type AgentGroup } from '../lib/hierarchy'; +import { groupAgents, getGroupStats, filterAgents, getAgentDisplayName, type AgentGroup } from '../lib/hierarchy'; import { STATUS_COLORS } from '../lib/colors'; export interface AgentListProps { @@ -187,6 +187,7 @@ function AgentGroupComponent({ agent={agent} isSelected={agent.name === selectedAgent} compact={compact} + displayNameOverride={getAgentDisplayName(agent.name)} onClick={onAgentSelect} onMessageClick={onAgentMessage} onReleaseClick={onReleaseClick} diff --git a/src/dashboard/react-components/App.tsx b/src/dashboard/react-components/App.tsx index 9764014c4..b814fd309 100644 --- a/src/dashboard/react-components/App.tsx +++ b/src/dashboard/react-components/App.tsx @@ -5,8 +5,8 @@ * Manages global state via hooks and provides context to child components. */ -import React, { useState, useCallback, useRef } from 'react'; -import type { Agent } from '../types'; +import React, { useState, useCallback, useRef, useEffect } from 'react'; +import type { Agent, Project } from '../types'; import { Sidebar } from './layout/Sidebar'; import { Header } from './layout/Header'; import { MessageList } from './MessageList'; @@ -31,6 +31,10 @@ export function App({ wsUrl }: AppProps) { // View mode state const [viewMode, setViewMode] = useState<'local' | 'fleet'>('local'); + // Project state for unified navigation + const [projects, setProjects] = useState([]); + const [currentProject, setCurrentProject] = useState(); + // Spawn modal state const [isSpawnModalOpen, setIsSpawnModalOpen] = useState(false); const [isSpawning, setIsSpawning] = useState(false); @@ -43,6 +47,16 @@ export function App({ wsUrl }: AppProps) { const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [settings, setSettings] = useState(defaultSettings); + // Mobile sidebar state + const [isSidebarOpen, setIsSidebarOpen] = useState(false); + + // Close sidebar when selecting an agent or project on mobile + const closeSidebarOnMobile = useCallback(() => { + if (window.innerWidth <= 768) { + setIsSidebarOpen(false); + } + }, []); + // Agent state management const { agents, @@ -75,11 +89,50 @@ export function App({ wsUrl }: AppProps) { // Check if fleet view is available const isFleetAvailable = Boolean(data?.fleet?.servers?.length); + // Fetch bridge/project data when fleet is available + useEffect(() => { + if (!isFleetAvailable) return; + + const fetchProjects = async () => { + const result = await api.getBridgeData(); + if (result.success && result.data) { + // Destructure to avoid non-null assertion in closure + const { servers, agents } = result.data; + // Convert fleet servers to projects + const projectList: Project[] = servers.map((server) => ({ + id: server.id, + path: server.url, + name: server.name || server.url.split('/').pop(), + agents: agents.filter((a) => a.server === server.id), + lead: undefined, // Could be enhanced to detect lead agent + })); + setProjects(projectList); + } + }; + + fetchProjects(); + // Refresh periodically + const interval = setInterval(fetchProjects, 30000); + return () => clearInterval(interval); + }, [isFleetAvailable]); + + // Handle project selection + const handleProjectSelect = useCallback((project: Project) => { + setCurrentProject(project.id); + // Optionally navigate to project's first agent or general channel + if (project.agents.length > 0) { + selectAgent(project.agents[0].name); + setCurrentChannel(project.agents[0].name); + } + closeSidebarOnMobile(); + }, [selectAgent, setCurrentChannel, closeSidebarOnMobile]); + // Handle agent selection const handleAgentSelect = useCallback((agent: Agent) => { selectAgent(agent.name); setCurrentChannel(agent.name); - }, [selectAgent, setCurrentChannel]); + closeSidebarOnMobile(); + }, [selectAgent, setCurrentChannel, closeSidebarOnMobile]); // Handle spawn button click const handleSpawnClick = useCallback(() => { @@ -97,7 +150,7 @@ export function App({ wsUrl }: AppProps) { setIsSpawning(true); setSpawnError(null); try { - const result = await api.spawnAgent({ name: config.name, cli: config.command }); + const result = await api.spawnAgent({ name: config.name, cli: config.command, team: config.team }); if (!result.success) { setSpawnError(result.error || 'Failed to spawn agent'); return false; @@ -189,17 +242,28 @@ export function App({ wsUrl }: AppProps) { return (
+ {/* Mobile Sidebar Overlay */} +
setIsSidebarOpen(false)} + /> + {/* Sidebar */} setIsSidebarOpen(false)} /> {/* Main Content */} @@ -210,6 +274,7 @@ export function App({ wsUrl }: AppProps) { selectedAgent={selectedAgent} onCommandPaletteOpen={handleCommandPaletteOpen} onSettingsClick={handleSettingsClick} + onMenuClick={() => setIsSidebarOpen(true)} /> {/* Content Area */} @@ -257,7 +322,10 @@ export function App({ wsUrl }: AppProps) { isOpen={isCommandPaletteOpen} onClose={() => setIsCommandPaletteOpen(false)} agents={agents} + projects={projects} + currentProject={currentProject} onAgentSelect={handleAgentSelect} + onProjectSelect={handleProjectSelect} onSpawnClick={handleSpawnClick} onGeneralClick={() => { selectAgent(null); @@ -302,10 +370,10 @@ function MessageComposer({ recipient, agents, onSend, isSending, error }: Messag const [message, setMessage] = useState(''); const [cursorPosition, setCursorPosition] = useState(0); const [showMentions, setShowMentions] = useState(false); - const inputRef = useRef(null); + const textareaRef = useRef(null); // Check for @mention on input change - const handleInputChange = (e: React.ChangeEvent) => { + const handleInputChange = (e: React.ChangeEvent) => { const value = e.target.value; const cursorPos = e.target.selectionStart || 0; setMessage(value); @@ -316,16 +384,26 @@ function MessageComposer({ recipient, agents, onSend, isSending, error }: Messag setShowMentions(query !== null); }; + // Handle keyboard events - Enter to send, Shift+Enter for new line + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + if (message.trim() && !isSending) { + handleSubmit(e as unknown as React.FormEvent); + } + } + }; + // Handle mention selection const handleMentionSelect = (mention: string, newValue: string) => { setMessage(newValue); setShowMentions(false); - // Focus input and set cursor after the mention + // Focus textarea and set cursor after the mention setTimeout(() => { - if (inputRef.current) { - inputRef.current.focus(); + if (textareaRef.current) { + textareaRef.current.focus(); const pos = newValue.indexOf(' ') + 1; - inputRef.current.setSelectionRange(pos, pos); + textareaRef.current.setSelectionRange(pos, pos); } }, 0); }; @@ -377,23 +455,35 @@ function MessageComposer({ recipient, agents, onSend, isSending, error }: Messag onClose={() => setShowMentions(false)} isVisible={showMentions} /> - setCursorPosition((e.target as HTMLInputElement).selectionStart || 0)} + onKeyDown={handleKeyDown} + onSelect={(e) => setCursorPosition((e.target as HTMLTextAreaElement).selectionStart || 0)} disabled={isSending} + rows={1} />
{error && {error}} @@ -550,10 +640,15 @@ export const appStyles = ` border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 6px; font-size: 14px; + font-family: inherit; color: #d1d2d3; outline: none; box-sizing: border-box; transition: border-color 0.2s; + resize: none; + min-height: 40px; + max-height: 120px; + overflow-y: auto; } .composer-input::placeholder { diff --git a/src/dashboard/react-components/BroadcastComposer.tsx b/src/dashboard/react-components/BroadcastComposer.tsx index a955870fa..c062aa791 100644 --- a/src/dashboard/react-components/BroadcastComposer.tsx +++ b/src/dashboard/react-components/BroadcastComposer.tsx @@ -220,7 +220,7 @@ export function BroadcastComposer({ onClick={() => setShowTemplates(!showTemplates)} > - Templates + Templates {showTemplates && (
@@ -245,7 +245,7 @@ export function BroadcastComposer({ (targetType === 'server' ? selectedServers.size === 0 : selectedAgents.size === 0))} > {isSending ? : } - {isSending ? 'Sending...' : 'Broadcast'} + {isSending ? 'Sending...' : 'Broadcast'}
@@ -648,4 +648,43 @@ export const broadcastComposerStyles = ` color: #dc2626; font-size: 13px; } + +/* Responsive styles */ +@media (max-width: 768px) { + .broadcast-input-actions { + gap: 8px; + } + + .broadcast-send-btn { + padding: 8px 12px; + font-size: 12px; + } + + .broadcast-template-btn { + padding: 6px 10px; + } +} + +@media (max-width: 480px) { + .broadcast-input-wrapper { + padding: 12px; + } + + .broadcast-send-btn { + padding: 8px; + min-width: auto; + } + + .broadcast-send-text { + display: none; + } + + .broadcast-template-text { + display: none; + } + + .broadcast-template-btn { + padding: 8px; + } +} `; diff --git a/src/dashboard/react-components/CommandPalette.tsx b/src/dashboard/react-components/CommandPalette.tsx index fba2623be..676b14db7 100644 --- a/src/dashboard/react-components/CommandPalette.tsx +++ b/src/dashboard/react-components/CommandPalette.tsx @@ -6,14 +6,14 @@ */ import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'; -import type { Agent } from '../types'; +import type { Agent, Project } from '../types'; import { getAgentColor, getAgentInitials } from '../lib/colors'; export interface Command { id: string; label: string; description?: string; - category: 'agents' | 'actions' | 'navigation' | 'settings'; + category: 'agents' | 'actions' | 'navigation' | 'settings' | 'projects'; icon?: React.ReactNode; shortcut?: string; action: () => void; @@ -23,7 +23,10 @@ export interface CommandPaletteProps { isOpen: boolean; onClose: () => void; agents: Agent[]; + projects?: Project[]; + currentProject?: string; onAgentSelect: (agent: Agent) => void; + onProjectSelect?: (project: Project) => void; onSpawnClick: () => void; onSettingsClick?: () => void; onGeneralClick?: () => void; @@ -34,7 +37,10 @@ export function CommandPalette({ isOpen, onClose, agents, + projects = [], + currentProject, onAgentSelect, + onProjectSelect, onSpawnClick, onSettingsClick, onGeneralClick, @@ -48,6 +54,24 @@ export function CommandPalette({ // Build command list const commands = useMemo(() => { const cmds: Command[] = [ + // Project commands (if projects available) + ...projects.map((project) => { + const displayName = project.name || project.path.split('/').pop() || project.id; + const isCurrent = project.id === currentProject; + return { + id: `project-${project.id}`, + label: displayName, + description: isCurrent + ? `Current project • ${project.agents.length} agents` + : `${project.agents.length} agents`, + category: 'projects' as const, + icon: , + action: () => { + onProjectSelect?.(project); + onClose(); + }, + }; + }), // Agent commands ...agents.map((agent) => ({ id: `agent-${agent.name}`, @@ -117,7 +141,7 @@ export function CommandPalette({ ...customCommands, ]; return cmds; - }, [agents, onAgentSelect, onSpawnClick, onSettingsClick, onGeneralClick, onClose, customCommands]); + }, [agents, projects, currentProject, onAgentSelect, onProjectSelect, onSpawnClick, onSettingsClick, onGeneralClick, onClose, customCommands]); // Filter commands based on query const filteredCommands = useMemo(() => { @@ -146,7 +170,7 @@ export function CommandPalette({ // Flatten for keyboard navigation const flatCommands = useMemo(() => { - const order = ['agents', 'actions', 'navigation', 'settings']; + const order = ['projects', 'agents', 'actions', 'navigation', 'settings']; return order.flatMap((cat) => groupedCommands[cat] || []); }, [groupedCommands]); @@ -202,6 +226,7 @@ export function CommandPalette({ if (!isOpen) return null; const categoryLabels: Record = { + projects: 'Projects', agents: 'Agents', actions: 'Actions', navigation: 'Navigation', @@ -333,6 +358,14 @@ function SettingsIcon() { ); } +function FolderIcon() { + return ( + + + + ); +} + /** * CSS styles for the command palette - Dark mode */ diff --git a/src/dashboard/react-components/ProjectList.tsx b/src/dashboard/react-components/ProjectList.tsx new file mode 100644 index 000000000..9b2c08758 --- /dev/null +++ b/src/dashboard/react-components/ProjectList.tsx @@ -0,0 +1,633 @@ +/** + * ProjectList Component + * + * Displays projects with nested agents in a flat hierarchy. + * Each project is a collapsible section with its agents listed directly underneath. + */ + +import React, { useState, useMemo, useEffect } from 'react'; +import type { Agent, Project } from '../types'; +import { AgentCard } from './AgentCard'; +import { STATUS_COLORS, getAgentColor } from '../lib/colors'; + +/** + * Gets the simple display name for an agent within a team group. + * Since the team header already shows context, just show the agent's role/name. + * + * Examples: + * - "Frontend-Lead" in team "Frontend" → "Lead" + * - "Lead" in team "Lead" → "Lead" + * - "backend-api" in team "backend" → "Api" + */ +function stripTeamPrefix(agentName: string, teamName: string): string { + const lowerAgent = agentName.toLowerCase(); + const lowerTeam = teamName.toLowerCase().replace(/-?team$/i, ''); + + // Pattern 1: Team prefix with dash separator (e.g., "frontend-lead" → "lead") + if (lowerTeam && lowerAgent.startsWith(lowerTeam + '-')) { + const stripped = agentName.substring(lowerTeam.length + 1); + return capitalizeWords(stripped); + } + + // Pattern 2: Team prefix with underscore separator + if (lowerTeam && lowerAgent.startsWith(lowerTeam + '_')) { + const stripped = agentName.substring(lowerTeam.length + 1); + return capitalizeWords(stripped); + } + + // Pattern 3: Last segment of hyphenated name (e.g., "LeadFrontend-Dev" → "Dev") + const parts = agentName.split('-'); + if (parts.length > 1) { + return capitalizeWords(parts[parts.length - 1]); + } + + // No prefix to strip, return capitalized original + return capitalizeWords(agentName); +} + +/** + * Capitalizes words in a string (handles dash and underscore separators) + */ +function capitalizeWords(str: string): string { + return str + .split(/[-_]/) + .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(' '); +} + +export interface ProjectListProps { + projects: Project[]; + localAgents?: Agent[]; + currentProject?: string; + selectedAgent?: string; + searchQuery?: string; + onProjectSelect?: (project: Project) => void; + onAgentSelect?: (agent: Agent, project?: Project) => void; + onReleaseClick?: (agent: Agent) => void; + compact?: boolean; +} + +export function ProjectList({ + projects, + localAgents = [], + currentProject, + selectedAgent, + searchQuery = '', + onProjectSelect, + onAgentSelect, + onReleaseClick, + compact = false, +}: ProjectListProps) { + const [expandedProjects, setExpandedProjects] = useState>( + () => new Set(projects.map((p) => p.id)) + ); + + // Filter projects and agents based on search query + const filteredData = useMemo(() => { + const query = searchQuery.toLowerCase().trim(); + if (!query) { + return { projects, localAgents }; + } + + // Filter local agents + const filteredLocal = localAgents.filter( + (a) => + a.name.toLowerCase().includes(query) || + a.currentTask?.toLowerCase().includes(query) + ); + + // Filter projects (show project if name matches OR any agent matches) + const filteredProjects = projects + .map((project) => { + const projectNameMatches = + project.name?.toLowerCase().includes(query) || + project.path.toLowerCase().includes(query); + + const filteredAgents = project.agents.filter( + (a) => + a.name.toLowerCase().includes(query) || + a.currentTask?.toLowerCase().includes(query) + ); + + // Include project if name matches or has matching agents + if (projectNameMatches || filteredAgents.length > 0) { + return { + ...project, + agents: projectNameMatches ? project.agents : filteredAgents, + }; + } + return null; + }) + .filter(Boolean) as Project[]; + + return { projects: filteredProjects, localAgents: filteredLocal }; + }, [projects, localAgents, searchQuery]); + + const toggleProject = (projectId: string) => { + setExpandedProjects((prev) => { + const next = new Set(prev); + if (next.has(projectId)) { + next.delete(projectId); + } else { + next.add(projectId); + } + return next; + }); + }; + + const totalAgents = + filteredData.localAgents.length + + filteredData.projects.reduce((sum, p) => sum + p.agents.length, 0); + + if (totalAgents === 0 && projects.length === 0 && localAgents.length === 0) { + return ( +
+ +

No projects or agents

+
+ ); + } + + if (totalAgents === 0 && searchQuery) { + return ( +
+ +

No results for "{searchQuery}"

+
+ ); + } + + return ( +
+ {/* Local agents section (current project) */} + {filteredData.localAgents.length > 0 && ( + toggleProject('__local__')} + onAgentSelect={(agent) => onAgentSelect?.(agent)} + onReleaseClick={onReleaseClick} + /> + )} + + {/* Bridged projects */} + {filteredData.projects.map((project) => ( + toggleProject(project.id)} + onProjectSelect={() => onProjectSelect?.(project)} + onAgentSelect={(agent) => onAgentSelect?.(agent, project)} + onReleaseClick={onReleaseClick} + /> + ))} +
+ ); +} + +interface ProjectSectionProps { + project: Project; + isExpanded: boolean; + isCurrentProject: boolean; + selectedAgent?: string; + compact?: boolean; + onToggle: () => void; + onProjectSelect?: () => void; + onAgentSelect?: (agent: Agent) => void; + onReleaseClick?: (agent: Agent) => void; +} + +interface TeamGroup { + name: string; + agents: Agent[]; +} + +function ProjectSection({ + project, + isExpanded, + isCurrentProject, + selectedAgent, + compact, + onToggle, + onProjectSelect, + onAgentSelect, + onReleaseClick, +}: ProjectSectionProps) { + const [expandedTeams, setExpandedTeams] = useState>(new Set()); + + const stats = useMemo(() => { + let online = 0; + let needsAttention = 0; + for (const agent of project.agents) { + if (agent.status === 'online') online++; + if (agent.needsAttention) needsAttention++; + } + return { online, needsAttention, total: project.agents.length }; + }, [project.agents]); + + // Group agents by team (optional user-defined grouping) + const { teams, ungroupedAgents } = useMemo(() => { + const teamMap = new Map(); + const ungrouped: Agent[] = []; + + for (const agent of project.agents) { + if (agent.team) { + const existing = teamMap.get(agent.team) || []; + existing.push(agent); + teamMap.set(agent.team, existing); + } else { + ungrouped.push(agent); + } + } + + const teams: TeamGroup[] = Array.from(teamMap.entries()) + .map(([name, agents]) => ({ name, agents })) + .sort((a, b) => a.name.localeCompare(b.name)); + + return { teams, ungroupedAgents: ungrouped }; + }, [project.agents]); + + const toggleTeam = (teamName: string) => { + setExpandedTeams((prev) => { + const next = new Set(prev); + if (next.has(teamName)) { + next.delete(teamName); + } else { + next.add(teamName); + } + return next; + }); + }; + + // Auto-expand teams when project expands + useEffect(() => { + if (isExpanded && expandedTeams.size === 0 && teams.length > 0) { + setExpandedTeams(new Set(teams.map((t) => t.name))); + } + }, [isExpanded, teams, expandedTeams]); + + const projectColor = getAgentColor(project.name || project.id); + const displayName = project.name || project.path.split('/').pop() || project.id; + + return ( +
+ + + {isExpanded && ( +
+ {/* Team groups (optional user-defined) */} + {teams.map((team) => ( +
+ + {expandedTeams.has(team.name) && ( +
+ {team.agents.map((agent) => ( + + ))} +
+ )} +
+ ))} + + {/* Ungrouped agents (no team assigned) */} + {ungroupedAgents.map((agent) => ( + + ))} +
+ )} +
+ ); +} + +function ChevronIcon({ expanded }: { expanded: boolean }) { + return ( + + + + ); +} + +function FolderIcon() { + return ( + + + + ); +} + +function TeamIcon() { + return ( + + + + + + + ); +} + +function EmptyIcon() { + return ( + + + + ); +} + +function SearchIcon() { + return ( + + + + + ); +} + +/** + * CSS styles for the project list + */ +export const projectListStyles = ` +.project-list { + display: flex; + flex-direction: column; + gap: 4px; +} + +.project-list-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px 20px; + color: #888; + text-align: center; +} + +.project-list-empty svg { + margin-bottom: 12px; + opacity: 0.5; +} + +.project-section { + margin-bottom: 4px; +} + +.project-section.current .project-header { + background: rgba(0, 255, 200, 0.08); +} + +.project-header { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 10px 12px; + background: none; + border: none; + cursor: pointer; + font-size: 13px; + text-align: left; + border-radius: 6px; + transition: background 0.2s; + position: relative; + color: #e8e8e8; +} + +.project-header:hover { + background: var(--project-light); +} + +.project-color-bar { + position: absolute; + left: 0; + top: 4px; + bottom: 4px; + width: 3px; + background: var(--project-color); + border-radius: 2px; +} + +.folder-icon { + color: var(--project-color); + flex-shrink: 0; +} + +.project-name { + font-weight: 600; + color: #e8e8e8; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.project-count { + color: #888; + font-weight: normal; + flex-shrink: 0; +} + +.project-stats { + margin-left: auto; + display: flex; + gap: 8px; + flex-shrink: 0; +} + +.stat { + display: flex; + align-items: center; + gap: 4px; + font-size: 11px; + color: #666; +} + +.stat-dot { + width: 6px; + height: 6px; + border-radius: 50%; +} + +.lead-indicator { + color: #ffd700; + font-size: 12px; + margin-left: 4px; +} + +.project-agents { + padding: 4px 0 4px 20px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.chevron-icon { + transition: transform 0.2s; + color: #888; + flex-shrink: 0; +} + +.chevron-icon.expanded { + transform: rotate(90deg); +} + +/* Team grouping styles */ +.team-group { + margin-bottom: 2px; +} + +.team-header { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + padding: 6px 8px; + background: none; + border: none; + cursor: pointer; + font-size: 12px; + text-align: left; + border-radius: 4px; + transition: background 0.2s; + color: #b8b8b8; +} + +.team-header:hover { + background: rgba(255, 255, 255, 0.05); +} + +.team-icon { + color: #888; + flex-shrink: 0; +} + +.team-name { + font-weight: 500; + color: #b8b8b8; +} + +.team-count { + color: #666; + font-weight: normal; +} + +.team-agents { + padding: 2px 0 2px 16px; + display: flex; + flex-direction: column; + gap: 4px; +} +`; diff --git a/src/dashboard/react-components/SpawnModal.tsx b/src/dashboard/react-components/SpawnModal.tsx index b24b2dab4..2fa0331cd 100644 --- a/src/dashboard/react-components/SpawnModal.tsx +++ b/src/dashboard/react-components/SpawnModal.tsx @@ -12,6 +12,7 @@ export interface SpawnConfig { name: string; command: string; cwd?: string; + team?: string; } export interface SpawnModalProps { @@ -70,6 +71,7 @@ export function SpawnModal({ const [name, setName] = useState(''); const [customCommand, setCustomCommand] = useState(''); const [cwd, setCwd] = useState(''); + const [team, setTeam] = useState(''); const [localError, setLocalError] = useState(null); const nameInputRef = useRef(null); @@ -90,6 +92,7 @@ export function SpawnModal({ setName(''); setCustomCommand(''); setCwd(''); + setTeam(''); setLocalError(null); setTimeout(() => nameInputRef.current?.focus(), 100); } @@ -134,6 +137,7 @@ export function SpawnModal({ name: finalName, command: command.trim(), cwd: cwd.trim() || undefined, + team: team.trim() || undefined, }); if (success) { @@ -264,6 +268,22 @@ export function SpawnModal({ />
+ {/* Team Assignment (optional) */} +
+ + setTeam(e.target.value)} + disabled={isSpawning} + /> +
+ {/* Error Display */} {displayError && (
diff --git a/src/dashboard/react-components/layout/Header.tsx b/src/dashboard/react-components/layout/Header.tsx index d4cce0852..3fc58f5b9 100644 --- a/src/dashboard/react-components/layout/Header.tsx +++ b/src/dashboard/react-components/layout/Header.tsx @@ -15,6 +15,8 @@ export interface HeaderProps { selectedAgent?: Agent | null; onCommandPaletteOpen?: () => void; onSettingsClick?: () => void; + /** Mobile: open sidebar handler */ + onMenuClick?: () => void; } export function Header({ @@ -22,12 +24,22 @@ export function Header({ selectedAgent, onCommandPaletteOpen, onSettingsClick, + onMenuClick, }: HeaderProps) { const isGeneral = currentChannel === 'general'; const colors = selectedAgent ? getAgentColor(selectedAgent.name) : null; return (
+ {/* Mobile hamburger menu button */} + +
{isGeneral ? ( <> @@ -74,6 +86,14 @@ export function Header({ ⌘K + + + +