agent-relay 1.0.6 → 1.0.8

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 (72) hide show
  1. package/README.md +18 -6
  2. package/dist/cli/index.d.ts +2 -0
  3. package/dist/cli/index.d.ts.map +1 -1
  4. package/dist/cli/index.js +344 -3
  5. package/dist/cli/index.js.map +1 -1
  6. package/dist/daemon/agent-registry.d.ts +60 -0
  7. package/dist/daemon/agent-registry.d.ts.map +1 -0
  8. package/dist/daemon/agent-registry.js +158 -0
  9. package/dist/daemon/agent-registry.js.map +1 -0
  10. package/dist/daemon/connection.d.ts +11 -1
  11. package/dist/daemon/connection.d.ts.map +1 -1
  12. package/dist/daemon/connection.js +31 -2
  13. package/dist/daemon/connection.js.map +1 -1
  14. package/dist/daemon/index.d.ts +2 -0
  15. package/dist/daemon/index.d.ts.map +1 -1
  16. package/dist/daemon/index.js +2 -0
  17. package/dist/daemon/index.js.map +1 -1
  18. package/dist/daemon/registry.d.ts +9 -0
  19. package/dist/daemon/registry.d.ts.map +1 -0
  20. package/dist/daemon/registry.js +9 -0
  21. package/dist/daemon/registry.js.map +1 -0
  22. package/dist/daemon/router.d.ts +34 -2
  23. package/dist/daemon/router.d.ts.map +1 -1
  24. package/dist/daemon/router.js +111 -1
  25. package/dist/daemon/router.js.map +1 -1
  26. package/dist/daemon/server.d.ts +1 -0
  27. package/dist/daemon/server.d.ts.map +1 -1
  28. package/dist/daemon/server.js +60 -13
  29. package/dist/daemon/server.js.map +1 -1
  30. package/dist/dashboard/public/index.html +625 -16
  31. package/dist/dashboard/server.d.ts +1 -1
  32. package/dist/dashboard/server.d.ts.map +1 -1
  33. package/dist/dashboard/server.js +125 -7
  34. package/dist/dashboard/server.js.map +1 -1
  35. package/dist/index.d.ts +1 -0
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/protocol/types.d.ts +15 -1
  38. package/dist/protocol/types.d.ts.map +1 -1
  39. package/dist/storage/adapter.d.ts +53 -0
  40. package/dist/storage/adapter.d.ts.map +1 -1
  41. package/dist/storage/adapter.js +3 -0
  42. package/dist/storage/adapter.js.map +1 -1
  43. package/dist/storage/sqlite-adapter.d.ts +58 -1
  44. package/dist/storage/sqlite-adapter.d.ts.map +1 -1
  45. package/dist/storage/sqlite-adapter.js +374 -47
  46. package/dist/storage/sqlite-adapter.js.map +1 -1
  47. package/dist/utils/project-namespace.d.ts.map +1 -1
  48. package/dist/utils/project-namespace.js +22 -1
  49. package/dist/utils/project-namespace.js.map +1 -1
  50. package/dist/wrapper/client.d.ts +22 -3
  51. package/dist/wrapper/client.d.ts.map +1 -1
  52. package/dist/wrapper/client.js +59 -9
  53. package/dist/wrapper/client.js.map +1 -1
  54. package/dist/wrapper/parser.d.ts +110 -4
  55. package/dist/wrapper/parser.d.ts.map +1 -1
  56. package/dist/wrapper/parser.js +296 -84
  57. package/dist/wrapper/parser.js.map +1 -1
  58. package/dist/wrapper/tmux-wrapper.d.ts +100 -9
  59. package/dist/wrapper/tmux-wrapper.d.ts.map +1 -1
  60. package/dist/wrapper/tmux-wrapper.js +441 -83
  61. package/dist/wrapper/tmux-wrapper.js.map +1 -1
  62. package/docs/AGENTS.md +27 -27
  63. package/docs/CHANGELOG.md +1 -1
  64. package/docs/DESIGN_V2.md +1079 -0
  65. package/docs/INTEGRATION-GUIDE.md +926 -0
  66. package/docs/PROPOSAL-trajectories.md +1582 -0
  67. package/docs/PROTOCOL.md +3 -3
  68. package/docs/SCALING_ANALYSIS.md +280 -0
  69. package/docs/TMUX_IMPLEMENTATION_NOTES.md +9 -9
  70. package/docs/TMUX_IMPROVEMENTS.md +968 -0
  71. package/docs/competitive-analysis-mcp-agent-mail.md +389 -0
  72. package/package.json +6 -2
@@ -5,7 +5,7 @@
5
5
  * 1. Start agent in detached tmux session
6
6
  * 2. Attach user to tmux (they see real terminal)
7
7
  * 3. Background: poll capture-pane silently (no stdout writes)
8
- * 4. Background: parse @relay commands, send to daemon
8
+ * 4. Background: parse ->relay commands, send to daemon
9
9
  * 5. Background: inject messages via send-keys
10
10
  *
11
11
  * The key insight: user sees the REAL tmux session, not a proxy.
@@ -14,20 +14,44 @@
14
14
  import { exec, execSync, spawn } from 'node:child_process';
15
15
  import { promisify } from 'node:util';
16
16
  import { RelayClient } from './client.js';
