@agent-relay/wrapper 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/dist/__fixtures__/claude-outputs.d.ts +49 -0
  2. package/dist/__fixtures__/claude-outputs.d.ts.map +1 -0
  3. package/dist/__fixtures__/claude-outputs.js +443 -0
  4. package/dist/__fixtures__/claude-outputs.js.map +1 -0
  5. package/dist/__fixtures__/codex-outputs.d.ts +9 -0
  6. package/dist/__fixtures__/codex-outputs.d.ts.map +1 -0
  7. package/dist/__fixtures__/codex-outputs.js +94 -0
  8. package/dist/__fixtures__/codex-outputs.js.map +1 -0
  9. package/dist/__fixtures__/gemini-outputs.d.ts +19 -0
  10. package/dist/__fixtures__/gemini-outputs.d.ts.map +1 -0
  11. package/dist/__fixtures__/gemini-outputs.js +144 -0
  12. package/dist/__fixtures__/gemini-outputs.js.map +1 -0
  13. package/dist/__fixtures__/index.d.ts +68 -0
  14. package/dist/__fixtures__/index.d.ts.map +1 -0
  15. package/dist/__fixtures__/index.js +44 -0
  16. package/dist/__fixtures__/index.js.map +1 -0
  17. package/dist/auth-detection.d.ts +49 -0
  18. package/dist/auth-detection.d.ts.map +1 -0
  19. package/dist/auth-detection.js +199 -0
  20. package/dist/auth-detection.js.map +1 -0
  21. package/dist/base-wrapper.d.ts +225 -0
  22. package/dist/base-wrapper.d.ts.map +1 -0
  23. package/dist/base-wrapper.js +572 -0
  24. package/dist/base-wrapper.js.map +1 -0
  25. package/dist/client.d.ts +254 -0
  26. package/dist/client.d.ts.map +1 -0
  27. package/dist/client.js +801 -0
  28. package/dist/client.js.map +1 -0
  29. package/dist/id-generator.d.ts +35 -0
  30. package/dist/id-generator.d.ts.map +1 -0
  31. package/dist/id-generator.js +60 -0
  32. package/dist/id-generator.js.map +1 -0
  33. package/dist/idle-detector.d.ts +110 -0
  34. package/dist/idle-detector.d.ts.map +1 -0
  35. package/dist/idle-detector.js +304 -0
  36. package/dist/idle-detector.js.map +1 -0
  37. package/dist/inbox.d.ts +37 -0
  38. package/dist/inbox.d.ts.map +1 -0
  39. package/dist/inbox.js +73 -0
  40. package/dist/inbox.js.map +1 -0
  41. package/dist/index.d.ts +37 -0
  42. package/dist/index.d.ts.map +1 -0
  43. package/dist/index.js +47 -0
  44. package/dist/index.js.map +1 -0
  45. package/dist/parser.d.ts +236 -0
  46. package/dist/parser.d.ts.map +1 -0
  47. package/dist/parser.js +1238 -0
  48. package/dist/parser.js.map +1 -0
  49. package/dist/prompt-composer.d.ts +67 -0
  50. package/dist/prompt-composer.d.ts.map +1 -0
  51. package/dist/prompt-composer.js +168 -0
  52. package/dist/prompt-composer.js.map +1 -0
  53. package/dist/relay-pty-orchestrator.d.ts +407 -0
  54. package/dist/relay-pty-orchestrator.d.ts.map +1 -0
  55. package/dist/relay-pty-orchestrator.js +1885 -0
  56. package/dist/relay-pty-orchestrator.js.map +1 -0
  57. package/dist/shared.d.ts +201 -0
  58. package/dist/shared.d.ts.map +1 -0
  59. package/dist/shared.js +341 -0
  60. package/dist/shared.js.map +1 -0
  61. package/dist/stuck-detector.d.ts +161 -0
  62. package/dist/stuck-detector.d.ts.map +1 -0
  63. package/dist/stuck-detector.js +402 -0
  64. package/dist/stuck-detector.js.map +1 -0
  65. package/dist/tmux-resolver.d.ts +55 -0
  66. package/dist/tmux-resolver.d.ts.map +1 -0
  67. package/dist/tmux-resolver.js +175 -0
  68. package/dist/tmux-resolver.js.map +1 -0
  69. package/dist/tmux-wrapper.d.ts +345 -0
  70. package/dist/tmux-wrapper.d.ts.map +1 -0
  71. package/dist/tmux-wrapper.js +1747 -0
  72. package/dist/tmux-wrapper.js.map +1 -0
  73. package/dist/trajectory-integration.d.ts +292 -0
  74. package/dist/trajectory-integration.d.ts.map +1 -0
  75. package/dist/trajectory-integration.js +979 -0
  76. package/dist/trajectory-integration.js.map +1 -0
  77. package/dist/wrapper-types.d.ts +41 -0
  78. package/dist/wrapper-types.d.ts.map +1 -0
  79. package/dist/wrapper-types.js +7 -0
  80. package/dist/wrapper-types.js.map +1 -0
  81. package/package.json +63 -0
  82. package/src/__fixtures__/claude-outputs.ts +471 -0
  83. package/src/__fixtures__/codex-outputs.ts +99 -0
  84. package/src/__fixtures__/gemini-outputs.ts +151 -0
  85. package/src/__fixtures__/index.ts +47 -0
  86. package/src/auth-detection.ts +244 -0
  87. package/src/base-wrapper.test.ts +540 -0
  88. package/src/base-wrapper.ts +741 -0
  89. package/src/client.test.ts +262 -0
  90. package/src/client.ts +984 -0
  91. package/src/id-generator.test.ts +71 -0
  92. package/src/id-generator.ts +69 -0
  93. package/src/idle-detector.test.ts +390 -0
  94. package/src/idle-detector.ts +370 -0
  95. package/src/inbox.test.ts +233 -0
  96. package/src/inbox.ts +89 -0
  97. package/src/index.ts +170 -0
  98. package/src/parser.regression.test.ts +251 -0
  99. package/src/parser.test.ts +1359 -0
  100. package/src/parser.ts +1477 -0
  101. package/src/prompt-composer.test.ts +219 -0
  102. package/src/prompt-composer.ts +231 -0
  103. package/src/relay-pty-orchestrator.test.ts +1027 -0
  104. package/src/relay-pty-orchestrator.ts +2270 -0
  105. package/src/shared.test.ts +221 -0
  106. package/src/shared.ts +454 -0
  107. package/src/stuck-detector.test.ts +303 -0
  108. package/src/stuck-detector.ts +511 -0
  109. package/src/tmux-resolver.test.ts +104 -0
  110. package/src/tmux-resolver.ts +207 -0
  111. package/src/tmux-wrapper.test.ts +316 -0
  112. package/src/tmux-wrapper.ts +2010 -0
  113. package/src/trajectory-detection.test.ts +151 -0
  114. package/src/trajectory-integration.ts +1261 -0
  115. package/src/wrapper-types.ts +45 -0
