@agent-relay/wrapper 2.0.18 → 2.0.20

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-relay/wrapper",
3
- "version": "2.0.18",
3
+ "version": "2.0.20",
4
4
  "description": "CLI agent wrappers for Agent Relay - tmux, pty integration",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -30,11 +30,11 @@
30
30
  "clean": "rm -rf dist"
31
31
  },
32
32
  "dependencies": {
33
- "@agent-relay/api-types": "2.0.18",
34
- "@agent-relay/protocol": "2.0.18",
35
- "@agent-relay/config": "2.0.18",
36
- "@agent-relay/continuity": "2.0.18",
37
- "@agent-relay/resiliency": "2.0.18"
33
+ "@agent-relay/api-types": "2.0.20",
34
+ "@agent-relay/protocol": "2.0.20",
35
+ "@agent-relay/config": "2.0.20",
36
+ "@agent-relay/continuity": "2.0.20",
37
+ "@agent-relay/resiliency": "2.0.20"
38
38
  },
39
39
  "devDependencies": {
40
40
  "typescript": "^5.9.3",
@@ -59,5 +59,5 @@
59
59
  "url": "git+https://github.com/AgentWorkforce/relay.git",
60
60
  "directory": "packages/wrapper"
61
61
  },
62
- "license": "MIT"
62
+ "license": "Apache-2.0"
63
63
  }
@@ -17,11 +17,16 @@ vi.mock('./client.js', () => ({
17
17
  name,
18
18
  state: 'READY' as string,
19
19
  sentMessages: [] as Array<{ to: string; body: string; kind: string; meta?: unknown }>,
20
+ sentChannelMessages: [] as Array<{ channel: string; body: string; options?: unknown }>,
20
21
  onMessage: null as ((from: string, payload: any, messageId: string, meta?: any, originalTo?: string) => void) | null,
21
22
  sendMessage: vi.fn().mockImplementation(function(this: any, to: string, body: string, kind: string, meta?: unknown) {
22
23
  this.sentMessages.push({ to, body, kind, meta });
23
24
  return true;
24
25
  }),
26
+ sendChannelMessage: vi.fn().mockImplementation(function(this: any, channel: string, body: string, options?: unknown) {
27
+ this.sentChannelMessages.push({ channel, body, options });
28
+ return true;
29
+ }),
25
30
  destroy: vi.fn(),
26
31
  })),
27
32
  }));
@@ -462,6 +467,50 @@ describe('BaseWrapper', () => {
462
467
 
463
468
  expect(wrapper.testClient.sentMessages).toHaveLength(0);
464
469
  });
470
+
471
+ it('does not add hash when client not ready (allows retry)', () => {
472
+ // First attempt - client not ready
473
+ wrapper.testClient.state = 'CONNECTING';
474
+ wrapper.testSendRelayCommand({ to: 'ReceiverAgent', body: 'Hello' });
475
+ expect(wrapper.testClient.sentMessages).toHaveLength(0);
476
+ expect(wrapper.testSentMessageHashes.size).toBe(0);
477
+
478
+ // Client becomes ready - retry should work
479
+ wrapper.testClient.state = 'READY';
480
+ wrapper.testSendRelayCommand({ to: 'ReceiverAgent', body: 'Hello' });
481
+ expect(wrapper.testClient.sentMessages).toHaveLength(1);
482
+ expect(wrapper.testSentMessageHashes.size).toBe(1);
483
+ });
484
+
485
+ it('uses sendChannelMessage for channel targets (starting with #)', () => {
486
+ wrapper.testSendRelayCommand({
487
+ to: '#general',
488
+ body: 'Hello channel!',
489
+ thread: 'thread-1',
490
+ });
491
+
492
+ // Should use channel message, not regular message
493
+ expect(wrapper.testClient.sentChannelMessages).toHaveLength(1);
494
+ expect(wrapper.testClient.sentMessages).toHaveLength(0);
495
+ expect(wrapper.testClient.sentChannelMessages[0].channel).toBe('#general');
496
+ expect(wrapper.testClient.sentChannelMessages[0].body).toBe('Hello channel!');
497
+ });
498
+
499
+ it('sends to different channels correctly', () => {
500
+ wrapper.testSendRelayCommand({ to: '#general', body: 'Hello general!' });
501
+ wrapper.testSendRelayCommand({ to: '#team', body: 'Hello team!' });
502
+
503
+ expect(wrapper.testClient.sentChannelMessages).toHaveLength(2);
504
+ expect(wrapper.testClient.sentMessages).toHaveLength(0);
505
+ });
506
+
507
+ it('uses sendMessage for non-channel targets', () => {
508
+ wrapper.testSendRelayCommand({ to: 'Bob', body: 'Direct message' });
509
+ wrapper.testSendRelayCommand({ to: '*', body: 'Broadcast message' });
510
+
511
+ expect(wrapper.testClient.sentMessages).toHaveLength(2);
512
+ expect(wrapper.testClient.sentChannelMessages).toHaveLength(0);
513
+ });
465
514
  });