17
- import { OutputParser } from './parser.js';
17
+ import { OutputParser, parseSummaryWithDetails, parseSessionEndFromOutput } from './parser.js';
18
18
  import { InboxManager } from './inbox.js';
19
+ import { SqliteStorageAdapter } from '../storage/sqlite-adapter.js';
20
+ import { getProjectPaths } from '../utils/project-namespace.js';
19
21
  const execAsync = promisify(exec);
22
+ const escapeRegex = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
23
+ // Constants for cursor stability detection in waitForClearInput
24
+ /** Number of consecutive polls with stable cursor before assuming input is clear */
25
+ const STABLE_CURSOR_THRESHOLD = 3;
26
+ /** Maximum cursor X position that indicates a prompt (typical prompts are 1-4 chars) */
27
+ const MAX_PROMPT_CURSOR_POSITION = 4;
28
+ /** Maximum characters to show in debug log truncation */
29
+ const DEBUG_LOG_TRUNCATE_LENGTH = 40;
30
+ /** Maximum characters to show in relay command log truncation */
31
+ const RELAY_LOG_TRUNCATE_LENGTH = 50;
32
+ /**
33
+ * Get the default relay prefix for a given CLI type.
34
+ * All agents now use '->relay:' as the unified prefix.
35
+ */
36
+ export function getDefaultPrefix(cliType) {
37
+ // Unified prefix for all agent types
38
+ return '->relay:';
39
+ }
20
40
  export class TmuxWrapper {
21
41
  config;
22
42
  sessionName;
23
43
  client;
24
44
  parser;
25
45
  inbox;
46
+ storage;
47
+ storageReady; // Resolves true if storage initialized, false if failed
26
48
  running = false;
27
49
  pollTimer;
28
50
  attachProcess;
29
51
  lastCapturedOutput = '';
30
52
  lastOutputTime = 0;
53
+ lastActivityTime = Date.now();
54
+ activityState = 'disconnected';
31
55
  recentlySentMessages = new Map();
32
56
  sentMessageHashes = new Set(); // Permanent dedup
33
57
  messageQueue = [];
@@ -36,6 +60,13 @@ export class TmuxWrapper {
36
60
  processedOutputLength = 0;
37
61
  lastDebugLog = 0;
38
62
  cliType;
63
+ relayPrefix;
64
+ lastSummaryHash = ''; // Dedup summary saves
65
+ lastSummaryRawContent = ''; // Dedup invalid JSON error logging
66
+ sessionEndProcessed = false; // Track if we've already processed session end
67
+ pendingRelayCommands = [];
68
+ queuedMessageHashes = new Set(); // For offline queue dedup
69
+ MAX_PENDING_RELAY_COMMANDS = 50;
39
70
  constructor(config) {
40
71
  this.config = {
41
72
  cols: process.stdout.columns || 120,
@@ -43,9 +74,10 @@ export class TmuxWrapper {
43
74
  pollInterval: 200, // Slightly slower polling since we're not displaying
44
75
  idleBeforeInjectMs: 1500,
45
76
  injectRetryMs: 500,
46
- debug: true,
77
+ debug: false,
47
78
  debugLogIntervalMs: 0,
48
79
  mouseMode: true, // Enable mouse scroll passthrough by default
80
+ activityIdleThresholdMs: 30_000, // Consider idle after 30s with no output
49
81
  ...config,
50
82
  };
51
83
  // Detect CLI type from command for special handling
@@ -65,14 +97,21 @@ export class TmuxWrapper {
65
97
  else {
66
98
  this.cliType = 'other';
67
99
  }
68
- // Generate unique session name
69
- this.sessionName = `relay-${config.name}-${process.pid}`;
100
+ // Determine relay prefix: explicit config > auto-detect from CLI type
101
+ this.relayPrefix = config.relayPrefix ?? getDefaultPrefix(this.cliType);
102
+ // Session name (one agent per name - starting a duplicate kills the existing one)
103
+ this.sessionName = `relay-${config.name}`;
70
104
  this.client = new RelayClient({
71
105
  agentName: config.name,
72
106
  socketPath: config.socketPath,
73
107
  cli: this.cliType,
108
+ program: this.config.program,
109
+ model: this.config.model,
110
+ task: this.config.task,
111
+ workingDirectory: this.config.cwd ?? process.cwd(),
112
+ quiet: true, // Keep stdout clean; we log to stderr via wrapper
74
113
  });
75
- this.parser = new OutputParser();
114
+ this.parser = new OutputParser({ prefix: this.relayPrefix });
76
115
  // Initialize inbox if using file-based messaging
77
116
  if (config.useInbox) {
78
117
  this.inbox = new InboxManager({
@@ -80,14 +119,33 @@ export class TmuxWrapper {
80
119
  inboxDir: config.inboxDir,
81
120
  });
82
121
  }
122
+ // Initialize storage for session/summary persistence
123
+ const projectPaths = getProjectPaths();
124
+ this.storage = new SqliteStorageAdapter({ dbPath: projectPaths.dbPath });
125
+ // Initialize asynchronously (don't block constructor) - methods await storageReady
126
+ this.storageReady = this.storage.init().then(() => true).catch(err => {
127
+ this.logStderr(`Failed to initialize storage: ${err.message}`, true);
128
+ this.storage = undefined;
129
+ return false;
130
+ });
83
131
  // Handle incoming messages from relay
84
- this.client.onMessage = (from, payload, messageId) => {
85
- this.handleIncomingMessage(from, payload, messageId);
132
+ this.client.onMessage = (from, payload, messageId, meta) => {
133
+ this.handleIncomingMessage(from, payload, messageId, meta);
86
134
  };
87
135
  this.client.onStateChange = (state) => {
88
136
  // Only log to stderr, never stdout (user is in tmux)
89
137
  if (state === 'READY') {
90
- this.logStderr(`Connected to relay daemon`);
138
+ this.logStderr('Connected to relay daemon');
139
+ this.flushQueuedRelayCommands();
140
+ }
141
+ else if (state === 'BACKOFF') {
142
+ this.logStderr('Relay unavailable, will retry (backoff)');
143
+ }
144
+ else if (state === 'DISCONNECTED') {
145
+ this.logStderr('Relay disconnected (offline mode)');
146
+ }
147
+ else if (state === 'CONNECTING') {
148
+ this.logStderr('Connecting to relay daemon...');
91
149
  }
92
150
  };
93
151
  }
@@ -95,7 +153,7 @@ export class TmuxWrapper {
95
153
  * Log to stderr (safe - doesn't interfere with tmux display)
96
154
  */
97
155
  logStderr(msg, force = false) {
98
- if (!force && this.config.debug === false)
156
+ if (!force && !this.config.debug)
99
157
  return;
100
158
  const now = Date.now();
101
159
  if (!force && this.config.debugLogIntervalMs && this.config.debugLogIntervalMs > 0) {
@@ -148,8 +206,9 @@ export class TmuxWrapper {
148
206
  this.inbox.init();
149
207
  }
150
208
  // Connect to relay daemon (in background, don't block)
151
- this.client.connect().catch(() => {
152
- // Silent - relay connection is optional
209
+ this.client.connect().catch((err) => {
210
+ // Connection failures will retry via client backoff; surface once to stderr.
211
+ this.logStderr(`Relay connect failed: ${err.message}. Will retry if enabled.`, true);
153
212
  });
154
213
  // Kill any existing session with this name
155
214
  try {
@@ -161,6 +220,7 @@ export class TmuxWrapper {
161
220
  // Build the command - properly quote args that contain spaces
162
221
  const fullCommand = this.buildCommand();
163
222
  this.logStderr(`Command: ${fullCommand}`);
223
+ this.logStderr(`Prefix: ${this.relayPrefix} (use ${this.relayPrefix}AgentName to send)`);
164
224
  // Create tmux session
165
225
  try {
166
226
  execSync(`tmux new-session -d -s ${this.sessionName} -x ${this.config.cols} -y ${this.config.rows}`, {
@@ -175,6 +235,8 @@ export class TmuxWrapper {
175
235
  'setw -g alternate-screen on', // Ensure alternate screen works
176
236
  // Pass through mouse scroll to application in alternate screen mode
177
237
  'set -ga terminal-overrides ",xterm*:Tc"',
238
+ 'set -g status-left-length 100', // Provide ample space for agent name in status bar
239
+ 'set -g mode-keys vi', // Predictable key table (avoid copy-mode surprises)
178
240
  ];
179
241
  // Add mouse mode if enabled (allows scroll passthrough to CLI apps)
180
242
  if (this.config.mouseMode) {
@@ -189,6 +251,25 @@ export class TmuxWrapper {
189
251
  // Some settings may not be available in older tmux versions
190
252
  }
191
253
  }
254
+ // Harden session against accidental copy-mode / mouse capture that interrupts agents
255
+ const tmuxCopyModeBlockers = [
256
+ 'unbind -T prefix [', // Disable prefix-[ copy-mode
257
+ 'unbind -T prefix PageUp', // Disable PageUp copy-mode entry
258
+ 'unbind -T root WheelUpPane', // Stop wheel from entering copy-mode
259
+ 'unbind -T root WheelDownPane',
260
+ 'unbind -T root MouseDrag1Pane',
261
+ 'bind -T root WheelUpPane send-keys -M', // Pass wheel events through to app
262
+ 'bind -T root WheelDownPane send-keys -M',
263
+ 'bind -T root MouseDrag1Pane send-keys -M',
264
+ ];
265
+ for (const setting of tmuxCopyModeBlockers) {
266
+ try {
267
+ execSync(`tmux ${setting}`, { stdio: 'pipe' });
268
+ }
269
+ catch {
270
+ // Ignore on older tmux versions lacking these key tables
271
+ }
272
+ }
192
273
  // Set environment variables
193
274
  for (const [key, value] of Object.entries({
194
275
  ...this.config.env,
@@ -211,6 +292,8 @@ export class TmuxWrapper {
211
292
  // Wait for session to be ready
212
293
  await this.waitForSession();
213
294
  this.running = true;
295
+ this.lastActivityTime = Date.now();
296
+ this.activityState = 'active';
214
297
  // Inject instructions for the agent (after a delay to let CLI initialize)
215
298
  setTimeout(() => this.injectInstructions(), 3000);
216
299
  // Start background polling (silent - no stdout writes)
@@ -227,9 +310,10 @@ export class TmuxWrapper {
227
310
  return;
228
311
  const instructions = [
229
312
  `[Agent Relay] You are "${this.config.name}" - connected for real-time messaging.`,
230
- `SEND: @relay:AgentName message (or @relay:* to broadcast)`,
231
- `RECEIVE: "Relay message from X [id]: content"`,
232
- `TRUNCATED: Run "agent-relay read <id>" if message seems cut off`,
313
+ `SEND: ${this.relayPrefix}AgentName message (or ${this.relayPrefix}* to broadcast)`,
314
+ `RECEIVE: Messages appear as "Relay message from X [id]: content"`,
315
+ `SUMMARY: Periodically output [[SUMMARY]]{"currentTask":"...","context":"..."}[[/SUMMARY]] to track progress`,
316
+ `END: Output [[SESSION_END]]{"summary":"..."}[[/SESSION_END]] when your task is complete`,
233
317
  ].join(' | ');
234
318
  try {
235
319
  await this.sendKeysLiteral(instructions);
@@ -311,7 +395,7 @@ export class TmuxWrapper {
311
395
  process.on('SIGTERM', cleanup);
312
396
  }
313
397
  /**
314
- * Start silent polling for @relay commands
398
+ * Start silent polling for ->relay commands
315
399
  * Does NOT write to stdout - just parses and sends to daemon
316
400
  */
317
401
  startSilentPolling() {
@@ -322,7 +406,7 @@ export class TmuxWrapper {
322
406
  }, this.config.pollInterval);
323
407
  }
324
408
  /**
325
- * Poll for @relay commands in output (silent)
409
+ * Poll for ->relay commands in output (silent)
326
410
  */
327
411
  async pollForRelayCommands() {
328
412
  if (!this.running)
@@ -330,9 +414,9 @@ export class TmuxWrapper {
330
414
  try {
331
415
  // Capture scrollback
332
416
  const { stdout } = await execAsync(
333
- // -J joins wrapped lines to avoid truncating @relay commands mid-line
417
+ // -J joins wrapped lines to avoid truncating ->relay commands mid-line
334
418
  `tmux capture-pane -t ${this.sessionName} -p -J -S - 2>/dev/null`);
335
- // Always parse the FULL capture for @relay commands
419
+ // Always parse the FULL capture for ->relay commands
336
420
  // This handles terminal UIs that rewrite content in place
337
421
  const cleanContent = this.stripAnsi(stdout);
338
422
  // Join continuation lines that TUIs split across multiple lines
@@ -341,13 +425,18 @@ export class TmuxWrapper {
341
425
  // Track last output time for injection timing
342
426
  if (stdout.length !== this.processedOutputLength) {
343
427
  this.lastOutputTime = Date.now();
428
+ this.markActivity();
344
429
  this.processedOutputLength = stdout.length;
345
430
  }
346
431
  // Send any commands found (deduplication handles repeats)
347
432
  for (const cmd of commands) {
348
- this.logStderr(`Found relay command: to=${cmd.to} body=${cmd.body.substring(0, 50)}...`);
349
433
  this.sendRelayCommand(cmd);
350
434
  }
435
+ // Check for [[SUMMARY]] blocks and save to storage
436
+ this.parseSummaryAndSave(cleanContent);
437
+ // Check for [[SESSION_END]] blocks to explicitly close session
438
+ this.parseSessionEndAndClose(cleanContent);
439
+ this.updateActivityState();
351
440
  // Also check for injection opportunity
352
441
  this.checkForInjectionOpportunity();
353
442
  }
@@ -365,24 +454,25 @@ export class TmuxWrapper {
365
454
  return str.replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, '');
366
455
  }
367
456
  /**
368
- * Join continuation lines after @relay commands.
457
+ * Join continuation lines after ->relay commands.
369
458
  * Claude Code and other TUIs insert real newlines in output, causing
370
- * @relay messages to span multiple lines. This joins indented
371
- * continuation lines back to the @relay line.
459
+ * ->relay messages to span multiple lines. This joins indented
460
+ * continuation lines back to the ->relay line.
372
461
  */
373
462
  joinContinuationLines(content) {
374
463
  const lines = content.split('\n');
375
464
  const result = [];
376
- // Pattern to detect @relay command line (with optional bullet prefix)
377
- const relayPattern = /^(?:\s*(?:[>$%#→➜›»●•◦‣⁃\-*⏺◆◇○□■]\s*)*)?@relay:/;
465
+ // Pattern to detect relay command line (with optional bullet prefix)
466
+ const escapedPrefix = escapeRegex(this.relayPrefix);
467
+ const relayPattern = new RegExp(`^(?:\\s*(?:[>$%#→➜›»●•◦‣⁃\\-*⏺◆◇○□■]\\s*)*)?${escapedPrefix}`);
378
468
  // Pattern to detect a continuation line (starts with spaces, no bullet/command)
379
- const continuationPattern = /^[ \t]+[^>$%#→➜›»●•◦‣⁃\-*⏺◆◇○□■@\s]/;
469
+ const continuationPattern = /^[ \t]+[^>$%#→➜›»●•◦‣⁃\-*⏺◆◇○□■\s]/;
380
470
  // Pattern to detect a new block/bullet (stops continuation)
381
471
  const newBlockPattern = /^(?:\s*)?[>$%#→➜›»●•◦‣⁃\-*⏺◆◇○□■]/;
382
472
  let i = 0;
383
473
  while (i < lines.length) {
384
474
  const line = lines[i];
385
- // Check if this is a @relay line
475
+ // Check if this is a ->relay line
386
476
  if (relayPattern.test(line)) {
387
477
  let joined = line;
388
478
  let j = i + 1;
@@ -416,44 +506,190 @@ export class TmuxWrapper {
416
506
  return result.join('\n');
417
507
  }
418
508
  /**
419
- * Escape string for ANSI-C quoting ($'...')
420
- * This handles special characters more reliably than mixing quote styles
509
+ * Record recent activity and transition back to active if needed.
421
510
  */
422
- escapeForAnsiC(str) {
423
- return str
424
- .replace(/\\/g, '\\\\') // Backslash
425
- .replace(/'/g, "\\'") // Single quote
426
- .replace(/\n/g, '\\n') // Newline
427
- .replace(/\r/g, '\\r') // Carriage return
428
- .replace(/\t/g, '\\t'); // Tab
511
+ markActivity() {
512
+ this.lastActivityTime = Date.now();
513
+ if (this.activityState === 'idle') {
514
+ this.activityState = 'active';
515
+ this.logStderr('Session active');
516
+ }
517
+ }
518
+ /**
519
+ * Update activity state based on idle threshold and trigger injections when idle.
520
+ */
521
+ updateActivityState() {
522
+ if (this.activityState === 'disconnected')
523
+ return;
524
+ const now = Date.now();
525
+ const idleThreshold = this.config.activityIdleThresholdMs ?? 30000;
526
+ const timeSinceActivity = now - this.lastActivityTime;
527
+ if (timeSinceActivity > idleThreshold && this.activityState === 'active') {
528
+ this.activityState = 'idle';
529
+ this.logStderr('Session went idle');
530
+ this.checkForInjectionOpportunity();
531
+ }
532
+ else if (timeSinceActivity <= idleThreshold && this.activityState === 'idle') {
533
+ this.activityState = 'active';
534
+ this.logStderr('Session active');
535
+ }
429
536
  }
430
537
  /**
431
538
  * Send relay command to daemon
432
539
  */
433
540
  sendRelayCommand(cmd) {
434
541
  const msgHash = `${cmd.to}:${cmd.body}`;
435
- // Permanent dedup - never send the same message twice
542
+ // Permanent dedup - never send the same message twice (silent)
436
543
  if (this.sentMessageHashes.has(msgHash)) {
437
- this.logStderr(`Skipping duplicate: ${cmd.to}`);
438
544
  return;
439
545
  }
440
- this.logStderr(`Attempting to send to ${cmd.to}, client state: ${this.client.state}`);
441
- const success = this.client.sendMessage(cmd.to, cmd.body, cmd.kind, cmd.data);
546
+ // If client not ready, queue for later and return
547
+ if (this.client.state !== 'READY') {
548
+ if (this.queuedMessageHashes.has(msgHash)) {
549
+ return; // Already queued
550
+ }
551
+ if (this.pendingRelayCommands.length >= this.MAX_PENDING_RELAY_COMMANDS) {
552
+ this.logStderr('Relay offline queue full, dropping oldest');
553
+ const dropped = this.pendingRelayCommands.shift();
554
+ if (dropped) {
555
+ this.queuedMessageHashes.delete(`${dropped.to}:${dropped.body}`);
556
+ }
557
+ }
558
+ this.pendingRelayCommands.push(cmd);
559
+ this.queuedMessageHashes.add(msgHash);
560
+ this.logStderr(`Relay offline; queued message to ${cmd.to}`);
561
+ return;
562
+ }
563
+ // Convert ParsedMessageMetadata to SendMeta if present
564
+ let sendMeta;
565
+ if (cmd.meta) {
566
+ sendMeta = {
567
+ importance: cmd.meta.importance,
568
+ replyTo: cmd.meta.replyTo,
569
+ requires_ack: cmd.meta.ackRequired,
570
+ };
571
+ }
572
+ const success = this.client.sendMessage(cmd.to, cmd.body, cmd.kind, cmd.data, cmd.thread, sendMeta);
442
573
  if (success) {
443
574
  this.sentMessageHashes.add(msgHash);
444
- this.logStderr(`→ ${cmd.to}: ${cmd.body.substring(0, 40)}...`);
575
+ this.queuedMessageHashes.delete(msgHash);
576
+ const truncatedBody = cmd.body.substring(0, Math.min(RELAY_LOG_TRUNCATE_LENGTH, cmd.body.length));
577
+ this.logStderr(`→ ${cmd.to}: ${truncatedBody}...`);
445
578
  }
446
- else {
447
- this.logStderr(`Failed to send to ${cmd.to} (client state: ${this.client.state})`);
579
+ else if (this.client.state !== 'READY') {
580
+ // Only log failure once per state change
581
+ this.logStderr(`Send failed (client ${this.client.state})`);
582
+ }
583
+ }
584
+ /**
585
+ * Flush any queued relay commands when the client reconnects.
586
+ */
587
+ flushQueuedRelayCommands() {
588
+ if (this.pendingRelayCommands.length === 0)
589
+ return;
590
+ const queued = [...this.pendingRelayCommands];
591
+ this.pendingRelayCommands = [];
592
+ this.queuedMessageHashes.clear();
593
+ for (const cmd of queued) {
594
+ this.sendRelayCommand(cmd);
448
595
  }
449
596
  }
597
+ /**
598
+ * Parse [[SUMMARY]] blocks from output and save to storage.
599
+ * Agents can output summaries to maintain running context:
600
+ *
601
+ * [[SUMMARY]]
602
+ * {"currentTask": "Implementing auth", "context": "Completed login flow"}
603
+ * [[/SUMMARY]]
604
+ */
605
+ parseSummaryAndSave(content) {
606
+ const result = parseSummaryWithDetails(content);
607
+ // No SUMMARY block found
608
+ if (!result.found)
609
+ return;
610
+ // Dedup based on raw content - prevents repeated error logging for same invalid JSON
611
+ if (result.rawContent === this.lastSummaryRawContent)
612
+ return;
613
+ this.lastSummaryRawContent = result.rawContent || '';
614
+ // Invalid JSON - log error once (deduped above)
615
+ if (!result.valid) {
616
+ this.logStderr('[parser] Invalid JSON in SUMMARY block');
617
+ return;
618
+ }
619
+ const summary = result.summary;
620
+ // Dedup valid summaries - don't save same summary twice
621
+ const summaryHash = JSON.stringify(summary);
622
+ if (summaryHash === this.lastSummaryHash)
623
+ return;
624
+ this.lastSummaryHash = summaryHash;
625
+ // Wait for storage to be ready before saving
626
+ this.storageReady.then(ready => {
627
+ if (!ready || !this.storage) {
628
+ this.logStderr('Cannot save summary: storage not initialized');
629
+ return;
630
+ }
631
+ const projectPaths = getProjectPaths();
632
+ this.storage.saveAgentSummary({
633
+ agentName: this.config.name,
634
+ projectId: projectPaths.projectId,
635
+ currentTask: summary.currentTask,
636
+ completedTasks: summary.completedTasks,
637
+ decisions: summary.decisions,
638
+ context: summary.context,
639
+ files: summary.files,
640
+ }).then(() => {
641
+ this.logStderr(`Saved agent summary: ${summary.currentTask || 'updated context'}`);
642
+ }).catch(err => {
643
+ this.logStderr(`Failed to save summary: ${err.message}`, true);
644
+ });
645
+ });
646
+ }
647
+ /**
648
+ * Parse [[SESSION_END]] blocks from output and close session explicitly.
649
+ * Agents output this to mark their work session as complete:
650
+ *
651
+ * [[SESSION_END]]
652
+ * {"summary": "Completed auth module", "completedTasks": ["login", "logout"]}
653
+ * [[/SESSION_END]]
654
+ */
655
+ parseSessionEndAndClose(content) {
656
+ if (this.sessionEndProcessed)
657
+ return; // Only process once per session
658
+ const sessionEnd = parseSessionEndFromOutput(content);
659
+ if (!sessionEnd)
660
+ return;
661
+ // Get session ID from client connection - if not available yet, don't set flag
662
+ // so we can retry when sessionId becomes available
663
+ const sessionId = this.client.currentSessionId;
664
+ if (!sessionId) {
665
+ this.logStderr('Cannot close session: no session ID yet, will retry');
666
+ return;
667
+ }
668
+ this.sessionEndProcessed = true;
669
+ // Wait for storage to be ready before attempting to close session
670
+ this.storageReady.then(ready => {
671
+ if (!ready || !this.storage) {
672
+ this.logStderr('Cannot close session: storage not initialized');
673
+ return;
674
+ }
675
+ this.storage.endSession(sessionId, {
676
+ summary: sessionEnd.summary,
677
+ closedBy: 'agent',
678
+ }).then(() => {
679
+ this.logStderr(`Session closed by agent: ${sessionEnd.summary || 'complete'}`);
680
+ }).catch(err => {
681
+ this.logStderr(`Failed to close session: ${err.message}`, true);
682
+ });
683
+ });
684
+ }
450
685
  /**
451
686
  * Handle incoming message from relay
452
687
  */
453
- handleIncomingMessage(from, payload, messageId) {
454
- this.logStderr(`← ${from}: ${payload.body.substring(0, 40)}...`);
688
+ handleIncomingMessage(from, payload, messageId, meta) {
689
+ const truncatedBody = payload.body.substring(0, Math.min(DEBUG_LOG_TRUNCATE_LENGTH, payload.body.length));
690
+ this.logStderr(`← ${from}: ${truncatedBody}...`);
455
691
  // Queue for injection
456
- this.messageQueue.push({ from, body: payload.body, messageId });
692
+ this.messageQueue.push({ from, body: payload.body, messageId, thread: payload.thread, importance: meta?.importance });
457
693
  // Write to inbox if enabled
458
694
  if (this.inbox) {
459
695
  this.inbox.addMessage(from, payload.body);
@@ -491,56 +727,60 @@ export class TmuxWrapper {
491
727
  this.logStderr(`Injecting message from ${msg.from} (cli: ${this.cliType})`);
492
728
  try {
493
729
  let sanitizedBody = msg.body.replace(/[\r\n]+/g, ' ').trim();
730
+ // Gemini interprets certain keywords (While, For, If, etc.) as shell commands
731
+ // Wrap in backticks to prevent shell keyword interpretation
732
+ if (this.cliType === 'gemini') {
733
+ sanitizedBody = `\`${sanitizedBody.replace(/`/g, "'")}\``;
734
+ }
494
735
  // Short message ID for display (first 8 chars)
495
736
  const shortId = msg.messageId.substring(0, 8);
496
- // Truncate very long messages to avoid display issues
497
- const maxLen = 2000;
498
- let wasTruncated = false;
499
- if (sanitizedBody.length > maxLen) {
500
- sanitizedBody = sanitizedBody.substring(0, maxLen) + '...';
501
- wasTruncated = true;
502
- }
737
+ // Remove message truncation to allow full messages to pass through
738
+ const wasTruncated = false;
503
739
  // Always include message ID; add lookup hint if truncated
504
740
  const idTag = `[${shortId}]`;
505
741
  const truncationHint = wasTruncated
506
742
  ? ` [TRUNCATED - run "agent-relay read ${msg.messageId}"]`
507
743
  : '';
508
- // Gemini CLI interprets input as shell commands, so we need special handling
509
- if (this.cliType === 'gemini') {
510
- // For Gemini: Use printf with %s to safely handle any characters
511
- // printf '%s\n' 'message' - the %s treats the argument as literal string
512
- // We use $'...' ANSI-C quoting which handles escapes more predictably
513
- const safeBody = this.escapeForAnsiC(sanitizedBody);
514
- const safeFrom = this.escapeForAnsiC(msg.from);
515
- const safeHint = this.escapeForAnsiC(truncationHint);
516
- const printfMsg = `printf '%s\\n' $'Relay message from ${safeFrom} ${idTag}: ${safeBody}${safeHint}'`;
517
- // Clear any partial input
744
+ // Wait for input to be clear before injecting
745
+ const waitTimeoutMs = this.config.inputWaitTimeoutMs ?? 5000;
746
+ const waitPollMs = this.config.inputWaitPollMs ?? 200;
747
+ const inputClear = await this.waitForClearInput(waitTimeoutMs, waitPollMs);
748
+ if (!inputClear) {
749
+ // Input still has text after timeout - clear it forcefully
750
+ this.logStderr('Input not clear after waiting, clearing forcefully');
518
751
  await this.sendKeys('Escape');
519
752
  await this.sleep(30);
520
753
  await this.sendKeys('C-u');
521
754
  await this.sleep(30);
522
- // Send printf command to display the message
523
- await this.sendKeysLiteral(printfMsg);
524
- await this.sleep(50);
525
- await this.sendKeys('Enter');
526
- this.logStderr(`Injection complete (gemini printf mode)`);
527
755
  }
528
- else {
529
- // Standard injection for Claude, Codex, etc.
530
- // Format: Relay message from Sender [abc12345]: content
531
- const injection = `Relay message from ${msg.from} ${idTag}: ${sanitizedBody}${truncationHint}`;
532
- // Clear any partial input
533
- await this.sendKeys('Escape');
534
- await this.sleep(30);
535
- await this.sendKeys('C-u');
536
- await this.sleep(30);
537
- // Type the message
538
- await this.sendKeysLiteral(injection);
539
- await this.sleep(50);
540
- // Submit
541
- await this.sendKeys('Enter');
542
- this.logStderr(`Injection complete`);
756
+ // For Gemini: check if we're at a shell prompt ($) vs chat prompt (>)
757
+ // If at shell prompt, skip injection to avoid shell command execution
758
+ if (this.cliType === 'gemini') {
759
+ const lastLine = await this.getLastLine();
760
+ const cleanLine = this.stripAnsi(lastLine).trim();
761
+ if (/^\$\s*$/.test(cleanLine) || /^\s*\$\s*$/.test(cleanLine)) {
762
+ this.logStderr('Gemini at shell prompt, skipping injection to avoid shell execution');
763
+ // Re-queue the message for later
764
+ this.messageQueue.unshift(msg);
765
+ this.isInjecting = false;
766
+ setTimeout(() => this.checkForInjectionOpportunity(), 2000);
767
+ return;
768
+ }
543
769
  }
770
+ // Standard injection for all CLIs including Gemini
771
+ // Format: Relay message from Sender [abc12345] [thread:xxx] [!]: content
772
+ // Thread/importance hints are compact and optional to not break TUIs
773
+ const threadHint = msg.thread ? ` [thread:${msg.thread}]` : '';
774
+ // Importance indicator: [!!] for high (>75), [!] for medium (>50), none for low/default
775
+ const importanceHint = msg.importance !== undefined && msg.importance > 75 ? ' [!!]' :
776
+ msg.importance !== undefined && msg.importance > 50 ? ' [!]' : '';
777
+ const injection = `Relay message from ${msg.from} ${idTag}${threadHint}${importanceHint}: ${sanitizedBody}${truncationHint}`;
778
+ // Type the message as literal text
779
+ await this.sendKeysLiteral(injection);
780
+ await this.sleep(50);
781
+ // Submit
782
+ await this.sendKeys('Enter');
783
+ this.logStderr(`Injection complete`);
544
784
  }
545
785
  catch (err) {
546
786
  this.logStderr(`Injection failed: ${err.message}`, true);
@@ -576,6 +816,121 @@ export class TmuxWrapper {
576
816
  sleep(ms) {
577
817
  return new Promise(r => setTimeout(r, ms));
578
818
  }
819
+ /**
820
+ * Reset session-specific state for wrapper reuse.
821
+ * Call this when starting a new session with the same wrapper instance.
822
+ */
823
+ resetSessionState() {
824
+ this.sessionEndProcessed = false;
825
+ this.lastSummaryHash = '';
826
+ this.lastSummaryRawContent = '';
827
+ }
828
+ /**
829
+ * Get the prompt pattern for the current CLI type.
830
+ */
831
+ getPromptPattern() {
832
+ const promptPatterns = {
833
+ claude: /^[>›»]\s*$/, // Claude: "> " or similar
834
+ gemini: /^[>›»]\s*$/, // Gemini: "> "
835
+ codex: /^[>›»]\s*$/, // Codex: "> "
836
+ other: /^[>$%#➜›»]\s*$/, // Shell or other: "$ ", "> ", etc.
837
+ };
838
+ return promptPatterns[this.cliType] || promptPatterns.other;
839
+ }
840
+ /**
841
+ * Capture the last non-empty line from the tmux pane.
842
+ */
843
+ async getLastLine() {
844
+ try {
845
+ const { stdout } = await execAsync(`tmux capture-pane -t ${this.sessionName} -p -J 2>/dev/null`);
846
+ const lines = stdout.split('\n').filter(l => l.length > 0);
847
+ return lines[lines.length - 1] || '';
848
+ }
849
+ catch {
850
+ return '';
851
+ }
852
+ }
853
+ /**
854
+ * Detect if the provided line contains visible user input (beyond the prompt).
855
+ */
856
+ hasVisibleInput(line) {
857
+ const cleanLine = this.stripAnsi(line).trimEnd();
858
+ if (cleanLine === '')
859
+ return false;
860
+ return !this.getPromptPattern().test(cleanLine);
861
+ }
862
+ /**
863
+ * Check if the input line is clear (no user-typed text after the prompt).
864
+ * Returns true if the last visible line appears to be just a prompt.
865
+ */
866
+ async isInputClear(lastLine) {
867
+ try {
868
+ const lineToCheck = lastLine ?? await this.getLastLine();
869
+ const cleanLine = this.stripAnsi(lineToCheck).trimEnd();
870
+ const isClear = this.getPromptPattern().test(cleanLine);
871
+ if (this.config.debug) {
872
+ const truncatedLine = cleanLine.substring(0, Math.min(DEBUG_LOG_TRUNCATE_LENGTH, cleanLine.length));
873
+ this.logStderr(`isInputClear: lastLine="${truncatedLine}", clear=${isClear}`);
874
+ }
875
+ return isClear;
876
+ }
877
+ catch {
878
+ // If we can't capture, assume not clear (safer)
879
+ return false;
880
+ }
881
+ }
882
+ /**
883
+ * Get cursor X position to detect input length.
884
+ * Returns the cursor column (0-indexed).
885
+ */
886
+ async getCursorX() {
887
+ try {
888
+ const { stdout } = await execAsync(`tmux display-message -t ${this.sessionName} -p "#{cursor_x}" 2>/dev/null`);
889
+ return parseInt(stdout.trim(), 10) || 0;
890
+ }
891
+ catch {
892
+ return 0;
893
+ }
894
+ }
895
+ /**
896
+ * Wait for the input line to be clear before injecting.
897
+ * Polls until the input appears empty or timeout is reached.
898
+ *
899
+ * @param maxWaitMs Maximum time to wait (default 5000ms)
900
+ * @param pollIntervalMs How often to check (default 200ms)
901
+ * @returns true if input became clear, false if timed out
902
+ */
903
+ async waitForClearInput(maxWaitMs = 5000, pollIntervalMs = 200) {
904
+ const startTime = Date.now();
905
+ let lastCursorX = -1;
906
+ let stableCursorCount = 0;
907
+ while (Date.now() - startTime < maxWaitMs) {
908
+ const lastLine = await this.getLastLine();
909
+ // Check if input line is just a prompt
910
+ if (await this.isInputClear(lastLine)) {
911
+ return true;
912
+ }
913
+ const hasInput = this.hasVisibleInput(lastLine);
914
+ // Also check cursor stability - if cursor is moving, agent is typing
915
+ const cursorX = await this.getCursorX();
916
+ if (!hasInput && cursorX === lastCursorX) {
917
+ stableCursorCount++;
918
+ // If cursor has been stable for enough polls and at typical prompt position,
919
+ // the agent might be done but we just can't match the prompt pattern
920
+ if (stableCursorCount >= STABLE_CURSOR_THRESHOLD && cursorX <= MAX_PROMPT_CURSOR_POSITION) {
921
+ this.logStderr(`waitForClearInput: cursor stable at x=${cursorX}, assuming clear`);
922
+ return true;
923
+ }
924
+ }
925
+ else {
926
+ stableCursorCount = 0;
927
+ lastCursorX = cursorX;
928
+ }
929
+ await this.sleep(pollIntervalMs);
930
+ }
931
+ this.logStderr(`waitForClearInput: timed out after ${maxWaitMs}ms`);
932
+ return false;
933
+ }
579
934
  /**
580
935
  * Stop and cleanup
581
936
  */
@@ -583,6 +938,9 @@ export class TmuxWrapper {
583
938
  if (!this.running)
584
939
  return;
585
940
  this.running = false;
941
+ this.activityState = 'disconnected';
942
+ // Reset session state for potential reuse
943
+ this.resetSessionState();
586
944
  // Stop polling
587
945
  if (this.pollTimer) {
588
946
  clearInterval(this.pollTimer);