agent-relay 1.0.7 → 1.0.9

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 (140) hide show
  1. package/README.md +176 -6
  2. package/dist/bridge/config.d.ts +41 -0
  3. package/dist/bridge/config.d.ts.map +1 -0
  4. package/dist/bridge/config.js +143 -0
  5. package/dist/bridge/config.js.map +1 -0
  6. package/dist/bridge/index.d.ts +10 -0
  7. package/dist/bridge/index.d.ts.map +1 -0
  8. package/dist/bridge/index.js +10 -0
  9. package/dist/bridge/index.js.map +1 -0
  10. package/dist/bridge/multi-project-client.d.ts +99 -0
  11. package/dist/bridge/multi-project-client.d.ts.map +1 -0
  12. package/dist/bridge/multi-project-client.js +386 -0
  13. package/dist/bridge/multi-project-client.js.map +1 -0
  14. package/dist/bridge/spawner.d.ts +46 -0
  15. package/dist/bridge/spawner.d.ts.map +1 -0
  16. package/dist/bridge/spawner.js +223 -0
  17. package/dist/bridge/spawner.js.map +1 -0
  18. package/dist/bridge/types.d.ts +55 -0
  19. package/dist/bridge/types.d.ts.map +1 -0
  20. package/dist/bridge/types.js +6 -0
  21. package/dist/bridge/types.js.map +1 -0
  22. package/dist/bridge/utils.d.ts +30 -0
  23. package/dist/bridge/utils.d.ts.map +1 -0
  24. package/dist/bridge/utils.js +54 -0
  25. package/dist/bridge/utils.js.map +1 -0
  26. package/dist/cli/index.d.ts +2 -0
  27. package/dist/cli/index.d.ts.map +1 -1
  28. package/dist/cli/index.js +906 -6
  29. package/dist/cli/index.js.map +1 -1
  30. package/dist/daemon/agent-registry.d.ts +60 -0
  31. package/dist/daemon/agent-registry.d.ts.map +1 -0
  32. package/dist/daemon/agent-registry.js +163 -0
  33. package/dist/daemon/agent-registry.js.map +1 -0
  34. package/dist/daemon/connection.d.ts +33 -1
  35. package/dist/daemon/connection.d.ts.map +1 -1
  36. package/dist/daemon/connection.js +86 -11
  37. package/dist/daemon/connection.js.map +1 -1
  38. package/dist/daemon/index.d.ts +2 -0
  39. package/dist/daemon/index.d.ts.map +1 -1
  40. package/dist/daemon/index.js +2 -0
  41. package/dist/daemon/index.js.map +1 -1
  42. package/dist/daemon/registry.d.ts +9 -0
  43. package/dist/daemon/registry.d.ts.map +1 -0
  44. package/dist/daemon/registry.js +9 -0
  45. package/dist/daemon/registry.js.map +1 -0
  46. package/dist/daemon/router.d.ts +61 -2
  47. package/dist/daemon/router.d.ts.map +1 -1
  48. package/dist/daemon/router.js +219 -4
  49. package/dist/daemon/router.js.map +1 -1
  50. package/dist/daemon/server.d.ts +9 -0
  51. package/dist/daemon/server.d.ts.map +1 -1
  52. package/dist/daemon/server.js +135 -16
  53. package/dist/daemon/server.js.map +1 -1
  54. package/dist/dashboard/metrics.d.ts +105 -0
  55. package/dist/dashboard/metrics.d.ts.map +1 -0
  56. package/dist/dashboard/metrics.js +192 -0
  57. package/dist/dashboard/metrics.js.map +1 -0
  58. package/dist/dashboard/needs-attention.d.ts +24 -0
  59. package/dist/dashboard/needs-attention.d.ts.map +1 -0
  60. package/dist/dashboard/needs-attention.js +78 -0
  61. package/dist/dashboard/needs-attention.js.map +1 -0
  62. package/dist/dashboard/public/bridge.html +1272 -0
  63. package/dist/dashboard/public/index.html +2094 -347
  64. package/dist/dashboard/public/js/app.js +184 -0
  65. package/dist/dashboard/public/js/app.js.map +7 -0
  66. package/dist/dashboard/public/metrics.html +999 -0
  67. package/dist/dashboard/server.d.ts +14 -1
  68. package/dist/dashboard/server.d.ts.map +1 -1
  69. package/dist/dashboard/server.js +689 -16
  70. package/dist/dashboard/server.js.map +1 -1
  71. package/dist/dashboard/start.js +1 -1
  72. package/dist/dashboard/start.js.map +1 -1
  73. package/dist/dashboard-v2/index.d.ts +10 -0
  74. package/dist/dashboard-v2/index.d.ts.map +1 -0
  75. package/dist/dashboard-v2/index.js +54 -0
  76. package/dist/dashboard-v2/index.js.map +1 -0
  77. package/dist/dashboard-v2/lib/api.d.ts +95 -0
  78. package/dist/dashboard-v2/lib/api.d.ts.map +1 -0
  79. package/dist/dashboard-v2/lib/api.js +270 -0
  80. package/dist/dashboard-v2/lib/api.js.map +1 -0
  81. package/dist/dashboard-v2/lib/colors.d.ts +61 -0
  82. package/dist/dashboard-v2/lib/colors.d.ts.map +1 -0
  83. package/dist/dashboard-v2/lib/colors.js +198 -0
  84. package/dist/dashboard-v2/lib/colors.js.map +1 -0
  85. package/dist/dashboard-v2/lib/hierarchy.d.ts +74 -0
  86. package/dist/dashboard-v2/lib/hierarchy.d.ts.map +1 -0
  87. package/dist/dashboard-v2/lib/hierarchy.js +196 -0
  88. package/dist/dashboard-v2/lib/hierarchy.js.map +1 -0
  89. package/dist/dashboard-v2/types/index.d.ts +154 -0
  90. package/dist/dashboard-v2/types/index.d.ts.map +1 -0
  91. package/dist/dashboard-v2/types/index.js +6 -0
  92. package/dist/dashboard-v2/types/index.js.map +1 -0
  93. package/dist/index.d.ts +1 -0
  94. package/dist/index.d.ts.map +1 -1
  95. package/dist/protocol/types.d.ts +15 -1
  96. package/dist/protocol/types.d.ts.map +1 -1
  97. package/dist/storage/adapter.d.ts +74 -1
  98. package/dist/storage/adapter.d.ts.map +1 -1
  99. package/dist/storage/adapter.js +39 -0
  100. package/dist/storage/adapter.js.map +1 -1
  101. package/dist/storage/sqlite-adapter.d.ts +92 -1
  102. package/dist/storage/sqlite-adapter.d.ts.map +1 -1
  103. package/dist/storage/sqlite-adapter.js +615 -47
  104. package/dist/storage/sqlite-adapter.js.map +1 -1
  105. package/dist/utils/agent-config.d.ts +45 -0
  106. package/dist/utils/agent-config.d.ts.map +1 -0
  107. package/dist/utils/agent-config.js +118 -0
  108. package/dist/utils/agent-config.js.map +1 -0
  109. package/dist/utils/project-namespace.d.ts.map +1 -1
  110. package/dist/utils/project-namespace.js +22 -1
  111. package/dist/utils/project-namespace.js.map +1 -1
  112. package/dist/wrapper/client.d.ts +30 -3
  113. package/dist/wrapper/client.d.ts.map +1 -1
  114. package/dist/wrapper/client.js +85 -9
  115. package/dist/wrapper/client.js.map +1 -1
  116. package/dist/wrapper/parser.d.ts +127 -4
  117. package/dist/wrapper/parser.d.ts.map +1 -1
  118. package/dist/wrapper/parser.js +622 -86
  119. package/dist/wrapper/parser.js.map +1 -1
  120. package/dist/wrapper/tmux-wrapper.d.ts +136 -10
  121. package/dist/wrapper/tmux-wrapper.d.ts.map +1 -1
  122. package/dist/wrapper/tmux-wrapper.js +599 -79
  123. package/dist/wrapper/tmux-wrapper.js.map +1 -1
  124. package/docs/AGENTS.md +132 -27
  125. package/docs/ARCHITECTURE_DECISIONS.md +175 -0
  126. package/docs/CHANGELOG.md +1 -1
  127. package/docs/COMPETITIVE_ANALYSIS.md +897 -0
  128. package/docs/DESIGN_BRIDGE_STAFFING.md +878 -0
  129. package/docs/DESIGN_V2.md +1079 -0
  130. package/docs/INTEGRATION-GUIDE.md +926 -0
  131. package/docs/MONETIZATION.md +1679 -0
  132. package/docs/PROPOSAL-trajectories.md +1582 -0
  133. package/docs/PROTOCOL.md +3 -3
  134. package/docs/SCALING_ANALYSIS.md +280 -0
  135. package/docs/TMUX_IMPLEMENTATION_NOTES.md +9 -9
  136. package/docs/TMUX_IMPROVEMENTS.md +968 -0
  137. package/docs/agent-relay-snippet.md +61 -0
  138. package/docs/competitive-analysis-mcp-agent-mail.md +389 -0
  139. package/docs/dashboard-v2-plan.md +179 -0
  140. package/package.json +10 -3