466
515
 
467
516
  describe('joinContinuationLines', () => {
@@ -456,15 +456,8 @@ export abstract class BaseWrapper extends EventEmitter {
456
456
  console.error(`[base-wrapper] Skipped duplicate message to ${cmd.to}`);
457
457
  return;
458
458
  }
459
- this.sentMessageHashes.add(hash);
460
459
 
461
- // Limit hash set size
462
- if (this.sentMessageHashes.size > 500) {
463
- const oldest = this.sentMessageHashes.values().next().value;
464
- if (oldest) this.sentMessageHashes.delete(oldest);
465
- }
466
-
467
- // Only send if client ready
460
+ // Only send if client ready - check BEFORE adding hash to avoid blocking retries
468
461
  if (this.client.state !== 'READY') {
469
462
  console.error(`[base-wrapper] Client not ready (state=${this.client.state}), dropping message to ${cmd.to}`);
470
463
  return;
@@ -481,14 +474,29 @@ export abstract class BaseWrapper extends EventEmitter {
481
474
  };
482
475
  }
483
476
 
477
+ // Helper to mark message as sent (only after successful transmission)
478
+ const markSent = () => {
479
+ this.sentMessageHashes.add(hash);
480
+ // Limit hash set size
481
+ if (this.sentMessageHashes.size > 500) {
482
+ const oldest = this.sentMessageHashes.values().next().value;
483
+ if (oldest) this.sentMessageHashes.delete(oldest);
484
+ }
485
+ };
486
+
484
487
  // Check if target is a channel (starts with #)
485
488
  if (cmd.to.startsWith('#')) {
486
489
  // Use CHANNEL_MESSAGE protocol for channel targets
487
490
  console.error(`[base-wrapper] Sending CHANNEL_MESSAGE to ${cmd.to}`);
488
- this.client.sendChannelMessage(cmd.to, cmd.body, {
491
+ const success = this.client.sendChannelMessage(cmd.to, cmd.body, {
489
492
  thread: cmd.thread,
490
493
  data: cmd.data,
491
494
  });
495
+ if (success) {
496
+ markSent();
497
+ } else {
498
+ console.error(`[base-wrapper] sendChannelMessage failed for ${cmd.to}`);
499
+ }
492
500
  } else {
493
501
  // Use SEND protocol for direct messages and broadcasts
494
502
  if (cmd.sync?.blocking) {
@@ -497,11 +505,18 @@ export abstract class BaseWrapper extends EventEmitter {
497
505
  kind: cmd.kind,
498
506
  data: cmd.data,
499
507
  thread: cmd.thread,
508
+ }).then(() => {
509
+ markSent();
500
510
  }).catch((err) => {
501
511
  console.error(`[base-wrapper] sendAndWait failed for ${cmd.to}: ${err.message}`);
502
512
  });
503
513
  } else {
504
- this.client.sendMessage(cmd.to, cmd.body, cmd.kind, cmd.data, cmd.thread, sendMeta);
514
+ const success = this.client.sendMessage(cmd.to, cmd.body, cmd.kind, cmd.data, cmd.thread, sendMeta);
515
+ if (success) {
516
+ markSent();
517
+ } else {
518
+ console.error(`[base-wrapper] sendMessage failed for ${cmd.to}`);
519
+ }
505
520
  }
506
521
  }
507
522
  }
@@ -386,5 +386,33 @@ describe('UniversalIdleDetector', () => {
386
386
  const result = detector.checkIdle();
387
387
  expect(result.inEditorMode).toBe(false);
388
388
  });
389
+
390
+ it('returns false for Claude CLI status bar (not actual vim)', () => {
391
+ // Claude CLI shows "-- INSERT --" followed by permission indicators
392
+ // This is NOT vim editor mode - it's Claude's vim keybindings mode
393
+ detector.onOutput('> Try "how do I log an error?"\n-- INSERT -- ⏵⏵ bypass permissions on (shift+tab to cycle)');
394
+ expect(detector.isInEditorMode()).toBe(false);
395
+ });
396
+
397
+ it('returns false for Claude CLI NORMAL mode status bar', () => {
398
+ detector.onOutput('> Try "how do I log an error?"\n-- NORMAL -- ⏵⏵ bypass permissions on');
399
+ expect(detector.isInEditorMode()).toBe(false);
400
+ });
401
+
402
+ it('returns false for Claude CLI with ▶ symbol', () => {
403
+ detector.onOutput('Some prompt\n-- INSERT -- ▶ strict mode');
404
+ expect(detector.isInEditorMode()).toBe(false);
405
+ });
406
+
407
+ it('still detects actual vim INSERT mode at end of line', () => {
408
+ // Vim shows "-- INSERT --" alone at the end of the line
409
+ detector.onOutput('~\n~\n-- INSERT --\n');
410
+ expect(detector.isInEditorMode()).toBe(true);
411
+ });
412
+
413
+ it('still detects vim INSERT mode with trailing whitespace', () => {
414
+ detector.onOutput('~\n-- INSERT -- ');
415
+ expect(detector.isInEditorMode()).toBe(true);
416
+ });
389
417
  });
