diff --git a/.beads/deletions.jsonl b/.beads/deletions.jsonl new file mode 100644 index 000000000..e69de29bb diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 9839f725d..a04b63e70 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,4 +1,4 @@ -{"id":"agent-relay-4e0","title":"Fix message truncation - messages cut off at source","description":"Incoming relay messages are being truncated mid-sentence. Even reading full message with 'agent-relay read \u003cid\u003e' shows incomplete content. GraniteElk identified something in parser.ts:40 but needs investigation.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"MistyShelter","created_at":"2025-12-19T23:40:35.082717+01:00","updated_at":"2025-12-19T23:41:17.887867+01:00"} +{"id":"agent-relay-4e0","title":"Fix message truncation - messages cut off at source","description":"Root cause found: parser.ts:40 inline regex only captures single line. Multi-line messages are split by parsePassThrough() at line 206. Fix options: (1) Allow continuation lines in inline format, (2) Use block format for multi-line, (3) Add heuristic to join lines until next @relay pattern.","status":"closed","priority":2,"issue_type":"bug","assignee":"MistyShelter","created_at":"2025-12-19T23:40:35.082717+01:00","updated_at":"2025-12-20T00:03:54.806087+01:00","closed_at":"2025-12-20T00:03:54.806087+01:00"} {"id":"agent-relay-4ft","title":"Merge project info into status command","description":"","status":"closed","priority":2,"issue_type":"task","assignee":"Pruner","created_at":"2025-12-19T21:59:52.685495+01:00","updated_at":"2025-12-19T22:06:44.276187+01:00","closed_at":"2025-12-19T22:06:44.276187+01:00"} {"id":"agent-relay-6ny","title":"Fix message truncation: store full message in SQLite first, include ID in relay","description":"","status":"closed","priority":1,"issue_type":"bug","created_at":"2025-12-19T22:04:36.168862+01:00","updated_at":"2025-12-19T22:08:28.207532+01:00","closed_at":"2025-12-19T22:08:28.207532+01:00"} {"id":"agent-relay-7yo","title":"Update CLAUDE.md with new CLI commands","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-19T22:00:02.661859+01:00","updated_at":"2025-12-19T22:07:27.766701+01:00","closed_at":"2025-12-19T22:07:27.766701+01:00","dependencies":[{"issue_id":"agent-relay-7yo","depends_on_id":"agent-relay-85z","type":"blocks","created_at":"2025-12-19T22:00:25.731921+01:00","created_by":"daemon"},{"issue_id":"agent-relay-7yo","depends_on_id":"agent-relay-4ft","type":"blocks","created_at":"2025-12-19T22:00:25.772241+01:00","created_by":"daemon"},{"issue_id":"agent-relay-7yo","depends_on_id":"agent-relay-bd0","type":"blocks","created_at":"2025-12-19T22:00:25.80776+01:00","created_by":"daemon"},{"issue_id":"agent-relay-7yo","depends_on_id":"agent-relay-f3q","type":"blocks","created_at":"2025-12-19T22:00:25.843131+01:00","created_by":"daemon"}]} @@ -8,5 +8,5 @@ {"id":"agent-relay-bei","title":"Gemini (CleverBeacon) bash syntax errors - unexpected EOF","description":"Gemini agent gets bash errors: 'unexpected EOF while looking for matching quote' and 'syntax error: unexpected end of file' when running shell commands. Possibly related to quote escaping in the relay wrapper.","status":"closed","priority":2,"issue_type":"bug","assignee":"GraniteElk","created_at":"2025-12-19T23:40:36.464079+01:00","updated_at":"2025-12-19T23:45:46.05609+01:00","closed_at":"2025-12-19T23:45:46.05609+01:00"} {"id":"agent-relay-cli-simplify","title":"Simplify agent-relay CLI interface","description":"The CLI has too many commands. Consolidate into a clean, simple interface:\n\n1. `relay start` should also kick off the dashboard automatically\n2. Merge redundant team-* commands\n3. Remove rarely-used commands or make them subcommands\n4. Target: 5-7 top-level commands max\n\nCurrent commands to evaluate:\n- start, stop, status (keep)\n- wrap (keep)\n- project (keep or merge into status)\n- send (keep)\n- team-setup, team-status, team-send, team-check, team-listen, team-start (consolidate)\n- msg-read (make subcommand or integrate)\n- dashboard (merge into start)","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-19T22:00:00Z","updated_at":"2025-12-19T22:08:44.107992+01:00","closed_at":"2025-12-19T22:08:44.107992+01:00"} {"id":"agent-relay-f3q","title":"Make msg-read a subcommand of send or message","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-19T21:59:55.123772+01:00","updated_at":"2025-12-19T22:04:51.112763+01:00","closed_at":"2025-12-19T22:04:51.112763+01:00"} -{"id":"agent-relay-ghy","title":"Team config: auto-spawn agents or auto-assign names from teams.json","description":"When a project has a teams.json config file, agent-relay up should either:\n\n1. **Auto-spawn option**: Automatically kick off terminal sessions for each agent defined in teams.json\n2. **Auto-assign option**: When users manually start sessions with `agent-relay -n \u003cname\u003e claude`, validate the name against teams.json and auto-assign roles/permissions\n\n## teams.json format\n```json\n{\n \"team\": \"my-project\",\n \"agents\": [\n {\"name\": \"Coordinator\", \"cli\": \"claude\", \"role\": \"coordinator\"},\n {\"name\": \"LeadDev\", \"cli\": \"claude\", \"role\": \"developer\"},\n {\"name\": \"Reviewer\", \"cli\": \"claude\", \"role\": \"reviewer\"}\n ],\n \"autoSpawn\": false\n}\n```\n\n## Behavior\n- If `autoSpawn: true`, `agent-relay up` spawns tmux sessions for each agent\n- If `autoSpawn: false`, validate names against config when agents connect\n- Store teams.json in project root or .agent-relay/teams.json\n\n## Commands\n- `agent-relay up --spawn` - force spawn all agents\n- `agent-relay up --no-spawn` - just start daemon, manual agent starts","status":"open","priority":2,"issue_type":"feature","created_at":"2025-12-19T23:13:02.482971+01:00","updated_at":"2025-12-19T23:13:02.482971+01:00"} +{"id":"agent-relay-ghy","title":"Team config: auto-spawn agents or auto-assign names from teams.json","description":"When a project has a teams.json config file, agent-relay up should either:\n\n1. **Auto-spawn option**: Automatically kick off terminal sessions for each agent defined in teams.json\n2. **Auto-assign option**: When users manually start sessions with `agent-relay -n \u003cname\u003e claude`, validate the name against teams.json and auto-assign roles/permissions\n\n## teams.json format\n```json\n{\n \"team\": \"my-project\",\n \"agents\": [\n {\"name\": \"Coordinator\", \"cli\": \"claude\", \"role\": \"coordinator\"},\n {\"name\": \"LeadDev\", \"cli\": \"claude\", \"role\": \"developer\"},\n {\"name\": \"Reviewer\", \"cli\": \"claude\", \"role\": \"reviewer\"}\n ],\n \"autoSpawn\": false\n}\n```\n\n## Behavior\n- If `autoSpawn: true`, `agent-relay up` spawns tmux sessions for each agent\n- If `autoSpawn: false`, validate names against config when agents connect\n- Store teams.json in project root or .agent-relay/teams.json\n\n## Commands\n- `agent-relay up --spawn` - force spawn all agents\n- `agent-relay up --no-spawn` - just start daemon, manual agent starts","status":"in_progress","priority":2,"issue_type":"feature","created_at":"2025-12-19T23:13:02.482971+01:00","updated_at":"2025-12-19T23:56:21.713993+01:00"} {"id":"agent-relay-ucw","title":"Dashboard: multi-project navigation or dynamic port allocation","description":"When the dashboard is already running for one project, users should be able to either: (1) Navigate between different projects in a single dashboard view, OR (2) Start a new dashboard instance on an automatically allocated available port for a different project. Currently if a dashboard is running, starting another project conflicts.","status":"open","priority":2,"issue_type":"feature","created_at":"2025-12-19T23:40:18.667766+01:00","updated_at":"2025-12-19T23:40:25.702792+01:00"} diff --git a/package.json b/package.json index 746711bf1..d1c87ca38 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-relay", - "version": "1.0.5", + "version": "1.0.6", "description": "Real-time agent-to-agent communication system", "type": "module", "main": "dist/index.js", diff --git a/src/dashboard/server.ts b/src/dashboard/server.ts index 914ca2587..3dd9dd0ed 100644 --- a/src/dashboard/server.ts +++ b/src/dashboard/server.ts @@ -254,22 +254,40 @@ export async function startDashboard(port: number, dataDir: string, dbPath?: str } } - return new Promise((resolve, reject) => { - try { - server.listen(port, () => { - console.log(`Dashboard running at http://localhost:${port}`); - console.log(`Monitoring: ${dataDir}`); - // We do NOT resolve here to keep the process alive - // But we must resolve if the user sends SIGINT? - // The main process handles SIGINT. - }); - - server.on('error', (err) => { - console.error('Server error:', err); - reject(err); + // Try to find an available port, starting from the requested port + const findAvailablePort = async (startPort: number, maxAttempts = 10): Promise => { + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const portToTry = startPort + attempt; + const isAvailable = await new Promise((resolve) => { + const testServer = http.createServer(); + testServer.once('error', () => resolve(false)); + testServer.once('listening', () => { + testServer.close(); + resolve(true); }); - } catch (e) { - reject(e); + testServer.listen(portToTry); + }); + + if (isAvailable) { + return portToTry; } + console.log(`Port ${portToTry} in use, trying ${portToTry + 1}...`); + } + throw new Error(`Could not find available port after trying ${startPort}-${startPort + maxAttempts - 1}`); + }; + + const availablePort = await findAvailablePort(port); + + return new Promise((resolve, reject) => { + server.listen(availablePort, () => { + console.log(`Dashboard running at http://localhost:${availablePort}`); + console.log(`Monitoring: ${dataDir}`); + resolve(); + }); + + server.on('error', (err) => { + console.error('Server error:', err); + reject(err); + }); }); } diff --git a/src/wrapper/parser.test.ts b/src/wrapper/parser.test.ts index 0e2be2416..815b3116d 100644 --- a/src/wrapper/parser.test.ts +++ b/src/wrapper/parser.test.ts @@ -70,6 +70,33 @@ describe('OutputParser', () => { expect(result.commands[1].body).toBe('Second message'); }); + it('parses multi-line inline command with indented continuation', () => { + // TUI wrapping indents continuation lines + const result = parser.parse('@relay:agent2 First line\n Second line\n'); + + expect(result.commands).toHaveLength(1); + expect(result.commands[0].body).toBe('First line\n Second line'); + expect(result.output).toBe(''); + }); + + it('does not swallow subsequent inline command after indented continuation', () => { + const result = parser.parse('@relay:agent1 First line\n Second line\n@relay:agent2 Next\n'); + + expect(result.commands).toHaveLength(2); + expect(result.commands[0].body).toBe('First line\n Second line'); + expect(result.commands[1].body).toBe('Next'); + expect(result.output).toBe(''); + }); + + it('does not treat non-indented lines as continuation', () => { + // Non-indented lines after @relay should be regular output + const result = parser.parse('@relay:agent2 Message\nRegular output\n'); + + expect(result.commands).toHaveLength(1); + expect(result.commands[0].body).toBe('Message'); + expect(result.output).toBe('Regular output\n'); + }); + it('does not require spaces in target name', () => { const result = parser.parse('@relay:agent-with-dashes Message here\n'); diff --git a/src/wrapper/parser.ts b/src/wrapper/parser.ts index e1b2d81a1..347bc79ed 100644 --- a/src/wrapper/parser.ts +++ b/src/wrapper/parser.ts @@ -202,7 +202,7 @@ export class OutputParser { * across chunks. */ private parsePassThrough(data: string, commands: ParsedCommand[]): string { - // Simple approach: split data, check each line (complete or not), rebuild output + // Simple approach: split data, check each line (complete or not), rebuild output const lines = data.split('\n'); const hasTrailingNewline = data.endsWith('\n'); @@ -229,7 +229,54 @@ export class OutputParser { // Only check complete lines for relay commands. const result = this.processLine(line); if (result.command) { - commands.push(result.command); + // Collect continuation lines (in the same chunk) so inline messages can span multiple lines. + let body = result.command.body; + const rawLines = [result.command.raw]; + let consumed = 0; + + while (i + 1 < lines.length) { + const nextIsLast = i + 1 === lines.length - 1; + const nextLine = lines[i + 1]; + + // Do not consume an incomplete trailing line (no newline terminator) + if (nextIsLast && !hasTrailingNewline) { + break; + } + + const nextStripped = stripAnsi(nextLine); + + // Stop at empty lines - they end the continuation + if (nextStripped.trim() === '') { + break; + } + + // Stop if the next line starts another inline command, code fence, or block marker + if ( + INLINE_RELAY.test(nextStripped) || + INLINE_THINKING.test(nextStripped) || + CODE_FENCE.test(nextStripped) || + nextStripped.includes('[[RELAY]]') || + BLOCK_END.test(nextStripped) + ) { + break; + } + + // Only consume as continuation if the line is INDENTED (starts with whitespace) + // This handles TUI wrapping where continuation lines are indented + // Non-indented lines are regular output, not continuation + if (!/^[ \t]/.test(nextLine)) { + break; + } + + consumed++; + i++; // Skip the consumed continuation line + body += '\n' + nextLine; + rawLines.push(nextLine); + } + + commands.push({ ...result.command, body, raw: rawLines.join('\n') }); + strippedCount += consumed + 1; + continue; } if (result.output !== null) { outputLines.push(result.output); diff --git a/src/wrapper/tmux-wrapper.ts b/src/wrapper/tmux-wrapper.ts index f4af8cdb6..b27fdabd1 100644 --- a/src/wrapper/tmux-wrapper.ts +++ b/src/wrapper/tmux-wrapper.ts @@ -410,7 +410,9 @@ export class TmuxWrapper { // Always parse the FULL capture for @relay commands // This handles terminal UIs that rewrite content in place const cleanContent = this.stripAnsi(stdout); - const { commands } = this.parser.parse(cleanContent); + // Join continuation lines that TUIs split across multiple lines + const joinedContent = this.joinContinuationLines(cleanContent); + const { commands } = this.parser.parse(joinedContent); // Track last output time for injection timing if (stdout.length !== this.processedOutputLength) { @@ -420,6 +422,7 @@ export class TmuxWrapper { // Send any commands found (deduplication handles repeats) for (const cmd of commands) { + this.logStderr(`Found relay command: to=${cmd.to} body=${cmd.body.substring(0, 50)}...`); this.sendRelayCommand(cmd); } @@ -441,6 +444,63 @@ export class TmuxWrapper { return str.replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, ''); } + /** + * Join continuation lines after @relay commands. + * Claude Code and other TUIs insert real newlines in output, causing + * @relay messages to span multiple lines. This joins indented + * continuation lines back to the @relay line. + */ + private joinContinuationLines(content: string): string { + const lines = content.split('\n'); + const result: string[] = []; + + // Pattern to detect @relay command line (with optional bullet prefix) + const relayPattern = /^(?:\s*(?:[>$%#→➜›»●•◦‣⁃\-*⏺◆◇○□■]\s*)*)?@relay:/; + // Pattern to detect a continuation line (starts with spaces, no bullet/command) + const continuationPattern = /^[ \t]+[^>$%#→➜›»●•◦‣⁃\-*⏺◆◇○□■@\s]/; + // Pattern to detect a new block/bullet (stops continuation) + const newBlockPattern = /^(?:\s*)?[>$%#→➜›»●•◦‣⁃\-*⏺◆◇○□■]/; + + let i = 0; + while (i < lines.length) { + const line = lines[i]; + + // Check if this is a @relay line + if (relayPattern.test(line)) { + let joined = line; + let j = i + 1; + + // Look ahead for continuation lines + while (j < lines.length) { + const nextLine = lines[j]; + + // Empty line stops continuation + if (nextLine.trim() === '') break; + + // New bullet/block stops continuation + if (newBlockPattern.test(nextLine)) break; + + // Check if it looks like a continuation (indented text) + if (continuationPattern.test(nextLine)) { + // Join with space, trimming the indentation + joined += ' ' + nextLine.trim(); + j++; + } else { + break; + } + } + + result.push(joined); + i = j; // Skip the lines we joined + } else { + result.push(line); + i++; + } + } + + return result.join('\n'); + } + /** * Escape string for ANSI-C quoting ($'...') * This handles special characters more reliably than mixing quote styles @@ -461,12 +521,18 @@ export class TmuxWrapper { const msgHash = `${cmd.to}:${cmd.body}`; // Permanent dedup - never send the same message twice - if (this.sentMessageHashes.has(msgHash)) return; + if (this.sentMessageHashes.has(msgHash)) { + this.logStderr(`Skipping duplicate: ${cmd.to}`); + return; + } + this.logStderr(`Attempting to send to ${cmd.to}, client state: ${this.client.state}`); const success = this.client.sendMessage(cmd.to, cmd.body, cmd.kind, cmd.data); if (success) { this.sentMessageHashes.add(msgHash); this.logStderr(`→ ${cmd.to}: ${cmd.body.substring(0, 40)}...`); + } else { + this.logStderr(`Failed to send to ${cmd.to} (client state: ${this.client.state})`); } }