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