package/src/parser.ts ADDED
@@ -0,0 +1,1477 @@
1
+ /**
2
+ * PTY Output Parser
3
+ * Extracts relay commands from agent terminal output.
4
+ *
5
+ * Supports two formats:
6
+ * 1. Inline: ->relay:<target> <message> (single line, start of line only)
7
+ * 2. Block: [[RELAY]]{ json }[[/RELAY]] (multi-line, structured)
8
+ *
9
+ * Rules:
10
+ * - Inline only matches at start of line (after whitespace)
11
+ * - Ignores content inside code fences
12
+ * - Escape with \->relay: to output literal
13
+ * - Block format is preferred for structured data
14
+ */
15
+
16
+ import type { PayloadKind } from '@agent-relay/protocol/types';
17
+
18
+ export interface ParsedCommand {
19
+ to: string;
20
+ kind: PayloadKind;
21
+ body: string;
22
+ data?: Record<string, unknown>;
23
+ /** Optional thread ID for grouping related messages */
24
+ thread?: string;
25
+ /** Optional project for cross-project messaging (e.g., ->relay:project:agent) */
26
+ project?: string;
27
+ /** Optional thread project for cross-project threads (e.g., [thread:project:id]) */
28
+ threadProject?: string;
29
+ /** Optional sync metadata parsed from [await] */
30
+ sync?: { blocking: boolean; timeoutMs?: number };
31
+ raw: string;
32
+ meta?: ParsedMessageMetadata;
33
+ }
34
+
35
+ export interface ParserOptions {
36
+ maxBlockBytes?: number;
37
+ enableInline?: boolean;
38
+ enableBlock?: boolean;
39
+ /** Relay prefix pattern (default: '->relay:') */
40
+ prefix?: string;
41
+ /** Thinking prefix pattern (default: '->thinking:') */
42
+ thinkingPrefix?: string;
43
+ }
44
+
45
+ const DEFAULT_OPTIONS: Required<ParserOptions> = {
46
+ maxBlockBytes: 1024 * 1024, // 1 MiB
47
+ enableInline: true,
48
+ enableBlock: true,
49
+ prefix: '->relay:',
50
+ thinkingPrefix: '->thinking:',
51
+ };
52
+
53
+ // Static patterns (not prefix-dependent)
54
+ const BLOCK_END = /\[\[\/RELAY\]\]/;
55
+ const BLOCK_METADATA_START = '[[RELAY_METADATA]]';
56
+ const BLOCK_METADATA_END = /\[\[\/RELAY_METADATA\]\]/;
57
+ const CODE_FENCE = /^```/;
58
+
59
+ // Fenced inline patterns: ->relay:Target <<< ... >>>
60
+ // Two patterns for fence end detection:
61
+ // - FENCE_END_START: ">>>" at the start of a line (with optional leading whitespace)
62
+ // - FENCE_END_LINE: ">>>" at the end of a line (content followed by >>>)
63
+ // Note: Escaped \>>> should NOT trigger fence end (see isEscapedFenceEnd)
64
+ const FENCE_END_START = /^(?:\s*)?>>>/;
65
+ const FENCE_END_LINE = />>>\s*$/;
66
+ const FENCE_END = new RegExp(`${FENCE_END_START.source}|${FENCE_END_LINE.source}`);
67
+
68
+ // Escape patterns for literal <<< and >>> in content
69
+ // Use \<<< to output literal <<<, use \>>> to output literal >>>
70
+ const ESCAPED_FENCE_START = /\\<<</g;
71
+ const ESCAPED_FENCE_END = /\\>>>/g;
72
+
73
+ // Maximum lines in a fenced block before assuming it's stuck
74
+ // Lower value (30) ensures messages get sent even if agent forgets >>>
75
+ // Most relay messages are under 20 lines; 30 gives buffer for longer ones
76
+ const MAX_FENCED_LINES = 30;
77
+
78
+ // Continuation helpers
79
+ const BULLET_OR_NUMBERED_LIST = /^[ \t]*([\-*•◦‣⏺◆◇○□■]|[0-9]+[.)])\s+/;
80
+ const PROMPTISH_LINE = /^[\s]*[>$%#➜›»][\s]*$/;
81
+ const RELAY_INJECTION_PREFIX = /^\s*Relay message from /;
82
+ const MAX_INLINE_CONTINUATION_LINES = 30;
83
+
84
+ // Spawn/release command patterns - these should NOT be parsed as relay messages
85
+ // They are handled separately by the wrappers (pty-wrapper.ts, tmux-wrapper.ts)
86
+ const SPAWN_COMMAND_PATTERN = /->relay:spawn\s+\S+/i;
87
+ const RELEASE_COMMAND_PATTERN = /->relay:release\s+\S+/i;
88
+
89
+ // JSON relay format: ->relay.json:{...}
90
+ // Simple single-line format that's resilient and won't interfere with normal conversation
91
+ const JSON_RELAY_PATTERN = /->relay\.json:(\{[^\n]+\})/;
92
+
93
+ /**
94
+ * Parsed JSON relay message structure
95
+ */
96
+ interface JsonRelayMessage {
97
+ kind: 'message' | 'spawn' | 'release';
98
+ to?: string;
99
+ body?: string;
100
+ name?: string;
101
+ cli?: string;
102
+ task?: string;
103
+ thread?: string;
104
+ }
105
+
106
+ /**
107
+ * Check if a line is a spawn or release command that should be handled
108
+ * by the wrapper's spawn subsystem, not parsed as a relay message.
109
+ */
110
+ function isSpawnOrReleaseCommand(line: string): boolean {
111
+ return SPAWN_COMMAND_PATTERN.test(line) || RELEASE_COMMAND_PATTERN.test(line);
112
+ }
113
+
114
+ // Claude extended thinking block markers - skip content inside these
115
+ const THINKING_START = new RegExp(String.raw`<` + `thinking>`);
116
+ const THINKING_END = new RegExp(String.raw`</` + `thinking>`);
117
+
118
+ /**
119
+ * Patterns that indicate instructional/example text that should NOT be parsed as actual commands.
120
+ * These are common in system prompts, documentation, and injected instructions.
121
+ */
122
+ const INSTRUCTIONAL_MARKERS = [
123
+ /\bSEND:\s*$/i, // "SEND:" at end of body (instruction prefix)
124
+ /\bPROTOCOL:\s*\(\d+\)/i, // "PROTOCOL: (1)" - numbered protocol instructions
125
+ /\bExample:/i, // "Example:" marker
126
+ /\\->relay:/, // Escaped relay prefix in body (documentation)
127
+ /\\->thinking:/, // Escaped thinking prefix in body (documentation)
128
+ /^AgentName\s+/, // Body starting with "AgentName" (placeholder in examples)
129
+ /^Target\s+/, // Body starting with "Target" (placeholder in examples)
130
+ /\[Agent Relay\]/, // Injected instruction header
131
+ /MULTI-LINE:/i, // Multi-line format instruction
132
+ /RECEIVE:/i, // Receive instruction marker
133
+ ];
134
+
135
+ /**
136
+ * Placeholder target names commonly used in documentation and examples.
137
+ * Messages to these targets should be filtered out as instructional text.
138
+ */
139
+ const PLACEHOLDER_TARGETS = new Set([
140
+ 'agentname',
141
+ 'target',
142
+ 'recipient',
143
+ 'yourtarget',
144
+ 'targetagent',
145
+ 'someagent',
146
+ 'otheragent',
147
+ 'worker', // Too generic, often used in examples
148
+ // NOTE: Don't add 'agent', 'name', 'lead', 'developer', etc. - these can be valid agent names!
149
+ ]);
150
+
151
+ /**
152
+ * Check if a parsed relay command body looks like instructional/example text.
153
+ * These patterns commonly appear in system prompts and documentation.
154
+ */
155
+ function isInstructionalText(body: string): boolean {
156
+ return INSTRUCTIONAL_MARKERS.some(pattern => pattern.test(body));
157
+ }
158
+
159
+ /**
160
+ * Check if a target name is a placeholder commonly used in documentation/examples.
161
+ * These should not be treated as real message targets.
162
+ */
163
+ export function isPlaceholderTarget(target: string): boolean {
164
+ return PLACEHOLDER_TARGETS.has(target.toLowerCase());
165
+ }
166
+
167
+ /**
168
+ * Escape special regex characters in a string
169
+ */
170
+ function escapeRegex(str: string): string {
171
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
172
+ }
173
+
174
+ function parseAwaitTimeout(value: string, unit: string): number | undefined {
175
+ const parsed = parseInt(value, 10);
176
+ if (Number.isNaN(parsed)) return undefined;
177
+ const multiplier = unit === 'h' ? 3600000 : unit === 'm' ? 60000 : 1000;
178
+ return parsed * multiplier;
179
+ }
180
+
181
+ function parseInlineTags(text: string): {
182
+ body: string;
183
+ thread?: string;
184
+ threadProject?: string;
185
+ sync?: ParsedCommand['sync'];
186
+ } {
187
+ let remaining = text;
188
+ let thread: string | undefined;
189
+ let threadProject: string | undefined;
190
+ let sync: ParsedCommand['sync'] | undefined;
191
+
192
+ while (true) {
193
+ const awaitMatch = remaining.match(/^\s*\[await(?::(\d+)([smh]))?\]\s*/);
194
+ if (awaitMatch) {
195
+ const timeoutMs = awaitMatch[1] && awaitMatch[2]
196
+ ? parseAwaitTimeout(awaitMatch[1], awaitMatch[2])
197
+ : undefined;
198
+ sync = { blocking: true, timeoutMs };
199
+ remaining = remaining.slice(awaitMatch[0].length);
200
+ continue;
201
+ }
202
+
203
+ const threadMatch = remaining.match(/^\s*\[thread:(?:([\w-]+):)?([\w-]+)\]\s*/);
204
+ if (threadMatch) {
205
+ threadProject = threadMatch[1] || undefined;
206
+ thread = threadMatch[2];
207
+ remaining = remaining.slice(threadMatch[0].length);
208
+ continue;
209
+ }
210
+
211
+ break;
212
+ }
213
+
214
+ return {
215
+ body: remaining.trimStart(),
216
+ thread,
217
+ threadProject,
218
+ sync,
219
+ };
220
+ }
221
+
222
+ /**
223
+ * Build inline pattern for a given prefix
224
+ * Allow common input prefixes: >, $, %, #, →, ➜, bullets (●•◦‣⁃-*⏺◆◇○□■), box chars (│┃┆┇┊┋╎╏), and their variations
225
+ *
226
+ * Supports optional thread syntax:
227
+ * - ->relay:Target [thread:id] message (local thread)
228
+ * - ->relay:Target [thread:project:id] message (cross-project thread)
229
+ * Thread IDs can contain alphanumeric chars, hyphens, underscores
230
+ */
231
+ function buildInlinePattern(prefix: string): RegExp {
232
+ const escaped = escapeRegex(prefix);
233
+ // Group 1: target, Group 2: message body (may include tags like [thread] or [await])
234
+ // Includes box drawing characters (│┃┆┇┊┋╎╏) and sparkle (✦) for Gemini CLI output
235
+ return new RegExp(`^(?:\\s*(?:[>$%#→➜›»●•◦‣⁃\\-*⏺◆◇○□■│┃┆┇┊┋╎╏✦]\\s*)*)?${escaped}(\\S+)\\s+(.+)$`);
236
+ }
237
+
238
+ /**
239
+ * Build fenced inline pattern for multi-line messages: ->relay:Target <<<
240
+ * This opens a fenced block that continues until >>> is seen on its own line.
241
+ * Supports cross-project thread syntax: [thread:project:id]
242
+ * Group 1: target, Group 2: optional thread project, Group 3: thread ID
243
+ */
244
+ function buildFencedInlinePattern(prefix: string): RegExp {
245
+ const escaped = escapeRegex(prefix);
246
+ return new RegExp(`^(?:\\s*(?:[>$%#→➜›»●•◦‣⁃\\-*⏺◆◇○□■│┃┆┇┊┋╎╏✦]\\s*)*)?${escaped}(\\S+)(?:\\s+(.+?))?\\s+<<<\\s*$`);
247
+ }
248
+
249
+ /**
250
+ * Build escape pattern for a given prefix (e.g., \->relay: or \->)
251
+ */
252
+ function buildEscapePattern(prefix: string, thinkingPrefix: string): RegExp {
253
+ // Extract the first character(s) that would be escaped
254
+ const prefixEscaped = escapeRegex(prefix);
255
+ const thinkingEscaped = escapeRegex(thinkingPrefix);
256
+ return new RegExp(`^(\\s*)\\\\(${prefixEscaped}|${thinkingEscaped})`);
257
+ }
258
+
259
+ // ANSI escape sequence pattern for stripping
260
+ // eslint-disable-next-line no-control-regex
261
+ const ANSI_PATTERN = /\x1b\[[0-9;?]*[a-zA-Z]|\x1b\].*?(?:\x07|\x1b\\)|\r/g;
262
+
263
+ // Pattern for orphaned CSI sequences that lost their escape byte
264
+ // These look like [?25h, [?2026l, [0m, etc. at the start of content
265
+ // Requires at least one digit or question mark to avoid stripping legitimate text like [Agent
266
+ const ORPHANED_CSI_PATTERN = /^\s*(\[(?:\?|\d)\d*[A-Za-z])+\s*/g;
267
+
268
+ /**
269
+ * Parse a target string that may contain cross-project syntax.
270
+ * Supports: "agent" (local) or "project:agent" (cross-project)
271
+ *
272
+ * @param target The raw target string from the relay command
273
+ * @returns Object with `to` (agent name) and optional `project`
274
+ */
275
+ function parseTarget(target: string): { to: string; project?: string } {
276
+ // Check for cross-project syntax: project:agent
277
+ // Only split on FIRST colon to allow agent names with colons
278
+ const colonIndex = target.indexOf(':');
279
+
280
+ if (colonIndex > 0 && colonIndex < target.length - 1) {
281
+ // Has a colon with content on both sides
282
+ const project = target.substring(0, colonIndex);
283
+ const agent = target.substring(colonIndex + 1);
284
+ return { to: agent, project };
285
+ }
286
+
287
+ // Local target (no colon or malformed)
288
+ return { to: target };
289
+ }
290
+
291
+ /**
292
+ * Strip ANSI escape codes from a string for pattern matching.
293
+ * Also strips orphaned CSI sequences that may have lost their escape byte.
294
+ */
295
+ function stripAnsi(str: string): string {
296
+ let result = str.replace(ANSI_PATTERN, '');
297
+ // Strip orphaned CSI sequences at the start of the string
298
+ result = result.replace(ORPHANED_CSI_PATTERN, '');
299
+ return result;
300
+ }
301
+
302
+ /**
303
+ * Check if a line contains an escaped fence end (\>>>) that should NOT trigger fence close.
304
+ * Returns true if the >>> is escaped (preceded by backslash).
305
+ */
306
+ function isEscapedFenceEnd(line: string): boolean {
307
+ // Check if >>> at end of line is escaped
308
+ if (/\\>>>\s*$/.test(line)) {
309
+ return true;
310
+ }
311
+ // Check if >>> at start of line is escaped
312
+ if (/^(?:\s*)?\\>>>/.test(line)) {
313
+ return true;
314
+ }
315
+ return false;
316
+ }
317
+
318
+ /**
319
+ * Unescape fence markers in content.
320
+ * Converts \<<< to <<< and \>>> to >>>
321
+ */
322
+ function unescapeFenceMarkers(content: string): string {
323
+ return content
324
+ .replace(ESCAPED_FENCE_START, '<<<')
325
+ .replace(ESCAPED_FENCE_END, '>>>');
326
+ }
327
+
328
+ export class OutputParser {
329
+ private options: Required<ParserOptions>;
330
+ private inCodeFence = false;
331
+ private inBlock = false;
332
+ private blockBuffer = '';
333
+ private blockType: 'RELAY' | 'RELAY_METADATA' | null = null;
334
+ private lastParsedMetadata: ParsedMessageMetadata | null = null;
335
+
336
+ // Claude extended thinking block state - skip content inside <thinking>...</thinking>
337
+ private inThinkingBlock = false;
338
+
339
+ // Fenced inline state: ->relay:Target <<< ... >>>
340
+ private inFencedInline = false;
341
+ private fencedInlineBuffer = '';
342
+ private fencedInlineTarget = '';
343
+ private fencedInlineThread: string | undefined = undefined;
344
+ private fencedInlineThreadProject: string | undefined = undefined;
345
+ private fencedInlineProject: string | undefined = undefined;
346
+ private fencedInlineRaw: string[] = [];
347
+ private fencedInlineKind: 'message' | 'thinking' = 'message';
348
+ private fencedInlineSync: ParsedCommand['sync'] | undefined = undefined;
349
+
350
+ // Dynamic patterns based on prefix configuration
351
+ private inlineRelayPattern: RegExp;
352
+ private inlineThinkingPattern: RegExp;
353
+ private fencedRelayPattern: RegExp;
354
+ private fencedThinkingPattern: RegExp;
355
+ private escapePattern: RegExp;
356
+
357
+ constructor(options: ParserOptions = {}) {
358
+ this.options = { ...DEFAULT_OPTIONS, ...options };
359
+
360
+ // Build patterns based on configured prefixes
361
+ this.inlineRelayPattern = buildInlinePattern(this.options.prefix);
362
+ this.inlineThinkingPattern = buildInlinePattern(this.options.thinkingPrefix);
363
+ this.fencedRelayPattern = buildFencedInlinePattern(this.options.prefix);
364
+ this.fencedThinkingPattern = buildFencedInlinePattern(this.options.thinkingPrefix);
365
+ this.escapePattern = buildEscapePattern(this.options.prefix, this.options.thinkingPrefix);
366
+ }
367
+
368
+ /**
369
+ * Get the configured relay prefix
370
+ */
371
+ get prefix(): string {
372
+ return this.options.prefix;
373
+ }
374
+
375
+ /**
376
+ * Push data into the parser and extract commands.
377
+ * Returns array of parsed commands and cleaned output.
378
+ *
379
+ * Design: Pass through data with minimal buffering to preserve terminal rendering.
380
+ * Only buffer content when inside [[RELAY]]...[[/RELAY]] blocks.
381
+ */
382
+ parse(data: string): { commands: ParsedCommand[]; output: string } {
383
+ const commands: ParsedCommand[] = [];
384
+ let output = '';
385
+
386
+ // If we're inside a fenced inline block, accumulate until we see >>>
387
+ if (this.inFencedInline) {
388
+ return this.parseFencedInlineMode(data, commands);
389
+ }
390
+
391
+ // If we're inside a block, accumulate until we see the end
392
+ if (this.inBlock && this.blockType) {
393
+ return this.parseInBlockMode(data, commands, this.blockType);
394
+ }
395
+
396
+ // Find [[RELAY_METADATA]] or [[RELAY]] that's at the start of a line
397
+ const blockStart = this.findBlockStart(data);
398
+
399
+ if (this.options.enableBlock && blockStart.index !== -1 && blockStart.identifier) {
400
+ const blockStartIdentifier = blockStart.identifier;
401
+ const before = data.substring(0, blockStart.index);
402
+ const after = data.substring(blockStart.index + blockStartIdentifier.length);
403
+
404
+ // Output everything before the block start
405
+ if (before) {
406
+ const beforeResult = this.parsePassThrough(before, commands);
407
+ output += beforeResult;
408
+ }
409
+
410
+ // Enter block mode
411
+ this.inBlock = true;
412
+ this.blockType = blockStartIdentifier === BLOCK_METADATA_START ? 'RELAY_METADATA' : 'RELAY';
413
+ this.blockBuffer = after;
414
+
415
+ // Check size limit before processing
416
+ if (this.blockBuffer.length > this.options.maxBlockBytes) {
417
+ console.error('[parser] Block too large, discarding');
418
+ this.inBlock = false;
419
+ this.blockBuffer = '';
420
+ this.blockType = null;
421
+ return { commands, output };
422
+ }
423
+
424
+ // Check if block ends in same chunk
425
+ const blockEndPattern = this.blockType === 'RELAY_METADATA' ? BLOCK_METADATA_END : BLOCK_END;
426
+ if (blockEndPattern.test(this.blockBuffer)) {
427
+ const blockResult = this.finishBlock(this.blockType);
428
+ if (blockResult.command) {
429
+ commands.push(blockResult.command);
430
+ }
431
+ if (blockResult.remaining) {
432
+ // Recursively parse anything after the block
433
+ const afterResult = this.parse(blockResult.remaining);
434
+ commands.push(...afterResult.commands);
435
+ output += afterResult.output;
436
+ }
437
+ }
438
+
439
+ return { commands, output };
440
+ }
441
+
442
+ // Pass-through mode: output data immediately, only parse complete lines for relay commands
443
+ output = this.parsePassThrough(data, commands);
444
+ return { commands, output };
445
+ }
446
+
447
+ /**
448
+ * Find [[RELAY_METADATA]] or [[RELAY]] that's at the start of a line and not inside a code fence.
449
+ * Returns the index and identifier, or -1 and null if not found.
450
+ */
451
+ private findBlockStart(data: string): { index: number; identifier: string | null } {
452
+ // Track code fence state through the data
453
+ let inFence = this.inCodeFence;
454
+ let searchStart = 0;
455
+
456
+ // Prioritize RELAY_METADATA over RELAY
457
+ const blockIdentifiers = [BLOCK_METADATA_START, '[[RELAY]]'];
458
+
459
+ while (searchStart < data.length) {
460
+ let earliestBlockIdx = -1;
461
+ let earliestBlockIdentifier: string | null = null;
462
+
463
+ for (const identifier of blockIdentifiers) {
464
+ const currentBlockIdx = data.indexOf(identifier, searchStart);
465
+ if (currentBlockIdx !== -1 && (earliestBlockIdx === -1 || currentBlockIdx < earliestBlockIdx)) {
466
+ earliestBlockIdx = currentBlockIdx;
467
+ earliestBlockIdentifier = identifier;
468
+ }
469
+ }
470
+
471
+ // No more blocks found
472
+ if (earliestBlockIdx === -1) {
473
+ // Still update code fence state for remaining data
474
+ let fenceIdx = data.indexOf('```', searchStart);
475
+ while (fenceIdx !== -1) {
476
+ inFence = !inFence;
477
+ searchStart = fenceIdx + 3;
478
+ fenceIdx = data.indexOf('```', searchStart);
479
+ }
480
+ return { index: -1, identifier: null };
481
+ }
482
+
483
+ // Process any code fences before this block
484
+ let tempIdx = searchStart;
485
+ while (true) {
486
+ const nextFence = data.indexOf('```', tempIdx);
487
+ if (nextFence === -1 || nextFence >= earliestBlockIdx) break;
488
+ inFence = !inFence;
489
+ tempIdx = nextFence + 3;
490
+ }
491
+
492
+ // If we're inside a code fence, skip this block
493
+ if (inFence) {
494
+ searchStart = earliestBlockIdx + (earliestBlockIdentifier?.length ?? 0); // Skip past the block
495
+ continue;
496
+ }
497
+
498
+ // Check if block is at start of a line
499
+ if (earliestBlockIdx === 0) {
500
+ return { index: 0, identifier: earliestBlockIdentifier }; // At very start
501
+ }
502
+
503
+ // Look backwards for the start of line
504
+ const beforeBlock = data.substring(0, earliestBlockIdx);
505
+ const lastNewline = beforeBlock.lastIndexOf('\n');
506
+ const lineStart = beforeBlock.substring(lastNewline + 1);
507
+
508
+ // Must be only whitespace before block on this line
509
+ if (/^\s*$/.test(lineStart)) {
510
+ return { index: earliestBlockIdx, identifier: earliestBlockIdentifier };
511
+ }
512
+
513
+ // Not at start of line, keep searching
514
+ searchStart = earliestBlockIdx + (earliestBlockIdentifier?.length ?? 0);
515
+ }
516
+
517
+ return { index: -1, identifier: null };
518
+ }
519
+
520
+ /**
521
+ * Parse data in pass-through mode - TRUE pass-through for terminal rendering.
522
+ * Output is exactly the input data, minus any relay command lines found in this chunk.
523
+ * No cross-chunk buffering to avoid double-output issues.
524
+ *
525
+ * IMPORTANT: We ONLY parse complete lines (i.e. those terminated by `\n` in the
526
+ * current chunk). The final unterminated line (if any) is passed through without
527
+ * parsing. This intentionally avoids cross-chunk detection when a line is split
528
+ * across chunks.
529
+ */
530
+ private parsePassThrough(data: string, commands: ParsedCommand[]): string {
531
+ // Simple approach: split data, check each line (complete or not), rebuild output
532
+ const lines = data.split('\n');
533
+ const hasTrailingNewline = data.endsWith('\n');
534
+
535
+ const isInlineStart = (line: string): boolean => {
536
+ return this.inlineRelayPattern.test(line) || this.inlineThinkingPattern.test(line);
537
+ };
538
+
539
+ const isFencedInlineStart = (line: string): { target: string; thread?: string; threadProject?: string; project?: string; kind: 'message' | 'thinking'; sync?: ParsedCommand['sync'] } | null => {
540
+ const stripped = stripAnsi(line);
541
+ const relayMatch = stripped.match(this.fencedRelayPattern);
542
+ if (relayMatch) {
543
+ const [, target, tagSection] = relayMatch;
544
+ const { body, thread, threadProject, sync } = parseInlineTags(tagSection ?? '');
545
+ if (body.trim().length > 0) return null;
546
+ const { to, project } = parseTarget(target);
547
+ return { target: to, thread, threadProject, project, kind: 'message', sync };
548
+ }
549
+ const thinkingMatch = stripped.match(this.fencedThinkingPattern);
550
+ if (thinkingMatch) {
551
+ const [, target, tagSection] = thinkingMatch;
552
+ const { body, thread, threadProject, sync } = parseInlineTags(tagSection ?? '');
553
+ if (body.trim().length > 0) return null;
554
+ const { to, project } = parseTarget(target);
555
+ return { target: to, thread, threadProject, project, kind: 'thinking', sync };
556
+ }
557
+ return null;
558
+ };
559
+
560
+ const isBlockMarker = (line: string): boolean => {
561
+ return CODE_FENCE.test(line) || line.includes('[[RELAY]]') || BLOCK_END.test(line);
562
+ };
563
+
564
+ const shouldStopContinuation = (line: string, continuationCount: number, lines: string[], currentIndex: number): boolean => {
565
+ const trimmed = line.trim();
566
+ if (isInlineStart(line)) return true;
567
+ if (isFencedInlineStart(line)) return true;
568
+ if (isBlockMarker(line)) return true;
569
+ if (PROMPTISH_LINE.test(trimmed)) return true;
570
+ if (RELAY_INJECTION_PREFIX.test(line)) return true; // Avoid swallowing injected inbound messages
571
+
572
+ // Allow blank lines only in structured content like tables or between numbered sections
573
+ if (trimmed === '') {
574
+ // If we haven't started continuation yet, stop on blank
575
+ if (continuationCount === 0) return true;
576
+
577
+ // Look ahead to see if there's more content that looks like structured markdown
578
+ for (let j = currentIndex + 1; j < lines.length; j++) {
579
+ const nextLine = lines[j].trim();
580
+ if (nextLine === '') {
581
+ // Double blank line always stops
582
+ return true;
583
+ }
584
+ // Only continue for table rows or numbered list items after blank
585
+ if (/^\|/.test(nextLine)) return false; // Table row
586
+ if (/^\d+[.)]\s/.test(nextLine)) return false; // Numbered list like "1." or "2)"
587
+ // Stop for anything else after a blank line
588
+ return true;
589
+ }
590
+ return true; // No more content, stop
591
+ }
592
+ return false;
593
+ };
594
+
595
+ const isContinuationLine = (
596
+ original: string,
597
+ stripped: string,
598
+ prevStripped: string,
599
+ continuationCount: number
600
+ ): boolean => {
601
+ // Note: shouldStopContinuation is already checked in the main loop before calling this
602
+ if (/^[ \t]/.test(original)) return true; // Indented lines from TUI wrapping
603
+ if (BULLET_OR_NUMBERED_LIST.test(stripped)) return true; // Bullet/numbered lists after ->relay:
604
+ const prevTrimmed = prevStripped.trimEnd();
605
+ const prevSuggestsContinuation = prevTrimmed !== '' && /[:;,\-–—…]$/.test(prevTrimmed);
606
+ if (prevSuggestsContinuation) return true;
607
+ // If we've already continued once, allow subsequent lines until a stop condition
608
+ if (continuationCount > 0) return true;
609
+ return false;
610
+ };
611
+
612
+ const outputLines: string[] = [];
613
+ let strippedCount = 0;
614
+
615
+ for (let i = 0; i < lines.length; i++) {
616
+ const line = lines[i];
617
+ const isLastLine = i === lines.length - 1;
618
+
619
+ // Skip the empty string after a trailing newline
620
+ if (isLastLine && hasTrailingNewline && line === '') {
621
+ continue;
622
+ }
623
+
624
+ // If the chunk does NOT end in a newline, the last line is incomplete.
625
+ // Pass it through unmodified and do not attempt to parse it.
626
+ if (isLastLine && !hasTrailingNewline) {
627
+ outputLines.push(line);
628
+ continue;
629
+ }
630
+
631
+ // Skip Claude extended thinking blocks - don't parse or output their content
632
+ // Check for thinking end first (to handle end tag on same line as start)
633
+ if (this.inThinkingBlock) {
634
+ if (THINKING_END.test(line)) {
635
+ this.inThinkingBlock = false;
636
+ }
637
+ // Skip this line - don't output thinking content
638
+ strippedCount++;
639
+ continue;
640
+ }
641
+ // Check for thinking start
642
+ if (THINKING_START.test(line)) {
643
+ this.inThinkingBlock = true;
644
+ // Also check if it ends on the same line (inline thinking block)
645
+ if (THINKING_END.test(line)) {
646
+ this.inThinkingBlock = false;
647
+ }
648
+ // Skip this line - don't output thinking content
649
+ strippedCount++;
650
+ continue;
651
+ }
652
+
653
+ // Skip spawn/release commands BEFORE checking fenced format
654
+ // This prevents ->relay:spawn Worker cli <<< from being parsed as a message to "spawn"
655
+ const strippedForSpawnCheck = stripAnsi(line);
656
+ if (isSpawnOrReleaseCommand(strippedForSpawnCheck)) {
657
+ outputLines.push(line);
658
+ continue;
659
+ }
660
+
661
+ // Check for fenced inline start: ->relay:Target <<<
662
+ const fencedStart = isFencedInlineStart(line);
663
+ if (fencedStart && this.options.enableInline) {
664
+ // Skip placeholder target names early (common in documentation/examples)
665
+ if (isPlaceholderTarget(fencedStart.target)) {
666
+ outputLines.push(line);
667
+ continue;
668
+ }
669
+
670
+ // Enter fenced inline mode
671
+ this.inFencedInline = true;
672
+ this.fencedInlineTarget = fencedStart.target;
673
+ this.fencedInlineThread = fencedStart.thread;
674
+ this.fencedInlineThreadProject = fencedStart.threadProject;
675
+ this.fencedInlineProject = fencedStart.project;
676
+ this.fencedInlineKind = fencedStart.kind;
677
+ this.fencedInlineSync = fencedStart.sync;
678
+ this.fencedInlineBuffer = '';
679
+ this.fencedInlineRaw = [line];
680
+
681
+ // Process remaining lines in fenced mode
682
+ if (i + 1 < lines.length) {
683
+ // Don't double-add trailing newline - the empty string at end of lines array
684
+ // already accounts for it when we join
685
+ const remainingLines = lines.slice(i + 1);
686
+ const remaining = remainingLines.join('\n') + (hasTrailingNewline && remainingLines[remainingLines.length - 1] !== '' ? '\n' : '');
687
+ const result = this.parseFencedInlineMode(remaining, commands);
688
+ strippedCount++;
689
+
690
+ // Combine output
691
+ let output = outputLines.join('\n');
692
+ if (hasTrailingNewline && outputLines.length > 0 && !this.inFencedInline) {
693
+ output += '\n';
694
+ }
695
+ output += result.output;
696
+ return output;
697
+ }
698
+
699
+ // No more lines - waiting for more data
700
+ strippedCount++;
701
+ let output = outputLines.join('\n');
702
+ if (hasTrailingNewline && outputLines.length > 0) {
703
+ output += '\n';
704
+ }
705
+ return output;
706
+ }
707
+
708
+ if (line.length > 0) {
709
+ // Only check complete lines for relay commands.
710
+ const result = this.processLine(line);
711
+ if (result.command) {
712
+ // Collect continuation lines (in the same chunk) so inline messages can span multiple lines.
713
+ let body = result.command.body;
714
+ const rawLines = [result.command.raw];
715
+ let consumed = 0;
716
+ let continuationLines = 0;
717
+
718
+ while (i + 1 < lines.length) {
719
+ const nextIsLast = i + 1 === lines.length - 1;
720
+ const nextLine = lines[i + 1];
721
+
722
+ // Do not consume an incomplete trailing line (no newline terminator)
723
+ if (nextIsLast && !hasTrailingNewline) {
724
+ break;
725
+ }
726
+
727
+ const nextStripped = stripAnsi(nextLine);
728
+ const prevStripped = stripAnsi(rawLines[rawLines.length - 1] ?? '');
729
+
730
+ // Stop if this line clearly marks a new block, prompt, or inline command
731
+ if (shouldStopContinuation(nextStripped, continuationLines, lines, i + 1)) {
732
+ break;
733
+ }
734
+
735
+ if (continuationLines >= MAX_INLINE_CONTINUATION_LINES) {
736
+ break;
737
+ }
738
+
739
+ // Consume as continuation if it looks like it belongs to the ->relay message
740
+ if (!isContinuationLine(nextLine, nextStripped, prevStripped, continuationLines)) {
741
+ break;
742
+ }
743
+
744
+ consumed++;
745
+ i++; // Skip the consumed continuation line
746
+ continuationLines++;
747
+ body += '\n' + nextLine;
748
+ rawLines.push(nextLine);
749
+ }
750
+
751
+ commands.push({ ...result.command, body, raw: rawLines.join('\n') });
752
+ strippedCount += consumed + 1;
753
+ continue;
754
+ }
755
+ if (result.output !== null) {
756
+ outputLines.push(result.output);
757
+ } else {
758
+ // Line was stripped (relay command)
759
+ strippedCount++;
760
+ }
761
+ } else {
762
+ // Empty line - preserve it
763
+ outputLines.push('');
764
+ }
765
+ }
766
+
767
+ // Rebuild output
768
+ if (outputLines.length === 0 && strippedCount > 0) {
769
+ // All lines were relay commands - return empty
770
+ return '';
771
+ }
772
+
773
+ let output = outputLines.join('\n');
774
+
775
+ // Add trailing newline if original had one AND we have content
776
+ if (hasTrailingNewline && outputLines.length > 0) {
777
+ output += '\n';
778
+ }
779
+
780
+ return output;
781
+ }
782
+
783
+ /**
784
+ * Parse while inside a [[RELAY]] block - buffer until we see [[/RELAY]].
785
+ */
786
+ private parseInBlockMode(data: string, commands: ParsedCommand[], blockType: 'RELAY' | 'RELAY_METADATA'): { commands: ParsedCommand[]; output: string } {
787
+ this.blockBuffer += data;
788
+
789
+ // Check size limit
790
+ if (this.blockBuffer.length > this.options.maxBlockBytes) {
791
+ console.error('[parser] Block too large, discarding');
792
+ this.inBlock = false;
793
+ this.blockBuffer = '';
794
+ this.blockType = null;
795
+ return { commands, output: '' };
796
+ }
797
+
798
+ // Check for block end
799
+ const blockEndPattern = blockType === 'RELAY_METADATA' ? BLOCK_METADATA_END : BLOCK_END;
800
+ if (blockEndPattern.test(this.blockBuffer)) {
801
+ const result = this.finishBlock(blockType);
802
+ if (result.command) {
803
+ commands.push(result.command);
804
+ }
805
+
806
+ let output = '';
807
+ if (result.remaining) {
808
+ // Recursively parse anything after the block
809
+ const afterResult = this.parse(result.remaining);
810
+ commands.push(...afterResult.commands);
811
+ output = afterResult.output;
812
+ }
813
+
814
+ return { commands, output };
815
+ }
816
+
817
+ // Still inside block, no output yet
818
+ return { commands, output: '' };
819
+ }
820
+
821
+ /**
822
+ * Process a single complete line for inline relay commands.
823
+ * Block handling is done at the parse() level, not here.
824
+ *
825
+ * IMPORTANT: We strip ANSI codes for pattern matching, but preserve
826
+ * the original line for output to maintain terminal rendering.
827
+ *
828
+ * OPTIMIZATION: Early exit for lines that can't possibly be relay commands.
829
+ * Most lines don't contain relay patterns, so we avoid expensive regex/ANSI
830
+ * stripping for the common case.
831
+ */
832
+ private processLine(line: string): { command: ParsedCommand | null; output: string | null } {
833
+ // FAST PATH: Quick string check before any expensive operations
834
+ // Most lines don't contain relay commands, so early exit is a big win
835
+ // Check for prefix bases (without the colon to handle custom prefixes like '>>')
836
+ const relayBase = this.options.prefix.replace(/:$/, '');
837
+ const thinkingBase = this.options.thinkingPrefix.replace(/:$/, '');
838
+ const hasRelayPattern = line.includes(relayBase) || line.includes(thinkingBase);
839
+ const hasBlockPattern = line.includes('[[') || line.includes('```');
840
+ const hasJsonRelay = line.includes('->relay.json:');
841
+
842
+ if (!hasRelayPattern && !hasBlockPattern && !hasJsonRelay) {
843
+ return { command: null, output: line };
844
+ }
845
+
846
+ // Check for JSON relay format first (preferred): ->relay.json:{...}
847
+ if (hasJsonRelay) {
848
+ const stripped = stripAnsi(line);
849
+ const jsonMatch = stripped.match(JSON_RELAY_PATTERN);
850
+ if (jsonMatch) {
851
+ try {
852
+ const parsed = JSON.parse(jsonMatch[1]) as JsonRelayMessage;
853
+ const kind = parsed.kind || 'message';
854
+
855
+ // Handle different command kinds
856
+ if (kind === 'message' && parsed.to) {
857
+ return {
858
+ command: {
859
+ to: parsed.to,
860
+ kind: 'message',
861
+ body: parsed.body || '',
862
+ thread: parsed.thread,
863
+ raw: jsonMatch[0],
864
+ },
865
+ output: null,
866
+ };
867
+ }
868
+ // spawn/release are handled by the wrapper, not here
869
+ // But we still strip the line from output
870
+ return { command: null, output: null };
871
+ } catch {
872
+ // Invalid JSON, pass through
873
+ console.error('[parser] Invalid JSON in ->relay.json:', jsonMatch[1]);
874
+ }
875
+ }
876
+ }
877
+
878
+ // Strip ANSI codes for pattern matching (only when potentially needed)
879
+ const stripped = stripAnsi(line);
880
+
881
+ // Handle code fences
882
+ if (CODE_FENCE.test(stripped)) {
883
+ this.inCodeFence = !this.inCodeFence;
884
+ return { command: null, output: line };
885
+ }
886
+
887
+ // Inside code fence - pass through
888
+ if (this.inCodeFence) {
889
+ return { command: null, output: line };
890
+ }
891
+
892
+ // Check for escaped inline (on stripped text)
893
+ const escapeMatch = stripped.match(this.escapePattern);
894
+ if (escapeMatch) {
895
+ // Output with escape removed (remove the backslash before the prefix)
896
+ const unescaped = line.replace(/\\/, '');
897
+ return { command: null, output: unescaped };
898
+ }
899
+
900
+ // Skip spawn/release commands - they are handled by the wrapper's spawn subsystem
901
+ // These should not be parsed as regular relay messages
902
+ if (isSpawnOrReleaseCommand(stripped)) {
903
+ return { command: null, output: line };
904
+ }
905
+
906
+ // Check for inline relay (on stripped text)
907
+ if (this.options.enableInline) {
908
+ const relayMatch = stripped.match(this.inlineRelayPattern);
909
+ if (relayMatch) {
910
+ const [raw, target, bodyWithTags] = relayMatch;
911
+ const { body, thread, threadProject, sync } = parseInlineTags(bodyWithTags);
912
+
913
+ // Skip instructional/example text (common in system prompts)
914
+ if (isInstructionalText(body)) {
915
+ console.error(`[parser] Filtered inline message to ${target} - instructional text. Body: ${body.substring(0, 100)}`);
916
+ return { command: null, output: line };
917
+ }
918
+
919
+ const { to, project } = parseTarget(target);
920
+
921
+ // Skip placeholder target names (common in documentation/examples)
922
+ if (isPlaceholderTarget(to)) {
923
+ console.error(`[parser] Filtered inline message - placeholder target: ${to}`);
924
+ return { command: null, output: line };
925
+ }
926
+
927
+ return {
928
+ command: {
929
+ to,
930
+ kind: 'message',
931
+ body,
932
+ thread: thread || undefined, // undefined if no thread specified
933
+ threadProject: threadProject || undefined, // undefined if local thread
934
+ project, // undefined if local, set if cross-project
935
+ sync,
936
+ raw,
937
+ },
938
+ output: null, // Don't output relay commands
939
+ };
940
+ }
941
+
942
+ const thinkingMatch = stripped.match(this.inlineThinkingPattern);
943
+ if (thinkingMatch) {
944
+ const [raw, target, bodyWithTags] = thinkingMatch;
945
+ const { body, thread, threadProject, sync } = parseInlineTags(bodyWithTags);
946
+
947
+ // Skip instructional/example text (common in system prompts)
948
+ if (isInstructionalText(body)) {
949
+ return { command: null, output: line };
950
+ }
951
+
952
+ const { to, project } = parseTarget(target);
953
+
954
+ // Skip placeholder target names (common in documentation/examples)
955
+ if (isPlaceholderTarget(to)) {
956
+ return { command: null, output: line };
957
+ }
958
+
959
+ return {
960
+ command: {
961
+ to,
962
+ kind: 'thinking',
963
+ body,
964
+ thread: thread || undefined,
965
+ threadProject: threadProject || undefined,
966
+ project,
967
+ sync,
968
+ raw,
969
+ },
970
+ output: null,
971
+ };
972
+ }
973
+ }
974
+
975
+ // Regular line
976
+ return { command: null, output: line };
977
+ }
978
+
979
+ /**
980
+ * Finish processing a block and extract command.
981
+ * Returns the command (if valid) and any remaining content after [[/RELAY]].
982
+ */
983
+ private finishBlock(blockType: 'RELAY' | 'RELAY_METADATA'): { command: ParsedCommand | null; remaining: string | null; metadata: ParsedMessageMetadata | null } {
984
+ const blockEndIdentifier = blockType === 'RELAY_METADATA' ? BLOCK_METADATA_END.source : BLOCK_END.source;
985
+ const endIdx = this.blockBuffer.indexOf(blockEndIdentifier.replace(/\\/g, '')); // Remove regex escapes for indexOf
986
+ const jsonStr = this.blockBuffer.substring(0, endIdx).trim();
987
+ const remaining = this.blockBuffer.substring(endIdx + blockEndIdentifier.replace(/\\/g, '').length) || null;
988
+
989
+ this.inBlock = false;
990
+ this.blockBuffer = '';
991
+ this.blockType = null;
992
+
993
+ if (blockType === 'RELAY_METADATA') {
994
+ try {
995
+ const metadata = JSON.parse(jsonStr) as ParsedMessageMetadata;
996
+ this.lastParsedMetadata = metadata;
997
+ return { command: null, remaining, metadata };
998
+ } catch (err) {
999
+ console.error('[parser] Invalid JSON in RELAY_METADATA block:', err);
1000
+ this.lastParsedMetadata = null;
1001
+ return { command: null, remaining, metadata: null };
1002
+ }
1003
+ } else { // blockType === 'RELAY'
1004
+ try {
1005
+ const parsed = JSON.parse(jsonStr);
1006
+
1007
+ // Validate required fields
1008
+ if (!parsed.to || !parsed.type) {
1009
+ console.error('[parser] Block missing required fields (to, type)');
1010
+ this.lastParsedMetadata = null; // Clear metadata even if RELAY block is invalid
1011
+ return { command: null, remaining, metadata: null };
1012
+ }
1013
+
1014
+ // Handle cross-project syntax in block format
1015
+ // Supports both explicit "project" field and "project:agent" in "to" field
1016
+ let to = parsed.to;
1017
+ let project = parsed.project;
1018
+
1019
+ if (!project && typeof to === 'string') {
1020
+ // Check if "to" field uses project:agent syntax
1021
+ const targetParsed = parseTarget(to);
1022
+ to = targetParsed.to;
1023
+ project = targetParsed.project;
1024
+ }
1025
+
1026
+ const command: ParsedCommand = {
1027
+ to,
1028
+ kind: parsed.type as PayloadKind,
1029
+ body: parsed.body ?? parsed.text ?? '',
1030
+ data: parsed.data,
1031
+ thread: parsed.thread || undefined,
1032
+ project: project || undefined,
1033
+ raw: jsonStr,
1034
+ meta: this.lastParsedMetadata || undefined, // Attach last parsed metadata
1035
+ };
1036
+
1037
+ this.lastParsedMetadata = null; // Clear after use
1038
+
1039
+ return {
1040
+ command,
1041
+ remaining,
1042
+ metadata: null,
1043
+ };
1044
+ } catch (err) {
1045
+ console.error('[parser] Invalid JSON in RELAY block:', err);
1046
+ this.lastParsedMetadata = null;
1047
+ return { command: null, remaining, metadata: null };
1048
+ }
1049
+ }
1050
+ }
1051
+
1052
+ /**
1053
+ * Check if the current fenced inline command should be filtered out.
1054
+ * Returns true if the command looks like instructional/example text.
1055
+ */
1056
+ private shouldFilterFencedInline(target: string, body: string): boolean {
1057
+ // Check for placeholder target names
1058
+ if (isPlaceholderTarget(target)) {
1059
+ // Silently filter placeholder targets (common in documentation)
1060
+ return true;
1061
+ }
1062
+ // Check for instructional body content
1063
+ if (isInstructionalText(body)) {
1064
+ // Silently filter instructional text (common in system prompts)
1065
+ return true;
1066
+ }
1067
+ return false;
1068
+ }
1069
+
1070
+ /**
1071
+ * Parse while inside a fenced inline block (->relay:Target <<< ... >>>).
1072
+ * Accumulates lines until >>> is seen on its own line.
1073
+ */
1074
+ private parseFencedInlineMode(data: string, commands: ParsedCommand[]): { commands: ParsedCommand[]; output: string } {
1075
+ const lines = data.split('\n');
1076
+ const hasTrailingNewline = data.endsWith('\n');
1077
+ let output = '';
1078
+ let consecutiveBlankLines = 0;
1079
+
1080
+ for (let i = 0; i < lines.length; i++) {
1081
+ const line = lines[i];
1082
+ const isLastLine = i === lines.length - 1;
1083
+ const stripped = stripAnsi(line);
1084
+
1085
+ // Track consecutive blank lines for auto-close
1086
+ if (stripped === '') {
1087
+ consecutiveBlankLines++;
1088
+ } else {
1089
+ consecutiveBlankLines = 0;
1090
+ }
1091
+
1092
+ // Auto-close on double blank line (agent forgot >>>)
1093
+ // Only if we have actual content to send
1094
+ if (consecutiveBlankLines >= 2 && this.fencedInlineBuffer.trim().length > 0) {
1095
+ const body = unescapeFenceMarkers(stripAnsi(this.fencedInlineBuffer.trim()));
1096
+
1097
+ // Skip instructional/example text (common in system prompts and documentation)
1098
+ if (!this.shouldFilterFencedInline(this.fencedInlineTarget, body)) {
1099
+ const command: ParsedCommand = {
1100
+ to: this.fencedInlineTarget,
1101
+ kind: this.fencedInlineKind,
1102
+ body,
1103
+ thread: this.fencedInlineThread,
1104
+ threadProject: this.fencedInlineThreadProject,
1105
+ project: this.fencedInlineProject,
1106
+ sync: this.fencedInlineSync,
1107
+ raw: this.fencedInlineRaw.join('\n'),
1108
+ };
1109
+ commands.push(command);
1110
+ }
1111
+
1112
+ // Reset fenced inline state
1113
+ this.inFencedInline = false;
1114
+ this.fencedInlineBuffer = '';
1115
+ this.fencedInlineTarget = '';
1116
+ this.fencedInlineThread = undefined;
1117
+ this.fencedInlineThreadProject = undefined;
1118
+ this.fencedInlineProject = undefined;
1119
+ this.fencedInlineRaw = [];
1120
+ this.fencedInlineKind = 'message';
1121
+ this.fencedInlineSync = undefined;
1122
+
1123
+ // Process remaining lines in normal mode
1124
+ const remainingLines = lines.slice(i);
1125
+ const remaining = remainingLines.join('\n') + (hasTrailingNewline ? '\n' : '');
1126
+ const result = this.parse(remaining);
1127
+ commands.push(...result.commands);
1128
+ return { commands, output: result.output };
1129
+ }
1130
+
1131
+ // Check if a new relay command started (means previous fenced block was never closed)
1132
+ // Auto-close and SEND the incomplete message instead of discarding it
1133
+ // This preserves message content when agents forget to close with >>>
1134
+ if (this.inlineRelayPattern.test(stripped) || this.fencedRelayPattern.test(stripped)) {
1135
+ // Auto-close and send the incomplete fenced block (if it has content)
1136
+ if (this.fencedInlineBuffer.trim().length > 0) {
1137
+ const body = unescapeFenceMarkers(stripAnsi(this.fencedInlineBuffer.trim()));
1138
+
1139
+ // Skip instructional/example text (common in system prompts and documentation)
1140
+ if (!this.shouldFilterFencedInline(this.fencedInlineTarget, body)) {
1141
+ const command: ParsedCommand = {
1142
+ to: this.fencedInlineTarget,
1143
+ kind: this.fencedInlineKind,
1144
+ body,
1145
+ thread: this.fencedInlineThread,
1146
+ threadProject: this.fencedInlineThreadProject,
1147
+ project: this.fencedInlineProject,
1148
+ sync: this.fencedInlineSync,
1149
+ raw: this.fencedInlineRaw.join('\n'),
1150
+ };
1151
+ commands.push(command);
1152
+ }
1153
+ }
1154
+
1155
+ // Reset fenced inline state
1156
+ this.inFencedInline = false;
1157
+ this.fencedInlineBuffer = '';
1158
+ this.fencedInlineTarget = '';
1159
+ this.fencedInlineThread = undefined;
1160
+ this.fencedInlineThreadProject = undefined;
1161
+ this.fencedInlineProject = undefined;
1162
+ this.fencedInlineRaw = [];
1163
+ this.fencedInlineKind = 'message';
1164
+ this.fencedInlineSync = undefined;
1165
+
1166
+ // Process remaining lines (including this one) in normal mode
1167
+ const remainingLines = lines.slice(i);
1168
+ const remaining = remainingLines.join('\n') + (hasTrailingNewline ? '\n' : '');
1169
+ const result = this.parse(remaining);
1170
+ commands.push(...result.commands);
1171
+ return { commands, output: result.output };
1172
+ }
1173
+
1174
+ // Check if this line closes the fenced block
1175
+ // Skip if the >>> is escaped (\>>>)
1176
+ if (FENCE_END.test(stripped) && !isEscapedFenceEnd(stripped)) {
1177
+ // If >>> is at end of line (not start), extract content before it
1178
+ const endsWithFence = />>>\s*$/.test(stripped) && !/^(?:\s*)?>>>/.test(stripped);
1179
+ if (endsWithFence) {
1180
+ const contentBeforeFence = stripped.replace(/>>>\s*$/, '');
1181
+ if (contentBeforeFence.trim()) {
1182
+ if (this.fencedInlineBuffer.length > 0) {
1183
+ this.fencedInlineBuffer += '\n' + contentBeforeFence;
1184
+ } else {
1185
+ this.fencedInlineBuffer = contentBeforeFence;
1186
+ }
1187
+ }
1188
+ }
1189
+
1190
+ // Complete the fenced inline command - unescape any \<<< and \>>> in content
1191
+ const body = unescapeFenceMarkers(stripAnsi(this.fencedInlineBuffer.trim()));
1192
+ this.fencedInlineRaw.push(line);
1193
+
1194
+ // Skip instructional/example text (common in system prompts and documentation)
1195
+ if (!this.shouldFilterFencedInline(this.fencedInlineTarget, body)) {
1196
+ const command: ParsedCommand = {
1197
+ to: this.fencedInlineTarget,
1198
+ kind: this.fencedInlineKind,
1199
+ body,
1200
+ thread: this.fencedInlineThread,
1201
+ threadProject: this.fencedInlineThreadProject,
1202
+ project: this.fencedInlineProject,
1203
+ sync: this.fencedInlineSync,
1204
+ raw: this.fencedInlineRaw.join('\n'),
1205
+ };
1206
+ commands.push(command);
1207
+ }
1208
+
1209
+ // Reset fenced inline state
1210
+ this.inFencedInline = false;
1211
+ this.fencedInlineBuffer = '';
1212
+ this.fencedInlineTarget = '';
1213
+ this.fencedInlineThread = undefined;
1214
+ this.fencedInlineThreadProject = undefined;
1215
+ this.fencedInlineProject = undefined;
1216
+ this.fencedInlineRaw = [];
1217
+ this.fencedInlineKind = 'message';
1218
+ this.fencedInlineSync = undefined;
1219
+
1220
+ // Process remaining lines after the fence close
1221
+ // Only process if there's actual content after the closing fence
1222
+ const remainingLines = lines.slice(i + 1);
1223
+ // Filter out trailing empty string from split
1224
+ const hasContent = remainingLines.some((l, idx) =>
1225
+ l.trim() !== '' || (idx < remainingLines.length - 1));
1226
+
1227
+ if (hasContent) {
1228
+ const remaining = remainingLines.join('\n') + (hasTrailingNewline ? '\n' : '');
1229
+ const result = this.parse(remaining);
1230
+ commands.push(...result.commands);
1231
+ output += result.output;
1232
+ }
1233
+ return { commands, output };
1234
+ }
1235
+
1236
+ // Accumulate this line into the buffer (preserving blank lines within content)
1237
+ // But skip trailing empty line from split (when input ends with \n)
1238
+ const isTrailingEmpty = isLastLine && line === '' && hasTrailingNewline;
1239
+ if (!isTrailingEmpty) {
1240
+ if (this.fencedInlineBuffer.length > 0) {
1241
+ this.fencedInlineBuffer += '\n' + line;
1242
+ } else if (line.trim() !== '') {
1243
+ // Start accumulating from first non-blank line
1244
+ this.fencedInlineBuffer = line;
1245
+ }
1246
+ this.fencedInlineRaw.push(line);
1247
+ }
1248
+
1249
+ // Check size limit
1250
+ if (this.fencedInlineBuffer.length > this.options.maxBlockBytes) {
1251
+ console.error('[parser] Fenced inline block too large, discarding');
1252
+ this.inFencedInline = false;
1253
+ this.fencedInlineBuffer = '';
1254
+ this.fencedInlineTarget = '';
1255
+ this.fencedInlineThread = undefined;
1256
+ this.fencedInlineThreadProject = undefined;
1257
+ this.fencedInlineProject = undefined;
1258
+ this.fencedInlineRaw = [];
1259
+ this.fencedInlineKind = 'message';
1260
+ this.fencedInlineSync = undefined;
1261
+ return { commands, output: '' };
1262
+ }
1263
+
1264
+ // Check line count limit - prevents stuck fenced mode from blocking all messages
1265
+ if (this.fencedInlineRaw.length > MAX_FENCED_LINES) {
1266
+ console.error('[parser] Fenced inline block exceeded max lines, discarding');
1267
+ this.inFencedInline = false;
1268
+ this.fencedInlineBuffer = '';
1269
+ this.fencedInlineTarget = '';
1270
+ this.fencedInlineThread = undefined;
1271
+ this.fencedInlineThreadProject = undefined;
1272
+ this.fencedInlineProject = undefined;
1273
+ this.fencedInlineRaw = [];
1274
+ this.fencedInlineKind = 'message';
1275
+ this.fencedInlineSync = undefined;
1276
+ return { commands, output: '' };
1277
+ }
1278
+ }
1279
+
1280
+ // Still waiting for >>> - return empty output (content is buffered)
1281
+ return { commands, output: '' };
1282
+ }
1283
+
1284
+ /**
1285
+ * Flush any remaining buffer (call on stream end).
1286
+ */
1287
+ flush(): { commands: ParsedCommand[]; output: string } {
1288
+ const result = this.parse('\n');
1289
+ this.inBlock = false;
1290
+ this.blockBuffer = '';
1291
+ this.blockType = null;
1292
+ this.lastParsedMetadata = null;
1293
+ this.inCodeFence = false;
1294
+ this.inThinkingBlock = false;
1295
+ this.inFencedInline = false;
1296
+ this.fencedInlineBuffer = '';
1297
+ this.fencedInlineTarget = '';
1298
+ this.fencedInlineThread = undefined;
1299
+ this.fencedInlineThreadProject = undefined;
1300
+ this.fencedInlineProject = undefined;
1301
+ this.fencedInlineRaw = [];
1302
+ this.fencedInlineKind = 'message';
1303
+ this.fencedInlineSync = undefined;
1304
+ return result;
1305
+ }
1306
+
1307
+ /**
1308
+ * Reset parser state.
1309
+ */
1310
+ reset(): void {
1311
+ this.inBlock = false;
1312
+ this.blockBuffer = '';
1313
+ this.blockType = null;
1314
+ this.lastParsedMetadata = null;
1315
+ this.inCodeFence = false;
1316
+ this.inThinkingBlock = false;
1317
+ this.inFencedInline = false;
1318
+ this.fencedInlineBuffer = '';
1319
+ this.fencedInlineTarget = '';
1320
+ this.fencedInlineThread = undefined;
1321
+ this.fencedInlineThreadProject = undefined;
1322
+ this.fencedInlineProject = undefined;
1323
+ this.fencedInlineRaw = [];
1324
+ this.fencedInlineKind = 'message';
1325
+ this.fencedInlineSync = undefined;
1326
+ }
1327
+ }
1328
+
1329
+ /**
1330
+ * Parsed message metadata block from agent output.
1331
+ */
1332
+ export interface ParsedMessageMetadata {
1333
+ subject?: string;
1334
+ importance?: number;
1335
+ replyTo?: string;
1336
+ ackRequired?: boolean;
1337
+ }
1338
+
1339
+ /**
1340
+ * Result of attempting to parse a RELAY_METADATA block.
1341
+ */
1342
+ export interface MetadataParseResult {
1343
+ found: boolean;
1344
+ valid: boolean;
1345
+ metadata: ParsedMessageMetadata | null;
1346
+ rawContent: string | null; // Raw block content for deduplication
1347
+ }
1348
+
1349
+ /**
1350
+ * Parse [[RELAY_METADATA]]...[[/RELAY_METADATA]] blocks from agent output.
1351
+ * Agents can output metadata to enhance messages.
1352
+ *
1353
+ * Format:
1354
+ * [[RELAY_METADATA]]
1355
+ * {
1356
+ * "subject": "Task update",
1357
+ * "importance": 80,
1358
+ * "replyTo": "msg-abc123",
1359
+ * "ackRequired": true
1360
+ * }
1361
+ * [[/RELAY_METADATA]]
1362
+ */
1363
+ export function parseRelayMetadataFromOutput(output: string): MetadataParseResult {
1364
+ const match = output.match(/\[\[RELAY_METADATA\]\]([\s\S]*?)\[\[\/RELAY_METADATA\]\]/);
1365
+
1366
+ if (!match) {
1367
+ return { found: false, valid: false, metadata: null, rawContent: null };
1368
+ }
1369
+
1370
+ const rawContent = match[1].trim();
1371
+
1372
+ try {
1373
+ const metadata = JSON.parse(rawContent) as ParsedMessageMetadata;
1374
+ return { found: true, valid: true, metadata, rawContent };
1375
+ } catch {
1376
+ return { found: true, valid: false, metadata: null, rawContent };
1377
+ }
1378
+ }
1379
+
1380
+ /**
1381
+ * Parsed summary block from agent output.
1382
+ */
1383
+ export interface ParsedSummary {
1384
+ currentTask?: string;
1385
+ completedTasks?: string[];
1386
+ decisions?: string[];
1387
+ context?: string;
1388
+ files?: string[];
1389
+ }
1390
+
1391
+ /**
1392
+ * Result of attempting to parse a SUMMARY block.
1393
+ */
1394
+ export interface SummaryParseResult {
1395
+ found: boolean;
1396
+ valid: boolean;
1397
+ summary: ParsedSummary | null;
1398
+ rawContent: string | null; // Raw block content for deduplication
1399
+ }
1400
+
1401
+ /**
1402
+ * Parse [[SUMMARY]]...[[/SUMMARY]] blocks from agent output.
1403
+ * Agents can output summaries to keep a running context of their work.
1404
+ *
1405
+ * Format:
1406
+ * [[SUMMARY]]
1407
+ * {
1408
+ * "currentTask": "Working on auth module",
1409
+ * "context": "Completed login flow, now implementing logout",
1410
+ * "files": ["src/auth.ts", "src/session.ts"]
1411
+ * }
1412
+ * [[/SUMMARY]]
1413
+ */
1414
+ export function parseSummaryFromOutput(output: string): ParsedSummary | null {
1415
+ const result = parseSummaryWithDetails(output);
1416
+ return result.summary;
1417
+ }
1418
+
1419
+ /**
1420
+ * Parse SUMMARY block with full details for deduplication.
1421
+ * Returns raw content to allow caller to dedupe before logging errors.
1422
+ */
1423
+ export function parseSummaryWithDetails(output: string): SummaryParseResult {
1424
+ const match = output.match(/\[\[SUMMARY\]\]([\s\S]*?)\[\[\/SUMMARY\]\]/);
1425
+
1426
+ if (!match) {
1427
+ return { found: false, valid: false, summary: null, rawContent: null };
1428
+ }
1429
+
1430
+ const rawContent = match[1].trim();
1431
+
1432
+ try {
1433
+ const summary = JSON.parse(rawContent) as ParsedSummary;
1434
+ return { found: true, valid: true, summary, rawContent };
1435
+ } catch {
1436
+ return { found: true, valid: false, summary: null, rawContent };
1437
+ }
1438
+ }
1439
+
1440
+ /**
1441
+ * Session end marker from agent output.
1442
+ */
1443
+ export interface SessionEndMarker {
1444
+ summary?: string;
1445
+ completedTasks?: string[];
1446
+ }
1447
+
1448
+ /**
1449
+ * Parse [[SESSION_END]]...[[/SESSION_END]] blocks from agent output.
1450
+ * Agents output this to explicitly mark their session as complete.
1451
+ *
1452
+ * Format:
1453
+ * [[SESSION_END]]
1454
+ * {"summary": "Completed auth module implementation", "completedTasks": ["login", "logout"]}
1455
+ * [[/SESSION_END]]
1456
+ *
1457
+ * Or simply: [[SESSION_END]][[/SESSION_END]] for a clean close without summary.
1458
+ */
1459
+ export function parseSessionEndFromOutput(output: string): SessionEndMarker | null {
1460
+ const match = output.match(/\[\[SESSION_END\]\]([\s\S]*?)\[\[\/SESSION_END\]\]/);
1461
+
1462
+ if (!match) {
1463
+ return null;
1464
+ }
1465
+
1466
+ const content = match[1].trim();
1467
+ if (!content) {
1468
+ return {}; // Empty marker = session ended without summary
1469
+ }
1470
+
1471
+ try {
1472
+ return JSON.parse(content) as SessionEndMarker;
1473
+ } catch {
1474
+ // If not valid JSON, treat the content as a plain summary string
1475
+ return { summary: content };
1476
+ }
1477
+ }