@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
@@ -0,0 +1,1885 @@
1
+ /**
2
+ * RelayPtyOrchestrator - Orchestrates the relay-pty Rust binary
3
+ *
4
+ * This wrapper spawns the relay-pty binary and communicates via Unix socket.
5
+ * It provides the same interface as PtyWrapper but with improved latency
6
+ * (~550ms vs ~1700ms) by using direct PTY writes instead of tmux send-keys.
7
+ *
8
+ * Architecture:
9
+ * 1. Spawn relay-pty --name {agentName} -- {command} as child process
10
+ * 2. Connect to socket for injection:
11
+ * - With WORKSPACE_ID: /tmp/relay/{workspaceId}/sockets/{agentName}.sock
12
+ * - Without: /tmp/relay-pty-{agentName}.sock (legacy)
13
+ * 3. Parse stdout for relay commands (relay-pty echoes all output)
14
+ * 4. Translate SEND envelopes → inject messages via socket
15
+ *
16
+ * @see docs/RUST_WRAPPER_DESIGN.md for protocol details
17
+ */
18
+ import { spawn } from 'node:child_process';
19
+ import { createConnection } from 'node:net';
20
+ import { createHash } from 'node:crypto';
21
+ import { join, dirname } from 'node:path';
22
+ import { existsSync, unlinkSync, mkdirSync, symlinkSync, lstatSync, rmSync, watch, readdirSync } from 'node:fs';
23
+ import { getProjectPaths } from '@agent-relay/config/project-namespace';
24
+ import { fileURLToPath } from 'node:url';
25
+ // Get the directory where this module is located
26
+ const __filename = fileURLToPath(import.meta.url);
27
+ const __dirname = dirname(__filename);
28
+ import { BaseWrapper } from './base-wrapper.js';
29
+ import { parseSummaryWithDetails, parseSessionEndFromOutput } from './parser.js';
30
+ import { stripAnsi, sleep, buildInjectionString, verifyInjection, INJECTION_CONSTANTS, AdaptiveThrottle, } from './shared.js';
31
+ import { getMemoryMonitor, formatBytes, } from '@agent-relay/resiliency';
32
+ // ============================================================================
33
+ // Types for relay-pty socket protocol
34
+ // ============================================================================
35
+ const MAX_SOCKET_PATH_LENGTH = 107;
36
+ function hashWorkspaceId(workspaceId) {
37
+ return createHash('sha256').update(workspaceId).digest('hex').slice(0, 12);
38
+ }
39
+ /**
40
+ * Orchestrator for relay-pty Rust binary
41
+ *
42
+ * Extends BaseWrapper to provide the same interface as PtyWrapper
43
+ * but uses the relay-pty binary for improved injection reliability.
44
+ */
45
+ export class RelayPtyOrchestrator extends BaseWrapper {
46
+ config;
47
+ // Process management
48
+ relayPtyProcess;
49
+ socketPath;
50
+ _logPath;
51
+ _outboxPath;
52
+ _legacyOutboxPath; // Legacy /tmp/relay-outbox path for backwards compat
53
+ _canonicalOutboxPath; // Canonical ~/.agent-relay/outbox path (agents write here)
54
+ _workspaceId; // For symlink setup
55
+ socket;
56
+ socketConnected = false;
57
+ // Output buffering
58
+ outputBuffer = '';
59
+ rawBuffer = '';
60
+ lastParsedLength = 0;
61
+ // Interactive mode (show output to terminal)
62
+ isInteractive = false;
63
+ // Injection state
64
+ pendingInjections = new Map();
65
+ backpressureActive = false;
66
+ readyForMessages = false;
67
+ // Adaptive throttle for message queue - adjusts delay based on success/failure
68
+ throttle = new AdaptiveThrottle();
69
+ // Unread message indicator state
70
+ lastUnreadIndicatorTime = 0;
71
+ UNREAD_INDICATOR_COOLDOWN_MS = 5000; // Don't spam indicators
72
+ // Track whether any output has been received from the CLI
73
+ hasReceivedOutput = false;
74
+ // Queue monitor for stuck message detection
75
+ queueMonitorTimer;
76
+ QUEUE_MONITOR_INTERVAL_MS = 30000; // Check every 30 seconds
77
+ // Protocol monitor for detecting agent mistakes (e.g., empty AGENT_RELAY_NAME)
78
+ protocolWatcher;
79
+ protocolReminderCooldown = 0; // Prevent spam
80
+ PROTOCOL_REMINDER_COOLDOWN_MS = 30000; // 30 second cooldown between reminders
81
+ // Periodic protocol reminder for long sessions (agents sometimes forget the protocol)
82
+ periodicReminderTimer;
83
+ PERIODIC_REMINDER_INTERVAL_MS = 45 * 60 * 1000; // 45 minutes
84
+ sessionStartTime = 0;
85
+ // Track if agent is being gracefully stopped (vs crashed)
86
+ isGracefulStop = false;
87
+ // Memory/CPU monitoring
88
+ memoryMonitor;
89
+ memoryAlertHandler = null;
90
+ // Note: sessionEndProcessed and lastSummaryRawContent are inherited from BaseWrapper
91
+ constructor(config) {
92
+ super(config);
93
+ this.config = config;
94
+ // Get project paths (used for logs and local mode)
95
+ const projectPaths = getProjectPaths(config.cwd);
96
+ // Canonical outbox path - agents ALWAYS write here (transparent symlink in workspace mode)
97
+ // Uses ~/.agent-relay/outbox/{agentName}/ so agents don't need to know about workspace IDs
98
+ this._canonicalOutboxPath = join(projectPaths.dataDir, 'outbox', config.name);
99
+ // Check for workspace namespacing (for multi-tenant cloud deployment)
100
+ // WORKSPACE_ID can be in process.env or passed via config.env
101
+ const workspaceId = config.env?.WORKSPACE_ID || process.env.WORKSPACE_ID;
102
+ this._workspaceId = workspaceId;
103
+ if (workspaceId) {
104
+ // Workspace mode: relay-pty watches the actual workspace path
105
+ // Canonical path (~/.agent-relay/outbox/) will be symlinked to workspace path
106
+ const getWorkspacePaths = (id) => {
107
+ const workspaceDir = `/tmp/relay/${id}`;
108
+ return {
109
+ workspaceDir,
110
+ socketPath: `${workspaceDir}/sockets/${config.name}.sock`,
111
+ outboxPath: `${workspaceDir}/outbox/${config.name}`,
112
+ };
113
+ };
114
+ let paths = getWorkspacePaths(workspaceId);
115
+ if (paths.socketPath.length > MAX_SOCKET_PATH_LENGTH) {
116
+ const hashedWorkspaceId = hashWorkspaceId(workspaceId);
117
+ const hashedPaths = getWorkspacePaths(hashedWorkspaceId);
118
+ console.warn(`[relay-pty-orchestrator:${config.name}] Socket path too long (${paths.socketPath.length} chars); using hashed workspace id ${hashedWorkspaceId}`);
119
+ paths = hashedPaths;
120
+ }
121
+ if (paths.socketPath.length > MAX_SOCKET_PATH_LENGTH) {
122
+ throw new Error(`Socket path exceeds ${MAX_SOCKET_PATH_LENGTH} chars: ${paths.socketPath.length}`);
123
+ }
124
+ this.socketPath = paths.socketPath;
125
+ // relay-pty watches the actual workspace path
126
+ this._outboxPath = paths.outboxPath;
127
+ // Legacy path for backwards compat (older agents might still use /tmp/relay-outbox)
128
+ this._legacyOutboxPath = `/tmp/relay-outbox/${config.name}`;
129
+ }
130
+ else {
131
+ // Local mode: use ~/.agent-relay paths directly (no symlinks needed)
132
+ this._outboxPath = this._canonicalOutboxPath;
133
+ // Socket at ~/.agent-relay/{projectId}/sockets/{agentName}.sock
134
+ this.socketPath = join(projectPaths.dataDir, 'sockets', `${config.name}.sock`);
135
+ // No legacy path needed for local mode
136
+ this._legacyOutboxPath = this._outboxPath;
137
+ }
138
+ if (this.socketPath.length > MAX_SOCKET_PATH_LENGTH) {
139
+ throw new Error(`Socket path exceeds ${MAX_SOCKET_PATH_LENGTH} chars: ${this.socketPath.length}`);
140
+ }
141
+ // Generate log path using project paths
142
+ this._logPath = join(projectPaths.teamDir, 'worker-logs', `${config.name}.log`);
143
+ // Check if we're running interactively (stdin is a TTY)
144
+ // If headless mode is forced via config, always use pipes
145
+ this.isInteractive = config.headless ? false : (process.stdin.isTTY === true);
146
+ // Initialize memory monitor (shared singleton, 10s polling interval)
147
+ this.memoryMonitor = getMemoryMonitor({ checkIntervalMs: 10_000 });
148
+ }
149
+ /**
150
+ * Debug log - only outputs when debug is enabled
151
+ */
152
+ log(message) {
153
+ if (this.config.debug) {
154
+ console.log(`[relay-pty-orchestrator:${this.config.name}] ${message}`);
155
+ }
156
+ }
157
+ /**
158
+ * Error log - always outputs (errors are important)
159
+ */
160
+ logError(message) {
161
+ if (this.config.debug) {
162
+ console.error(`[relay-pty-orchestrator:${this.config.name}] ERROR: ${message}`);
163
+ }
164
+ }
165
+ /**
166
+ * Get the outbox path for this agent (for documentation purposes)
167
+ */
168
+ get outboxPath() {
169
+ return this._outboxPath;
170
+ }
171
+ // =========================================================================
172
+ // Abstract method implementations (required by BaseWrapper)
173
+ // =========================================================================
174
+ /**
175
+ * Start the relay-pty process and connect to socket
176
+ */
177
+ async start() {
178
+ if (this.running)
179
+ return;
180
+ this.log(` Starting...`);
181
+ // Ensure socket directory exists (for workspace-namespaced paths)
182
+ const socketDir = dirname(this.socketPath);
183
+ try {
184
+ if (!existsSync(socketDir)) {
185
+ mkdirSync(socketDir, { recursive: true });
186
+ this.log(` Created socket directory: ${socketDir}`);
187
+ }
188
+ }
189
+ catch (err) {
190
+ this.logError(` Failed to create socket directory: ${err.message}`);
191
+ }
192
+ // Clean up any stale socket from previous crashed process
193
+ try {
194
+ if (existsSync(this.socketPath)) {
195
+ this.log(` Removing stale socket: ${this.socketPath}`);
196
+ unlinkSync(this.socketPath);
197
+ }
198
+ }
199
+ catch (err) {
200
+ this.logError(` Failed to clean up socket: ${err.message}`);
201
+ }
202
+ // Set up outbox directory structure
203
+ // - Workspace mode:
204
+ // 1. Create actual workspace path /tmp/relay/{workspaceId}/outbox/{name}
205
+ // 2. Symlink canonical ~/.agent-relay/outbox/{name} -> workspace path
206
+ // 3. Optional: symlink /tmp/relay-outbox/{name} -> workspace path (backwards compat)
207
+ // - Local mode: just create ~/.agent-relay/{projectId}/outbox/{name} directly
208
+ try {
209
+ // Ensure the actual outbox directory exists (where relay-pty watches)
210
+ const outboxDir = dirname(this._outboxPath);
211
+ if (!existsSync(outboxDir)) {
212
+ mkdirSync(outboxDir, { recursive: true });
213
+ }
214
+ if (!existsSync(this._outboxPath)) {
215
+ mkdirSync(this._outboxPath, { recursive: true });
216
+ }
217
+ this.log(` Created outbox directory: ${this._outboxPath}`);
218
+ // In workspace mode, create symlinks so agents can use canonical path
219
+ if (this._workspaceId) {
220
+ // Helper to create a symlink, cleaning up existing path first
221
+ const createSymlinkSafe = (linkPath, targetPath) => {
222
+ const linkParent = dirname(linkPath);
223
+ if (!existsSync(linkParent)) {
224
+ mkdirSync(linkParent, { recursive: true });
225
+ }
226
+ if (existsSync(linkPath)) {
227
+ try {
228
+ const stats = lstatSync(linkPath);
229
+ if (stats.isSymbolicLink()) {
230
+ unlinkSync(linkPath);
231
+ }
232
+ else if (stats.isDirectory()) {
233
+ rmSync(linkPath, { recursive: true, force: true });
234
+ }
235
+ }
236
+ catch {
237
+ // Ignore cleanup errors
238
+ }
239
+ }
240
+ symlinkSync(targetPath, linkPath);
241
+ this.log(` Created symlink: ${linkPath} -> ${targetPath}`);
242
+ };
243
+ // Symlink canonical path (~/.agent-relay/outbox/{name}) -> workspace path
244
+ // This is the PRIMARY symlink - agents write to canonical path, relay-pty watches workspace path
245
+ if (this._canonicalOutboxPath !== this._outboxPath) {
246
+ createSymlinkSafe(this._canonicalOutboxPath, this._outboxPath);
247
+ }
248
+ // Also create legacy /tmp/relay-outbox symlink for backwards compat with older agents
249
+ if (this._legacyOutboxPath !== this._outboxPath && this._legacyOutboxPath !== this._canonicalOutboxPath) {
250
+ createSymlinkSafe(this._legacyOutboxPath, this._outboxPath);
251
+ }
252
+ }
253
+ }
254
+ catch (err) {
255
+ this.logError(` Failed to set up outbox: ${err.message}`);
256
+ }
257
+ // Find relay-pty binary
258
+ const binaryPath = this.findRelayPtyBinary();
259
+ if (!binaryPath) {
260
+ throw new Error('relay-pty binary not found. Build with: cd relay-pty && cargo build --release');
261
+ }
262
+ this.log(` Using binary: ${binaryPath}`);
263
+ // Connect to relay daemon first
264
+ try {
265
+ await this.client.connect();
266
+ this.log(` Relay daemon connected`);
267
+ }
268
+ catch (err) {
269
+ this.logError(` Relay connect failed: ${err.message}`);
270
+ }
271
+ // Spawn relay-pty process
272
+ await this.spawnRelayPty(binaryPath);
273
+ // Wait for socket to become available and connect
274
+ await this.connectToSocket();
275
+ this.running = true;
276
+ this.readyForMessages = true;
277
+ this.startStuckDetection();
278
+ this.startQueueMonitor();
279
+ this.startProtocolMonitor();
280
+ this.startPeriodicReminder();
281
+ this.log(` Ready for messages`);
282
+ this.log(` Socket connected: ${this.socketConnected}`);
283
+ this.log(` Relay client state: ${this.client.state}`);
284
+ // Process any queued messages
285
+ this.processMessageQueue();
286
+ }
287
+ /**
288
+ * Stop the relay-pty process gracefully
289
+ */
290
+ async stop() {
291
+ if (!this.running)
292
+ return;
293
+ this.isGracefulStop = true; // Mark as graceful to prevent crash broadcast
294
+ this.running = false;
295
+ this.stopStuckDetection();
296
+ this.stopQueueMonitor();
297
+ this.stopProtocolMonitor();
298
+ this.stopPeriodicReminder();
299
+ // Unregister from memory monitor
300
+ this.memoryMonitor.unregister(this.config.name);
301
+ if (this.memoryAlertHandler) {
302
+ this.memoryMonitor.off('alert', this.memoryAlertHandler);
303
+ this.memoryAlertHandler = null;
304
+ }
305
+ this.log(` Stopping...`);
306
+ // Send shutdown command via socket
307
+ if (this.socket && this.socketConnected) {
308
+ try {
309
+ await this.sendSocketRequest({ type: 'shutdown' });
310
+ }
311
+ catch {
312
+ // Ignore errors during shutdown
313
+ }
314
+ }
315
+ // Close socket
316
+ this.disconnectSocket();
317
+ // Kill process if still running
318
+ if (this.relayPtyProcess && !this.relayPtyProcess.killed) {
319
+ this.relayPtyProcess.kill('SIGTERM');
320
+ // Force kill after timeout
321
+ await Promise.race([
322
+ new Promise((resolve) => {
323
+ this.relayPtyProcess?.on('exit', () => resolve());
324
+ }),
325
+ sleep(5000).then(() => {
326
+ if (this.relayPtyProcess && !this.relayPtyProcess.killed) {
327
+ this.relayPtyProcess.kill('SIGKILL');
328
+ }
329
+ }),
330
+ ]);
331
+ }
332
+ // Cleanup relay client
333
+ this.destroyClient();
334
+ // Clean up socket file
335
+ try {
336
+ if (existsSync(this.socketPath)) {
337
+ unlinkSync(this.socketPath);
338
+ this.log(` Cleaned up socket: ${this.socketPath}`);
339
+ }
340
+ }
341
+ catch (err) {
342
+ this.logError(` Failed to clean up socket: ${err.message}`);
343
+ }
344
+ this.log(` Stopped`);
345
+ }
346
+ /**
347
+ * Inject content into the agent via socket
348
+ */
349
+ async performInjection(_content) {
350
+ // This is called by BaseWrapper but we handle injection differently
351
+ // via the socket protocol in processMessageQueue
352
+ throw new Error('Use injectMessage() instead of performInjection()');
353
+ }
354
+ /**
355
+ * Get cleaned output for parsing
356
+ */
357
+ getCleanOutput() {
358
+ return stripAnsi(this.rawBuffer);
359
+ }
360
+ // =========================================================================
361
+ // Process management
362
+ // =========================================================================
363
+ /**
364
+ * Find the relay-pty binary
365
+ */
366
+ findRelayPtyBinary() {
367
+ // Check config path first
368
+ if (this.config.relayPtyPath && existsSync(this.config.relayPtyPath)) {
369
+ return this.config.relayPtyPath;
370
+ }
371
+ // Get the project root (three levels up from packages/wrapper/dist/)
372
+ // packages/wrapper/dist/ -> packages/wrapper -> packages -> project root
373
+ const projectRoot = join(__dirname, '..', '..', '..');
374
+ // Check common locations (ordered by priority)
375
+ const candidates = [
376
+ // Primary: installed by postinstall from platform-specific binary
377
+ join(projectRoot, 'bin', 'relay-pty'),
378
+ // Development: local Rust build
379
+ join(projectRoot, 'relay-pty', 'target', 'release', 'relay-pty'),
380
+ join(projectRoot, 'relay-pty', 'target', 'debug', 'relay-pty'),
381
+ // Local build in cwd (for development)
382
+ join(process.cwd(), 'relay-pty', 'target', 'release', 'relay-pty'),
383
+ join(process.cwd(), 'relay-pty', 'target', 'debug', 'relay-pty'),
384
+ // Installed globally
385
+ '/usr/local/bin/relay-pty',
386
+ // In node_modules (when installed as dependency)
387
+ join(process.cwd(), 'node_modules', 'agent-relay', 'bin', 'relay-pty'),
388
+ join(process.cwd(), 'node_modules', '.bin', 'relay-pty'),
389
+ ];
390
+ for (const candidate of candidates) {
391
+ if (existsSync(candidate)) {
392
+ return candidate;
393
+ }
394
+ }
395
+ return null;
396
+ }
397
+ /**
398
+ * Spawn the relay-pty process
399
+ */
400
+ async spawnRelayPty(binaryPath) {
401
+ // Get terminal dimensions for proper rendering
402
+ const rows = process.stdout.rows || 24;
403
+ const cols = process.stdout.columns || 80;
404
+ const args = [
405
+ '--name', this.config.name,
406
+ '--socket', this.socketPath,
407
+ '--idle-timeout', String(this.config.idleBeforeInjectMs ?? 500),
408
+ '--json-output', // Enable Rust parsing output
409
+ '--rows', String(rows),
410
+ '--cols', String(cols),
411
+ '--log-level', 'warn', // Only show warnings and errors
412
+ '--log-file', this._logPath, // Enable output logging
413
+ '--outbox', this._outboxPath, // Enable file-based relay messages
414
+ '--', this.config.command,
415
+ ...(this.config.args ?? []),
416
+ ];
417
+ this.log(` Spawning: ${binaryPath} ${args.join(' ')}`);
418
+ // For interactive mode, let Rust directly inherit stdin/stdout from the terminal
419
+ // This is more robust than manual forwarding through pipes
420
+ // We still pipe stderr to capture JSON parsed commands
421
+ const stdio = this.isInteractive
422
+ ? ['inherit', 'inherit', 'pipe'] // Rust handles terminal directly
423
+ : ['pipe', 'pipe', 'pipe']; // Headless mode - we handle I/O
424
+ const proc = spawn(binaryPath, args, {
425
+ cwd: this.config.cwd ?? process.cwd(),
426
+ env: {
427
+ ...process.env,
428
+ ...this.config.env,
429
+ AGENT_RELAY_NAME: this.config.name,
430
+ AGENT_RELAY_OUTBOX: this._canonicalOutboxPath, // Agents use this for outbox path
431
+ TERM: 'xterm-256color',
432
+ },
433
+ stdio,
434
+ });
435
+ this.relayPtyProcess = proc;
436
+ // Handle stdout (agent output) - only in headless mode
437
+ if (!this.isInteractive && proc.stdout) {
438
+ proc.stdout.on('data', (data) => {
439
+ const text = data.toString();
440
+ this.handleOutput(text);
441
+ });
442
+ }
443
+ // Handle stderr (relay-pty logs and JSON output) - always needed
444
+ if (proc.stderr) {
445
+ proc.stderr.on('data', (data) => {
446
+ const text = data.toString();
447
+ this.handleStderr(text);
448
+ });
449
+ }
450
+ // Handle exit
451
+ proc.on('exit', (code, signal) => {
452
+ const exitCode = code ?? (signal === 'SIGKILL' ? 137 : 1);
453
+ this.log(` Process exited: code=${exitCode} signal=${signal}`);
454
+ this.running = false;
455
+ // Get crash context before unregistering from memory monitor
456
+ const crashContext = this.memoryMonitor.getCrashContext(this.config.name);
457
+ // Unregister from memory monitor
458
+ this.memoryMonitor.unregister(this.config.name);
459
+ if (this.memoryAlertHandler) {
460
+ this.memoryMonitor.off('alert', this.memoryAlertHandler);
461
+ this.memoryAlertHandler = null;
462
+ }
463
+ // Broadcast crash notification if not a graceful stop
464
+ if (!this.isGracefulStop && this.client.state === 'READY') {
465
+ const canBroadcast = typeof this.client.broadcast === 'function';
466
+ const isNormalExit = exitCode === 0;
467
+ const wasKilled = signal === 'SIGKILL' || signal === 'SIGTERM' || exitCode === 137;
468
+ if (!isNormalExit) {
469
+ const reason = wasKilled
470
+ ? `killed by signal ${signal || 'SIGKILL'}`
471
+ : `exit code ${exitCode}`;
472
+ // Include crash context analysis if available
473
+ const contextInfo = crashContext.likelyCause !== 'unknown'
474
+ ? ` Likely cause: ${crashContext.likelyCause}. ${crashContext.analysisNotes.slice(0, 2).join('. ')}`
475
+ : '';
476
+ const message = `AGENT CRASHED: "${this.config.name}" has died unexpectedly (${reason}).${contextInfo}`;
477
+ this.log(` Broadcasting crash notification: ${message}`);
478
+ if (canBroadcast) {
479
+ this.client.broadcast(message, 'message', {
480
+ isSystemMessage: true,
481
+ agentName: this.config.name,
482
+ exitCode,
483
+ signal: signal || undefined,
484
+ crashType: 'unexpected_exit',
485
+ crashContext: {
486
+ likelyCause: crashContext.likelyCause,
487
+ peakMemory: crashContext.peakMemory,
488
+ averageMemory: crashContext.averageMemory,
489
+ memoryTrend: crashContext.memoryTrend,
490
+ },
491
+ });
492
+ }
493
+ else {
494
+ this.log(' broadcast skipped: client.broadcast not available');
495
+ }
496
+ }
497
+ }
498
+ this.emit('exit', exitCode);
499
+ this.config.onExit?.(exitCode);
500
+ });
501
+ // Handle error
502
+ proc.on('error', (err) => {
503
+ this.logError(` Process error: ${err.message}`);
504
+ this.emit('error', err);
505
+ });
506
+ // Wait for process to start
507
+ await sleep(500);
508
+ if (proc.exitCode !== null) {
509
+ throw new Error(`relay-pty exited immediately with code ${proc.exitCode}`);
510
+ }
511
+ // Register for memory/CPU monitoring
512
+ if (proc.pid) {
513
+ this.memoryMonitor.register(this.config.name, proc.pid);
514
+ this.memoryMonitor.start(); // Idempotent - starts if not already running
515
+ // Set up alert handler to send resource alerts to dashboard only (not other agents)
516
+ this.memoryAlertHandler = (alert) => {
517
+ if (alert.agentName !== this.config.name)
518
+ return;
519
+ if (this.client.state !== 'READY')
520
+ return;
521
+ const message = alert.type === 'recovered'
522
+ ? `AGENT RECOVERED: "${this.config.name}" memory usage returned to normal.`
523
+ : `AGENT RESOURCE ALERT: "${this.config.name}" - ${alert.message} (${formatBytes(alert.currentRss)})`;
524
+ this.log(` Sending resource alert to users: ${message}`);
525
+ // Send to all human users - agents don't need to know about each other's resource usage
526
+ this.client.sendMessage('@users', message, 'message', {
527
+ isSystemMessage: true,
528
+ agentName: this.config.name,
529
+ alertType: alert.type,
530
+ currentMemory: alert.currentRss,
531
+ threshold: alert.threshold,
532
+ recommendation: alert.recommendation,
533
+ });
534
+ };
535
+ this.memoryMonitor.on('alert', this.memoryAlertHandler);
536
+ }
537
+ }
538
+ /**
539
+ * Handle output from relay-pty stdout (headless mode only)
540
+ * In interactive mode, stdout goes directly to terminal via inherited stdio
541
+ */
542
+ handleOutput(data) {
543
+ // Skip processing if agent is no longer running (prevents ghost messages after release)
544
+ if (!this.running) {
545
+ return;
546
+ }
547
+ this.rawBuffer += data;
548
+ this.outputBuffer += data;
549
+ this.hasReceivedOutput = true;
550
+ // Feed to idle detector
551
+ this.feedIdleDetectorOutput(data);
552
+ // Check for unread messages and append indicator if needed
553
+ const indicator = this.formatUnreadIndicator();
554
+ const outputWithIndicator = indicator ? data + indicator : data;
555
+ // Emit output event (with indicator if present)
556
+ this.emit('output', outputWithIndicator);
557
+ // Stream to daemon if configured
558
+ if (this.config.streamLogs !== false && this.client.state === 'READY') {
559
+ this.client.sendLog(outputWithIndicator);
560
+ }
561
+ // Parse for relay commands
562
+ this.parseRelayCommands();
563
+ // Check for summary and session end
564
+ const cleanContent = stripAnsi(this.rawBuffer);
565
+ this.checkForSummary(cleanContent);
566
+ this.checkForSessionEnd(cleanContent);
567
+ }
568
+ /**
569
+ * Format an unread message indicator if there are pending messages.
570
+ * Returns empty string if no pending messages or within cooldown period.
571
+ *
572
+ * Example output:
573
+ * ───────────────────────────
574
+ * 📬 2 unread messages (from: Alice, Bob)
575
+ */
576
+ formatUnreadIndicator() {
577
+ const queueLength = this.messageQueue.length;
578
+ if (queueLength === 0) {
579
+ return '';
580
+ }
581
+ // Check cooldown to avoid spamming
582
+ const now = Date.now();
583
+ if (now - this.lastUnreadIndicatorTime < this.UNREAD_INDICATOR_COOLDOWN_MS) {
584
+ return '';
585
+ }
586
+ this.lastUnreadIndicatorTime = now;
587
+ // Collect unique sender names
588
+ const senders = [...new Set(this.messageQueue.map(m => m.from))];
589
+ const senderList = senders.slice(0, 3).join(', ');
590
+ const moreCount = senders.length > 3 ? ` +${senders.length - 3} more` : '';
591
+ const line = '─'.repeat(27);
592
+ const messageWord = queueLength === 1 ? 'message' : 'messages';
593
+ return `\n${line}\n📬 ${queueLength} unread ${messageWord} (from: ${senderList}${moreCount})\n`;
594
+ }
595
+ /**
596
+ * Handle stderr from relay-pty (logs and JSON parsed commands)
597
+ */
598
+ handleStderr(data) {
599
+ // Skip processing if agent is no longer running (prevents ghost messages after release)
600
+ if (!this.running) {
601
+ return;
602
+ }
603
+ // relay-pty outputs JSON parsed commands to stderr with --json-output
604
+ const lines = data.split('\n').filter(l => l.trim());
605
+ for (const line of lines) {
606
+ if (line.startsWith('{')) {
607
+ // JSON output - parsed relay command from Rust
608
+ try {
609
+ const parsed = JSON.parse(line);
610
+ if (parsed.type === 'relay_command' && parsed.kind) {
611
+ // Log parsed commands (only in debug mode to avoid TUI pollution)
612
+ if (parsed.kind === 'spawn' || parsed.kind === 'release') {
613
+ this.log(`Rust parsed [${parsed.kind}]: ${JSON.stringify({
614
+ spawn_name: parsed.spawn_name,
615
+ spawn_cli: parsed.spawn_cli,
616
+ spawn_task: parsed.spawn_task?.substring(0, 50),
617
+ release_name: parsed.release_name,
618
+ })}`);
619
+ }
620
+ else {
621
+ this.log(`Rust parsed [${parsed.kind}]: ${parsed.from} -> ${parsed.to}`);
622
+ }
623
+ this.handleRustParsedCommand(parsed);
624
+ }
625
+ }
626
+ catch (e) {
627
+ // Not JSON, just log (only in debug mode)
628
+ if (this.config.debug) {
629
+ console.error(`[relay-pty:${this.config.name}] ${line}`);
630
+ }
631
+ }
632
+ }
633
+ else {
634
+ // Non-JSON stderr - only show in debug mode (logs, info messages)
635
+ if (this.config.debug) {
636
+ console.error(`[relay-pty:${this.config.name}] ${line}`);
637
+ }
638
+ }
639
+ }
640
+ }
641
+ /**
642
+ * Handle a parsed command from Rust relay-pty
643
+ * Rust outputs structured JSON with 'kind' field: "message", "spawn", "release"
644
+ */
645
+ handleRustParsedCommand(parsed) {
646
+ switch (parsed.kind) {
647
+ case 'spawn':
648
+ if (parsed.spawn_name && parsed.spawn_cli) {
649
+ this.log(` Spawn detected: ${parsed.spawn_name} (${parsed.spawn_cli})`);
650
+ this.handleSpawnCommand(parsed.spawn_name, parsed.spawn_cli, parsed.spawn_task || '');
651
+ }
652
+ break;
653
+ case 'release':
654
+ if (parsed.release_name) {
655
+ this.log(`Release: ${parsed.release_name}`);
656
+ this.handleReleaseCommand(parsed.release_name);
657
+ }
658
+ else {
659
+ this.logError(`Missing release_name in parsed command: ${JSON.stringify(parsed)}`);
660
+ }
661
+ break;
662
+ case 'message':
663
+ default:
664
+ this.sendRelayCommand({
665
+ to: parsed.to,
666
+ kind: 'message',
667
+ body: parsed.body,
668
+ thread: parsed.thread,
669
+ raw: parsed.raw,
670
+ });
671
+ break;
672
+ }
673
+ }
674
+ /**
675
+ * Handle spawn command (from Rust stderr JSON parsing)
676
+ *
677
+ * Note: We do NOT send the initial task message here because the spawner
678
+ * now handles it after waitUntilCliReady(). Sending it here would cause
679
+ * duplicate task delivery.
680
+ */
681
+ handleSpawnCommand(name, cli, task) {
682
+ const key = `spawn:${name}:${cli}`;
683
+ if (this.processedSpawnCommands.has(key)) {
684
+ this.log(`Spawn already processed: ${key}`);
685
+ return;
686
+ }
687
+ this.processedSpawnCommands.add(key);
688
+ // Log spawn attempts (only in debug mode to avoid TUI pollution)
689
+ this.log(`SPAWN REQUEST: ${name} (${cli})`);
690
+ this.log(` dashboardPort=${this.config.dashboardPort}, onSpawn=${!!this.config.onSpawn}`);
691
+ // Try dashboard API first, fall back to callback
692
+ // The spawner will send the task after waitUntilCliReady()
693
+ if (this.config.dashboardPort) {
694
+ this.log(`Calling dashboard API at port ${this.config.dashboardPort}`);
695
+ this.spawnViaDashboardApi(name, cli, task)
696
+ .then(() => {
697
+ this.log(`SPAWN SUCCESS: ${name} via dashboard API`);
698
+ })
699
+ .catch(err => {
700
+ this.logError(`SPAWN FAILED: ${name} - ${err.message}`);
701
+ if (this.config.onSpawn) {
702
+ this.log(`Falling back to onSpawn callback`);
703
+ Promise.resolve(this.config.onSpawn(name, cli, task))
704
+ .catch(e => this.logError(`SPAWN CALLBACK FAILED: ${e.message}`));
705
+ }
706
+ });
707
+ }
708
+ else if (this.config.onSpawn) {
709
+ this.log(`Using onSpawn callback directly`);
710
+ Promise.resolve(this.config.onSpawn(name, cli, task))
711
+ .catch(e => this.logError(`SPAWN CALLBACK FAILED: ${e.message}`));
712
+ }
713
+ else {
714
+ this.logError(`SPAWN FAILED: No spawn mechanism available! (dashboardPort=${this.config.dashboardPort}, onSpawn=${!!this.config.onSpawn})`);
715
+ }
716
+ }
717
+ /**
718
+ * Handle release command
719
+ */
720
+ handleReleaseCommand(name) {
721
+ const key = `release:${name}`;
722
+ if (this.processedReleaseCommands.has(key)) {
723
+ return;
724
+ }
725
+ this.processedReleaseCommands.add(key);
726
+ this.log(` Release: ${name}`);
727
+ // Try dashboard API first, fall back to callback
728
+ if (this.config.dashboardPort) {
729
+ this.releaseViaDashboardApi(name).catch(err => {
730
+ this.logError(` Dashboard release failed: ${err.message}`);
731
+ this.config.onRelease?.(name);
732
+ });
733
+ }
734
+ else if (this.config.onRelease) {
735
+ this.config.onRelease(name);
736
+ }
737
+ }
738
+ /**
739
+ * Spawn agent via dashboard API
740
+ */
741
+ async spawnViaDashboardApi(name, cli, task) {
742
+ const url = `http://localhost:${this.config.dashboardPort}/api/spawn`;
743
+ const body = {
744
+ name,
745
+ cli,
746
+ task,
747
+ spawnerName: this.config.name, // Include spawner name so task appears from correct agent
748
+ };
749
+ try {
750
+ const response = await fetch(url, {
751
+ method: 'POST',
752
+ headers: { 'Content-Type': 'application/json' },
753
+ body: JSON.stringify(body),
754
+ });
755
+ if (!response.ok) {
756
+ const errorBody = await response.text().catch(() => 'unknown');
757
+ throw new Error(`HTTP ${response.status}: ${errorBody}`);
758
+ }
759
+ const result = await response.json().catch(() => ({}));
760
+ if (result.success === false) {
761
+ throw new Error(result.error || 'Spawn failed without specific error');
762
+ }
763
+ }
764
+ catch (err) {
765
+ // Enhance error with context
766
+ if (err.code === 'ECONNREFUSED') {
767
+ throw new Error(`Dashboard not reachable at ${url} (connection refused)`);
768
+ }
769
+ throw err;
770
+ }
771
+ }
772
+ /**
773
+ * Release agent via dashboard API
774
+ */
775
+ async releaseViaDashboardApi(name) {
776
+ const response = await fetch(`http://localhost:${this.config.dashboardPort}/api/spawned/${encodeURIComponent(name)}`, {
777
+ method: 'DELETE',
778
+ });
779
+ if (!response.ok) {
780
+ const body = await response.json().catch(() => ({ error: 'Unknown' }));
781
+ throw new Error(`HTTP ${response.status}: ${body.error || 'Unknown error'}`);
782
+ }
783
+ this.log(`Released ${name} via dashboard API`);
784
+ }
785
+ // =========================================================================
786
+ // Socket communication
787
+ // =========================================================================
788
+ /**
789
+ * Connect to the relay-pty socket
790
+ */
791
+ async connectToSocket() {
792
+ const timeout = this.config.socketConnectTimeoutMs ?? 5000;
793
+ const maxAttempts = this.config.socketReconnectAttempts ?? 3;
794
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
795
+ try {
796
+ await this.attemptSocketConnection(timeout);
797
+ this.log(` Socket connected`);
798
+ return;
799
+ }
800
+ catch (err) {
801
+ this.logError(` Socket connect attempt ${attempt}/${maxAttempts} failed: ${err.message}`);
802
+ if (attempt < maxAttempts) {
803
+ await sleep(1000 * attempt); // Exponential backoff
804
+ }
805
+ }
806
+ }
807
+ throw new Error(`Failed to connect to socket after ${maxAttempts} attempts`);
808
+ }
809
+ /**
810
+ * Attempt a single socket connection
811
+ */
812
+ attemptSocketConnection(timeout) {
813
+ return new Promise((resolve, reject) => {
814
+ const timer = setTimeout(() => {
815
+ reject(new Error('Socket connection timeout'));
816
+ }, timeout);
817
+ this.socket = createConnection(this.socketPath, () => {
818
+ clearTimeout(timer);
819
+ this.socketConnected = true;
820
+ resolve();
821
+ });
822
+ this.socket.on('error', (err) => {
823
+ clearTimeout(timer);
824
+ this.socketConnected = false;
825
+ reject(err);
826
+ });
827
+ this.socket.on('close', () => {
828
+ this.socketConnected = false;
829
+ this.log(` Socket closed`);
830
+ });
831
+ // Handle incoming data (responses)
832
+ let buffer = '';
833
+ this.socket.on('data', (data) => {
834
+ buffer += data.toString();
835
+ // Process complete lines
836
+ const lines = buffer.split('\n');
837
+ buffer = lines.pop() ?? ''; // Keep incomplete line in buffer
838
+ for (const line of lines) {
839
+ if (line.trim()) {
840
+ this.handleSocketResponse(line);
841
+ }
842
+ }
843
+ });
844
+ });
845
+ }
846
+ /**
847
+ * Disconnect from socket
848
+ */
849
+ disconnectSocket() {
850
+ if (this.socket) {
851
+ this.socket.destroy();
852
+ this.socket = undefined;
853
+ this.socketConnected = false;
854
+ }
855
+ // Reject all pending injections
856
+ for (const [_id, pending] of this.pendingInjections) {
857
+ clearTimeout(pending.timeout);
858
+ pending.reject(new Error('Socket disconnected'));
859
+ }
860
+ this.pendingInjections.clear();
861
+ }
862
+ /**
863
+ * Send a request to the socket and optionally wait for response
864
+ */
865
+ sendSocketRequest(request) {
866
+ return new Promise((resolve, reject) => {
867
+ if (!this.socket || !this.socketConnected) {
868
+ reject(new Error('Socket not connected'));
869
+ return;
870
+ }
871
+ const json = JSON.stringify(request) + '\n';
872
+ this.socket.write(json, (err) => {
873
+ if (err) {
874
+ reject(err);
875
+ }
876
+ else {
877
+ resolve();
878
+ }
879
+ });
880
+ });
881
+ }
882
+ /**
883
+ * Handle a response from the socket
884
+ */
885
+ handleSocketResponse(line) {
886
+ try {
887
+ const response = JSON.parse(line);
888
+ switch (response.type) {
889
+ case 'inject_result':
890
+ // handleInjectResult is async (does verification), but we don't await here
891
+ // Errors are handled internally by the method
892
+ this.handleInjectResult(response).catch((err) => {
893
+ this.logError(` Error handling inject result: ${err.message}`);
894
+ });
895
+ break;
896
+ case 'status':
897
+ // Status responses are typically requested explicitly
898
+ this.log(` Status: idle=${response.agent_idle} queue=${response.queue_length}`);
899
+ break;
900
+ case 'backpressure':
901
+ this.handleBackpressure(response);
902
+ break;
903
+ case 'error':
904
+ this.logError(` Socket error: ${response.message}`);
905
+ break;
906
+ case 'shutdown_ack':
907
+ this.log(` Shutdown acknowledged`);
908
+ break;
909
+ }
910
+ }
911
+ catch (err) {
912
+ this.logError(` Failed to parse socket response: ${err.message}`);
913
+ }
914
+ }
915
+ /**
916
+ * Handle injection result response
917
+ * After Rust reports 'delivered', verifies the message appeared in output.
918
+ * If verification fails, retries up to MAX_RETRIES times.
919
+ */
920
+ async handleInjectResult(response) {
921
+ this.log(` handleInjectResult: id=${response.id.substring(0, 8)} status=${response.status}`);
922
+ const pending = this.pendingInjections.get(response.id);
923
+ if (!pending) {
924
+ // Response for unknown message - might be from a previous session
925
+ this.log(` No pending injection found for ${response.id.substring(0, 8)}`);
926
+ return;
927
+ }
928
+ if (response.status === 'delivered') {
929
+ // Rust says it sent the message + Enter key
930
+ // Now verify the message actually appeared in the terminal output
931
+ this.log(` Message ${pending.shortId} marked delivered by Rust, verifying in output...`);
932
+ // In interactive mode, we can't verify because stdout goes directly to terminal
933
+ // Trust Rust's "delivered" status in this case
934
+ if (this.isInteractive) {
935
+ this.log(` Interactive mode - trusting Rust delivery status`);
936
+ clearTimeout(pending.timeout);
937
+ this.pendingInjections.delete(response.id);
938
+ if (pending.retryCount === 0) {
939
+ this.injectionMetrics.successFirstTry++;
940
+ }
941
+ else {
942
+ this.injectionMetrics.successWithRetry++;
943
+ }
944
+ this.injectionMetrics.total++;
945
+ pending.resolve(true);
946
+ this.log(` Message ${pending.shortId} delivered (interactive mode) ✓`);
947
+ return;
948
+ }
949
+ // Give a brief moment for output to be captured
950
+ await sleep(100);
951
+ // Verify the message pattern appears in captured output
952
+ const verified = await verifyInjection(pending.shortId, pending.from, async () => this.getCleanOutput());
953
+ if (verified) {
954
+ clearTimeout(pending.timeout);
955
+ this.pendingInjections.delete(response.id);
956
+ // Update metrics based on retry count (0 = first try)
957
+ if (pending.retryCount === 0) {
958
+ this.injectionMetrics.successFirstTry++;
959
+ }
960
+ else {
961
+ this.injectionMetrics.successWithRetry++;
962
+ this.log(` Message ${pending.shortId} succeeded on attempt ${pending.retryCount + 1}`);
963
+ }
964
+ this.injectionMetrics.total++;
965
+ pending.resolve(true);
966
+ this.log(` Message ${pending.shortId} verified in output ✓`);
967
+ }
968
+ else {
969
+ // Message was "delivered" but not found in output
970
+ // This is the bug case - Enter key may not have been processed
971
+ this.log(` Message ${pending.shortId} NOT found in output after delivery`);
972
+ // Check if we should retry
973
+ if (pending.retryCount < INJECTION_CONSTANTS.MAX_RETRIES - 1) {
974
+ this.log(` Retrying injection (attempt ${pending.retryCount + 2}/${INJECTION_CONSTANTS.MAX_RETRIES})`);
975
+ clearTimeout(pending.timeout);
976
+ this.pendingInjections.delete(response.id);
977
+ // Wait before retry with backoff
978
+ await sleep(INJECTION_CONSTANTS.RETRY_BACKOFF_MS * (pending.retryCount + 1));
979
+ // IMPORTANT: Check again if message appeared (late verification / race condition fix)
980
+ // The previous injection may have succeeded but verification timed out
981
+ const lateVerified = await verifyInjection(pending.shortId, pending.from, async () => this.getCleanOutput());
982
+ if (lateVerified) {
983
+ this.log(` Message ${pending.shortId} found on late verification, skipping retry`);
984
+ if (pending.retryCount === 0) {
985
+ this.injectionMetrics.successFirstTry++;
986
+ }
987
+ else {
988
+ this.injectionMetrics.successWithRetry++;
989
+ }
990
+ this.injectionMetrics.total++;
991
+ pending.resolve(true);
992
+ return;
993
+ }
994
+ // Re-inject by sending another socket request
995
+ // The original promise will be resolved when this retry completes
996
+ // Prepend [RETRY] to help agent notice this is a retry
997
+ const retryBody = pending.originalBody.startsWith('[RETRY]')
998
+ ? pending.originalBody
999
+ : `[RETRY] ${pending.originalBody}`;
1000
+ const retryRequest = {
1001
+ type: 'inject',
1002
+ id: response.id,
1003
+ from: pending.from,
1004
+ body: retryBody,
1005
+ priority: 1, // Higher priority for retries
1006
+ };
1007
+ // Create new pending entry with incremented retry count
1008
+ const newTimeout = setTimeout(() => {
1009
+ this.logError(` Retry timeout for ${pending.shortId}`);
1010
+ this.pendingInjections.delete(response.id);
1011
+ pending.resolve(false);
1012
+ }, 30000);
1013
+ this.pendingInjections.set(response.id, {
1014
+ ...pending,
1015
+ timeout: newTimeout,
1016
+ retryCount: pending.retryCount + 1,
1017
+ originalBody: retryBody, // Use retry body for subsequent retries
1018
+ });
1019
+ this.sendSocketRequest(retryRequest).catch((err) => {
1020
+ this.logError(` Retry request failed: ${err.message}`);
1021
+ clearTimeout(newTimeout);
1022
+ this.pendingInjections.delete(response.id);
1023
+ pending.resolve(false);
1024
+ });
1025
+ }
1026
+ else {
1027
+ // Max retries exceeded
1028
+ this.logError(` Message ${pending.shortId} failed after ${INJECTION_CONSTANTS.MAX_RETRIES} attempts - NOT found in output`);
1029
+ clearTimeout(pending.timeout);
1030
+ this.pendingInjections.delete(response.id);
1031
+ this.injectionMetrics.failed++;
1032
+ this.injectionMetrics.total++;
1033
+ pending.resolve(false);
1034
+ this.emit('injection-failed', {
1035
+ messageId: response.id,
1036
+ from: pending.from,
1037
+ error: 'Message delivered but not verified in output after max retries',
1038
+ });
1039
+ }
1040
+ }
1041
+ }
1042
+ else if (response.status === 'failed') {
1043
+ clearTimeout(pending.timeout);
1044
+ this.pendingInjections.delete(response.id);
1045
+ this.injectionMetrics.failed++;
1046
+ this.injectionMetrics.total++;
1047
+ pending.resolve(false);
1048
+ this.logError(` Message ${pending.shortId} failed: ${response.error}`);
1049
+ this.emit('injection-failed', {
1050
+ messageId: response.id,
1051
+ from: pending.from,
1052
+ error: response.error ?? 'Unknown error',
1053
+ });
1054
+ }
1055
+ // queued/injecting are intermediate states - wait for final status
1056
+ }
1057
+ /**
1058
+ * Handle backpressure notification
1059
+ */
1060
+ handleBackpressure(response) {
1061
+ const wasActive = this.backpressureActive;
1062
+ this.backpressureActive = !response.accept;
1063
+ if (this.backpressureActive !== wasActive) {
1064
+ this.log(` Backpressure: ${this.backpressureActive ? 'ACTIVE' : 'cleared'} (queue=${response.queue_length})`);
1065
+ this.emit('backpressure', { queueLength: response.queue_length, accept: response.accept });
1066
+ // Resume processing if backpressure cleared
1067
+ if (!this.backpressureActive) {
1068
+ this.processMessageQueue();
1069
+ }
1070
+ }
1071
+ }
1072
+ // =========================================================================
1073
+ // Message handling
1074
+ // =========================================================================
1075
+ /**
1076
+ * Inject a message into the agent via socket
1077
+ */
1078
+ async injectMessage(msg, retryCount = 0) {
1079
+ const shortId = msg.messageId.substring(0, 8);
1080
+ this.log(` === INJECT START: ${shortId} from ${msg.from} (attempt ${retryCount + 1}) ===`);
1081
+ if (!this.socket || !this.socketConnected) {
1082
+ this.logError(` Cannot inject - socket not connected`);
1083
+ return false;
1084
+ }
1085
+ // Build injection content
1086
+ const content = buildInjectionString(msg);
1087
+ this.log(` Injection content (${content.length} bytes): ${content.substring(0, 100)}...`);
1088
+ // Create request
1089
+ const request = {
1090
+ type: 'inject',
1091
+ id: msg.messageId,
1092
+ from: msg.from,
1093
+ body: content,
1094
+ priority: msg.importance ?? 0,
1095
+ };
1096
+ this.log(` Sending inject request to socket...`);
1097
+ // Create promise for result
1098
+ return new Promise((resolve, reject) => {
1099
+ const timeout = setTimeout(() => {
1100
+ this.logError(` Inject timeout for ${shortId} after 30s`);
1101
+ this.pendingInjections.delete(msg.messageId);
1102
+ resolve(false); // Timeout = failure
1103
+ }, 30000); // 30 second timeout for injection
1104
+ this.pendingInjections.set(msg.messageId, {
1105
+ resolve,
1106
+ reject,
1107
+ timeout,
1108
+ from: msg.from,
1109
+ shortId,
1110
+ retryCount,
1111
+ originalBody: content,
1112
+ });
1113
+ // Send request
1114
+ this.sendSocketRequest(request)
1115
+ .then(() => {
1116
+ this.log(` Socket request sent for ${shortId}`);
1117
+ })
1118
+ .catch((err) => {
1119
+ this.logError(` Socket request failed for ${shortId}: ${err.message}`);
1120
+ clearTimeout(timeout);
1121
+ this.pendingInjections.delete(msg.messageId);
1122
+ resolve(false);
1123
+ });
1124
+ });
1125
+ }
1126
+ /** Maximum retries for failed injections before giving up */
1127
+ static MAX_INJECTION_RETRIES = 5;
1128
+ /** Backoff delay multiplier (ms) for retries: delay = BASE * 2^retryCount */
1129
+ static INJECTION_RETRY_BASE_MS = 2000;
1130
+ /**
1131
+ * Process queued messages
1132
+ */
1133
+ async processMessageQueue() {
1134
+ if (!this.readyForMessages || this.backpressureActive || this.isInjecting) {
1135
+ return;
1136
+ }
1137
+ if (this.messageQueue.length === 0) {
1138
+ return;
1139
+ }
1140
+ // Check if agent is in editor mode - delay injection if so
1141
+ const idleResult = this.idleDetector.checkIdle();
1142
+ if (idleResult.inEditorMode) {
1143
+ this.log(` Agent in editor mode, delaying injection (queue: ${this.messageQueue.length})`);
1144
+ // Check again in 2 seconds
1145
+ setTimeout(() => this.processMessageQueue(), 2000);
1146
+ return;
1147
+ }
1148
+ this.isInjecting = true;
1149
+ const msg = this.messageQueue.shift();
1150
+ const retryCount = msg._retryCount ?? 0;
1151
+ const bodyPreview = msg.body.substring(0, 50).replace(/\n/g, '\\n');
1152
+ this.log(` Processing message from ${msg.from}: "${bodyPreview}..." (remaining=${this.messageQueue.length}, retry=${retryCount})`);
1153
+ try {
1154
+ const success = await this.injectMessage(msg);
1155
+ // Metrics are now tracked in handleInjectResult which knows about retries
1156
+ if (!success) {
1157
+ // Record failure for adaptive throttling
1158
+ this.throttle.recordFailure();
1159
+ // Re-queue with backoff if under retry limit
1160
+ if (retryCount < RelayPtyOrchestrator.MAX_INJECTION_RETRIES) {
1161
+ const backoffMs = RelayPtyOrchestrator.INJECTION_RETRY_BASE_MS * Math.pow(2, retryCount);
1162
+ this.log(` Re-queuing message ${msg.messageId.substring(0, 8)} for retry ${retryCount + 1} in ${backoffMs}ms`);
1163
+ msg._retryCount = retryCount + 1;
1164
+ // Add to front of queue for priority
1165
+ this.messageQueue.unshift(msg);
1166
+ // Wait before retrying
1167
+ this.isInjecting = false;
1168
+ setTimeout(() => this.processMessageQueue(), backoffMs);
1169
+ return;
1170
+ }
1171
+ this.logError(` Injection failed for message ${msg.messageId.substring(0, 8)} after ${retryCount} retries`);
1172
+ this.config.onInjectionFailed?.(msg.messageId, 'Injection failed after max retries');
1173
+ this.sendSyncAck(msg.messageId, msg.sync, 'ERROR', { error: 'injection_failed_max_retries' });
1174
+ }
1175
+ else {
1176
+ // Record success for adaptive throttling
1177
+ this.throttle.recordSuccess();
1178
+ this.sendSyncAck(msg.messageId, msg.sync, 'OK');
1179
+ }
1180
+ }
1181
+ catch (err) {
1182
+ this.logError(` Injection error: ${err.message}`);
1183
+ // Track metrics for exceptions (not handled by handleInjectResult)
1184
+ this.injectionMetrics.failed++;
1185
+ this.injectionMetrics.total++;
1186
+ // Record failure for adaptive throttling
1187
+ this.throttle.recordFailure();
1188
+ this.sendSyncAck(msg.messageId, msg.sync, 'ERROR', { error: err.message });
1189
+ }
1190
+ finally {
1191
+ this.isInjecting = false;
1192
+ // Process next message after adaptive delay (faster when healthy, slower under stress)
1193
+ if (this.messageQueue.length > 0 && !this.backpressureActive) {
1194
+ const delay = this.throttle.getDelay();
1195
+ setTimeout(() => this.processMessageQueue(), delay);
1196
+ }
1197
+ }
1198
+ }
1199
+ /**
1200
+ * Override handleIncomingMessage to trigger queue processing
1201
+ */
1202
+ handleIncomingMessage(from, payload, messageId, meta, originalTo) {
1203
+ this.log(` === MESSAGE RECEIVED: ${messageId.substring(0, 8)} from ${from} ===`);
1204
+ this.log(` Body preview: ${payload.body?.substring(0, 100) ?? '(no body)'}...`);
1205
+ super.handleIncomingMessage(from, payload, messageId, meta, originalTo);
1206
+ this.log(` Queue length after add: ${this.messageQueue.length}`);
1207
+ this.processMessageQueue();
1208
+ }
1209
+ // =========================================================================
1210
+ // Queue monitor - Detect and process stuck messages
1211
+ // =========================================================================
1212
+ /**
1213
+ * Start the queue monitor to periodically check for stuck messages.
1214
+ * This ensures messages don't get orphaned in the queue when the agent is idle.
1215
+ */
1216
+ startQueueMonitor() {
1217
+ if (this.queueMonitorTimer) {
1218
+ return; // Already started
1219
+ }
1220
+ this.log(` Starting queue monitor (interval: ${this.QUEUE_MONITOR_INTERVAL_MS}ms)`);
1221
+ this.queueMonitorTimer = setInterval(() => {
1222
+ this.checkForStuckQueue();
1223
+ }, this.QUEUE_MONITOR_INTERVAL_MS);
1224
+ // Don't keep process alive just for queue monitoring
1225
+ this.queueMonitorTimer.unref?.();
1226
+ }
1227
+ /**
1228
+ * Stop the queue monitor.
1229
+ */
1230
+ stopQueueMonitor() {
1231
+ if (this.queueMonitorTimer) {
1232
+ clearInterval(this.queueMonitorTimer);
1233
+ this.queueMonitorTimer = undefined;
1234
+ this.log(` Queue monitor stopped`);
1235
+ }
1236
+ }
1237
+ // =========================================================================
1238
+ // Protocol monitoring (detect agent mistakes like empty AGENT_RELAY_NAME)
1239
+ // =========================================================================
1240
+ /**
1241
+ * Start watching for protocol issues in the outbox directory.
1242
+ * Detects common mistakes like:
1243
+ * - Empty AGENT_RELAY_NAME causing files at outbox//
1244
+ * - Files created directly in outbox/ instead of agent subdirectory
1245
+ */
1246
+ startProtocolMonitor() {
1247
+ // Get the outbox parent directory (one level up from agent's outbox)
1248
+ const parentDir = dirname(this._canonicalOutboxPath);
1249
+ // Ensure parent directory exists
1250
+ try {
1251
+ if (!existsSync(parentDir)) {
1252
+ mkdirSync(parentDir, { recursive: true });
1253
+ }
1254
+ }
1255
+ catch {
1256
+ // Ignore - directory may already exist
1257
+ }
1258
+ try {
1259
+ this.protocolWatcher = watch(parentDir, (eventType, filename) => {
1260
+ if (eventType === 'rename' && filename) {
1261
+ // Check for files directly in parent (not in agent subdirectory)
1262
+ // This happens when $AGENT_RELAY_NAME is empty
1263
+ const fullPath = join(parentDir, filename);
1264
+ try {
1265
+ // If it's a file (not directory) directly in the parent, that's an issue
1266
+ if (existsSync(fullPath) && !lstatSync(fullPath).isDirectory()) {
1267
+ this.handleProtocolIssue('file_in_root', filename);
1268
+ }
1269
+ // Check for empty-named directory (double slash symptom)
1270
+ if (filename === '' || filename.startsWith('/')) {
1271
+ this.handleProtocolIssue('empty_agent_name', filename);
1272
+ }
1273
+ }
1274
+ catch {
1275
+ // Ignore stat errors
1276
+ }
1277
+ }
1278
+ });
1279
+ // Don't keep process alive just for protocol monitoring
1280
+ this.protocolWatcher.unref?.();
1281
+ this.log(` Protocol monitor started on ${parentDir}`);
1282
+ }
1283
+ catch (err) {
1284
+ // Don't fail start() if protocol monitoring fails
1285
+ this.logError(` Failed to start protocol monitor: ${err.message}`);
1286
+ }
1287
+ // Also do an initial scan for existing issues
1288
+ this.scanForProtocolIssues();
1289
+ }
1290
+ /**
1291
+ * Stop the protocol monitor.
1292
+ */
1293
+ stopProtocolMonitor() {
1294
+ if (this.protocolWatcher) {
1295
+ this.protocolWatcher.close();
1296
+ this.protocolWatcher = undefined;
1297
+ this.log(` Protocol monitor stopped`);
1298
+ }
1299
+ }
1300
+ /**
1301
+ * Scan for existing protocol issues (called once at startup).
1302
+ */
1303
+ scanForProtocolIssues() {
1304
+ const parentDir = dirname(this._canonicalOutboxPath);
1305
+ try {
1306
+ if (!existsSync(parentDir))
1307
+ return;
1308
+ const entries = readdirSync(parentDir);
1309
+ for (const entry of entries) {
1310
+ const fullPath = join(parentDir, entry);
1311
+ try {
1312
+ // Check for files directly in parent (should only be directories)
1313
+ if (!lstatSync(fullPath).isDirectory()) {
1314
+ this.handleProtocolIssue('file_in_root', entry);
1315
+ break; // Only report once
1316
+ }
1317
+ }
1318
+ catch {
1319
+ // Ignore stat errors
1320
+ }
1321
+ }
1322
+ }
1323
+ catch {
1324
+ // Ignore scan errors
1325
+ }
1326
+ }
1327
+ /**
1328
+ * Handle a detected protocol issue by injecting a helpful reminder.
1329
+ */
1330
+ handleProtocolIssue(issue, filename) {
1331
+ const now = Date.now();
1332
+ // Respect cooldown to avoid spamming
1333
+ if (now - this.protocolReminderCooldown < this.PROTOCOL_REMINDER_COOLDOWN_MS) {
1334
+ return;
1335
+ }
1336
+ this.protocolReminderCooldown = now;
1337
+ this.log(` Protocol issue detected: ${issue} (${filename})`);
1338
+ const reminders = {
1339
+ empty_agent_name: `⚠️ **Protocol Issue Detected**
1340
+
1341
+ Your \`$AGENT_RELAY_NAME\` environment variable appears to be empty or unset.
1342
+ Your agent name is: **${this.config.name}**
1343
+
1344
+ Correct outbox path: \`$AGENT_RELAY_OUTBOX\`
1345
+
1346
+ When writing relay files, use:
1347
+ \`\`\`bash
1348
+ cat > $AGENT_RELAY_OUTBOX/msg << 'EOF'
1349
+ TO: TargetAgent
1350
+
1351
+ Your message here
1352
+ EOF
1353
+ \`\`\`
1354
+ Then output: \`->relay-file:msg\``,
1355
+ file_in_root: `⚠️ **Protocol Issue Detected**
1356
+
1357
+ Found file "${filename}" directly in the outbox directory instead of in your agent's subdirectory.
1358
+ Your agent name is: **${this.config.name}**
1359
+
1360
+ Correct outbox path: \`$AGENT_RELAY_OUTBOX\`
1361
+
1362
+ Files should be created in your agent's directory:
1363
+ \`\`\`bash
1364
+ cat > $AGENT_RELAY_OUTBOX/${filename} << 'EOF'
1365
+ TO: TargetAgent
1366
+
1367
+ Your message here
1368
+ EOF
1369
+ \`\`\``,
1370
+ };
1371
+ const reminder = reminders[issue];
1372
+ if (reminder) {
1373
+ this.injectProtocolReminder(reminder);
1374
+ }
1375
+ }
1376
+ /**
1377
+ * Inject a protocol reminder message to the agent.
1378
+ */
1379
+ injectProtocolReminder(message) {
1380
+ const queuedMsg = {
1381
+ from: 'system',
1382
+ body: message,
1383
+ messageId: `protocol-reminder-${Date.now()}`,
1384
+ importance: 2, // Higher priority
1385
+ };
1386
+ this.messageQueue.unshift(queuedMsg); // Add to front of queue
1387
+ this.log(` Queued protocol reminder (queue size: ${this.messageQueue.length})`);
1388
+ // Trigger processing if not already in progress
1389
+ if (!this.isInjecting && this.readyForMessages) {
1390
+ this.processMessageQueue();
1391
+ }
1392
+ }
1393
+ // =========================================================================
1394
+ // Periodic protocol reminders (for long sessions where agents forget protocol)
1395
+ // =========================================================================
1396
+ /**
1397
+ * Start sending periodic protocol reminders.
1398
+ * Agents in long sessions sometimes forget the relay protocol - these
1399
+ * reminders help them stay on track without user intervention.
1400
+ */
1401
+ startPeriodicReminder() {
1402
+ this.sessionStartTime = Date.now();
1403
+ this.periodicReminderTimer = setInterval(() => {
1404
+ this.sendPeriodicProtocolReminder();
1405
+ }, this.PERIODIC_REMINDER_INTERVAL_MS);
1406
+ // Don't keep process alive just for reminders
1407
+ this.periodicReminderTimer.unref?.();
1408
+ const intervalMinutes = Math.round(this.PERIODIC_REMINDER_INTERVAL_MS / 60000);
1409
+ this.log(` Periodic protocol reminder started (interval: ${intervalMinutes} minutes)`);
1410
+ }
1411
+ /**
1412
+ * Stop periodic protocol reminders.
1413
+ */
1414
+ stopPeriodicReminder() {
1415
+ if (this.periodicReminderTimer) {
1416
+ clearInterval(this.periodicReminderTimer);
1417
+ this.periodicReminderTimer = undefined;
1418
+ this.log(` Periodic protocol reminder stopped`);
1419
+ }
1420
+ }
1421
+ /**
1422
+ * Send a periodic protocol reminder to the agent.
1423
+ * This reminds agents about proper relay communication format after long sessions.
1424
+ */
1425
+ sendPeriodicProtocolReminder() {
1426
+ // Don't send if not ready
1427
+ if (!this.running || !this.readyForMessages) {
1428
+ return;
1429
+ }
1430
+ const sessionDurationMinutes = Math.round((Date.now() - this.sessionStartTime) / 60000);
1431
+ const reminder = `📋 **Protocol Reminder** (Session: ${sessionDurationMinutes} minutes)
1432
+
1433
+ You are **${this.config.name}** in a multi-agent relay system. Here's how to communicate:
1434
+
1435
+ **Sending Messages:**
1436
+ \`\`\`bash
1437
+ cat > $AGENT_RELAY_OUTBOX/msg << 'EOF'
1438
+ TO: *
1439
+
1440
+ Your message here
1441
+ EOF
1442
+ \`\`\`
1443
+ Then output: \`->relay-file:msg\`
1444
+
1445
+ Use \`TO: *\` to broadcast to all agents, or \`TO: AgentName\` for a specific agent.
1446
+
1447
+ **Spawning Agents:**
1448
+ \`\`\`bash
1449
+ cat > $AGENT_RELAY_OUTBOX/spawn << 'EOF'
1450
+ KIND: spawn
1451
+ NAME: WorkerName
1452
+ CLI: claude
1453
+
1454
+ Task description here
1455
+ EOF
1456
+ \`\`\`
1457
+ Then output: \`->relay-file:spawn\`
1458
+
1459
+ **Protocol Tips:**
1460
+ - Always ACK when you receive a task: "ACK: Brief description"
1461
+ - Send DONE when complete: "DONE: What was accomplished"
1462
+ - Keep your lead informed of progress
1463
+
1464
+ 📖 See **AGENTS.md** in the project root for full protocol documentation.`;
1465
+ this.log(` Sending periodic protocol reminder (session: ${sessionDurationMinutes}m)`);
1466
+ this.injectProtocolReminder(reminder);
1467
+ }
1468
+ /**
1469
+ * Check for messages stuck in the queue and process them if the agent is idle.
1470
+ *
1471
+ * This handles cases where:
1472
+ * 1. Messages arrived while the agent was busy and the retry mechanism failed
1473
+ * 2. Socket disconnection/reconnection left messages orphaned
1474
+ * 3. Injection timeouts occurred without proper queue resumption
1475
+ */
1476
+ checkForStuckQueue() {
1477
+ // Skip if not ready for messages
1478
+ if (!this.readyForMessages || !this.running) {
1479
+ return;
1480
+ }
1481
+ // Skip if queue is empty
1482
+ if (this.messageQueue.length === 0) {
1483
+ return;
1484
+ }
1485
+ // Skip if currently injecting (processing is in progress)
1486
+ if (this.isInjecting) {
1487
+ return;
1488
+ }
1489
+ // Skip if backpressure is active
1490
+ if (this.backpressureActive) {
1491
+ return;
1492
+ }
1493
+ // Check if the agent is idle (high confidence)
1494
+ const idleResult = this.idleDetector.checkIdle({ minSilenceMs: 2000 });
1495
+ if (!idleResult.isIdle) {
1496
+ // Agent is still working, let it finish
1497
+ return;
1498
+ }
1499
+ // We have messages in the queue, agent is idle, not currently injecting
1500
+ // This is a stuck queue situation - trigger processing
1501
+ const senders = [...new Set(this.messageQueue.map(m => m.from))];
1502
+ this.log(` ⚠️ Queue monitor: Found ${this.messageQueue.length} stuck message(s) from [${senders.join(', ')}]`);
1503
+ this.log(` ⚠️ Agent is idle (confidence: ${(idleResult.confidence * 100).toFixed(0)}%), triggering queue processing`);
1504
+ // Process the queue
1505
+ this.processMessageQueue();
1506
+ }
1507
+ // =========================================================================
1508
+ // Output parsing
1509
+ // =========================================================================
1510
+ /**
1511
+ * Parse relay commands from output
1512
+ */
1513
+ parseRelayCommands() {
1514
+ const cleanContent = stripAnsi(this.rawBuffer);
1515
+ if (cleanContent.length <= this.lastParsedLength) {
1516
+ return;
1517
+ }
1518
+ // Parse new content with lookback for fenced messages
1519
+ const lookbackStart = Math.max(0, this.lastParsedLength - 500);
1520
+ const contentToParse = cleanContent.substring(lookbackStart);
1521
+ // Parse fenced messages
1522
+ this.parseFencedMessages(contentToParse);
1523
+ // Parse single-line messages
1524
+ this.parseSingleLineMessages(contentToParse);
1525
+ // Parse spawn/release commands
1526
+ this.parseSpawnReleaseCommands(contentToParse);
1527
+ this.lastParsedLength = cleanContent.length;
1528
+ }
1529
+ /**
1530
+ * Parse fenced multi-line messages
1531
+ */
1532
+ parseFencedMessages(content) {
1533
+ const escapedPrefix = this.relayPrefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1534
+ const fencePattern = new RegExp(`${escapedPrefix}(\\S+)(?:\\s+\\[thread:([\\w-]+)\\])?\\s*<<<([\\s\\S]*?)>>>`, 'g');
1535
+ let match;
1536
+ while ((match = fencePattern.exec(content)) !== null) {
1537
+ const target = match[1];
1538
+ const thread = match[2];
1539
+ const body = match[3].trim();
1540
+ if (!body || target === 'spawn' || target === 'release') {
1541
+ continue;
1542
+ }
1543
+ this.sendRelayCommand({
1544
+ to: target,
1545
+ kind: 'message',
1546
+ body,
1547
+ thread,
1548
+ raw: match[0],
1549
+ });
1550
+ }
1551
+ }
1552
+ /**
1553
+ * Parse single-line messages
1554
+ */
1555
+ parseSingleLineMessages(content) {
1556
+ const lines = content.split('\n');
1557
+ const escapedPrefix = this.relayPrefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1558
+ const pattern = new RegExp(`${escapedPrefix}(\\S+)(?:\\s+\\[thread:([\\w-]+)\\])?\\s+(.+)$`);
1559
+ for (const line of lines) {
1560
+ // Skip fenced messages
1561
+ if (line.includes('<<<') || line.includes('>>>')) {
1562
+ continue;
1563
+ }
1564
+ const match = line.match(pattern);
1565
+ if (!match) {
1566
+ continue;
1567
+ }
1568
+ const target = match[1];
1569
+ const thread = match[2];
1570
+ const body = match[3].trim();
1571
+ if (!body || target === 'spawn' || target === 'release') {
1572
+ continue;
1573
+ }
1574
+ this.sendRelayCommand({
1575
+ to: target,
1576
+ kind: 'message',
1577
+ body,
1578
+ thread,
1579
+ raw: line,
1580
+ });
1581
+ }
1582
+ }
1583
+ // =========================================================================
1584
+ // Summary and session end detection
1585
+ // =========================================================================
1586
+ /**
1587
+ * Check for [[SUMMARY]] blocks
1588
+ */
1589
+ checkForSummary(content) {
1590
+ const result = parseSummaryWithDetails(content);
1591
+ if (!result.found || !result.valid) {
1592
+ return;
1593
+ }
1594
+ if (result.rawContent === this.lastSummaryRawContent) {
1595
+ return;
1596
+ }
1597
+ this.lastSummaryRawContent = result.rawContent ?? '';
1598
+ this.emit('summary', {
1599
+ agentName: this.config.name,
1600
+ summary: result.summary,
1601
+ });
1602
+ }
1603
+ /**
1604
+ * Check for [[SESSION_END]] blocks
1605
+ */
1606
+ checkForSessionEnd(content) {
1607
+ if (this.sessionEndProcessed) {
1608
+ return;
1609
+ }
1610
+ const sessionEnd = parseSessionEndFromOutput(content);
1611
+ if (!sessionEnd) {
1612
+ return;
1613
+ }
1614
+ this.sessionEndProcessed = true;
1615
+ this.emit('session-end', {
1616
+ agentName: this.config.name,
1617
+ marker: sessionEnd,
1618
+ });
1619
+ }
1620
+ // =========================================================================
1621
+ // Public API
1622
+ // =========================================================================
1623
+ /**
1624
+ * Query status from relay-pty
1625
+ */
1626
+ async queryStatus() {
1627
+ if (!this.socket || !this.socketConnected) {
1628
+ return null;
1629
+ }
1630
+ try {
1631
+ await this.sendSocketRequest({ type: 'status' });
1632
+ // Response will come asynchronously via handleSocketResponse
1633
+ // For now, return null - could implement request/response matching
1634
+ return null;
1635
+ }
1636
+ catch {
1637
+ return null;
1638
+ }
1639
+ }
1640
+ /**
1641
+ * Wait for the CLI to be ready to receive messages.
1642
+ * This waits for:
1643
+ * 1. The CLI to produce at least one output (it has started)
1644
+ * 2. The CLI to become idle (it's ready for input)
1645
+ *
1646
+ * This is more reliable than a random sleep because it waits for
1647
+ * actual signals from the CLI rather than guessing how long it takes to start.
1648
+ *
1649
+ * @param timeoutMs Maximum time to wait (default: 30s)
1650
+ * @param pollMs Polling interval (default: 100ms)
1651
+ * @returns true if CLI is ready, false if timeout
1652
+ */
1653
+ async waitUntilCliReady(timeoutMs = 30000, pollMs = 100) {
1654
+ const startTime = Date.now();
1655
+ this.log(` Waiting for CLI to be ready (timeout: ${timeoutMs}ms)`);
1656
+ // In interactive mode, stdout is inherited (not captured), so hasReceivedOutput
1657
+ // will never be set. Trust that the process is ready if it's running.
1658
+ if (this.isInteractive) {
1659
+ this.log(` Interactive mode - trusting process is ready`);
1660
+ // Give a brief moment for the CLI to initialize its TUI.
1661
+ // 500ms is a conservative estimate based on typical CLI startup times:
1662
+ // - Claude CLI: ~200-300ms to show initial prompt
1663
+ // - Codex/Gemini: ~300-400ms
1664
+ // This delay is only used in interactive mode where we can't detect output.
1665
+ // In non-interactive mode, we poll for actual output instead.
1666
+ await sleep(500);
1667
+ return this.running;
1668
+ }
1669
+ // Phase 1: Wait for first output (CLI has started)
1670
+ while (Date.now() - startTime < timeoutMs) {
1671
+ if (this.hasReceivedOutput) {
1672
+ this.log(` CLI has started producing output`);
1673
+ break;
1674
+ }
1675
+ await sleep(pollMs);
1676
+ }
1677
+ if (!this.hasReceivedOutput) {
1678
+ this.log(` Timeout waiting for CLI to produce output`);
1679
+ return false;
1680
+ }
1681
+ // Phase 2: Wait for idle state (CLI is ready for input)
1682
+ const remainingTime = timeoutMs - (Date.now() - startTime);
1683
+ if (remainingTime <= 0) {
1684
+ return false;
1685
+ }
1686
+ const idleResult = await this.waitForIdleState(remainingTime, pollMs);
1687
+ if (idleResult.isIdle) {
1688
+ this.log(` CLI is idle and ready (confidence: ${idleResult.confidence.toFixed(2)})`);
1689
+ return true;
1690
+ }
1691
+ this.log(` Timeout waiting for CLI to become idle`);
1692
+ return false;
1693
+ }
1694
+ /**
1695
+ * Check if the CLI has produced any output yet.
1696
+ * Useful for checking if the CLI has started without blocking.
1697
+ * In interactive mode, returns true if process is running (output isn't captured).
1698
+ */
1699
+ hasCliStarted() {
1700
+ // In interactive mode, stdout isn't captured so hasReceivedOutput is never set
1701
+ if (this.isInteractive) {
1702
+ return this.running;
1703
+ }
1704
+ return this.hasReceivedOutput;
1705
+ }
1706
+ /**
1707
+ * Check if the orchestrator is ready to receive and inject messages.
1708
+ * This requires:
1709
+ * 1. relay-pty process spawned
1710
+ * 2. Socket connected to relay-pty
1711
+ * 3. running flag set
1712
+ *
1713
+ * Use this to verify the agent can actually receive injected messages,
1714
+ * not just that the CLI is running.
1715
+ */
1716
+ isReadyForMessages() {
1717
+ return this.readyForMessages && this.running && this.socketConnected;
1718
+ }
1719
+ /**
1720
+ * Wait until the orchestrator is ready to receive and inject messages.
1721
+ * This is more comprehensive than waitUntilCliReady because it ensures:
1722
+ * 1. CLI is ready (has output and is idle)
1723
+ * 2. Orchestrator is ready (socket connected, can inject)
1724
+ *
1725
+ * @param timeoutMs Maximum time to wait (default: 30s)
1726
+ * @param pollMs Polling interval (default: 100ms)
1727
+ * @returns true if ready, false if timeout
1728
+ */
1729
+ async waitUntilReadyForMessages(timeoutMs = 30000, pollMs = 100) {
1730
+ const startTime = Date.now();
1731
+ this.log(` Waiting for orchestrator to be ready for messages (timeout: ${timeoutMs}ms)`);
1732
+ // First wait for CLI to be ready (output + idle)
1733
+ const cliReady = await this.waitUntilCliReady(timeoutMs, pollMs);
1734
+ if (!cliReady) {
1735
+ this.log(` CLI not ready within timeout`);
1736
+ return false;
1737
+ }
1738
+ // Then wait for readyForMessages flag
1739
+ const remainingTime = timeoutMs - (Date.now() - startTime);
1740
+ if (remainingTime <= 0) {
1741
+ this.log(` No time remaining to wait for readyForMessages`);
1742
+ return this.isReadyForMessages();
1743
+ }
1744
+ while (Date.now() - startTime < timeoutMs) {
1745
+ if (this.isReadyForMessages()) {
1746
+ this.log(` Orchestrator is ready for messages`);
1747
+ return true;
1748
+ }
1749
+ await sleep(pollMs);
1750
+ }
1751
+ this.log(` Timeout waiting for orchestrator to be ready for messages`);
1752
+ return false;
1753
+ }
1754
+ /**
1755
+ * Get raw output buffer
1756
+ */
1757
+ getRawOutput() {
1758
+ return this.rawBuffer;
1759
+ }
1760
+ /**
1761
+ * Check if backpressure is active
1762
+ */
1763
+ isBackpressureActive() {
1764
+ return this.backpressureActive;
1765
+ }
1766
+ /**
1767
+ * Get the socket path
1768
+ */
1769
+ getSocketPath() {
1770
+ return this.socketPath;
1771
+ }
1772
+ /**
1773
+ * Get the relay-pty process PID
1774
+ */
1775
+ get pid() {
1776
+ return this.relayPtyProcess?.pid;
1777
+ }
1778
+ /**
1779
+ * Get the log file path (not used by relay-pty, returns undefined)
1780
+ */
1781
+ get logPath() {
1782
+ return this._logPath;
1783
+ }
1784
+ /**
1785
+ * Kill the process forcefully
1786
+ */
1787
+ async kill() {
1788
+ this.isGracefulStop = true; // Mark as intentional to prevent crash broadcast
1789
+ if (this.relayPtyProcess && !this.relayPtyProcess.killed) {
1790
+ this.relayPtyProcess.kill('SIGKILL');
1791
+ }
1792
+ this.running = false;
1793
+ this.disconnectSocket();
1794
+ this.destroyClient();
1795
+ }
1796
+ /**
1797
+ * Get output lines (for compatibility with PtyWrapper)
1798
+ * @param limit Maximum number of lines to return
1799
+ */
1800
+ getOutput(limit) {
1801
+ const lines = this.rawBuffer.split('\n');
1802
+ if (limit && limit > 0) {
1803
+ return lines.slice(-limit);
1804
+ }
1805
+ return lines;
1806
+ }
1807
+ /**
1808
+ * Write data directly to the process stdin
1809
+ * @param data Data to write
1810
+ */
1811
+ async write(data) {
1812
+ if (!this.relayPtyProcess || !this.relayPtyProcess.stdin) {
1813
+ throw new Error('Process not running');
1814
+ }
1815
+ const buffer = typeof data === 'string' ? Buffer.from(data) : data;
1816
+ this.relayPtyProcess.stdin.write(buffer);
1817
+ }
1818
+ /**
1819
+ * Inject a task using the socket-based injection system with verification.
1820
+ * This is the preferred method for spawned agent task delivery.
1821
+ *
1822
+ * @param task The task text to inject
1823
+ * @param from The sender name (default: "spawner")
1824
+ * @returns Promise resolving to true if injection succeeded, false otherwise
1825
+ */
1826
+ async injectTask(task, from = 'spawner') {
1827
+ if (!this.socket || !this.socketConnected) {
1828
+ this.log(` Socket not connected for task injection, falling back to stdin write`);
1829
+ // Fallback to direct write if socket not available
1830
+ try {
1831
+ await this.write(task + '\n');
1832
+ return true;
1833
+ }
1834
+ catch (err) {
1835
+ this.logError(` Stdin write fallback failed: ${err.message}`);
1836
+ return false;
1837
+ }
1838
+ }
1839
+ const messageId = `task-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`;
1840
+ const shortId = messageId.substring(0, 8);
1841
+ this.log(` Injecting task via socket: ${shortId}`);
1842
+ // Create request
1843
+ const request = {
1844
+ type: 'inject',
1845
+ id: messageId,
1846
+ from,
1847
+ body: task,
1848
+ priority: 0, // High priority for initial task
1849
+ };
1850
+ // Send with timeout and verification
1851
+ return new Promise((resolve) => {
1852
+ const timeout = setTimeout(() => {
1853
+ this.logError(` Task inject timeout for ${shortId} after 30s`);
1854
+ this.pendingInjections.delete(messageId);
1855
+ resolve(false);
1856
+ }, 30000);
1857
+ this.pendingInjections.set(messageId, {
1858
+ resolve,
1859
+ reject: () => resolve(false),
1860
+ timeout,
1861
+ from,
1862
+ shortId,
1863
+ retryCount: 0,
1864
+ originalBody: task,
1865
+ });
1866
+ this.sendSocketRequest(request)
1867
+ .then(() => {
1868
+ this.log(` Task inject request sent: ${shortId}`);
1869
+ })
1870
+ .catch((err) => {
1871
+ this.logError(` Task inject socket request failed: ${err.message}`);
1872
+ clearTimeout(timeout);
1873
+ this.pendingInjections.delete(messageId);
1874
+ resolve(false);
1875
+ });
1876
+ });
1877
+ }
1878
+ /**
1879
+ * Get the agent ID (from continuity if available)
1880
+ */
1881
+ getAgentId() {
1882
+ return this.agentId;
1883
+ }
1884
+ }
1885
+ //# sourceMappingURL=relay-pty-orchestrator.js.map