Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file added .beads/deletions.jsonl
Empty file.
4 changes: 2 additions & 2 deletions .beads/issues.jsonl
Original file line number Diff line number Diff line change
@@ -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"}]}
Expand All @@ -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"}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
48 changes: 33 additions & 15 deletions src/dashboard/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number> => {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const portToTry = startPort + attempt;
const isAvailable = await new Promise<boolean>((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);
});
});
}
27 changes: 27 additions & 0 deletions src/wrapper/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down
51 changes: 49 additions & 2 deletions src/wrapper/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand All @@ -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);
Expand Down
70 changes: 68 additions & 2 deletions src/wrapper/tmux-wrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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);
}

Expand All @@ -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
Expand All @@ -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})`);
}
}

Expand Down
Loading