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.
- package/README.md +176 -6
- package/dist/bridge/config.d.ts +41 -0
- package/dist/bridge/config.d.ts.map +1 -0
- package/dist/bridge/config.js +143 -0
- package/dist/bridge/config.js.map +1 -0
- package/dist/bridge/index.d.ts +10 -0
- package/dist/bridge/index.d.ts.map +1 -0
- package/dist/bridge/index.js +10 -0
- package/dist/bridge/index.js.map +1 -0
- package/dist/bridge/multi-project-client.d.ts +99 -0
- package/dist/bridge/multi-project-client.d.ts.map +1 -0
- package/dist/bridge/multi-project-client.js +386 -0
- package/dist/bridge/multi-project-client.js.map +1 -0
- package/dist/bridge/spawner.d.ts +46 -0
- package/dist/bridge/spawner.d.ts.map +1 -0
- package/dist/bridge/spawner.js +223 -0
- package/dist/bridge/spawner.js.map +1 -0
- package/dist/bridge/types.d.ts +55 -0
- package/dist/bridge/types.d.ts.map +1 -0
- package/dist/bridge/types.js +6 -0
- package/dist/bridge/types.js.map +1 -0
- package/dist/bridge/utils.d.ts +30 -0
- package/dist/bridge/utils.d.ts.map +1 -0
- package/dist/bridge/utils.js +54 -0
- package/dist/bridge/utils.js.map +1 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +906 -6
- 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 +163 -0
- package/dist/daemon/agent-registry.js.map +1 -0
- package/dist/daemon/connection.d.ts +33 -1
- package/dist/daemon/connection.d.ts.map +1 -1
- package/dist/daemon/connection.js +86 -11
- 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 +61 -2
- package/dist/daemon/router.d.ts.map +1 -1
- package/dist/daemon/router.js +219 -4
- package/dist/daemon/router.js.map +1 -1
- package/dist/daemon/server.d.ts +9 -0
- package/dist/daemon/server.d.ts.map +1 -1
- package/dist/daemon/server.js +135 -16
- package/dist/daemon/server.js.map +1 -1
- package/dist/dashboard/metrics.d.ts +105 -0
- package/dist/dashboard/metrics.d.ts.map +1 -0
- package/dist/dashboard/metrics.js +192 -0
- package/dist/dashboard/metrics.js.map +1 -0
- package/dist/dashboard/needs-attention.d.ts +24 -0
- package/dist/dashboard/needs-attention.d.ts.map +1 -0
- package/dist/dashboard/needs-attention.js +78 -0
- package/dist/dashboard/needs-attention.js.map +1 -0
- package/dist/dashboard/public/bridge.html +1272 -0
- package/dist/dashboard/public/index.html +2094 -347
- package/dist/dashboard/public/js/app.js +184 -0
- package/dist/dashboard/public/js/app.js.map +7 -0
- package/dist/dashboard/public/metrics.html +999 -0
- package/dist/dashboard/server.d.ts +14 -1
- package/dist/dashboard/server.d.ts.map +1 -1
- package/dist/dashboard/server.js +689 -16
- package/dist/dashboard/server.js.map +1 -1
- package/dist/dashboard/start.js +1 -1
- package/dist/dashboard/start.js.map +1 -1
- package/dist/dashboard-v2/index.d.ts +10 -0
- package/dist/dashboard-v2/index.d.ts.map +1 -0
- package/dist/dashboard-v2/index.js +54 -0
- package/dist/dashboard-v2/index.js.map +1 -0
- package/dist/dashboard-v2/lib/api.d.ts +95 -0
- package/dist/dashboard-v2/lib/api.d.ts.map +1 -0
- package/dist/dashboard-v2/lib/api.js +270 -0
- package/dist/dashboard-v2/lib/api.js.map +1 -0
- package/dist/dashboard-v2/lib/colors.d.ts +61 -0
- package/dist/dashboard-v2/lib/colors.d.ts.map +1 -0
- package/dist/dashboard-v2/lib/colors.js +198 -0
- package/dist/dashboard-v2/lib/colors.js.map +1 -0
- package/dist/dashboard-v2/lib/hierarchy.d.ts +74 -0
- package/dist/dashboard-v2/lib/hierarchy.d.ts.map +1 -0
- package/dist/dashboard-v2/lib/hierarchy.js +196 -0
- package/dist/dashboard-v2/lib/hierarchy.js.map +1 -0
- package/dist/dashboard-v2/types/index.d.ts +154 -0
- package/dist/dashboard-v2/types/index.d.ts.map +1 -0
- package/dist/dashboard-v2/types/index.js +6 -0
- package/dist/dashboard-v2/types/index.js.map +1 -0
- 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 +74 -1
- package/dist/storage/adapter.d.ts.map +1 -1
- package/dist/storage/adapter.js +39 -0
- package/dist/storage/adapter.js.map +1 -1
- package/dist/storage/sqlite-adapter.d.ts +92 -1
- package/dist/storage/sqlite-adapter.d.ts.map +1 -1
- package/dist/storage/sqlite-adapter.js +615 -47
- package/dist/storage/sqlite-adapter.js.map +1 -1
- package/dist/utils/agent-config.d.ts +45 -0
- package/dist/utils/agent-config.d.ts.map +1 -0
- package/dist/utils/agent-config.js +118 -0
- package/dist/utils/agent-config.js.map +1 -0
- 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 +30 -3
- package/dist/wrapper/client.d.ts.map +1 -1
- package/dist/wrapper/client.js +85 -9
- package/dist/wrapper/client.js.map +1 -1
- package/dist/wrapper/parser.d.ts +127 -4
- package/dist/wrapper/parser.d.ts.map +1 -1
- package/dist/wrapper/parser.js +622 -86
- package/dist/wrapper/parser.js.map +1 -1
- package/dist/wrapper/tmux-wrapper.d.ts +136 -10
- package/dist/wrapper/tmux-wrapper.d.ts.map +1 -1
- package/dist/wrapper/tmux-wrapper.js +599 -79
- package/dist/wrapper/tmux-wrapper.js.map +1 -1
- package/docs/AGENTS.md +132 -27
- package/docs/ARCHITECTURE_DECISIONS.md +175 -0
- package/docs/CHANGELOG.md +1 -1
- package/docs/COMPETITIVE_ANALYSIS.md +897 -0
- package/docs/DESIGN_BRIDGE_STAFFING.md +878 -0
- package/docs/DESIGN_V2.md +1079 -0
- package/docs/INTEGRATION-GUIDE.md +926 -0
- package/docs/MONETIZATION.md +1679 -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/agent-relay-snippet.md +61 -0
- package/docs/competitive-analysis-mcp-agent-mail.md +389 -0
- package/docs/dashboard-v2-plan.md +179 -0
- 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
|
|
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:
|
|
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
|
-
//
|
|
69
|
-
this.
|
|
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(
|
|
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
|
|
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
|
-
//
|
|
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:
|
|
231
|
-
`
|
|
232
|
-
`
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
478
|
+
* Join continuation lines after ->relay commands.
|
|
368
479
|
* Claude Code and other TUIs insert real newlines in output, causing
|
|
369
|
-
*
|
|
370
|
-
* continuation lines back to the
|
|
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
|
|
376
|
-
const
|
|
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]+[
|
|
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
|
|
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
|
|
400
|
-
joined += '
|
|
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
|
-
*
|
|
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
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
.
|
|
425
|
-
.
|
|
426
|
-
|
|
427
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
//
|
|
495
|
-
const
|
|
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
|
-
//
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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);
|