From 68c8773719b4538c1cd620f4c9acdd34658d4c8d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Dec 2025 09:46:16 +0000 Subject: [PATCH 1/2] feat: Add fenced multi-line message format (<<< ... >>>) Implements unambiguous multi-line message parsing using fence markers. The new syntax allows agents to send messages with blank lines, code blocks, and complex formatting without truncation: ->relay:Target <<< Multi-line content here. Can include blank lines. >>> - Added fenced inline parsing state machine in parser.ts - Added 13 comprehensive tests for fenced format - Updated CLAUDE.md documentation with new format - All 492 tests pass Fixes the fundamental issue where multi-line messages with complete line breaks would get cut off after the first line. --- CLAUDE.md | 33 +++++++ src/wrapper/parser.test.ts | 149 ++++++++++++++++++++++++++++ src/wrapper/parser.ts | 192 ++++++++++++++++++++++++++++++++++++- 3 files changed, 373 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 91fe3f2bd..5ab349c29 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,6 +82,23 @@ Output this in your response (not in a bash command): ->relay:AgentName Your message here ``` +### Multi-line Messages (Fenced Format) + +For messages with blank lines, code blocks, or complex formatting, use the fenced format: + +``` +->relay:AgentName <<< +Here's my analysis: + +1. First point +2. Second point + +The conclusion is clear. +>>> +``` + +The `<<<` opens the message block, `>>>` closes it. Everything between is captured exactly, including blank lines and code. + ### Broadcast to All ``` @@ -114,6 +131,7 @@ agent-relay read abc12345... ## Communication Patterns +### Simple Messages ``` ->relay:* STATUS: Starting work on auth module ->relay:* DONE: Auth module complete @@ -122,6 +140,21 @@ agent-relay read abc12345... ->relay:Architect QUESTION: JWT or sessions? ``` +### Multi-line (Fenced) Messages +``` +->relay:Reviewer <<< +REVIEW REQUEST: Authentication Module + +Please check these files: +- src/auth/login.ts +- src/auth/session.ts + +Key changes: +1. Added JWT validation +2. Fixed session expiry bug +>>> +``` + --- ## Pattern Rules diff --git a/src/wrapper/parser.test.ts b/src/wrapper/parser.test.ts index fdcbcea38..052ee6732 100644 --- a/src/wrapper/parser.test.ts +++ b/src/wrapper/parser.test.ts @@ -239,6 +239,155 @@ describe('OutputParser', () => { }); }); + describe('Fenced inline format - ->relay:Target <<< ... >>>', () => { + it('parses basic fenced inline message', () => { + const input = '->relay:agent2 <<<\nHello there\n>>>\n'; + const result = parser.parse(input); + + expect(result.commands).toHaveLength(1); + expect(result.commands[0]).toMatchObject({ + to: 'agent2', + kind: 'message', + body: 'Hello there', + }); + expect(result.output).toBe(''); + }); + + it('preserves blank lines within fenced message', () => { + const input = '->relay:agent2 <<<\nFirst paragraph\n\nSecond paragraph\n>>>\n'; + const result = parser.parse(input); + + expect(result.commands).toHaveLength(1); + expect(result.commands[0].body).toBe('First paragraph\n\nSecond paragraph'); + }); + + it('handles multi-line message with complex content', () => { + const input = `->relay:Lead <<< +Here's my analysis: + +1. First point +2. Second point + +The conclusion is... +>>> +`; + const result = parser.parse(input); + + expect(result.commands).toHaveLength(1); + expect(result.commands[0].to).toBe('Lead'); + expect(result.commands[0].body).toContain('First point'); + expect(result.commands[0].body).toContain('Second point'); + expect(result.commands[0].body).toContain('The conclusion is...'); + }); + + it('handles fenced message with code blocks inside', () => { + const input = `->relay:Dev <<< +Here's the code: + +\`\`\`typescript +function hello() { + console.log('Hi'); +} +\`\`\` + +Let me know if that works. +>>> +`; + const result = parser.parse(input); + + expect(result.commands).toHaveLength(1); + expect(result.commands[0].body).toContain('```typescript'); + expect(result.commands[0].body).toContain('function hello()'); + }); + + it('handles fenced thinking variant', () => { + const input = '->thinking:agent2 <<<\nConsidering options:\n- Option A\n- Option B\n>>>\n'; + const result = parser.parse(input); + + expect(result.commands).toHaveLength(1); + expect(result.commands[0]).toMatchObject({ + to: 'agent2', + kind: 'thinking', + }); + expect(result.commands[0].body).toContain('Option A'); + }); + + it('handles thread syntax in fenced messages', () => { + const input = '->relay:agent2 [thread:review-123] <<<\nMulti-line\nreview comments\n>>>\n'; + const result = parser.parse(input); + + expect(result.commands).toHaveLength(1); + expect(result.commands[0].thread).toBe('review-123'); + expect(result.commands[0].body).toBe('Multi-line\nreview comments'); + }); + + it('handles cross-project syntax in fenced messages', () => { + const input = '->relay:other-project:agent2 <<<\nCross-project message\n>>>\n'; + const result = parser.parse(input); + + expect(result.commands).toHaveLength(1); + expect(result.commands[0].to).toBe('agent2'); + expect(result.commands[0].project).toBe('other-project'); + }); + + it('processes content after fenced block closes', () => { + const input = '->relay:agent1 <<<\nFenced content\n>>>\n->relay:agent2 Regular inline\n'; + const result = parser.parse(input); + + expect(result.commands).toHaveLength(2); + expect(result.commands[0].to).toBe('agent1'); + expect(result.commands[0].body).toBe('Fenced content'); + expect(result.commands[1].to).toBe('agent2'); + expect(result.commands[1].body).toBe('Regular inline'); + }); + + it('accumulates across multiple parse calls (streaming)', () => { + const result1 = parser.parse('->relay:agent2 <<<\nFirst part\n'); + expect(result1.commands).toHaveLength(0); + expect(result1.output).toBe(''); + + const result2 = parser.parse('Second part\n'); + expect(result2.commands).toHaveLength(0); + + const result3 = parser.parse('>>>\n'); + expect(result3.commands).toHaveLength(1); + expect(result3.commands[0].body).toBe('First part\nSecond part'); + }); + + it('handles >>> with leading/trailing whitespace', () => { + const input = '->relay:agent2 <<<\nContent\n >>> \n'; + const result = parser.parse(input); + + expect(result.commands).toHaveLength(1); + expect(result.commands[0].body).toBe('Content'); + }); + + it('trims leading/trailing whitespace from body', () => { + const input = '->relay:agent2 <<<\n\n Content here \n\n>>>\n'; + const result = parser.parse(input); + + expect(result.commands).toHaveLength(1); + // Leading blank lines should be trimmed, content preserved + expect(result.commands[0].body).toBe('Content here'); + }); + + it('handles fenced message with only blank lines', () => { + const input = '->relay:agent2 <<<\n\n\n>>>\n'; + const result = parser.parse(input); + + expect(result.commands).toHaveLength(1); + expect(result.commands[0].body).toBe(''); + }); + + it('handles prefixes like bullets before fenced start', () => { + const input = '- ->relay:agent2 <<<\nContent from list\n>>>\n'; + const result = parser.parse(input); + + expect(result.commands).toHaveLength(1); + expect(result.commands[0].body).toBe('Content from list'); + }); + }); + describe('Code fence handling', () => { it('ignores ->relay: inside code fences', () => { const input = '```\n->relay:agent2 This should be ignored\n```\n'; diff --git a/src/wrapper/parser.ts b/src/wrapper/parser.ts index fa172c3a5..bd9bec23c 100644 --- a/src/wrapper/parser.ts +++ b/src/wrapper/parser.ts @@ -50,7 +50,12 @@ const DEFAULT_OPTIONS: Required = { const BLOCK_END = /\[\[\/RELAY\]\]/; const BLOCK_METADATA_START = '[[RELAY_METADATA]]'; const BLOCK_METADATA_END = /\[\[\/RELAY_METADATA\]\]/; -const CODE_FENCE = /^```/;// Continuation helpers +const CODE_FENCE = /^```/; + +// Fenced inline patterns: ->relay:Target <<< ... >>> +const FENCE_END = /^(?:\s*)?>>>(?:\s*)$/; + +// Continuation helpers const BULLET_OR_NUMBERED_LIST = /^[ \t]*([\-*•◦‣⏺◆◇○□■]|[0-9]+[.)])\s+/; const PROMPTISH_LINE = /^[\s]*[>$%#➜›»][\s]*$/; const RELAY_INJECTION_PREFIX = /^\s*Relay message from /; @@ -77,6 +82,16 @@ function buildInlinePattern(prefix: string): RegExp { return new RegExp(`^(?:\\s*(?:[>$%#→➜›»●•◦‣⁃\\-*⏺◆◇○□■│┃┆┇┊┋╎╏✦]\\s*)*)?${escaped}(\\S+)(?:\\s+\\[thread:([\\w-]+)\\])?\\s+(.+)$`); } +/** + * Build fenced inline pattern for multi-line messages: ->relay:Target <<< + * This opens a fenced block that continues until >>> is seen on its own line. + * Group 1: target, Group 2: optional thread ID + */ +function buildFencedInlinePattern(prefix: string): RegExp { + const escaped = escapeRegex(prefix); + return new RegExp(`^(?:\\s*(?:[>$%#→➜›»●•◦‣⁃\\-*⏺◆◇○□■│┃┆┇┊┋╎╏✦]\\s*)*)?${escaped}(\\S+)(?:\\s+\\[thread:([\\w-]+)\\])?\\s+<<<\\s*$`); +} + /** * Build escape pattern for a given prefix (e.g., \->relay: or \->) */ @@ -129,9 +144,20 @@ export class OutputParser { private blockType: 'RELAY' | 'RELAY_METADATA' | null = null; private lastParsedMetadata: ParsedMessageMetadata | null = null; + // Fenced inline state: ->relay:Target <<< ... >>> + private inFencedInline = false; + private fencedInlineBuffer = ''; + private fencedInlineTarget = ''; + private fencedInlineThread: string | undefined = undefined; + private fencedInlineProject: string | undefined = undefined; + private fencedInlineRaw: string[] = []; + private fencedInlineKind: 'message' | 'thinking' = 'message'; + // Dynamic patterns based on prefix configuration private inlineRelayPattern: RegExp; private inlineThinkingPattern: RegExp; + private fencedRelayPattern: RegExp; + private fencedThinkingPattern: RegExp; private escapePattern: RegExp; constructor(options: ParserOptions = {}) { @@ -140,6 +166,8 @@ export class OutputParser { // Build patterns based on configured prefixes this.inlineRelayPattern = buildInlinePattern(this.options.prefix); this.inlineThinkingPattern = buildInlinePattern(this.options.thinkingPrefix); + this.fencedRelayPattern = buildFencedInlinePattern(this.options.prefix); + this.fencedThinkingPattern = buildFencedInlinePattern(this.options.thinkingPrefix); this.escapePattern = buildEscapePattern(this.options.prefix, this.options.thinkingPrefix); } @@ -161,6 +189,11 @@ export class OutputParser { const commands: ParsedCommand[] = []; let output = ''; + // If we're inside a fenced inline block, accumulate until we see >>> + if (this.inFencedInline) { + return this.parseFencedInlineMode(data, commands); + } + // If we're inside a block, accumulate until we see the end if (this.inBlock && this.blockType) { return this.parseInBlockMode(data, commands, this.blockType); @@ -309,6 +342,23 @@ export class OutputParser { return this.inlineRelayPattern.test(line) || this.inlineThinkingPattern.test(line); }; + const isFencedInlineStart = (line: string): { target: string; thread?: string; project?: string; kind: 'message' | 'thinking' } | null => { + const stripped = stripAnsi(line); + const relayMatch = stripped.match(this.fencedRelayPattern); + if (relayMatch) { + const [, target, threadId] = relayMatch; + const { to, project } = parseTarget(target); + return { target: to, thread: threadId || undefined, project, kind: 'message' }; + } + const thinkingMatch = stripped.match(this.fencedThinkingPattern); + if (thinkingMatch) { + const [, target, threadId] = thinkingMatch; + const { to, project } = parseTarget(target); + return { target: to, thread: threadId || undefined, project, kind: 'thinking' }; + } + return null; + }; + const isBlockMarker = (line: string): boolean => { return CODE_FENCE.test(line) || line.includes('[[RELAY]]') || BLOCK_END.test(line); }; @@ -316,6 +366,7 @@ export class OutputParser { const shouldStopContinuation = (line: string, continuationCount: number, lines: string[], currentIndex: number): boolean => { const trimmed = line.trim(); if (isInlineStart(line)) return true; + if (isFencedInlineStart(line)) return true; if (isBlockMarker(line)) return true; if (PROMPTISH_LINE.test(trimmed)) return true; if (RELAY_INJECTION_PREFIX.test(line)) return true; // Avoid swallowing injected inbound messages @@ -382,6 +433,45 @@ export class OutputParser { continue; } + // Check for fenced inline start: ->relay:Target <<< + const fencedStart = isFencedInlineStart(line); + if (fencedStart && this.options.enableInline) { + // Enter fenced inline mode + this.inFencedInline = true; + this.fencedInlineTarget = fencedStart.target; + this.fencedInlineThread = fencedStart.thread; + this.fencedInlineProject = fencedStart.project; + this.fencedInlineKind = fencedStart.kind; + this.fencedInlineBuffer = ''; + this.fencedInlineRaw = [line]; + + // Process remaining lines in fenced mode + if (i + 1 < lines.length) { + // Don't double-add trailing newline - the empty string at end of lines array + // already accounts for it when we join + const remainingLines = lines.slice(i + 1); + const remaining = remainingLines.join('\n') + (hasTrailingNewline && remainingLines[remainingLines.length - 1] !== '' ? '\n' : ''); + const result = this.parseFencedInlineMode(remaining, commands); + strippedCount++; + + // Combine output + let output = outputLines.join('\n'); + if (hasTrailingNewline && outputLines.length > 0 && !this.inFencedInline) { + output += '\n'; + } + output += result.output; + return output; + } + + // No more lines - waiting for more data + strippedCount++; + let output = outputLines.join('\n'); + if (hasTrailingNewline && outputLines.length > 0) { + output += '\n'; + } + return output; + } + if (line.length > 0) { // Only check complete lines for relay commands. const result = this.processLine(line); @@ -639,6 +729,92 @@ export class OutputParser { } } + /** + * Parse while inside a fenced inline block (->relay:Target <<< ... >>>). + * Accumulates lines until >>> is seen on its own line. + */ + private parseFencedInlineMode(data: string, commands: ParsedCommand[]): { commands: ParsedCommand[]; output: string } { + const lines = data.split('\n'); + const hasTrailingNewline = data.endsWith('\n'); + let output = ''; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const isLastLine = i === lines.length - 1; + const stripped = stripAnsi(line); + + // Check if this line closes the fenced block + if (FENCE_END.test(stripped)) { + // Complete the fenced inline command + const body = this.fencedInlineBuffer.trim(); + this.fencedInlineRaw.push(line); + + const command: ParsedCommand = { + to: this.fencedInlineTarget, + kind: this.fencedInlineKind, + body, + thread: this.fencedInlineThread, + project: this.fencedInlineProject, + raw: this.fencedInlineRaw.join('\n'), + }; + commands.push(command); + + // Reset fenced inline state + this.inFencedInline = false; + this.fencedInlineBuffer = ''; + this.fencedInlineTarget = ''; + this.fencedInlineThread = undefined; + this.fencedInlineProject = undefined; + this.fencedInlineRaw = []; + this.fencedInlineKind = 'message'; + + // Process remaining lines after the fence close + // Only process if there's actual content after the closing fence + const remainingLines = lines.slice(i + 1); + // Filter out trailing empty string from split + const hasContent = remainingLines.some((l, idx) => + l.trim() !== '' || (idx < remainingLines.length - 1)); + + if (hasContent) { + const remaining = remainingLines.join('\n') + (hasTrailingNewline ? '\n' : ''); + const result = this.parse(remaining); + commands.push(...result.commands); + output += result.output; + } + return { commands, output }; + } + + // Accumulate this line into the buffer (preserving blank lines within content) + // But skip trailing empty line from split (when input ends with \n) + const isTrailingEmpty = isLastLine && line === '' && hasTrailingNewline; + if (!isTrailingEmpty) { + if (this.fencedInlineBuffer.length > 0) { + this.fencedInlineBuffer += '\n' + line; + } else if (line.trim() !== '') { + // Start accumulating from first non-blank line + this.fencedInlineBuffer = line; + } + this.fencedInlineRaw.push(line); + } + + // Check size limit + if (this.fencedInlineBuffer.length > this.options.maxBlockBytes) { + console.error('[parser] Fenced inline block too large, discarding'); + this.inFencedInline = false; + this.fencedInlineBuffer = ''; + this.fencedInlineTarget = ''; + this.fencedInlineThread = undefined; + this.fencedInlineProject = undefined; + this.fencedInlineRaw = []; + this.fencedInlineKind = 'message'; + return { commands, output: '' }; + } + } + + // Still waiting for >>> - return empty output (content is buffered) + return { commands, output: '' }; + } + /** * Flush any remaining buffer (call on stream end). */ @@ -649,6 +825,13 @@ export class OutputParser { this.blockType = null; this.lastParsedMetadata = null; this.inCodeFence = false; + this.inFencedInline = false; + this.fencedInlineBuffer = ''; + this.fencedInlineTarget = ''; + this.fencedInlineThread = undefined; + this.fencedInlineProject = undefined; + this.fencedInlineRaw = []; + this.fencedInlineKind = 'message'; return result; } @@ -661,6 +844,13 @@ export class OutputParser { this.blockType = null; this.lastParsedMetadata = null; this.inCodeFence = false; + this.inFencedInline = false; + this.fencedInlineBuffer = ''; + this.fencedInlineTarget = ''; + this.fencedInlineThread = undefined; + this.fencedInlineProject = undefined; + this.fencedInlineRaw = []; + this.fencedInlineKind = 'message'; } } From 8bf86beb7f0ed27f685a21c0288937dedcb069c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Dec 2025 09:54:22 +0000 Subject: [PATCH 2/2] docs: Add fenced multi-line format to all agent documentation Updates documentation across all agent-facing files: - AGENTS.md: Added fenced format section and examples - docs/AGENTS.md: Added fenced format to quick reference - .claude/skills/using-agent-relay/SKILL.md: Added to quick reference and examples - src/wrapper/tmux-wrapper.ts: Added MULTI-LINE hint to welcome message Agents now receive clear instructions about using <<< ... >>> for multi-line messages with blank lines or code blocks. --- .claude/skills/using-agent-relay/SKILL.md | 23 +++++++++++++++++++++++ AGENTS.md | 17 +++++++++++++++++ docs/AGENTS.md | 9 +++++++++ src/wrapper/tmux-wrapper.ts | 1 + 4 files changed, 50 insertions(+) diff --git a/.claude/skills/using-agent-relay/SKILL.md b/.claude/skills/using-agent-relay/SKILL.md index dd6b2423a..780ef895c 100644 --- a/.claude/skills/using-agent-relay/SKILL.md +++ b/.claude/skills/using-agent-relay/SKILL.md @@ -23,6 +23,7 @@ Real-time agent-to-agent messaging. Two modes: **tmux wrapper** (real-time, sub- | Pattern | Description | |---------|-------------| | `->relay:Name message` | Direct message (output as text) | +| `->relay:Name <<<`...`>>>` | Multi-line message with blank lines/code | | `->relay:* message` | Broadcast to all | | `[[RELAY]]{"to":"Name","body":"msg"}[[/RELAY]]` | Structured JSON | | `\->relay:` | Escape (literal output) | @@ -57,6 +58,28 @@ relay team status # Show team ->relay:* STATUS: Starting auth module. ``` +### Multi-line Messages (Fenced Format) + +For messages with blank lines, code blocks, or complex content: + +``` +->relay:Reviewer <<< +REVIEW REQUEST: Auth Module + +Please check: +- src/auth/login.ts +- src/auth/session.ts + +Key changes: +1. Added JWT validation +2. Fixed session expiry +>>> +``` + +The `<<<` opens the block, `>>>` closes it. Everything between is captured exactly. + +### Pattern Rules + Pattern must be at line start (whitespace/prefixes OK): ``` diff --git a/AGENTS.md b/AGENTS.md index eb6ed0b83..a6e5f1999 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,6 +40,23 @@ Output this in your response (not in a bash command): ->relay:AgentName Your message here ``` +### Multi-line Messages (Fenced Format) + +For messages with blank lines, code blocks, or complex formatting, use the fenced format: + +``` +->relay:AgentName <<< +Here's my analysis: + +1. First point +2. Second point + +The conclusion is clear. +>>> +``` + +The `<<<` opens the message block, `>>>` closes it. Everything between is captured exactly, including blank lines and code. + ### Broadcast to All ``` diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 92666b3e6..1c2da6908 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -165,6 +165,15 @@ relay team status ->relay:* Broadcast to all agents ``` +**Fenced format** (multi-line with blank lines/code): +``` +->relay:AgentName <<< +Multi-line message here. + +Can include blank lines and code. +>>> +``` + **Block format** (structured data): ``` [[RELAY]]{"to":"AgentName","type":"message","body":"Your message"}[[/RELAY]] diff --git a/src/wrapper/tmux-wrapper.ts b/src/wrapper/tmux-wrapper.ts index 4a2f53e43..7e1482890 100644 --- a/src/wrapper/tmux-wrapper.ts +++ b/src/wrapper/tmux-wrapper.ts @@ -399,6 +399,7 @@ export class TmuxWrapper { const instructions = [ `[Agent Relay] You are "${this.config.name}" - connected for real-time messaging.`, `SEND: ${this.relayPrefix}AgentName message (or ${this.relayPrefix}* to broadcast)`, + `MULTI-LINE: ${this.relayPrefix}AgentName <<< ... >>> for messages with blank lines`, `RECEIVE: Messages appear as "Relay message from X [id]: content"`, `SUMMARY: Periodically output [[SUMMARY]]{"currentTask":"...","context":"..."}[[/SUMMARY]] to track progress`, `END: Output [[SESSION_END]]{"summary":"..."}[[/SESSION_END]] when your task is complete`,