390
418
  });
@@ -142,20 +142,34 @@ export class UniversalIdleDetector {
142
142
  /**
143
143
  * Check if the agent is in an editor mode (vim INSERT, REPLACE, etc.).
144
144
  * When in editor mode, message injection should be delayed.
145
+ *
146
+ * Note: Claude CLI shows "-- INSERT --" in its status bar (for vim keybindings mode)
147
+ * but this is NOT the same as being in vim. We exclude Claude CLI's status bar
148
+ * by checking for the "⏵" symbol that follows its mode indicator.
145
149
  */
146
150
  isInEditorMode(): boolean {
147
151
  // Check the last portion of output for editor mode indicators
148
152
  const lastOutput = this.outputBuffer.slice(-500);
149
153
 
150
- // Vim/Neovim mode indicators
154
+ // Claude CLI status bar pattern - this is NOT vim editor mode
155
+ // Example: "-- INSERT -- ⏵⏵ bypass permissions on (shift+tab to cycle)"
156
+ // Also match: "-- NORMAL --", "-- VISUAL --" followed by Claude's UI elements
157
+ const claudeCliStatusBar = /-- (?:INSERT|NORMAL|VISUAL|REPLACE) --\s*[⏵⏴►▶]/;
158
+ if (claudeCliStatusBar.test(lastOutput)) {
159
+ return false;
160
+ }
161
+
162
+ // Vim/Neovim mode indicators (standalone, not part of Claude CLI status)
163
+ // These patterns require the mode indicator to be at the end of a line
164
+ // or followed only by whitespace, which matches vim's actual display
151
165
  const editorModePatterns = [
152
- /-- INSERT --/i,
153
- /-- REPLACE --/i,
154
- /-- VISUAL --/i,
155
- /-- VISUAL LINE --/i,
156
- /-- VISUAL BLOCK --/i,
157
- /-- SELECT --/i,
158
- /-- TERMINAL --/i,
166
+ /-- INSERT --\s*$/m,
167
+ /-- REPLACE --\s*$/m,
168
+ /-- VISUAL --\s*$/m,
169
+ /-- VISUAL LINE --\s*$/m,
170
+ /-- VISUAL BLOCK --\s*$/m,
171
+ /-- SELECT --\s*$/m,
172
+ /-- TERMINAL --\s*$/m,
159
173
  // Emacs indicators
160
174
  /\*\*\* Emacs/,
161
175
  /M-x/,
@@ -135,6 +135,10 @@ describe('RelayPtyOrchestrator', () => {
135
135
  // Mock waitUntilCliReady to resolve immediately in tests
136
136
  // This avoids waiting for CLI readiness checks which require complex simulation
137
137
  vi.spyOn(RelayPtyOrchestrator.prototype, 'waitUntilCliReady').mockResolvedValue(true);
138
+
139
+ // Mock isProcessAlive to return true - the real implementation uses process.kill(pid, 0)
140
+ // which fails for mock PIDs that don't correspond to real processes
141
+ vi.spyOn(RelayPtyOrchestrator.prototype as any, 'isProcessAlive').mockReturnValue(true);
138
142
  });
139
143
 
140
144
  afterEach(async () => {
@@ -251,6 +251,9 @@ export class RelayPtyOrchestrator extends BaseWrapper {
251
251
  // Track if agent is being gracefully stopped (vs crashed)
252
252
  private isGracefulStop = false;
253
253
 
254
+ // Track early process exit for better error messages
255
+ private earlyExitInfo?: { code: number | null; signal: NodeJS.Signals | null; stderr: string };
256
+
254
257
  // Memory/CPU monitoring
255
258
  private memoryMonitor: AgentMemoryMonitor;
256
259
  private memoryAlertHandler: ((alert: MemoryAlert) => void) | null = null;
@@ -574,7 +577,15 @@ export class RelayPtyOrchestrator extends BaseWrapper {
574
577
 
575
578
  this.log(` Using binary: ${binaryPath}`);
576
579
 
577
- // Connect to relay daemon first
580
+ // Spawn relay-pty process FIRST (before connecting to daemon)
581
+ // This ensures the CLI is actually running before we register with the daemon
582
+ await this.spawnRelayPty(binaryPath);
583
+
584
+ // Wait for socket to become available and connect
585
+ await this.connectToSocket();
586
+
587
+ // Connect to relay daemon AFTER CLI is spawned
588
+ // This prevents the spawner from seeing us as "registered" before the CLI runs
578
589
  try {
579
590
  await this.client.connect();
580
591
  this.log(` Relay daemon connected`);
@@ -582,12 +593,6 @@ export class RelayPtyOrchestrator extends BaseWrapper {
582
593
  this.logError(` Relay connect failed: ${err.message}`);
583
594
  }
584
595
 
585
- // Spawn relay-pty process
586
- await this.spawnRelayPty(binaryPath);
587
-
588
- // Wait for socket to become available and connect
589
- await this.connectToSocket();
590
-
591
596
  this.running = true;
592
597
  // DON'T set readyForMessages yet - wait for CLI to be ready first
593
598
  // This prevents messages from being injected during CLI startup
@@ -754,6 +759,9 @@ export class RelayPtyOrchestrator extends BaseWrapper {
754
759
 
755
760
  this.log(` Spawning: ${binaryPath} ${args.join(' ')}`);
756
761
 
762
+ // Reset early exit info from any previous spawn attempt
763
+ this.earlyExitInfo = undefined;
764
+
757
765
  // For interactive mode, let Rust directly inherit stdin/stdout from the terminal
758
766
  // This is more robust than manual forwarding through pipes
759
767
  // We still pipe stderr to capture JSON parsed commands
@@ -783,10 +791,15 @@ export class RelayPtyOrchestrator extends BaseWrapper {
783
791
  });
784
792
  }
785
793
 
794
+ // Capture stderr for early exit diagnosis
795
+ let stderrBuffer = '';
796
+
786
797
  // Handle stderr (relay-pty logs and JSON output) - always needed
798
+ // Also captures to buffer for error diagnostics if process dies early
787
799
  if (proc.stderr) {
788
800
  proc.stderr.on('data', (data: Buffer) => {
789
801
  const text = data.toString();
802
+ stderrBuffer += text;
790
803
  this.handleStderr(text);
791
804
  });
792
805
  }
@@ -795,6 +808,12 @@ export class RelayPtyOrchestrator extends BaseWrapper {
795
808
  proc.on('exit', (code, signal) => {
796
809
  const exitCode = code ?? (signal === 'SIGKILL' ? 137 : 1);
797
810
  this.log(` Process exited: code=${exitCode} signal=${signal}`);
811
+
812
+ // Capture early exit info for better error messages if socket not yet connected
813
+ if (!this.socketConnected) {
814
+ this.earlyExitInfo = { code, signal, stderr: stderrBuffer };
815
+ }
816
+
798
817
  this.running = false;
799
818
 
800
819
  // Get crash context before unregistering from memory monitor
@@ -1213,6 +1232,22 @@ export class RelayPtyOrchestrator extends BaseWrapper {
1213
1232
  // Socket communication
1214
1233
  // =========================================================================
1215
1234
 
1235
+ /**
1236
+ * Check if the relay-pty process is still alive
1237
+ */
1238
+ private isProcessAlive(): boolean {
1239
+ if (!this.relayPtyProcess || this.relayPtyProcess.exitCode !== null) {
1240
+ return false;
1241
+ }
1242
+ try {
1243
+ // Signal 0 checks if process exists without killing it
1244
+ process.kill(this.relayPtyProcess.pid!, 0);
1245
+ return true;
1246
+ } catch {
1247
+ return false;
1248
+ }
1249
+ }
1250
+
1216
1251
  /**
1217
1252
  * Connect to the relay-pty socket
1218
1253
  */
@@ -1221,6 +1256,21 @@ export class RelayPtyOrchestrator extends BaseWrapper {
1221
1256
  const maxAttempts = this.config.socketReconnectAttempts ?? 3;
1222
1257
 
1223
1258
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
1259
+ // Check if relay-pty process died before attempting connection
1260
+ if (!this.isProcessAlive()) {
1261
+ const exitInfo = this.earlyExitInfo;
1262
+ if (exitInfo) {
1263
+ const exitReason = exitInfo.signal
1264
+ ? `signal ${exitInfo.signal}`
1265
+ : `code ${exitInfo.code ?? 'unknown'}`;
1266
+ const stderrHint = exitInfo.stderr
1267
+ ? `\n stderr: ${exitInfo.stderr.trim().slice(0, 500)}`
1268
+ : '';
1269
+ throw new Error(`relay-pty process died early (${exitReason}).${stderrHint}`);
1270
+ }
1271
+ throw new Error('relay-pty process died before socket could be created');
1272
+ }
1273
+
1224
1274
  try {
1225
1275
  await this.attemptSocketConnection(timeout);
1226
1276
  this.log(` Socket connected`);
@@ -1233,6 +1283,18 @@ export class RelayPtyOrchestrator extends BaseWrapper {
1233
1283
  }
1234
1284
  }
1235
1285
 
1286
+ // Final check for process death after all attempts
1287
+ if (!this.isProcessAlive() && this.earlyExitInfo) {
1288
+ const exitInfo = this.earlyExitInfo;
1289
+ const exitReason = exitInfo.signal
1290
+ ? `signal ${exitInfo.signal}`
1291
+ : `code ${exitInfo.code ?? 'unknown'}`;
1292
+ const stderrHint = exitInfo.stderr
1293
+ ? `\n stderr: ${exitInfo.stderr.trim().slice(0, 500)}`
1294
+ : '';
1295
+ throw new Error(`relay-pty process died during socket connection (${exitReason}).${stderrHint}`);
1296
+ }
1297
+
1236
1298
  throw new Error(`Failed to connect to socket after ${maxAttempts} attempts`);
1237
1299
  }
1238
1300
 
@@ -969,6 +969,23 @@ export class TmuxWrapper extends BaseWrapper {
969
969
  };
970
970
  }
971
971
 
972
+ // Check if target is a channel (starts with #)
973
+ if (cmd.to.startsWith('#')) {
974
+ // Use CHANNEL_MESSAGE protocol for channel targets
975
+ this.logStderr(`→ [channel] ${cmd.to}: ${cmd.body.substring(0, Math.min(RELAY_LOG_TRUNCATE_LENGTH, cmd.body.length))}...`);
976
+ const success = this.client.sendChannelMessage(cmd.to, cmd.body, {
977
+ thread: cmd.thread,
978
+ data: cmd.data,
979
+ });
980
+ if (success) {
981
+ this.sentMessageHashes.add(msgHash);
982
+ this.queuedMessageHashes.delete(msgHash);
983
+ this.trajectory?.message('sent', this.config.name, cmd.to, cmd.body);
984
+ }
985
+ return;
986
+ }
987
+
988
+ // Use SEND protocol for direct messages and broadcasts
972
989
  if (cmd.sync?.blocking) {
973
990
  this.client.sendAndWait(cmd.to, cmd.body, {
974
991
  timeoutMs: cmd.sync.timeoutMs,