@@ -5,29 +5,54 @@
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.
12
12
  * We just do background parsing and injection.
13
13
  */
14
14
  import { exec, execSync, spawn } from 'node:child_process';
15
+ import crypto from 'node:crypto';
15
16
  import { promisify } from 'node:util';
16
17
  import { RelayClient } from './client.js';
17
- import { OutputParser } from './parser.js';
18
+ import { OutputParser, parseSummaryWithDetails, parseSessionEndFromOutput } from './parser.js';
18
19
  import { InboxManager } from './inbox.js';
20
+ import { SqliteStorageAdapter } from '../storage/sqlite-adapter.js';
21
+ import { getProjectPaths } from '../utils/project-namespace.js';
19
22
  const execAsync = promisify(exec);
23
+ const escapeRegex = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
24
+ // Constants for cursor stability detection in waitForClearInput
25
+ /** Number of consecutive polls with stable cursor before assuming input is clear */
26
+ const STABLE_CURSOR_THRESHOLD = 3;
27
+ /** Maximum cursor X position that indicates a prompt (typical prompts are 1-4 chars) */
28
+ const MAX_PROMPT_CURSOR_POSITION = 4;
29
+ /** Maximum characters to show in debug log truncation */
30
+ const DEBUG_LOG_TRUNCATE_LENGTH = 40;
31
+ /** Maximum characters to show in relay command log truncation */
32
+ const RELAY_LOG_TRUNCATE_LENGTH = 50;
33
+ /**
34
+ * Get the default relay prefix for a given CLI type.
35
+ * All agents now use '->relay:' as the unified prefix.
36
+ */
37
+ export function getDefaultPrefix(cliType) {
38
+ // Unified prefix for all agent types
39
+ return '->relay:';
40
+ }
20
41
  export class TmuxWrapper {
21
42
  config;
22
43
  sessionName;
23
44
  client;
24
45
  parser;
25
46
  inbox;
47
+ storage;
48
+ storageReady; // Resolves true if storage initialized, false if failed
26
49
  running = false;
27
50
  pollTimer;
28
51
  attachProcess;
29
52
  lastCapturedOutput = '';
30
53
  lastOutputTime = 0;
54
+ lastActivityTime = Date.now();
55
+ activityState = 'disconnected';
31
56
  recentlySentMessages = new Map();
32
57
  sentMessageHashes = new Set(); // Permanent dedup
33
58
  messageQueue = [];
@@ -36,6 +61,18 @@ export class TmuxWrapper {
36
61
  processedOutputLength = 0;
37
62
  lastDebugLog = 0;
38
63
  cliType;
64
+ relayPrefix;
65
+ lastSummaryHash = ''; // Dedup summary saves
66
+ lastSummaryRawContent = ''; // Dedup invalid JSON error logging
67
+ sessionEndProcessed = false; // Track if we've already processed session end
68
+ pendingRelayCommands = [];
69
+ queuedMessageHashes = new Set(); // For offline queue dedup
70
+ MAX_PENDING_RELAY_COMMANDS = 50;
71
+ processedSpawnCommands = new Set(); // Dedup spawn commands
72
+ processedReleaseCommands = new Set(); // Dedup release commands
73
+ receivedMessageIdSet = new Set();
74
+ receivedMessageIdOrder = [];
75
+ MAX_RECEIVED_MESSAGES = 2000;
39
76
  constructor(config) {
40
77
  this.config = {
41
78
  cols: process.stdout.columns || 120,
@@ -43,9 +80,12 @@ export class TmuxWrapper {
43
80
  pollInterval: 200, // Slightly slower polling since we're not displaying
44
81
  idleBeforeInjectMs: 1500,
45
82
  injectRetryMs: 500,
46
- debug: true,
83
+ debug: false,
47
84
  debugLogIntervalMs: 0,
48
85
  mouseMode: true, // Enable mouse scroll passthrough by default
86
+ activityIdleThresholdMs: 30_000, // Consider idle after 30s with no output
87
+ outputStabilityTimeoutMs: 2000,
88
+ outputStabilityPollMs: 200,
49
89
  ...config,
50
90
  };
51
91
  // Detect CLI type from command for special handling
@@ -62,17 +102,27 @@ export class TmuxWrapper {
62
102
  else if (cmdLower.includes('claude')) {
63
103
  this.cliType = 'claude';
64
104
  }
105
+ else if (cmdLower.includes('droid')) {
106
+ this.cliType = 'droid';
107
+ }
65
108
  else {
66
109
  this.cliType = 'other';
67
110
  }
68
- // Generate unique session name
69
- this.sessionName = `relay-${config.name}-${process.pid}`;
111
+ // Determine relay prefix: explicit config > auto-detect from CLI type
112
+ this.relayPrefix = config.relayPrefix ?? getDefaultPrefix(this.cliType);
113
+ // Session name (one agent per name - starting a duplicate kills the existing one)
114
+ this.sessionName = `relay-${config.name}`;
70
115
  this.client = new RelayClient({
71
116
  agentName: config.name,
72
117
  socketPath: config.socketPath,
73
118
  cli: this.cliType,
119
+ program: this.config.program,
120
+ model: this.config.model,
121
+ task: this.config.task,
122
+ workingDirectory: this.config.cwd ?? process.cwd(),
123
+ quiet: true, // Keep stdout clean; we log to stderr via wrapper
74
124
  });
75
- this.parser = new OutputParser();
125
+ this.parser = new OutputParser({ prefix: this.relayPrefix });
76
126
  // Initialize inbox if using file-based messaging
77
127
  if (config.useInbox) {
78
128
  this.inbox = new InboxManager({
@@ -80,14 +130,33 @@ export class TmuxWrapper {
80
130
  inboxDir: config.inboxDir,
81
131
  });
82
132
  }
133
+ // Initialize storage for session/summary persistence
134
+ const projectPaths = getProjectPaths();
135
+ this.storage = new SqliteStorageAdapter({ dbPath: projectPaths.dbPath });
136
+ // Initialize asynchronously (don't block constructor) - methods await storageReady
137
+ this.storageReady = this.storage.init().then(() => true).catch(err => {
138
+ this.logStderr(`Failed to initialize storage: ${err.message}`, true);
139
+ this.storage = undefined;
140
+ return false;
141
+ });
83
142
  // Handle incoming messages from relay
84
- this.client.onMessage = (from, payload, messageId) => {
85
- this.handleIncomingMessage(from, payload, messageId);
143
+ this.client.onMessage = (from, payload, messageId, meta) => {
144
+ this.handleIncomingMessage(from, payload, messageId, meta);
86
145
  };
87
146
  this.client.onStateChange = (state) => {
88
147
  // Only log to stderr, never stdout (user is in tmux)
89
148
  if (state === 'READY') {
90
- this.logStderr(`Connected to relay daemon`);
149
+ this.logStderr('Connected to relay daemon');
150
+ this.flushQueuedRelayCommands();
151
+ }
152
+ else if (state === 'BACKOFF') {
153
+ this.logStderr('Relay unavailable, will retry (backoff)');
154
+ }
155
+ else if (state === 'DISCONNECTED') {
156
+ this.logStderr('Relay disconnected (offline mode)');
157
+ }
158
+ else if (state === 'CONNECTING') {
159
+ this.logStderr('Connecting to relay daemon...');
91
160
  }
92
161
  };
93
162
  }
@@ -95,7 +164,7 @@ export class TmuxWrapper {
95
164
  * Log to stderr (safe - doesn't interfere with tmux display)
96
165
  */
97
166
  logStderr(msg, force = false) {
98
- if (!force && this.config.debug === false)
167
+ if (!force && !this.config.debug)
99
168
  return;
100
169
  const now = Date.now();
101
170
  if (!force && this.config.debugLogIntervalMs && this.config.debugLogIntervalMs > 0) {
@@ -148,8 +217,9 @@ export class TmuxWrapper {
148
217
  this.inbox.init();
149
218
  }
150
219
  // Connect to relay daemon (in background, don't block)
151
- this.client.connect().catch(() => {
152
- // Silent - relay connection is optional
220
+ this.client.connect().catch((err) => {
221
+ // Connection failures will retry via client backoff; surface once to stderr.
222
+ this.logStderr(`Relay connect failed: ${err.message}. Will retry if enabled.`, true);
153
223
  });
154
224
  // Kill any existing session with this name
155
225
  try {
@@ -161,6 +231,7 @@ export class TmuxWrapper {
161
231
  // Build the command - properly quote args that contain spaces
162
232
  const fullCommand = this.buildCommand();
163
233
  this.logStderr(`Command: ${fullCommand}`);
234
+ this.logStderr(`Prefix: ${this.relayPrefix} (use ${this.relayPrefix}AgentName to send)`);
164
235
  // Create tmux session
165
236
  try {
166
237
  execSync(`tmux new-session -d -s ${this.sessionName} -x ${this.config.cols} -y ${this.config.rows}`, {
@@ -175,6 +246,8 @@ export class TmuxWrapper {
175
246
  'setw -g alternate-screen on', // Ensure alternate screen works
176
247
  // Pass through mouse scroll to application in alternate screen mode
177
248
  'set -ga terminal-overrides ",xterm*:Tc"',
249
+ 'set -g status-left-length 100', // Provide ample space for agent name in status bar
250
+ 'set -g mode-keys vi', // Predictable key table (avoid copy-mode surprises)
178
251
  ];
179
252
  // Add mouse mode if enabled (allows scroll passthrough to CLI apps)
180
253
  if (this.config.mouseMode) {
@@ -189,6 +262,24 @@ export class TmuxWrapper {
189
262
  // Some settings may not be available in older tmux versions
190
263
  }
191
264
  }
265
+ // Mouse scroll should work for both TUIs (alternate screen) and plain shells.
266
+ // If the pane is in alternate screen, pass scroll to the app; otherwise enter copy-mode and scroll tmux history.
267
+ const tmuxMouseBindings = [
268
+ 'unbind -T root WheelUpPane',
269
+ 'unbind -T root WheelDownPane',
270
+ 'unbind -T root MouseDrag1Pane',
271
+ 'bind -T root WheelUpPane if-shell -F "#{alternate_on}" "send-keys -M" "copy-mode -e; send-keys -X scroll-up"',
272
+ 'bind -T root WheelDownPane if-shell -F "#{alternate_on}" "send-keys -M" "send-keys -X scroll-down"',
273
+ 'bind -T root MouseDrag1Pane if-shell -F "#{alternate_on}" "send-keys -M" "copy-mode -e"',
274
+ ];
275
+ for (const setting of tmuxMouseBindings) {
276
+ try {
277
+ execSync(`tmux ${setting}`, { stdio: 'pipe' });
278
+ }
279
+ catch {
280
+ // Ignore on older tmux versions lacking these key tables
281
+ }
282
+ }
192
283
  // Set environment variables
193
284
  for (const [key, value] of Object.entries({
194
285
  ...this.config.env,
@@ -211,6 +302,8 @@ export class TmuxWrapper {
211
302
  // Wait for session to be ready
212
303
  await this.waitForSession();
213
304
  this.running = true;
305
+ this.lastActivityTime = Date.now();
306
+ this.activityState = 'active';
214
307
  // Inject instructions for the agent (after a delay to let CLI initialize)
215
308
  setTimeout(() => this.injectInstructions(), 3000);
216
309
  // Start background polling (silent - no stdout writes)
@@ -225,11 +318,13 @@ export class TmuxWrapper {
225
318
  async injectInstructions() {
226
319
  if (!this.running)
227
320
  return;
321
+ // Use escaped prefix (\->relay:) in examples to prevent parser from treating them as real commands
322
+ const escapedPrefix = '\\' + this.relayPrefix;
228
323
  const instructions = [
229
324
  `[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`,
325
+ `SEND: ${escapedPrefix}AgentName message`,
326
+ `MULTI-LINE: ${escapedPrefix}AgentName <<<(newline)content(newline)>>> - ALWAYS end with >>> on its own line!`,
327
+ `RECEIVE: Messages appear as "Relay message from X [id]: content" - use "agent-relay read <id>" for long messages`,
233
328
  ].join(' | ');
234
329
  try {
235
330
  await this.sendKeysLiteral(instructions);
@@ -311,7 +406,7 @@ export class TmuxWrapper {
311
406
  process.on('SIGTERM', cleanup);
312
407
  }
313
408
  /**
314
- * Start silent polling for @relay commands
409
+ * Start silent polling for ->relay commands
315
410
  * Does NOT write to stdout - just parses and sends to daemon
316
411
  */
317
412
  startSilentPolling() {
@@ -322,7 +417,7 @@ export class TmuxWrapper {
322
417
  }, this.config.pollInterval);
323
418
  }
324
419
  /**
325
- * Poll for @relay commands in output (silent)
420
+ * Poll for ->relay commands in output (silent)
326
421
  */
327
422
  async pollForRelayCommands() {
328
423
  if (!this.running)
@@ -330,23 +425,39 @@ export class TmuxWrapper {
330
425
  try {
331
426
  // Capture scrollback
332
427
  const { stdout } = await execAsync(
333
- // -J joins wrapped lines to avoid truncating @relay commands mid-line
428
+ // -J joins wrapped lines to avoid truncating ->relay commands mid-line
334
429
  `tmux capture-pane -t ${this.sessionName} -p -J -S - 2>/dev/null`);
335
- // Always parse the FULL capture for @relay commands
430
+ // Always parse the FULL capture for ->relay commands
336
431
  // This handles terminal UIs that rewrite content in place
337
432
  const cleanContent = this.stripAnsi(stdout);
338
433
  // Join continuation lines that TUIs split across multiple lines
339
434
  const joinedContent = this.joinContinuationLines(cleanContent);
340
435
  const { commands } = this.parser.parse(joinedContent);
436
+ // Debug: log relay commands being parsed
437
+ if (commands.length > 0 && this.config.debug) {
438
+ for (const cmd of commands) {
439
+ const bodyPreview = cmd.body.substring(0, 80).replace(/\n/g, '\\n');
440
+ this.logStderr(`[RELAY_PARSED] to=${cmd.to}, body="${bodyPreview}...", lines=${cmd.body.split('\n').length}`);
441
+ }
442
+ }
341
443
  // Track last output time for injection timing
342
444
  if (stdout.length !== this.processedOutputLength) {
343
445
  this.lastOutputTime = Date.now();
446
+ this.markActivity();
344
447
  this.processedOutputLength = stdout.length;
345
448
  }
346
449
  // Send any commands found (deduplication handles repeats)
347
450
  for (const cmd of commands) {
348
451
  this.sendRelayCommand(cmd);
349
452
  }
453
+ // Check for [[SUMMARY]] blocks and save to storage
454
+ this.parseSummaryAndSave(cleanContent);
455
+ // Check for [[SESSION_END]] blocks to explicitly close session
456
+ this.parseSessionEndAndClose(cleanContent);
457
+ // Check for ->relay:spawn and ->relay:release commands (any agent can spawn)
458
+ // Use joinedContent to handle multi-line output from TUIs like Claude Code
459
+ this.parseSpawnReleaseCommands(joinedContent);
460
+ this.updateActivityState();
350
461
  // Also check for injection opportunity
351
462
  this.checkForInjectionOpportunity();
352
463
  }
@@ -364,24 +475,25 @@ export class TmuxWrapper {
364
475
  return str.replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, '');
365
476
  }
366
477
  /**
367
- * Join continuation lines after @relay commands.
478
+ * Join continuation lines after ->relay commands.
368
479
  * Claude Code and other TUIs insert real newlines in output, causing
369
- * @relay messages to span multiple lines. This joins indented
370
- * continuation lines back to the @relay line.
480
+ * ->relay messages to span multiple lines. This joins indented
481
+ * continuation lines back to the ->relay line.
371
482
  */
372
483
  joinContinuationLines(content) {
373
484
  const lines = content.split('\n');
374
485
  const result = [];
375
- // Pattern to detect @relay command line (with optional bullet prefix)
376
- const relayPattern = /^(?:\s*(?:[>$%#→➜›»●•◦‣⁃\-*⏺◆◇○□■]\s*)*)?@relay:/;
486
+ // Pattern to detect relay command line (with optional bullet prefix)
487
+ const escapedPrefix = escapeRegex(this.relayPrefix);
488
+ const relayPattern = new RegExp(`^(?:\\s*(?:[>$%#→➜›»●•◦‣⁃\\-*⏺◆◇○□■]\\s*)*)?${escapedPrefix}`);
377
489
  // Pattern to detect a continuation line (starts with spaces, no bullet/command)
378
- const continuationPattern = /^[ \t]+[^>$%#→➜›»●•◦‣⁃\-*⏺◆◇○□■@\s]/;
490
+ const continuationPattern = /^[ \t]+[^>$%#→➜›»●•◦‣⁃\-*⏺◆◇○□■\s]/;
379
491
  // Pattern to detect a new block/bullet (stops continuation)
380
492
  const newBlockPattern = /^(?:\s*)?[>$%#→➜›»●•◦‣⁃\-*⏺◆◇○□■]/;
381
493
  let i = 0;
382
494
  while (i < lines.length) {
383
495
  const line = lines[i];
384
- // Check if this is a @relay line
496
+ // Check if this is a ->relay line
385
497
  if (relayPattern.test(line)) {
386
498
  let joined = line;
387
499
  let j = i + 1;
@@ -396,8 +508,8 @@ export class TmuxWrapper {
396
508
  break;
397
509
  // Check if it looks like a continuation (indented text)
398
510
  if (continuationPattern.test(nextLine)) {
399
- // Join with space, trimming the indentation
400
- joined += ' ' + nextLine.trim();
511
+ // Join with newline to preserve multi-line message content
512
+ joined += '\n' + nextLine.trim();
401
513
  j++;
402
514
  }
403
515
  else {
@@ -415,16 +527,33 @@ export class TmuxWrapper {
415
527
  return result.join('\n');
416
528
  }
417
529
  /**
418
- * Escape string for ANSI-C quoting ($'...')
419
- * This handles special characters more reliably than mixing quote styles
530
+ * Record recent activity and transition back to active if needed.
420
531
  */
421
- escapeForAnsiC(str) {
422
- return str
423
- .replace(/\\/g, '\\\\') // Backslash
424
- .replace(/'/g, "\\'") // Single quote
425
- .replace(/\n/g, '\\n') // Newline
426
- .replace(/\r/g, '\\r') // Carriage return
427
- .replace(/\t/g, '\\t'); // Tab
532
+ markActivity() {
533
+ this.lastActivityTime = Date.now();
534
+ if (this.activityState === 'idle') {
535
+ this.activityState = 'active';
536
+ this.logStderr('Session active');
537
+ }
538
+ }
539
+ /**
540
+ * Update activity state based on idle threshold and trigger injections when idle.
541
+ */
542
+ updateActivityState() {
543
+ if (this.activityState === 'disconnected')
544
+ return;
545
+ const now = Date.now();
546
+ const idleThreshold = this.config.activityIdleThresholdMs ?? 30000;
547
+ const timeSinceActivity = now - this.lastActivityTime;
548
+ if (timeSinceActivity > idleThreshold && this.activityState === 'active') {
549
+ this.activityState = 'idle';
550
+ this.logStderr('Session went idle');
551
+ this.checkForInjectionOpportunity();
552
+ }
553
+ else if (timeSinceActivity <= idleThreshold && this.activityState === 'idle') {
554
+ this.activityState = 'active';
555
+ this.logStderr('Session active');
556
+ }
428
557
  }
429
558
  /**
430
559
  * Send relay command to daemon
@@ -435,23 +564,203 @@ export class TmuxWrapper {
435
564
  if (this.sentMessageHashes.has(msgHash)) {
436
565
  return;
437
566
  }
438
- const success = this.client.sendMessage(cmd.to, cmd.body, cmd.kind, cmd.data);
567
+ // If client not ready, queue for later and return
568
+ if (this.client.state !== 'READY') {
569
+ if (this.queuedMessageHashes.has(msgHash)) {
570
+ return; // Already queued
571
+ }
572
+ if (this.pendingRelayCommands.length >= this.MAX_PENDING_RELAY_COMMANDS) {
573
+ this.logStderr('Relay offline queue full, dropping oldest');
574
+ const dropped = this.pendingRelayCommands.shift();
575
+ if (dropped) {
576
+ this.queuedMessageHashes.delete(`${dropped.to}:${dropped.body}`);
577
+ }
578
+ }
579
+ this.pendingRelayCommands.push(cmd);
580
+ this.queuedMessageHashes.add(msgHash);
581
+ this.logStderr(`Relay offline; queued message to ${cmd.to}`);
582
+ return;
583
+ }
584
+ // Convert ParsedMessageMetadata to SendMeta if present
585
+ let sendMeta;
586
+ if (cmd.meta) {
587
+ sendMeta = {
588
+ importance: cmd.meta.importance,
589
+ replyTo: cmd.meta.replyTo,
590
+ requires_ack: cmd.meta.ackRequired,
591
+ };
592
+ }
593
+ const success = this.client.sendMessage(cmd.to, cmd.body, cmd.kind, cmd.data, cmd.thread, sendMeta);
439
594
  if (success) {
440
595
  this.sentMessageHashes.add(msgHash);
441
- this.logStderr(`→ ${cmd.to}: ${cmd.body.substring(0, 50)}...`);
596
+ this.queuedMessageHashes.delete(msgHash);
597
+ const truncatedBody = cmd.body.substring(0, Math.min(RELAY_LOG_TRUNCATE_LENGTH, cmd.body.length));
598
+ this.logStderr(`→ ${cmd.to}: ${truncatedBody}...`);
442
599
  }
443
600
  else if (this.client.state !== 'READY') {
444
601
  // Only log failure once per state change
445
602
  this.logStderr(`Send failed (client ${this.client.state})`);
446
603
  }
447
604
  }
605
+ /**
606
+ * Flush any queued relay commands when the client reconnects.
607
+ */
608
+ flushQueuedRelayCommands() {
609
+ if (this.pendingRelayCommands.length === 0)
610
+ return;
611
+ const queued = [...this.pendingRelayCommands];
612
+ this.pendingRelayCommands = [];
613
+ this.queuedMessageHashes.clear();
614
+ for (const cmd of queued) {
615
+ this.sendRelayCommand(cmd);
616
+ }
617
+ }
618
+ /**
619
+ * Parse [[SUMMARY]] blocks from output and save to storage.
620
+ * Agents can output summaries to maintain running context:
621
+ *
622
+ * [[SUMMARY]]
623
+ * {"currentTask": "Implementing auth", "context": "Completed login flow"}
624
+ * [[/SUMMARY]]
625
+ */
626
+ parseSummaryAndSave(content) {
627
+ const result = parseSummaryWithDetails(content);
628
+ // No SUMMARY block found
629
+ if (!result.found)
630
+ return;
631
+ // Dedup based on raw content - prevents repeated error logging for same invalid JSON
632
+ if (result.rawContent === this.lastSummaryRawContent)
633
+ return;
634
+ this.lastSummaryRawContent = result.rawContent || '';
635
+ // Invalid JSON - log error once (deduped above)
636
+ if (!result.valid) {
637
+ this.logStderr('[parser] Invalid JSON in SUMMARY block');
638
+ return;
639
+ }
640
+ const summary = result.summary;
641
+ // Dedup valid summaries - don't save same summary twice
642
+ const summaryHash = JSON.stringify(summary);
643
+ if (summaryHash === this.lastSummaryHash)
644
+ return;
645
+ this.lastSummaryHash = summaryHash;
646
+ // Wait for storage to be ready before saving
647
+ this.storageReady.then(ready => {
648
+ if (!ready || !this.storage) {
649
+ this.logStderr('Cannot save summary: storage not initialized');
650
+ return;
651
+ }
652
+ const projectPaths = getProjectPaths();
653
+ this.storage.saveAgentSummary({
654
+ agentName: this.config.name,
655
+ projectId: projectPaths.projectId,
656
+ currentTask: summary.currentTask,
657
+ completedTasks: summary.completedTasks,
658
+ decisions: summary.decisions,
659
+ context: summary.context,
660
+ files: summary.files,
661
+ }).then(() => {
662
+ this.logStderr(`Saved agent summary: ${summary.currentTask || 'updated context'}`);
663
+ }).catch(err => {
664
+ this.logStderr(`Failed to save summary: ${err.message}`, true);
665
+ });
666
+ });
667
+ }
668
+ /**
669
+ * Parse [[SESSION_END]] blocks from output and close session explicitly.
670
+ * Agents output this to mark their work session as complete:
671
+ *
672
+ * [[SESSION_END]]
673
+ * {"summary": "Completed auth module", "completedTasks": ["login", "logout"]}
674
+ * [[/SESSION_END]]
675
+ */
676
+ parseSessionEndAndClose(content) {
677
+ if (this.sessionEndProcessed)
678
+ return; // Only process once per session
679
+ const sessionEnd = parseSessionEndFromOutput(content);
680
+ if (!sessionEnd)
681
+ return;
682
+ // Get session ID from client connection - if not available yet, don't set flag
683
+ // so we can retry when sessionId becomes available
684
+ const sessionId = this.client.currentSessionId;
685
+ if (!sessionId) {
686
+ this.logStderr('Cannot close session: no session ID yet, will retry');
687
+ return;
688
+ }
689
+ this.sessionEndProcessed = true;
690
+ // Wait for storage to be ready before attempting to close session
691
+ this.storageReady.then(ready => {
692
+ if (!ready || !this.storage) {
693
+ this.logStderr('Cannot close session: storage not initialized');
694
+ return;
695
+ }
696
+ this.storage.endSession(sessionId, {
697
+ summary: sessionEnd.summary,
698
+ closedBy: 'agent',
699
+ }).then(() => {
700
+ this.logStderr(`Session closed by agent: ${sessionEnd.summary || 'complete'}`);
701
+ }).catch(err => {
702
+ this.logStderr(`Failed to close session: ${err.message}`, true);
703
+ });
704
+ });
705
+ }
706
+ /**
707
+ * Parse ->relay:spawn and ->relay:release commands from output.
708
+ * Format:
709
+ * ->relay:spawn WorkerName cli "task description"
710
+ * ->relay:release WorkerName
711
+ */
712
+ parseSpawnReleaseCommands(content) {
713
+ // Only process if callbacks are configured
714
+ if (!this.config.onSpawn && !this.config.onRelease)
715
+ return;
716
+ const lines = content.split('\n');
717
+ for (const line of lines) {
718
+ const trimmed = line.trim();
719
+ // Match ->relay:spawn WorkerName cli "task"
720
+ // Pattern: ->relay:spawn <name> <cli> "<task>" or ->relay:spawn <name> <cli> '<task>'
721
+ // Allow trailing whitespace and optional bullet prefixes that TUIs might add
722
+ const spawnMatch = trimmed.match(/^(?:[•\-*]\s*)?->relay:spawn\s+(\S+)\s+(\S+)\s+["'](.+?)["']\s*$/);
723
+ if (spawnMatch && this.config.onSpawn) {
724
+ const [, name, cli, task] = spawnMatch;
725
+ const spawnKey = `${name}:${cli}:${task}`;
726
+ // Dedup - only process each spawn once
727
+ if (!this.processedSpawnCommands.has(spawnKey)) {
728
+ this.processedSpawnCommands.add(spawnKey);
729
+ this.logStderr(`Spawn command: ${name} (${cli}) - "${task.substring(0, 50)}..."`);
730
+ this.config.onSpawn(name, cli, task).catch(err => {
731
+ this.logStderr(`Spawn failed: ${err.message}`, true);
732
+ });
733
+ }
734
+ continue;
735
+ }
736
+ // Match ->relay:release WorkerName
737
+ // Allow trailing whitespace and optional bullet prefixes
738
+ const releaseMatch = trimmed.match(/^(?:[•\-*]\s*)?->relay:release\s+(\S+)\s*$/);
739
+ if (releaseMatch && this.config.onRelease) {
740
+ const [, name] = releaseMatch;
741
+ // Dedup - only process each release once
742
+ if (!this.processedReleaseCommands.has(name)) {
743
+ this.processedReleaseCommands.add(name);
744
+ this.logStderr(`Release command: ${name}`);
745
+ this.config.onRelease(name).catch(err => {
746
+ this.logStderr(`Release failed: ${err.message}`, true);
747
+ });
748
+ }
749
+ }
750
+ }
751
+ }
448
752
  /**
449
753
  * Handle incoming message from relay
450
754
  */
451
- handleIncomingMessage(from, payload, messageId) {
452
- this.logStderr(`← ${from}: ${payload.body.substring(0, 40)}...`);
755
+ handleIncomingMessage(from, payload, messageId, meta) {
756
+ if (this.hasSeenIncoming(messageId)) {
757
+ this.logStderr(`← ${from}: duplicate delivery (${messageId.substring(0, 8)})`);
758
+ return;
759
+ }
760
+ const truncatedBody = payload.body.substring(0, Math.min(DEBUG_LOG_TRUNCATE_LENGTH, payload.body.length));
761
+ this.logStderr(`← ${from}: ${truncatedBody}...`);
453
762
  // Queue for injection
454
- this.messageQueue.push({ from, body: payload.body, messageId });
763
+ this.messageQueue.push({ from, body: payload.body, messageId, thread: payload.thread, importance: meta?.importance });
455
764
  // Write to inbox if enabled
456
765
  if (this.inbox) {
457
766
  this.inbox.addMessage(from, payload.body);
@@ -489,56 +798,69 @@ export class TmuxWrapper {
489
798
  this.logStderr(`Injecting message from ${msg.from} (cli: ${this.cliType})`);
490
799
  try {
491
800
  let sanitizedBody = msg.body.replace(/[\r\n]+/g, ' ').trim();
801
+ // Gemini interprets certain keywords (While, For, If, etc.) as shell commands
802
+ // Wrap in backticks to prevent shell keyword interpretation
803
+ if (this.cliType === 'gemini') {
804
+ sanitizedBody = `\`${sanitizedBody.replace(/`/g, "'")}\``;
805
+ }
492
806
  // Short message ID for display (first 8 chars)
493
807
  const shortId = msg.messageId.substring(0, 8);
494
- // Truncate very long messages to avoid display issues
495
- const maxLen = 2000;
496
- let wasTruncated = false;
497
- if (sanitizedBody.length > maxLen) {
498
- sanitizedBody = sanitizedBody.substring(0, maxLen) + '...';
499
- wasTruncated = true;
500
- }
808
+ // Remove message truncation to allow full messages to pass through
809
+ const wasTruncated = false;
501
810
  // Always include message ID; add lookup hint if truncated
502
811
  const idTag = `[${shortId}]`;
503
812
  const truncationHint = wasTruncated
504
813
  ? ` [TRUNCATED - run "agent-relay read ${msg.messageId}"]`
505
814
  : '';
506
- // Gemini CLI interprets input as shell commands, so we need special handling
507
- if (this.cliType === 'gemini') {
508
- // For Gemini: Use printf with %s to safely handle any characters
509
- // printf '%s\n' 'message' - the %s treats the argument as literal string
510
- // We use $'...' ANSI-C quoting which handles escapes more predictably
511
- const safeBody = this.escapeForAnsiC(sanitizedBody);
512
- const safeFrom = this.escapeForAnsiC(msg.from);
513
- const safeHint = this.escapeForAnsiC(truncationHint);
514
- const printfMsg = `printf '%s\\n' $'Relay message from ${safeFrom} ${idTag}: ${safeBody}${safeHint}'`;
515
- // Clear any partial input
815
+ // Wait for input to be clear before injecting
816
+ const waitTimeoutMs = this.config.inputWaitTimeoutMs ?? 5000;
817
+ const waitPollMs = this.config.inputWaitPollMs ?? 200;
818
+ const inputClear = await this.waitForClearInput(waitTimeoutMs, waitPollMs);
819
+ if (!inputClear) {
820
+ // Input still has text after timeout - clear it forcefully
821
+ this.logStderr('Input not clear after waiting, clearing forcefully');
516
822
  await this.sendKeys('Escape');
517
823
  await this.sleep(30);
518
824
  await this.sendKeys('C-u');
519
825
  await this.sleep(30);
520
- // Send printf command to display the message
521
- await this.sendKeysLiteral(printfMsg);
522
- await this.sleep(50);
523
- await this.sendKeys('Enter');
524
- this.logStderr(`Injection complete (gemini printf mode)`);
525
826
  }
526
- else {
527
- // Standard injection for Claude, Codex, etc.
528
- // Format: Relay message from Sender [abc12345]: content
529
- const injection = `Relay message from ${msg.from} ${idTag}: ${sanitizedBody}${truncationHint}`;
530
- // Clear any partial input
531
- await this.sendKeys('Escape');
532
- await this.sleep(30);
533
- await this.sendKeys('C-u');
534
- await this.sleep(30);
535
- // Type the message
536
- await this.sendKeysLiteral(injection);
537
- await this.sleep(50);
538
- // Submit
539
- await this.sendKeys('Enter');
540
- this.logStderr(`Injection complete`);
827
+ // Ensure pane output is stable to avoid interleaving with active generation
828
+ const stablePane = await this.waitForStablePane(this.config.outputStabilityTimeoutMs ?? 2000, this.config.outputStabilityPollMs ?? 200);
829
+ if (!stablePane) {
830
+ this.logStderr('Output still active, re-queuing injection');
831
+ this.messageQueue.unshift(msg);
832
+ this.isInjecting = false;
833
+ setTimeout(() => this.checkForInjectionOpportunity(), this.config.injectRetryMs ?? 500);
834
+ return;
541
835
  }
836
+ // For Gemini: check if we're at a shell prompt ($) vs chat prompt (>)
837
+ // If at shell prompt, skip injection to avoid shell command execution
838
+ if (this.cliType === 'gemini') {
839
+ const lastLine = await this.getLastLine();
840
+ const cleanLine = this.stripAnsi(lastLine).trim();
841
+ if (/^\$\s*$/.test(cleanLine) || /^\s*\$\s*$/.test(cleanLine)) {
842
+ this.logStderr('Gemini at shell prompt, skipping injection to avoid shell execution');
843
+ // Re-queue the message for later
844
+ this.messageQueue.unshift(msg);
845
+ this.isInjecting = false;
846
+ setTimeout(() => this.checkForInjectionOpportunity(), 2000);
847
+ return;
848
+ }
849
+ }
850
+ // Standard injection for all CLIs including Gemini
851
+ // Format: Relay message from Sender [abc12345] [thread:xxx] [!]: content
852
+ // Thread/importance hints are compact and optional to not break TUIs
853
+ const threadHint = msg.thread ? ` [thread:${msg.thread}]` : '';
854
+ // Importance indicator: [!!] for high (>75), [!] for medium (>50), none for low/default
855
+ const importanceHint = msg.importance !== undefined && msg.importance > 75 ? ' [!!]' :
856
+ msg.importance !== undefined && msg.importance > 50 ? ' [!]' : '';
857
+ const injection = `Relay message from ${msg.from} ${idTag}${threadHint}${importanceHint}: ${sanitizedBody}${truncationHint}`;
858
+ // Paste message as a bracketed paste to avoid interleaving with active output
859
+ await this.pasteLiteral(injection);
860
+ await this.sleep(30);
861
+ // Submit
862
+ await this.sendKeys('Enter');
863
+ this.logStderr(`Injection complete`);
542
864
  }
543
865
  catch (err) {
544
866
  this.logStderr(`Injection failed: ${err.message}`, true);
@@ -550,6 +872,20 @@ export class TmuxWrapper {
550
872
  }
551
873
  }
552
874
  }
875
+ hasSeenIncoming(messageId) {
876
+ if (this.receivedMessageIdSet.has(messageId)) {
877
+ return true;
878
+ }
879
+ this.receivedMessageIdSet.add(messageId);
880
+ this.receivedMessageIdOrder.push(messageId);
881
+ if (this.receivedMessageIdOrder.length > this.MAX_RECEIVED_MESSAGES) {
882
+ const oldest = this.receivedMessageIdOrder.shift();
883
+ if (oldest) {
884
+ this.receivedMessageIdSet.delete(oldest);
885
+ }
886
+ }
887
+ return false;
888
+ }
553
889
  /**
554
890
  * Send special keys to tmux
555
891
  */
@@ -571,9 +907,190 @@ export class TmuxWrapper {
571
907
  .replace(/!/g, '\\!');
572
908
  await execAsync(`tmux send-keys -t ${this.sessionName} -l "${escaped}"`);
573
909
  }
910
+ /**
911
+ * Paste text using tmux buffer with optional bracketed paste to avoid interleaving with ongoing output.
912
+ * Some CLIs (like droid) don't handle bracketed paste sequences properly, so we skip -p for them.
913
+ */
914
+ async pasteLiteral(text) {
915
+ // Sanitize newlines to keep injection single-line inside paste buffer
916
+ const sanitized = text.replace(/[\r\n]+/g, ' ');
917
+ const escaped = sanitized
918
+ .replace(/\\/g, '\\\\')
919
+ .replace(/"/g, '\\"')
920
+ .replace(/\$/g, '\\$')
921
+ .replace(/`/g, '\\`')
922
+ .replace(/!/g, '\\!');
923
+ // Set tmux buffer then paste
924
+ // Skip bracketed paste (-p) for CLIs that don't handle it properly (droid, other)
925
+ await execAsync(`tmux set-buffer -- "${escaped}"`);
926
+ const useBracketedPaste = this.cliType === 'claude' || this.cliType === 'codex' || this.cliType === 'gemini';
927
+ if (useBracketedPaste) {
928
+ await execAsync(`tmux paste-buffer -t ${this.sessionName} -p`);
929
+ }
930
+ else {
931
+ await execAsync(`tmux paste-buffer -t ${this.sessionName}`);
932
+ }
933
+ }
574
934
  sleep(ms) {
575
935
  return new Promise(r => setTimeout(r, ms));
576
936
  }
937
+ /**
938
+ * Reset session-specific state for wrapper reuse.
939
+ * Call this when starting a new session with the same wrapper instance.
940
+ */
941
+ resetSessionState() {
942
+ this.sessionEndProcessed = false;
943
+ this.lastSummaryHash = '';
944
+ this.lastSummaryRawContent = '';
945
+ }
946
+ /**
947
+ * Get the prompt pattern for the current CLI type.
948
+ */
949
+ getPromptPattern() {
950
+ const promptPatterns = {
951
+ claude: /^[>›»]\s*$/, // Claude: "> " or similar
952
+ gemini: /^[>›»]\s*$/, // Gemini: "> "
953
+ codex: /^[>›»]\s*$/, // Codex: "> "
954
+ other: /^[>$%#➜›»]\s*$/, // Shell or other: "$ ", "> ", etc.
955
+ };
956
+ return promptPatterns[this.cliType] || promptPatterns.other;
957
+ }
958
+ /**
959
+ * Capture the last non-empty line from the tmux pane.
960
+ */
961
+ async getLastLine() {
962
+ try {
963
+ const { stdout } = await execAsync(`tmux capture-pane -t ${this.sessionName} -p -J 2>/dev/null`);
964
+ const lines = stdout.split('\n').filter(l => l.length > 0);
965
+ return lines[lines.length - 1] || '';
966
+ }
967
+ catch {
968
+ return '';
969
+ }
970
+ }
971
+ /**
972
+ * Detect if the provided line contains visible user input (beyond the prompt).
973
+ */
974
+ hasVisibleInput(line) {
975
+ const cleanLine = this.stripAnsi(line).trimEnd();
976
+ if (cleanLine === '')
977
+ return false;
978
+ return !this.getPromptPattern().test(cleanLine);
979
+ }
980
+ /**
981
+ * Check if the input line is clear (no user-typed text after the prompt).
982
+ * Returns true if the last visible line appears to be just a prompt.
983
+ */
984
+ async isInputClear(lastLine) {
985
+ try {
986
+ const lineToCheck = lastLine ?? await this.getLastLine();
987
+ const cleanLine = this.stripAnsi(lineToCheck).trimEnd();
988
+ const isClear = this.getPromptPattern().test(cleanLine);
989
+ if (this.config.debug) {
990
+ const truncatedLine = cleanLine.substring(0, Math.min(DEBUG_LOG_TRUNCATE_LENGTH, cleanLine.length));
991
+ this.logStderr(`isInputClear: lastLine="${truncatedLine}", clear=${isClear}`);
992
+ }
993
+ return isClear;
994
+ }
995
+ catch {
996
+ // If we can't capture, assume not clear (safer)
997
+ return false;
998
+ }
999
+ }
1000
+ /**
1001
+ * Get cursor X position to detect input length.
1002
+ * Returns the cursor column (0-indexed).
1003
+ */
1004
+ async getCursorX() {
1005
+ try {
1006
+ const { stdout } = await execAsync(`tmux display-message -t ${this.sessionName} -p "#{cursor_x}" 2>/dev/null`);
1007
+ return parseInt(stdout.trim(), 10) || 0;
1008
+ }
1009
+ catch {
1010
+ return 0;
1011
+ }
1012
+ }
1013
+ /**
1014
+ * Wait for the input line to be clear before injecting.
1015
+ * Polls until the input appears empty or timeout is reached.
1016
+ *
1017
+ * @param maxWaitMs Maximum time to wait (default 5000ms)
1018
+ * @param pollIntervalMs How often to check (default 200ms)
1019
+ * @returns true if input became clear, false if timed out
1020
+ */
1021
+ async waitForClearInput(maxWaitMs = 5000, pollIntervalMs = 200) {
1022
+ const startTime = Date.now();
1023
+ let lastCursorX = -1;
1024
+ let stableCursorCount = 0;
1025
+ while (Date.now() - startTime < maxWaitMs) {
1026
+ const lastLine = await this.getLastLine();
1027
+ // Check if input line is just a prompt
1028
+ if (await this.isInputClear(lastLine)) {
1029
+ return true;
1030
+ }
1031
+ const hasInput = this.hasVisibleInput(lastLine);
1032
+ // Also check cursor stability - if cursor is moving, agent is typing
1033
+ const cursorX = await this.getCursorX();
1034
+ if (!hasInput && cursorX === lastCursorX) {
1035
+ stableCursorCount++;
1036
+ // If cursor has been stable for enough polls and at typical prompt position,
1037
+ // the agent might be done but we just can't match the prompt pattern
1038
+ if (stableCursorCount >= STABLE_CURSOR_THRESHOLD && cursorX <= MAX_PROMPT_CURSOR_POSITION) {
1039
+ this.logStderr(`waitForClearInput: cursor stable at x=${cursorX}, assuming clear`);
1040
+ return true;
1041
+ }
1042
+ }
1043
+ else {
1044
+ stableCursorCount = 0;
1045
+ lastCursorX = cursorX;
1046
+ }
1047
+ await this.sleep(pollIntervalMs);
1048
+ }
1049
+ this.logStderr(`waitForClearInput: timed out after ${maxWaitMs}ms`);
1050
+ return false;
1051
+ }
1052
+ /**
1053
+ * Capture a signature of the current pane content for stability checks.
1054
+ * Uses hash+length to cheaply detect changes without storing full content.
1055
+ */
1056
+ async capturePaneSignature() {
1057
+ try {
1058
+ const { stdout } = await execAsync(`tmux capture-pane -t ${this.sessionName} -p -J -S - 2>/dev/null`);
1059
+ const hash = crypto.createHash('sha1').update(stdout).digest('hex');
1060
+ return `${stdout.length}:${hash}`;
1061
+ }
1062
+ catch {
1063
+ return null;
1064
+ }
1065
+ }
1066
+ /**
1067
+ * Wait for pane output to stabilize before injecting to avoid interleaving with ongoing output.
1068
+ */
1069
+ async waitForStablePane(maxWaitMs = 2000, pollIntervalMs = 200, requiredStablePolls = 2) {
1070
+ const start = Date.now();
1071
+ let lastSig = await this.capturePaneSignature();
1072
+ if (!lastSig)
1073
+ return false;
1074
+ let stableCount = 0;
1075
+ while (Date.now() - start < maxWaitMs) {
1076
+ await this.sleep(pollIntervalMs);
1077
+ const sig = await this.capturePaneSignature();
1078
+ if (!sig)
1079
+ continue;
1080
+ if (sig === lastSig) {
1081
+ stableCount++;
1082
+ if (stableCount >= requiredStablePolls) {
1083
+ return true;
1084
+ }
1085
+ }
1086
+ else {
1087
+ stableCount = 0;
1088
+ lastSig = sig;
1089
+ }
1090
+ }
1091
+ this.logStderr(`waitForStablePane: timed out after ${maxWaitMs}ms`);
1092
+ return false;
1093
+ }
577
1094
  /**
578
1095
  * Stop and cleanup
579
1096
  */
@@ -581,6 +1098,9 @@ export class TmuxWrapper {
581
1098
  if (!this.running)
582
1099
  return;
583
1100
  this.running = false;
1101
+ this.activityState = 'disconnected';
1102
+ // Reset session state for potential reuse
1103
+ this.resetSessionState();
584
1104
  // Stop polling
585
1105
  if (this.pollTimer) {
586
1106
  clearInterval(this.pollTimer);