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.
- package/README.md +18 -6
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +344 -3
- package/dist/cli/index.js.map +1 -1
- package/dist/daemon/agent-registry.d.ts +60 -0
- package/dist/daemon/agent-registry.d.ts.map +1 -0
- package/dist/daemon/agent-registry.js +158 -0
- package/dist/daemon/agent-registry.js.map +1 -0
- package/dist/daemon/connection.d.ts +11 -1
- package/dist/daemon/connection.d.ts.map +1 -1
- package/dist/daemon/connection.js +31 -2
- package/dist/daemon/connection.js.map +1 -1
- package/dist/daemon/index.d.ts +2 -0
- package/dist/daemon/index.d.ts.map +1 -1
- package/dist/daemon/index.js +2 -0
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/registry.d.ts +9 -0
- package/dist/daemon/registry.d.ts.map +1 -0
- package/dist/daemon/registry.js +9 -0
- package/dist/daemon/registry.js.map +1 -0
- package/dist/daemon/router.d.ts +34 -2
- package/dist/daemon/router.d.ts.map +1 -1
- package/dist/daemon/router.js +111 -1
- package/dist/daemon/router.js.map +1 -1
- package/dist/daemon/server.d.ts +1 -0
- package/dist/daemon/server.d.ts.map +1 -1
- package/dist/daemon/server.js +60 -13
- package/dist/daemon/server.js.map +1 -1
- package/dist/dashboard/public/index.html +625 -16
- package/dist/dashboard/server.d.ts +1 -1
- package/dist/dashboard/server.d.ts.map +1 -1
- package/dist/dashboard/server.js +125 -7
- package/dist/dashboard/server.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/protocol/types.d.ts +15 -1
- package/dist/protocol/types.d.ts.map +1 -1
- package/dist/storage/adapter.d.ts +53 -0
- package/dist/storage/adapter.d.ts.map +1 -1
- package/dist/storage/adapter.js +3 -0
- package/dist/storage/adapter.js.map +1 -1
- package/dist/storage/sqlite-adapter.d.ts +58 -1
- package/dist/storage/sqlite-adapter.d.ts.map +1 -1
- package/dist/storage/sqlite-adapter.js +374 -47
- package/dist/storage/sqlite-adapter.js.map +1 -1
- package/dist/utils/project-namespace.d.ts.map +1 -1
- package/dist/utils/project-namespace.js +22 -1
- package/dist/utils/project-namespace.js.map +1 -1
- package/dist/wrapper/client.d.ts +22 -3
- package/dist/wrapper/client.d.ts.map +1 -1
- package/dist/wrapper/client.js +59 -9
- package/dist/wrapper/client.js.map +1 -1
- package/dist/wrapper/parser.d.ts +110 -4
- package/dist/wrapper/parser.d.ts.map +1 -1
- package/dist/wrapper/parser.js +296 -84
- package/dist/wrapper/parser.js.map +1 -1
- package/dist/wrapper/tmux-wrapper.d.ts +100 -9
- package/dist/wrapper/tmux-wrapper.d.ts.map +1 -1
- package/dist/wrapper/tmux-wrapper.js +441 -83
- package/dist/wrapper/tmux-wrapper.js.map +1 -1
- package/docs/AGENTS.md +27 -27
- package/docs/CHANGELOG.md +1 -1
- package/docs/DESIGN_V2.md +1079 -0
- package/docs/INTEGRATION-GUIDE.md +926 -0
- package/docs/PROPOSAL-trajectories.md +1582 -0
- package/docs/PROTOCOL.md +3 -3
- package/docs/SCALING_ANALYSIS.md +280 -0
- package/docs/TMUX_IMPLEMENTATION_NOTES.md +9 -9
- package/docs/TMUX_IMPROVEMENTS.md +968 -0
- package/docs/competitive-analysis-mcp-agent-mail.md +389 -0
- 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
|
|
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:
|
|
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
|
-
//
|
|
69
|
-
this.
|
|
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(
|
|
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
|
|
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
|
-
//
|
|
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:
|
|
231
|
-
`RECEIVE: "Relay message from X [id]: content"`,
|
|
232
|
-
`
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
457
|
+
* Join continuation lines after ->relay commands.
|
|
369
458
|
* Claude Code and other TUIs insert real newlines in output, causing
|
|
370
|
-
*
|
|
371
|
-
* continuation lines back to the
|
|
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
|
|
377
|
-
const
|
|
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]+[
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
.
|
|
426
|
-
.
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
|
|
441
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
497
|
-
const
|
|
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
|
-
//
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
const
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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);
|