@agent-relay/wrapper 0.1.0
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/dist/__fixtures__/claude-outputs.d.ts +49 -0
- package/dist/__fixtures__/claude-outputs.d.ts.map +1 -0
- package/dist/__fixtures__/claude-outputs.js +443 -0
- package/dist/__fixtures__/claude-outputs.js.map +1 -0
- package/dist/__fixtures__/codex-outputs.d.ts +9 -0
- package/dist/__fixtures__/codex-outputs.d.ts.map +1 -0
- package/dist/__fixtures__/codex-outputs.js +94 -0
- package/dist/__fixtures__/codex-outputs.js.map +1 -0
- package/dist/__fixtures__/gemini-outputs.d.ts +19 -0
- package/dist/__fixtures__/gemini-outputs.d.ts.map +1 -0
- package/dist/__fixtures__/gemini-outputs.js +144 -0
- package/dist/__fixtures__/gemini-outputs.js.map +1 -0
- package/dist/__fixtures__/index.d.ts +68 -0
- package/dist/__fixtures__/index.d.ts.map +1 -0
- package/dist/__fixtures__/index.js +44 -0
- package/dist/__fixtures__/index.js.map +1 -0
- package/dist/auth-detection.d.ts +49 -0
- package/dist/auth-detection.d.ts.map +1 -0
- package/dist/auth-detection.js +199 -0
- package/dist/auth-detection.js.map +1 -0
- package/dist/base-wrapper.d.ts +225 -0
- package/dist/base-wrapper.d.ts.map +1 -0
- package/dist/base-wrapper.js +572 -0
- package/dist/base-wrapper.js.map +1 -0
- package/dist/client.d.ts +254 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +801 -0
- package/dist/client.js.map +1 -0
- package/dist/id-generator.d.ts +35 -0
- package/dist/id-generator.d.ts.map +1 -0
- package/dist/id-generator.js +60 -0
- package/dist/id-generator.js.map +1 -0
- package/dist/idle-detector.d.ts +110 -0
- package/dist/idle-detector.d.ts.map +1 -0
- package/dist/idle-detector.js +304 -0
- package/dist/idle-detector.js.map +1 -0
- package/dist/inbox.d.ts +37 -0
- package/dist/inbox.d.ts.map +1 -0
- package/dist/inbox.js +73 -0
- package/dist/inbox.js.map +1 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +47 -0
- package/dist/index.js.map +1 -0
- package/dist/parser.d.ts +236 -0
- package/dist/parser.d.ts.map +1 -0
- package/dist/parser.js +1238 -0
- package/dist/parser.js.map +1 -0
- package/dist/prompt-composer.d.ts +67 -0
- package/dist/prompt-composer.d.ts.map +1 -0
- package/dist/prompt-composer.js +168 -0
- package/dist/prompt-composer.js.map +1 -0
- package/dist/relay-pty-orchestrator.d.ts +407 -0
- package/dist/relay-pty-orchestrator.d.ts.map +1 -0
- package/dist/relay-pty-orchestrator.js +1885 -0
- package/dist/relay-pty-orchestrator.js.map +1 -0
- package/dist/shared.d.ts +201 -0
- package/dist/shared.d.ts.map +1 -0
- package/dist/shared.js +341 -0
- package/dist/shared.js.map +1 -0
- package/dist/stuck-detector.d.ts +161 -0
- package/dist/stuck-detector.d.ts.map +1 -0
- package/dist/stuck-detector.js +402 -0
- package/dist/stuck-detector.js.map +1 -0
- package/dist/tmux-resolver.d.ts +55 -0
- package/dist/tmux-resolver.d.ts.map +1 -0
- package/dist/tmux-resolver.js +175 -0
- package/dist/tmux-resolver.js.map +1 -0
- package/dist/tmux-wrapper.d.ts +345 -0
- package/dist/tmux-wrapper.d.ts.map +1 -0
- package/dist/tmux-wrapper.js +1747 -0
- package/dist/tmux-wrapper.js.map +1 -0
- package/dist/trajectory-integration.d.ts +292 -0
- package/dist/trajectory-integration.d.ts.map +1 -0
- package/dist/trajectory-integration.js +979 -0
- package/dist/trajectory-integration.js.map +1 -0
- package/dist/wrapper-types.d.ts +41 -0
- package/dist/wrapper-types.d.ts.map +1 -0
- package/dist/wrapper-types.js +7 -0
- package/dist/wrapper-types.js.map +1 -0
- package/package.json +63 -0
- package/src/__fixtures__/claude-outputs.ts +471 -0
- package/src/__fixtures__/codex-outputs.ts +99 -0
- package/src/__fixtures__/gemini-outputs.ts +151 -0
- package/src/__fixtures__/index.ts +47 -0
- package/src/auth-detection.ts +244 -0
- package/src/base-wrapper.test.ts +540 -0
- package/src/base-wrapper.ts +741 -0
- package/src/client.test.ts +262 -0
- package/src/client.ts +984 -0
- package/src/id-generator.test.ts +71 -0
- package/src/id-generator.ts +69 -0
- package/src/idle-detector.test.ts +390 -0
- package/src/idle-detector.ts +370 -0
- package/src/inbox.test.ts +233 -0
- package/src/inbox.ts +89 -0
- package/src/index.ts +170 -0
- package/src/parser.regression.test.ts +251 -0
- package/src/parser.test.ts +1359 -0
- package/src/parser.ts +1477 -0
- package/src/prompt-composer.test.ts +219 -0
- package/src/prompt-composer.ts +231 -0
- package/src/relay-pty-orchestrator.test.ts +1027 -0
- package/src/relay-pty-orchestrator.ts +2270 -0
- package/src/shared.test.ts +221 -0
- package/src/shared.ts +454 -0
- package/src/stuck-detector.test.ts +303 -0
- package/src/stuck-detector.ts +511 -0
- package/src/tmux-resolver.test.ts +104 -0
- package/src/tmux-resolver.ts +207 -0
- package/src/tmux-wrapper.test.ts +316 -0
- package/src/tmux-wrapper.ts +2010 -0
- package/src/trajectory-detection.test.ts +151 -0
- package/src/trajectory-integration.ts +1261 -0
- package/src/wrapper-types.ts +45 -0
|
@@ -0,0 +1,2010 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TmuxWrapper - Attach-based tmux wrapper
|
|
3
|
+
*
|
|
4
|
+
* Architecture:
|
|
5
|
+
* 1. Start agent in detached tmux session
|
|
6
|
+
* 2. Attach user to tmux (they see real terminal)
|
|
7
|
+
* 3. Background: poll capture-pane silently (no stdout writes)
|
|
8
|
+
* 4. Background: parse ->relay commands, send to daemon
|
|
9
|
+
* 5. Background: inject messages via send-keys
|
|
10
|
+
*
|
|
11
|
+
* The key insight: user sees the REAL tmux session, not a proxy.
|
|
12
|
+
* We just do background parsing and injection.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { exec, execSync, spawn, ChildProcess } from 'node:child_process';
|
|
16
|
+
import crypto from 'node:crypto';
|
|
17
|
+
import { promisify } from 'node:util';
|
|
18
|
+
import { BaseWrapper, type BaseWrapperConfig } from './base-wrapper.js';
|
|
19
|
+
import { OutputParser, type ParsedCommand, type ParsedSummary, parseSummaryWithDetails, parseSessionEndFromOutput } from './parser.js';
|
|
20
|
+
import {
|
|
21
|
+
hasContinuityCommand,
|
|
22
|
+
parseContinuityCommand,
|
|
23
|
+
} from '@agent-relay/continuity';
|
|
24
|
+
import { InboxManager } from './inbox.js';
|
|
25
|
+
import type { SendPayload, SendMeta } from '@agent-relay/protocol/types';
|
|
26
|
+
import { SqliteStorageAdapter } from '@agent-relay/storage/sqlite-adapter';
|
|
27
|
+
import { getProjectPaths } from '@agent-relay/config/project-namespace';
|
|
28
|
+
import { getTmuxPath } from '@agent-relay/wrapper';
|
|
29
|
+
import { findAgentConfig } from '@agent-relay/config/agent-config';
|
|
30
|
+
import {
|
|
31
|
+
TrajectoryIntegration,
|
|
32
|
+
getTrajectoryIntegration,
|
|
33
|
+
detectPhaseFromContent,
|
|
34
|
+
detectToolCalls,
|
|
35
|
+
detectErrors,
|
|
36
|
+
getCompactTrailInstructions,
|
|
37
|
+
getTrailEnvVars,
|
|
38
|
+
type PDEROPhase,
|
|
39
|
+
} from '@agent-relay/wrapper';
|
|
40
|
+
import { escapeForShell } from '@agent-relay/config/bridge-utils';
|
|
41
|
+
import { detectProviderAuthRevocation } from './auth-detection.js';
|
|
42
|
+
import {
|
|
43
|
+
type CliType,
|
|
44
|
+
type InjectionCallbacks,
|
|
45
|
+
stripAnsi,
|
|
46
|
+
sleep,
|
|
47
|
+
getDefaultRelayPrefix,
|
|
48
|
+
buildInjectionString,
|
|
49
|
+
injectWithRetry as sharedInjectWithRetry,
|
|
50
|
+
INJECTION_CONSTANTS,
|
|
51
|
+
CLI_QUIRKS,
|
|
52
|
+
AdaptiveThrottle,
|
|
53
|
+
} from './shared.js';
|
|
54
|
+
import { getTmuxPanePid } from './idle-detector.js';
|
|
55
|
+
import { DEFAULT_TMUX_WRAPPER_CONFIG } from '@agent-relay/config/relay-config';
|
|
56
|
+
|
|
57
|
+
const execAsync = promisify(exec);
|
|
58
|
+
|
|
59
|
+
// Constants for cursor stability detection in waitForClearInput
|
|
60
|
+
/** Number of consecutive polls with stable cursor before assuming input is clear */
|
|
61
|
+
const STABLE_CURSOR_THRESHOLD = 3;
|
|
62
|
+
/** Maximum cursor X position that indicates a prompt (typical prompts are 1-4 chars) */
|
|
63
|
+
const MAX_PROMPT_CURSOR_POSITION = 4;
|
|
64
|
+
/** Maximum characters to show in debug log truncation */
|
|
65
|
+
const DEBUG_LOG_TRUNCATE_LENGTH = 40;
|
|
66
|
+
/** Maximum characters to show in relay command log truncation */
|
|
67
|
+
const RELAY_LOG_TRUNCATE_LENGTH = 50;
|
|
68
|
+
|
|
69
|
+
export interface TmuxWrapperConfig extends BaseWrapperConfig {
|
|
70
|
+
cols?: number;
|
|
71
|
+
rows?: number;
|
|
72
|
+
/** Optional program identifier (e.g., 'claude', 'gpt-4o') */
|
|
73
|
+
program?: string;
|
|
74
|
+
/** Optional model identifier (e.g., 'claude-3-opus') */
|
|
75
|
+
model?: string;
|
|
76
|
+
/** Use file-based inbox in addition to injection */
|
|
77
|
+
useInbox?: boolean;
|
|
78
|
+
/** Custom inbox directory */
|
|
79
|
+
inboxDir?: string;
|
|
80
|
+
/** Polling interval for capture-pane (ms) */
|
|
81
|
+
pollInterval?: number;
|
|
82
|
+
/** Enable debug logging to stderr */
|
|
83
|
+
debug?: boolean;
|
|
84
|
+
/** Throttle debug logs (ms) */
|
|
85
|
+
debugLogIntervalMs?: number;
|
|
86
|
+
/** Idle time after last output before injecting (ms) */
|
|
87
|
+
idleBeforeInjectMs?: number;
|
|
88
|
+
/** Retry interval while waiting for idle window (ms) */
|
|
89
|
+
injectRetryMs?: number;
|
|
90
|
+
/** How long with no output before marking session idle (ms) */
|
|
91
|
+
activityIdleThresholdMs?: number;
|
|
92
|
+
/** Max time to wait for clear input before injecting (ms) */
|
|
93
|
+
inputWaitTimeoutMs?: number;
|
|
94
|
+
/** Polling interval when waiting for clear input (ms) */
|
|
95
|
+
inputWaitPollMs?: number;
|
|
96
|
+
/** Enable tmux mouse mode for scroll passthrough (default: true) */
|
|
97
|
+
mouseMode?: boolean;
|
|
98
|
+
/** Max time to wait for stable pane output before injection (ms) */
|
|
99
|
+
outputStabilityTimeoutMs?: number;
|
|
100
|
+
/** Poll interval when checking pane stability before injection (ms) */
|
|
101
|
+
outputStabilityPollMs?: number;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Get the default relay prefix for a given CLI type.
|
|
106
|
+
* All agents now use '->relay:' as the unified prefix.
|
|
107
|
+
* @deprecated Use getDefaultRelayPrefix() from shared.js instead
|
|
108
|
+
*/
|
|
109
|
+
export function getDefaultPrefix(_cliType: CliType): string {
|
|
110
|
+
return getDefaultRelayPrefix();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export class TmuxWrapper extends BaseWrapper {
|
|
114
|
+
protected override config: TmuxWrapperConfig;
|
|
115
|
+
private sessionName: string;
|
|
116
|
+
private parser: OutputParser;
|
|
117
|
+
private inbox?: InboxManager;
|
|
118
|
+
private storage?: SqliteStorageAdapter;
|
|
119
|
+
private storageReady: Promise<boolean>; // Resolves true if storage initialized, false if failed
|
|
120
|
+
private pollTimer?: NodeJS.Timeout;
|
|
121
|
+
private attachProcess?: ChildProcess;
|
|
122
|
+
private lastCapturedOutput = '';
|
|
123
|
+
private lastOutputTime = 0;
|
|
124
|
+
private lastActivityTime = Date.now();
|
|
125
|
+
private activityState: 'active' | 'idle' | 'disconnected' = 'disconnected';
|
|
126
|
+
private recentlySentMessages: Map<string, number> = new Map();
|
|
127
|
+
// Track processed output to avoid re-parsing
|
|
128
|
+
private processedOutputLength = 0;
|
|
129
|
+
private lastLoggedLength = 0; // Track length for incremental log streaming
|
|
130
|
+
private lastDebugLog = 0;
|
|
131
|
+
private lastSummaryHash = ''; // Dedup summary saves
|
|
132
|
+
private pendingRelayCommands: ParsedCommand[] = [];
|
|
133
|
+
private queuedMessageHashes: Set<string> = new Set(); // For offline queue dedup
|
|
134
|
+
private readonly MAX_PENDING_RELAY_COMMANDS = 50;
|
|
135
|
+
private receivedMessageIdSet: Set<string> = new Set();
|
|
136
|
+
private receivedMessageIdOrder: string[] = [];
|
|
137
|
+
private readonly MAX_RECEIVED_MESSAGES = 2000;
|
|
138
|
+
private tmuxPath: string; // Resolved path to tmux binary (system or bundled)
|
|
139
|
+
private trajectory?: TrajectoryIntegration; // Trajectory tracking via trail
|
|
140
|
+
private lastDetectedPhase?: PDEROPhase; // Track last auto-detected PDERO phase
|
|
141
|
+
private seenToolCalls: Set<string> = new Set(); // Dedup tool call trajectory events
|
|
142
|
+
private seenErrors: Set<string> = new Set(); // Dedup error trajectory events
|
|
143
|
+
private authRevoked = false; // Track if auth has been revoked
|
|
144
|
+
private lastAuthCheck = 0; // Timestamp of last auth check (throttle)
|
|
145
|
+
private readonly AUTH_CHECK_INTERVAL = 5000; // Check auth status every 5 seconds max
|
|
146
|
+
|
|
147
|
+
// Adaptive throttle for message queue - adjusts delay based on success/failure
|
|
148
|
+
private throttle = new AdaptiveThrottle();
|
|
149
|
+
|
|
150
|
+
constructor(config: TmuxWrapperConfig) {
|
|
151
|
+
// Merge defaults with config
|
|
152
|
+
const mergedConfig: TmuxWrapperConfig = {
|
|
153
|
+
cols: process.stdout.columns || 120,
|
|
154
|
+
rows: process.stdout.rows || 40,
|
|
155
|
+
...DEFAULT_TMUX_WRAPPER_CONFIG,
|
|
156
|
+
...config,
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// Call parent constructor (initializes client, cliType, relayPrefix, continuity)
|
|
160
|
+
super(mergedConfig);
|
|
161
|
+
this.config = mergedConfig;
|
|
162
|
+
|
|
163
|
+
// Session name (one agent per name - starting a duplicate kills the existing one)
|
|
164
|
+
this.sessionName = `relay-${config.name}`;
|
|
165
|
+
|
|
166
|
+
// Resolve tmux path early so we fail fast if tmux isn't available
|
|
167
|
+
this.tmuxPath = getTmuxPath();
|
|
168
|
+
|
|
169
|
+
// Auto-detect agent role from .claude/agents/ or .openagents/ if task not provided
|
|
170
|
+
let detectedTask = this.config.task;
|
|
171
|
+
if (!detectedTask) {
|
|
172
|
+
const agentConfig = findAgentConfig(config.name, this.config.cwd);
|
|
173
|
+
if (agentConfig?.description) {
|
|
174
|
+
detectedTask = agentConfig.description;
|
|
175
|
+
this.logStderr(`Auto-detected role: ${detectedTask.substring(0, 60)}...`, true);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
this.parser = new OutputParser({ prefix: this.relayPrefix });
|
|
180
|
+
|
|
181
|
+
// Initialize inbox if using file-based messaging
|
|
182
|
+
if (config.useInbox) {
|
|
183
|
+
this.inbox = new InboxManager({
|
|
184
|
+
agentName: config.name,
|
|
185
|
+
inboxDir: config.inboxDir,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Initialize storage for session/summary persistence
|
|
190
|
+
const projectPaths = getProjectPaths();
|
|
191
|
+
this.storage = new SqliteStorageAdapter({ dbPath: projectPaths.dbPath });
|
|
192
|
+
// Initialize asynchronously (don't block constructor) - methods await storageReady
|
|
193
|
+
this.storageReady = this.storage.init().then(() => true).catch(err => {
|
|
194
|
+
this.logStderr(`Failed to initialize storage: ${err.message}`, true);
|
|
195
|
+
this.storage = undefined;
|
|
196
|
+
return false;
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// Initialize trajectory tracking via trail CLI
|
|
200
|
+
this.trajectory = getTrajectoryIntegration(projectPaths.projectId, config.name);
|
|
201
|
+
|
|
202
|
+
this.client.onStateChange = (state) => {
|
|
203
|
+
// Only log to stderr, never stdout (user is in tmux)
|
|
204
|
+
if (state === 'READY') {
|
|
205
|
+
this.logStderr('Connected to relay daemon');
|
|
206
|
+
this.flushQueuedRelayCommands();
|
|
207
|
+
} else if (state === 'BACKOFF') {
|
|
208
|
+
this.logStderr('Relay unavailable, will retry (backoff)');
|
|
209
|
+
} else if (state === 'DISCONNECTED') {
|
|
210
|
+
this.logStderr('Relay disconnected (offline mode)');
|
|
211
|
+
} else if (state === 'CONNECTING') {
|
|
212
|
+
this.logStderr('Connecting to relay daemon...');
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// =========================================================================
|
|
218
|
+
// Abstract method implementations
|
|
219
|
+
// =========================================================================
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Inject content into the tmux session via paste
|
|
223
|
+
*/
|
|
224
|
+
protected async performInjection(content: string): Promise<void> {
|
|
225
|
+
await this.pasteLiteral(content);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Get cleaned output for parsing (strip ANSI codes)
|
|
230
|
+
*/
|
|
231
|
+
protected getCleanOutput(): string {
|
|
232
|
+
return stripAnsi(this.lastCapturedOutput);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Log to stderr (safe - doesn't interfere with tmux display)
|
|
237
|
+
*/
|
|
238
|
+
private logStderr(msg: string, force = false): void {
|
|
239
|
+
if (!force && !this.config.debug) return;
|
|
240
|
+
|
|
241
|
+
const now = Date.now();
|
|
242
|
+
if (!force && this.config.debugLogIntervalMs && this.config.debugLogIntervalMs > 0) {
|
|
243
|
+
if (now - this.lastDebugLog < this.config.debugLogIntervalMs) {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
this.lastDebugLog = now;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Prefix with newline to avoid corrupting tmux status line
|
|
250
|
+
process.stderr.write(`\r[relay:${this.config.name}] ${msg}\n`);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Detect PDERO phase from output content and auto-transition if needed.
|
|
255
|
+
* Also detects tool calls and errors, recording them to the trajectory.
|
|
256
|
+
*/
|
|
257
|
+
private detectAndTransitionPhase(content: string): void {
|
|
258
|
+
if (!this.trajectory) return;
|
|
259
|
+
|
|
260
|
+
// Detect phase transitions
|
|
261
|
+
const detectedPhase = detectPhaseFromContent(content);
|
|
262
|
+
if (detectedPhase && detectedPhase !== this.lastDetectedPhase) {
|
|
263
|
+
const currentPhase = this.trajectory.getPhase();
|
|
264
|
+
if (detectedPhase !== currentPhase) {
|
|
265
|
+
this.trajectory.transition(detectedPhase, 'Auto-detected from output');
|
|
266
|
+
this.lastDetectedPhase = detectedPhase;
|
|
267
|
+
this.logStderr(`Phase transition: ${currentPhase || 'none'} → ${detectedPhase}`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Detect and record tool calls
|
|
272
|
+
// Note: We deduplicate by tool+status to record each unique tool type once per session
|
|
273
|
+
// (e.g., "Read" started, "Read" completed). This provides a summary of tools used
|
|
274
|
+
// without flooding the trajectory with every individual invocation.
|
|
275
|
+
const tools = detectToolCalls(content);
|
|
276
|
+
for (const tool of tools) {
|
|
277
|
+
const key = `${tool.tool}:${tool.status || 'started'}`;
|
|
278
|
+
if (!this.seenToolCalls.has(key)) {
|
|
279
|
+
this.seenToolCalls.add(key);
|
|
280
|
+
const statusLabel = tool.status === 'completed' ? ' (completed)' : '';
|
|
281
|
+
this.trajectory.event(`Tool: ${tool.tool}${statusLabel}`, 'tool_call');
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Detect and record errors
|
|
286
|
+
const errors = detectErrors(content);
|
|
287
|
+
for (const error of errors) {
|
|
288
|
+
if (!this.seenErrors.has(error.message)) {
|
|
289
|
+
this.seenErrors.add(error.message);
|
|
290
|
+
const prefix = error.type === 'warning' ? 'Warning' : 'Error';
|
|
291
|
+
this.trajectory.event(`${prefix}: ${error.message}`, 'error');
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Build the full command with proper quoting
|
|
298
|
+
* Args containing spaces need to be quoted
|
|
299
|
+
*/
|
|
300
|
+
private buildCommand(): string {
|
|
301
|
+
if (!this.config.args || this.config.args.length === 0) {
|
|
302
|
+
return this.config.command;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Quote any argument that contains spaces, quotes, or shell special chars
|
|
306
|
+
// Must handle: spaces, quotes, $, <, >, |, &, ;, (, ), `, etc.
|
|
307
|
+
const quotedArgs = this.config.args.map(arg => {
|
|
308
|
+
if (/[\s"'$<>|&;()`,!\\]/.test(arg)) {
|
|
309
|
+
// Use double quotes and escape internal quotes and special chars
|
|
310
|
+
const escaped = arg
|
|
311
|
+
.replace(/\\/g, '\\\\')
|
|
312
|
+
.replace(/"/g, '\\"')
|
|
313
|
+
.replace(/\$/g, '\\$')
|
|
314
|
+
.replace(/`/g, '\\`')
|
|
315
|
+
.replace(/!/g, '\\!');
|
|
316
|
+
return `"${escaped}"`;
|
|
317
|
+
}
|
|
318
|
+
return arg;
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
return `${this.config.command} ${quotedArgs.join(' ')}`;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Check if tmux session exists
|
|
326
|
+
*/
|
|
327
|
+
private async sessionExists(): Promise<boolean> {
|
|
328
|
+
try {
|
|
329
|
+
await execAsync(`"${this.tmuxPath}" has-session -t ${this.sessionName} 2>/dev/null`);
|
|
330
|
+
return true;
|
|
331
|
+
} catch {
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Start the wrapped agent process
|
|
338
|
+
*/
|
|
339
|
+
async start(): Promise<void> {
|
|
340
|
+
if (this.running) return;
|
|
341
|
+
|
|
342
|
+
// Initialize inbox if enabled
|
|
343
|
+
if (this.inbox) {
|
|
344
|
+
this.inbox.init();
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Connect to relay daemon (in background, don't block)
|
|
348
|
+
this.client.connect()
|
|
349
|
+
.then(() => {
|
|
350
|
+
this.logStderr(`Relay connected (state: ${this.client.state})`, true);
|
|
351
|
+
})
|
|
352
|
+
.catch((err: Error) => {
|
|
353
|
+
// Connection failures will retry via client backoff; surface once to stderr.
|
|
354
|
+
this.logStderr(`Relay connect failed: ${err.message}. Will retry if enabled.`, true);
|
|
355
|
+
this.logStderr(`Relay client state: ${this.client.state}`, true);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
// Kill any existing session with this name
|
|
359
|
+
try {
|
|
360
|
+
execSync(`"${this.tmuxPath}" kill-session -t ${this.sessionName} 2>/dev/null`);
|
|
361
|
+
} catch {
|
|
362
|
+
// Session doesn't exist, that's fine
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Build the command - properly quote args that contain spaces
|
|
366
|
+
const fullCommand = this.buildCommand();
|
|
367
|
+
this.logStderr(`Command: ${fullCommand}`);
|
|
368
|
+
this.logStderr(`Prefix: ${this.relayPrefix} (use ${this.relayPrefix}AgentName to send)`);
|
|
369
|
+
|
|
370
|
+
// Create tmux session
|
|
371
|
+
try {
|
|
372
|
+
execSync(`"${this.tmuxPath}" new-session -d -s ${this.sessionName} -x ${this.config.cols} -y ${this.config.rows}`, {
|
|
373
|
+
cwd: this.config.cwd ?? process.cwd(),
|
|
374
|
+
stdio: 'pipe',
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
// Configure tmux for seamless scrolling
|
|
378
|
+
// Mouse mode passes scroll events to the application when in alternate screen
|
|
379
|
+
const tmuxSettings = [
|
|
380
|
+
'set -g set-clipboard on', // Enable clipboard
|
|
381
|
+
'set -g history-limit 50000', // Large scrollback for when needed
|
|
382
|
+
'setw -g alternate-screen on', // Ensure alternate screen works
|
|
383
|
+
// Pass through mouse scroll to application in alternate screen mode
|
|
384
|
+
'set -ga terminal-overrides ",xterm*:Tc"',
|
|
385
|
+
'set -g status-left-length 100', // Provide ample space for agent name in status bar
|
|
386
|
+
'set -g mode-keys vi', // Predictable key table (avoid copy-mode surprises)
|
|
387
|
+
];
|
|
388
|
+
|
|
389
|
+
// Add mouse mode if enabled (allows scroll passthrough to CLI apps)
|
|
390
|
+
if (this.config.mouseMode) {
|
|
391
|
+
tmuxSettings.unshift('set -g mouse on');
|
|
392
|
+
this.logStderr('Mouse mode enabled (scroll should work in app)');
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
for (const setting of tmuxSettings) {
|
|
396
|
+
try {
|
|
397
|
+
execSync(`"${this.tmuxPath}" ${setting}`, { stdio: 'pipe' });
|
|
398
|
+
} catch {
|
|
399
|
+
// Some settings may not be available in older tmux versions
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Mouse scroll should work for both TUIs (alternate screen) and plain shells.
|
|
404
|
+
// If the pane is in alternate screen, pass scroll to the app; otherwise enter copy-mode and scroll tmux history.
|
|
405
|
+
const tmuxMouseBindings = [
|
|
406
|
+
'unbind -T root WheelUpPane',
|
|
407
|
+
'unbind -T root WheelDownPane',
|
|
408
|
+
'unbind -T root MouseDrag1Pane',
|
|
409
|
+
'bind -T root WheelUpPane if-shell -F "#{alternate_on}" "send-keys -M" "copy-mode -e; send-keys -X scroll-up"',
|
|
410
|
+
'bind -T root WheelDownPane if-shell -F "#{alternate_on}" "send-keys -M" "send-keys -X scroll-down"',
|
|
411
|
+
'bind -T root MouseDrag1Pane if-shell -F "#{alternate_on}" "send-keys -M" "copy-mode -e"',
|
|
412
|
+
];
|
|
413
|
+
|
|
414
|
+
for (const setting of tmuxMouseBindings) {
|
|
415
|
+
try {
|
|
416
|
+
execSync(`"${this.tmuxPath}" ${setting}`, { stdio: 'pipe' });
|
|
417
|
+
} catch {
|
|
418
|
+
// Ignore on older tmux versions lacking these key tables
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Set environment variables including trail/trajectory vars
|
|
423
|
+
const projectPaths = getProjectPaths();
|
|
424
|
+
const trailEnvVars = getTrailEnvVars(projectPaths.projectId, this.config.name, projectPaths.dataDir);
|
|
425
|
+
|
|
426
|
+
for (const [key, value] of Object.entries({
|
|
427
|
+
...this.config.env,
|
|
428
|
+
...trailEnvVars,
|
|
429
|
+
AGENT_RELAY_NAME: this.config.name,
|
|
430
|
+
TERM: 'xterm-256color',
|
|
431
|
+
})) {
|
|
432
|
+
// Use proper shell escaping to prevent command injection via env var values
|
|
433
|
+
const escaped = escapeForShell(value);
|
|
434
|
+
execSync(`"${this.tmuxPath}" setenv -t ${this.sessionName} ${key} "${escaped}"`);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Wait for shell to be ready (look for prompt)
|
|
438
|
+
await this.waitForShellReady();
|
|
439
|
+
|
|
440
|
+
// Send the command to run
|
|
441
|
+
this.logStderr('Sending command to tmux...');
|
|
442
|
+
await this.sendKeysLiteral(fullCommand);
|
|
443
|
+
await sleep(300); // Give shell time to process the command literal
|
|
444
|
+
this.logStderr('Sending Enter...');
|
|
445
|
+
await this.sendKeys('Enter');
|
|
446
|
+
await sleep(500); // Ensure Enter is processed and command starts before we continue
|
|
447
|
+
this.logStderr('Command sent');
|
|
448
|
+
|
|
449
|
+
} catch (err: any) {
|
|
450
|
+
throw new Error(`Failed to create tmux session: ${err.message}`);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Wait for session to be ready
|
|
454
|
+
await this.waitForSession();
|
|
455
|
+
|
|
456
|
+
this.running = true;
|
|
457
|
+
this.lastActivityTime = Date.now();
|
|
458
|
+
this.activityState = 'active';
|
|
459
|
+
|
|
460
|
+
// Initialize trajectory tracking (auto-start if task provided)
|
|
461
|
+
this.initializeTrajectory();
|
|
462
|
+
|
|
463
|
+
// Initialize continuity and get/create agentId
|
|
464
|
+
this.initializeAgentId();
|
|
465
|
+
|
|
466
|
+
// Start background polling (silent - no stdout writes)
|
|
467
|
+
this.startSilentPolling();
|
|
468
|
+
|
|
469
|
+
// Initialize idle detector with the tmux pane PID for process state inspection
|
|
470
|
+
this.initializeIdleDetectorPid();
|
|
471
|
+
this.startStuckDetection();
|
|
472
|
+
|
|
473
|
+
// Wait for agent to be ready, then inject instructions
|
|
474
|
+
// This replaces the fixed 3-second delay with actual readiness detection
|
|
475
|
+
this.waitForAgentReady().then(() => {
|
|
476
|
+
this.injectInstructions();
|
|
477
|
+
}).catch(err => {
|
|
478
|
+
this.logStderr(`Failed to wait for agent ready: ${err.message}`, true);
|
|
479
|
+
// Fall back to injecting after a delay
|
|
480
|
+
setTimeout(() => this.injectInstructions(), 3000);
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
// Attach user to tmux session
|
|
484
|
+
// This takes over stdin/stdout - user sees the real terminal
|
|
485
|
+
this.attachToSession();
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Initialize trajectory tracking
|
|
490
|
+
* Auto-starts a trajectory if task is provided in config
|
|
491
|
+
*/
|
|
492
|
+
private async initializeTrajectory(): Promise<void> {
|
|
493
|
+
if (!this.trajectory) return;
|
|
494
|
+
|
|
495
|
+
// Auto-start trajectory if task is provided
|
|
496
|
+
if (this.config.task) {
|
|
497
|
+
const success = await this.trajectory.initialize(this.config.task);
|
|
498
|
+
if (success) {
|
|
499
|
+
this.logStderr(`Trajectory started for task: ${this.config.task}`);
|
|
500
|
+
}
|
|
501
|
+
} else {
|
|
502
|
+
// Just initialize without starting a trajectory
|
|
503
|
+
await this.trajectory.initialize();
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Initialize agent ID for continuity/resume functionality (uses logStderr for tmux)
|
|
509
|
+
*/
|
|
510
|
+
protected override async initializeAgentId(): Promise<void> {
|
|
511
|
+
if (!this.continuity) return;
|
|
512
|
+
|
|
513
|
+
try {
|
|
514
|
+
let ledger;
|
|
515
|
+
|
|
516
|
+
// If resuming from a previous agent ID, try to find that ledger
|
|
517
|
+
if (this.config.resumeAgentId) {
|
|
518
|
+
ledger = await this.continuity.findLedgerByAgentId(this.config.resumeAgentId);
|
|
519
|
+
if (ledger) {
|
|
520
|
+
this.logStderr(`Resuming agent ID: ${ledger.agentId} (from previous session)`);
|
|
521
|
+
} else {
|
|
522
|
+
this.logStderr(`Resume agent ID ${this.config.resumeAgentId} not found, creating new`, true);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// If not resuming or resume ID not found, get or create ledger
|
|
527
|
+
if (!ledger) {
|
|
528
|
+
ledger = await this.continuity.getOrCreateLedger(
|
|
529
|
+
this.config.name,
|
|
530
|
+
this.cliType
|
|
531
|
+
);
|
|
532
|
+
this.logStderr(`Agent ID: ${ledger.agentId} (use this to resume if agent dies)`);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
this.agentId = ledger.agentId;
|
|
536
|
+
} catch (err: any) {
|
|
537
|
+
this.logStderr(`Failed to initialize agent ID: ${err.message}`, true);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Initialize the idle detector with the tmux pane PID.
|
|
543
|
+
* This enables process state inspection on Linux for more reliable idle detection.
|
|
544
|
+
*/
|
|
545
|
+
private async initializeIdleDetectorPid(): Promise<void> {
|
|
546
|
+
try {
|
|
547
|
+
const pid = await getTmuxPanePid(this.tmuxPath, this.sessionName);
|
|
548
|
+
if (pid) {
|
|
549
|
+
this.setIdleDetectorPid(pid);
|
|
550
|
+
this.logStderr(`Idle detector initialized with PID ${pid}`);
|
|
551
|
+
} else {
|
|
552
|
+
this.logStderr('Could not get pane PID for idle detection (will use output analysis)');
|
|
553
|
+
}
|
|
554
|
+
} catch (err: any) {
|
|
555
|
+
this.logStderr(`Failed to initialize idle detector PID: ${err.message}`);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Wait for the agent to be ready for input.
|
|
561
|
+
* Uses idle detection instead of a fixed delay.
|
|
562
|
+
*/
|
|
563
|
+
private async waitForAgentReady(): Promise<void> {
|
|
564
|
+
// Minimum wait to ensure the CLI process has started
|
|
565
|
+
await sleep(500);
|
|
566
|
+
|
|
567
|
+
// Wait for agent to become idle (CLI fully initialized)
|
|
568
|
+
const result = await this.waitForIdleState(10000, 200);
|
|
569
|
+
|
|
570
|
+
if (result.isIdle) {
|
|
571
|
+
this.logStderr(`Agent ready (confidence: ${(result.confidence * 100).toFixed(0)}%)`);
|
|
572
|
+
} else {
|
|
573
|
+
this.logStderr('Agent readiness timeout, proceeding anyway');
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Inject usage instructions for the agent including persistence protocol
|
|
579
|
+
*/
|
|
580
|
+
private async injectInstructions(): Promise<void> {
|
|
581
|
+
if (!this.running) return;
|
|
582
|
+
if (this.config.skipInstructions) return;
|
|
583
|
+
|
|
584
|
+
// Use escaped prefix (\->relay:) in examples to prevent parser from treating them as real commands
|
|
585
|
+
const escapedPrefix = '\\' + this.relayPrefix;
|
|
586
|
+
|
|
587
|
+
// Build instructions including relay and trail
|
|
588
|
+
const relayInstructions = [
|
|
589
|
+
`[Agent Relay] You are "${this.config.name}" - connected for real-time messaging.`,
|
|
590
|
+
`SEND: ${escapedPrefix}AgentName message`,
|
|
591
|
+
`MULTI-LINE: ${escapedPrefix}AgentName <<<(newline)content(newline)>>> - ALWAYS end with >>> on its own line!`,
|
|
592
|
+
`IMPORTANT: Do NOT include self-identification or preamble in messages. Start with your actual response content.`,
|
|
593
|
+
`PERSIST: Output [[SUMMARY]]{"currentTask":"...","context":"..."}[[/SUMMARY]] after major work.`,
|
|
594
|
+
`END: Output [[SESSION_END]]{"summary":"..."}[[/SESSION_END]] when session complete.`,
|
|
595
|
+
].join(' | ');
|
|
596
|
+
|
|
597
|
+
// Add trail instructions if available
|
|
598
|
+
const trailInstructions = getCompactTrailInstructions();
|
|
599
|
+
|
|
600
|
+
try {
|
|
601
|
+
await this.sendKeysLiteral(relayInstructions);
|
|
602
|
+
await sleep(50);
|
|
603
|
+
await this.sendKeys('Enter');
|
|
604
|
+
|
|
605
|
+
// Inject trail instructions
|
|
606
|
+
if (this.trajectory?.isTrailInstalledSync()) {
|
|
607
|
+
await sleep(100);
|
|
608
|
+
await this.sendKeysLiteral(trailInstructions);
|
|
609
|
+
await sleep(50);
|
|
610
|
+
await this.sendKeys('Enter');
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// Inject continuity context from previous session
|
|
614
|
+
await this.injectContinuityContext();
|
|
615
|
+
} catch {
|
|
616
|
+
// Silent fail - instructions are nice-to-have
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Inject continuity context from previous session
|
|
622
|
+
*/
|
|
623
|
+
private async injectContinuityContext(): Promise<void> {
|
|
624
|
+
if (!this.continuity || !this.running) return;
|
|
625
|
+
|
|
626
|
+
try {
|
|
627
|
+
const context = await this.continuity.getStartupContext(this.config.name);
|
|
628
|
+
if (context && context.formatted) {
|
|
629
|
+
// Inject a brief notification about loaded context
|
|
630
|
+
const notification = `[Continuity] Previous session context loaded. ${
|
|
631
|
+
context.ledger ? `Task: ${context.ledger.currentTask?.slice(0, 50) || 'unknown'}` : ''
|
|
632
|
+
}${context.handoff ? ` | Last handoff: ${context.handoff.createdAt.toISOString().split('T')[0]}` : ''}`;
|
|
633
|
+
|
|
634
|
+
await sleep(200);
|
|
635
|
+
await this.sendKeysLiteral(notification);
|
|
636
|
+
await sleep(50);
|
|
637
|
+
await this.sendKeys('Enter');
|
|
638
|
+
|
|
639
|
+
// Queue the full context for injection when agent is ready
|
|
640
|
+
this.messageQueue.push({
|
|
641
|
+
from: 'system',
|
|
642
|
+
body: context.formatted,
|
|
643
|
+
messageId: `continuity-startup-${Date.now()}`,
|
|
644
|
+
});
|
|
645
|
+
this.checkForInjectionOpportunity();
|
|
646
|
+
|
|
647
|
+
if (this.config.debug) {
|
|
648
|
+
this.logStderr(`[CONTINUITY] Loaded context for ${this.config.name}`);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
} catch (err: any) {
|
|
652
|
+
this.logStderr(`[CONTINUITY] Failed to load context: ${err.message}`, true);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Wait for tmux session to be ready
|
|
658
|
+
*/
|
|
659
|
+
private async waitForSession(maxWaitMs = 5000): Promise<void> {
|
|
660
|
+
const startTime = Date.now();
|
|
661
|
+
|
|
662
|
+
while (Date.now() - startTime < maxWaitMs) {
|
|
663
|
+
if (await this.sessionExists()) {
|
|
664
|
+
await new Promise(r => setTimeout(r, 200));
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
await new Promise(r => setTimeout(r, 100));
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
throw new Error('Timeout waiting for tmux session');
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Wait for shell prompt to appear (shell is ready for input)
|
|
675
|
+
*/
|
|
676
|
+
private async waitForShellReady(maxWaitMs = 10000): Promise<void> {
|
|
677
|
+
const startTime = Date.now();
|
|
678
|
+
// Common prompt endings: $, %, >, ➜, #
|
|
679
|
+
const promptPatterns = /[$%>#➜]\s*$/;
|
|
680
|
+
|
|
681
|
+
this.logStderr('Waiting for shell to initialize...');
|
|
682
|
+
|
|
683
|
+
while (Date.now() - startTime < maxWaitMs) {
|
|
684
|
+
try {
|
|
685
|
+
const { stdout } = await execAsync(
|
|
686
|
+
// -J joins wrapped lines so long prompts/messages stay intact
|
|
687
|
+
`"${this.tmuxPath}" capture-pane -t ${this.sessionName} -p -J 2>/dev/null`
|
|
688
|
+
);
|
|
689
|
+
|
|
690
|
+
// Check if the last non-empty line looks like a prompt
|
|
691
|
+
const lines = stdout.split('\n').filter(l => l.trim());
|
|
692
|
+
const lastLine = lines[lines.length - 1] || '';
|
|
693
|
+
|
|
694
|
+
if (promptPatterns.test(lastLine)) {
|
|
695
|
+
this.logStderr('Shell ready');
|
|
696
|
+
// Extra delay to ensure shell is fully ready
|
|
697
|
+
await sleep(200);
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
} catch {
|
|
701
|
+
// Session might not be ready yet
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
await sleep(200);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// Fallback: proceed anyway after timeout
|
|
708
|
+
this.logStderr('Shell ready timeout, proceeding anyway');
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* Attach user to tmux session
|
|
713
|
+
* This spawns tmux attach and lets it take over stdin/stdout
|
|
714
|
+
*/
|
|
715
|
+
private attachToSession(): void {
|
|
716
|
+
this.attachProcess = spawn(this.tmuxPath, ['attach-session', '-t', this.sessionName], {
|
|
717
|
+
stdio: 'inherit', // User's terminal connects directly to tmux
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
this.attachProcess.on('exit', (code) => {
|
|
721
|
+
this.logStderr(`Session ended (code: ${code})`, true);
|
|
722
|
+
this.stop();
|
|
723
|
+
process.exit(code ?? 0);
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
this.attachProcess.on('error', (err) => {
|
|
727
|
+
this.logStderr(`Attach error: ${err.message}`, true);
|
|
728
|
+
this.stop();
|
|
729
|
+
process.exit(1);
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
// Handle signals
|
|
733
|
+
const cleanup = () => {
|
|
734
|
+
this.stop();
|
|
735
|
+
};
|
|
736
|
+
process.on('SIGINT', cleanup);
|
|
737
|
+
process.on('SIGTERM', cleanup);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* Start silent polling for ->relay commands
|
|
742
|
+
* Does NOT write to stdout - just parses and sends to daemon
|
|
743
|
+
*/
|
|
744
|
+
private startSilentPolling(): void {
|
|
745
|
+
this.pollTimer = setInterval(() => {
|
|
746
|
+
this.pollForRelayCommands().catch(() => {
|
|
747
|
+
// Ignore poll errors
|
|
748
|
+
});
|
|
749
|
+
}, this.config.pollInterval);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* Poll for ->relay commands in output (silent)
|
|
754
|
+
*/
|
|
755
|
+
private async pollForRelayCommands(): Promise<void> {
|
|
756
|
+
if (!this.running) return;
|
|
757
|
+
|
|
758
|
+
try {
|
|
759
|
+
// Capture scrollback
|
|
760
|
+
const { stdout } = await execAsync(
|
|
761
|
+
// -J joins wrapped lines to avoid truncating ->relay commands mid-line
|
|
762
|
+
`"${this.tmuxPath}" capture-pane -t ${this.sessionName} -p -J -S - 2>/dev/null`
|
|
763
|
+
);
|
|
764
|
+
|
|
765
|
+
// Always parse the FULL capture for ->relay commands
|
|
766
|
+
// This handles terminal UIs that rewrite content in place
|
|
767
|
+
const cleanContent = stripAnsi(stdout);
|
|
768
|
+
// Join continuation lines that TUIs split across multiple lines
|
|
769
|
+
const joinedContent = this.joinContinuationLines(cleanContent);
|
|
770
|
+
const { commands, output: filteredOutput } = this.parser.parse(joinedContent);
|
|
771
|
+
|
|
772
|
+
// Debug: log relay commands being parsed
|
|
773
|
+
if (commands.length > 0 && this.config.debug) {
|
|
774
|
+
for (const cmd of commands) {
|
|
775
|
+
const bodyPreview = cmd.body.substring(0, 80).replace(/\n/g, '\\n');
|
|
776
|
+
this.logStderr(`[RELAY_PARSED] to=${cmd.to}, body="${bodyPreview}...", lines=${cmd.body.split('\n').length}`);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// Track last output time for injection timing
|
|
781
|
+
if (stdout.length !== this.processedOutputLength) {
|
|
782
|
+
this.lastOutputTime = Date.now();
|
|
783
|
+
this.markActivity();
|
|
784
|
+
|
|
785
|
+
// Feed new output to idle detector for more robust idle detection
|
|
786
|
+
const newOutput = stdout.substring(this.processedOutputLength);
|
|
787
|
+
this.feedIdleDetectorOutput(newOutput);
|
|
788
|
+
|
|
789
|
+
this.processedOutputLength = stdout.length;
|
|
790
|
+
|
|
791
|
+
// Stream new output to daemon for dashboard log viewing
|
|
792
|
+
// Use filtered output to exclude thinking blocks and relay commands
|
|
793
|
+
if (this.config.streamLogs && this.client.state === 'READY') {
|
|
794
|
+
// Send incremental filtered output since last log
|
|
795
|
+
const newContent = filteredOutput.substring(this.lastLoggedLength);
|
|
796
|
+
if (newContent.length > 0) {
|
|
797
|
+
this.client.sendLog(newContent);
|
|
798
|
+
this.lastLoggedLength = filteredOutput.length;
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// Send any commands found (deduplication handles repeats)
|
|
804
|
+
for (const cmd of commands) {
|
|
805
|
+
this.sendRelayCommand(cmd);
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// Check for [[SUMMARY]] blocks and save to storage
|
|
809
|
+
this.parseSummaryAndSave(cleanContent);
|
|
810
|
+
|
|
811
|
+
// Detect PDERO phase transitions from output content
|
|
812
|
+
this.detectAndTransitionPhase(cleanContent);
|
|
813
|
+
|
|
814
|
+
// Parse and handle continuity commands (->continuity:save, ->continuity:load, etc.)
|
|
815
|
+
await this.parseContinuityCommands(joinedContent);
|
|
816
|
+
|
|
817
|
+
// Check for [[SESSION_END]] blocks to explicitly close session
|
|
818
|
+
this.parseSessionEndAndClose(cleanContent);
|
|
819
|
+
|
|
820
|
+
// Check for ->relay:spawn and ->relay:release commands (any agent can spawn)
|
|
821
|
+
// Use joinedContent to handle multi-line output from TUIs like Claude Code
|
|
822
|
+
this.parseSpawnReleaseCommands(joinedContent);
|
|
823
|
+
|
|
824
|
+
// Check for auth revocation (limited sessions scenario)
|
|
825
|
+
this.checkAuthRevocation(cleanContent);
|
|
826
|
+
|
|
827
|
+
this.updateActivityState();
|
|
828
|
+
|
|
829
|
+
// Also check for injection opportunity
|
|
830
|
+
this.checkForInjectionOpportunity();
|
|
831
|
+
|
|
832
|
+
} catch (err: any) {
|
|
833
|
+
if (err.message?.includes('no such session')) {
|
|
834
|
+
this.stop();
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/**
|
|
840
|
+
* Record recent activity and transition back to active if needed.
|
|
841
|
+
*/
|
|
842
|
+
private markActivity(): void {
|
|
843
|
+
this.lastActivityTime = Date.now();
|
|
844
|
+
if (this.activityState === 'idle') {
|
|
845
|
+
this.activityState = 'active';
|
|
846
|
+
this.logStderr('Session active');
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* Update activity state based on idle threshold and trigger injections when idle.
|
|
852
|
+
*/
|
|
853
|
+
private updateActivityState(): void {
|
|
854
|
+
if (this.activityState === 'disconnected') return;
|
|
855
|
+
|
|
856
|
+
const now = Date.now();
|
|
857
|
+
const idleThreshold = this.config.activityIdleThresholdMs ?? 30000;
|
|
858
|
+
const timeSinceActivity = now - this.lastActivityTime;
|
|
859
|
+
|
|
860
|
+
if (timeSinceActivity > idleThreshold && this.activityState === 'active') {
|
|
861
|
+
this.activityState = 'idle';
|
|
862
|
+
this.logStderr('Session went idle');
|
|
863
|
+
this.checkForInjectionOpportunity();
|
|
864
|
+
} else if (timeSinceActivity <= idleThreshold && this.activityState === 'idle') {
|
|
865
|
+
this.activityState = 'active';
|
|
866
|
+
this.logStderr('Session active');
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/**
|
|
871
|
+
* Check if the CLI output indicates auth has been revoked.
|
|
872
|
+
* This can happen when the user authenticates elsewhere (limited sessions).
|
|
873
|
+
*/
|
|
874
|
+
private checkAuthRevocation(output: string): void {
|
|
875
|
+
// Don't check if already revoked or if we checked recently
|
|
876
|
+
if (this.authRevoked) return;
|
|
877
|
+
const now = Date.now();
|
|
878
|
+
if (now - this.lastAuthCheck < this.AUTH_CHECK_INTERVAL) return;
|
|
879
|
+
this.lastAuthCheck = now;
|
|
880
|
+
|
|
881
|
+
// Get the CLI type/provider from config
|
|
882
|
+
const provider = this.config.program || this.cliType || 'claude';
|
|
883
|
+
|
|
884
|
+
// Check for auth revocation patterns in recent output
|
|
885
|
+
const result = detectProviderAuthRevocation(output, provider);
|
|
886
|
+
|
|
887
|
+
if (result.detected && result.confidence !== 'low') {
|
|
888
|
+
this.authRevoked = true;
|
|
889
|
+
this.logStderr(`[AUTH] Auth revocation detected (${result.confidence} confidence): ${result.message}`);
|
|
890
|
+
|
|
891
|
+
// Send auth status message to daemon
|
|
892
|
+
if (this.client.state === 'READY') {
|
|
893
|
+
const authPayload = JSON.stringify({
|
|
894
|
+
type: 'auth_revoked',
|
|
895
|
+
agent: this.config.name,
|
|
896
|
+
provider,
|
|
897
|
+
message: result.message,
|
|
898
|
+
confidence: result.confidence,
|
|
899
|
+
timestamp: new Date().toISOString(),
|
|
900
|
+
});
|
|
901
|
+
this.client.sendMessage('#system', authPayload, 'message');
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// Emit event for listeners
|
|
905
|
+
this.emit('auth_revoked', {
|
|
906
|
+
agent: this.config.name,
|
|
907
|
+
provider,
|
|
908
|
+
message: result.message,
|
|
909
|
+
confidence: result.confidence,
|
|
910
|
+
});
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/**
|
|
915
|
+
* Reset auth revocation state (called after successful re-authentication)
|
|
916
|
+
*/
|
|
917
|
+
public resetAuthState(): void {
|
|
918
|
+
this.authRevoked = false;
|
|
919
|
+
this.lastAuthCheck = 0;
|
|
920
|
+
this.logStderr('[AUTH] Auth state reset');
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/**
|
|
924
|
+
* Check if auth has been revoked
|
|
925
|
+
*/
|
|
926
|
+
public isAuthRevoked(): boolean {
|
|
927
|
+
return this.authRevoked;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/**
|
|
931
|
+
* Send relay command to daemon (overrides BaseWrapper for offline queue support)
|
|
932
|
+
*/
|
|
933
|
+
protected override sendRelayCommand(cmd: ParsedCommand): void {
|
|
934
|
+
const msgHash = `${cmd.to}:${cmd.body}`;
|
|
935
|
+
|
|
936
|
+
// Permanent dedup - never send the same message twice
|
|
937
|
+
if (this.sentMessageHashes.has(msgHash)) {
|
|
938
|
+
this.logStderr(`[DEDUP] Skipped duplicate message to ${cmd.to} (hash already sent)`);
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// If client not ready, queue for later and return
|
|
943
|
+
if (this.client.state !== 'READY') {
|
|
944
|
+
if (this.queuedMessageHashes.has(msgHash)) {
|
|
945
|
+
return; // Already queued
|
|
946
|
+
}
|
|
947
|
+
if (this.pendingRelayCommands.length >= this.MAX_PENDING_RELAY_COMMANDS) {
|
|
948
|
+
this.logStderr('Relay offline queue full, dropping oldest');
|
|
949
|
+
const dropped = this.pendingRelayCommands.shift();
|
|
950
|
+
if (dropped) {
|
|
951
|
+
this.queuedMessageHashes.delete(`${dropped.to}:${dropped.body}`);
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
this.pendingRelayCommands.push(cmd);
|
|
955
|
+
this.queuedMessageHashes.add(msgHash);
|
|
956
|
+
this.logStderr(`Relay offline; queued message to ${cmd.to}`);
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// Convert ParsedMessageMetadata to SendMeta if present
|
|
961
|
+
let sendMeta: SendMeta | undefined;
|
|
962
|
+
if (cmd.meta) {
|
|
963
|
+
sendMeta = {
|
|
964
|
+
importance: cmd.meta.importance,
|
|
965
|
+
replyTo: cmd.meta.replyTo,
|
|
966
|
+
requires_ack: cmd.meta.ackRequired,
|
|
967
|
+
};
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
if (cmd.sync?.blocking) {
|
|
971
|
+
this.client.sendAndWait(cmd.to, cmd.body, {
|
|
972
|
+
timeoutMs: cmd.sync.timeoutMs,
|
|
973
|
+
kind: cmd.kind,
|
|
974
|
+
data: cmd.data,
|
|
975
|
+
thread: cmd.thread,
|
|
976
|
+
}).then(() => {
|
|
977
|
+
this.sentMessageHashes.add(msgHash);
|
|
978
|
+
this.queuedMessageHashes.delete(msgHash);
|
|
979
|
+
}).catch((err) => {
|
|
980
|
+
this.logStderr(`sendAndWait failed for ${cmd.to}: ${err.message}`, true);
|
|
981
|
+
});
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
const success = this.client.sendMessage(cmd.to, cmd.body, cmd.kind, cmd.data, cmd.thread, sendMeta);
|
|
986
|
+
if (success) {
|
|
987
|
+
this.sentMessageHashes.add(msgHash);
|
|
988
|
+
this.queuedMessageHashes.delete(msgHash);
|
|
989
|
+
const truncatedBody = cmd.body.substring(0, Math.min(RELAY_LOG_TRUNCATE_LENGTH, cmd.body.length));
|
|
990
|
+
this.logStderr(`→ ${cmd.to}: ${truncatedBody}...`);
|
|
991
|
+
|
|
992
|
+
// Record in trajectory via trail
|
|
993
|
+
this.trajectory?.message('sent', this.config.name, cmd.to, cmd.body);
|
|
994
|
+
} else if (this.client.state !== 'READY') {
|
|
995
|
+
// Only log failure once per state change
|
|
996
|
+
this.logStderr(`Send failed (client ${this.client.state})`);
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/**
|
|
1001
|
+
* Flush any queued relay commands when the client reconnects.
|
|
1002
|
+
*/
|
|
1003
|
+
private flushQueuedRelayCommands(): void {
|
|
1004
|
+
if (this.pendingRelayCommands.length === 0) return;
|
|
1005
|
+
|
|
1006
|
+
const queued = [...this.pendingRelayCommands];
|
|
1007
|
+
this.pendingRelayCommands = [];
|
|
1008
|
+
this.queuedMessageHashes.clear();
|
|
1009
|
+
|
|
1010
|
+
for (const cmd of queued) {
|
|
1011
|
+
this.sendRelayCommand(cmd);
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
/**
|
|
1016
|
+
* Parse [[SUMMARY]] blocks from output and save to storage.
|
|
1017
|
+
* Agents can output summaries to maintain running context:
|
|
1018
|
+
*
|
|
1019
|
+
* [[SUMMARY]]
|
|
1020
|
+
* {"currentTask": "Implementing auth", "context": "Completed login flow"}
|
|
1021
|
+
* [[/SUMMARY]]
|
|
1022
|
+
*/
|
|
1023
|
+
private parseSummaryAndSave(content: string): void {
|
|
1024
|
+
const result = parseSummaryWithDetails(content);
|
|
1025
|
+
|
|
1026
|
+
// No SUMMARY block found
|
|
1027
|
+
if (!result.found) return;
|
|
1028
|
+
|
|
1029
|
+
// Dedup based on raw content - prevents repeated error logging for same invalid JSON
|
|
1030
|
+
if (result.rawContent === this.lastSummaryRawContent) return;
|
|
1031
|
+
this.lastSummaryRawContent = result.rawContent || '';
|
|
1032
|
+
|
|
1033
|
+
// Invalid JSON - log error once (deduped above)
|
|
1034
|
+
if (!result.valid) {
|
|
1035
|
+
this.logStderr('[parser] Invalid JSON in SUMMARY block');
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
const summary = result.summary!;
|
|
1040
|
+
|
|
1041
|
+
// Dedup valid summaries - don't save same summary twice
|
|
1042
|
+
const summaryHash = JSON.stringify(summary);
|
|
1043
|
+
if (summaryHash === this.lastSummaryHash) return;
|
|
1044
|
+
this.lastSummaryHash = summaryHash;
|
|
1045
|
+
|
|
1046
|
+
// Save to continuity ledger for session recovery
|
|
1047
|
+
// This ensures the ledger has actual data instead of placeholders
|
|
1048
|
+
if (this.continuity) {
|
|
1049
|
+
this.saveSummaryToLedger(summary).catch(err => {
|
|
1050
|
+
this.logStderr(`Failed to save summary to ledger: ${err.message}`, true);
|
|
1051
|
+
});
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
// Wait for storage to be ready before saving to project storage
|
|
1055
|
+
this.storageReady.then(ready => {
|
|
1056
|
+
if (!ready || !this.storage) {
|
|
1057
|
+
this.logStderr('Cannot save summary: storage not initialized');
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
const projectPaths = getProjectPaths();
|
|
1062
|
+
this.storage.saveAgentSummary({
|
|
1063
|
+
agentName: this.config.name,
|
|
1064
|
+
projectId: projectPaths.projectId,
|
|
1065
|
+
currentTask: summary.currentTask,
|
|
1066
|
+
completedTasks: summary.completedTasks,
|
|
1067
|
+
decisions: summary.decisions,
|
|
1068
|
+
context: summary.context,
|
|
1069
|
+
files: summary.files,
|
|
1070
|
+
}).then(() => {
|
|
1071
|
+
this.logStderr(`Saved agent summary: ${summary.currentTask || 'updated context'}`);
|
|
1072
|
+
}).catch(err => {
|
|
1073
|
+
this.logStderr(`Failed to save summary: ${err.message}`, true);
|
|
1074
|
+
});
|
|
1075
|
+
});
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/**
|
|
1079
|
+
* Save a parsed summary to the continuity ledger (uses logStderr for tmux).
|
|
1080
|
+
* Maps summary fields to ledger fields for session recovery.
|
|
1081
|
+
*/
|
|
1082
|
+
protected override async saveSummaryToLedger(summary: ParsedSummary): Promise<void> {
|
|
1083
|
+
if (!this.continuity) return;
|
|
1084
|
+
|
|
1085
|
+
const updates: Record<string, unknown> = {};
|
|
1086
|
+
|
|
1087
|
+
// Map summary fields to ledger fields
|
|
1088
|
+
if (summary.currentTask) {
|
|
1089
|
+
updates.currentTask = summary.currentTask;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
if (summary.completedTasks && summary.completedTasks.length > 0) {
|
|
1093
|
+
updates.completed = summary.completedTasks;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
if (summary.context) {
|
|
1097
|
+
// Store context in inProgress as "next steps" hint
|
|
1098
|
+
updates.inProgress = [summary.context];
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
if (summary.files && summary.files.length > 0) {
|
|
1102
|
+
updates.fileContext = summary.files.map((f: string) => ({ path: f }));
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
// Only save if we have meaningful updates
|
|
1106
|
+
if (Object.keys(updates).length > 0) {
|
|
1107
|
+
await this.continuity.saveLedger(this.config.name, updates);
|
|
1108
|
+
this.logStderr('Saved summary to continuity ledger');
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
/**
|
|
1113
|
+
* Parse ->continuity: commands from output and handle them.
|
|
1114
|
+
* Supported commands:
|
|
1115
|
+
* ->continuity:save <<<...>>> - Save session state to ledger
|
|
1116
|
+
* ->continuity:load - Request context injection
|
|
1117
|
+
* ->continuity:search "query" - Search past handoffs
|
|
1118
|
+
* ->continuity:uncertain "..." - Mark item as uncertain
|
|
1119
|
+
* ->continuity:handoff <<<...>>> - Create explicit handoff
|
|
1120
|
+
*/
|
|
1121
|
+
protected override async parseContinuityCommands(content: string): Promise<void> {
|
|
1122
|
+
if (!this.continuity) return;
|
|
1123
|
+
if (!hasContinuityCommand(content)) return;
|
|
1124
|
+
|
|
1125
|
+
const command = parseContinuityCommand(content);
|
|
1126
|
+
if (!command) return;
|
|
1127
|
+
|
|
1128
|
+
// Create a hash for deduplication
|
|
1129
|
+
// For commands with content (save, handoff, uncertain), use content hash
|
|
1130
|
+
// For commands without content (load, search), allow each unique call
|
|
1131
|
+
const hasContent = command.content || command.query || command.item;
|
|
1132
|
+
const cmdHash = hasContent
|
|
1133
|
+
? `${command.type}:${command.content || command.query || command.item}`
|
|
1134
|
+
: `${command.type}:${Date.now()}`; // Allow load/search to run each time
|
|
1135
|
+
if (hasContent && this.processedContinuityCommands.has(cmdHash)) return;
|
|
1136
|
+
this.processedContinuityCommands.add(cmdHash);
|
|
1137
|
+
|
|
1138
|
+
// Limit dedup set size
|
|
1139
|
+
if (this.processedContinuityCommands.size > 100) {
|
|
1140
|
+
const oldest = this.processedContinuityCommands.values().next().value;
|
|
1141
|
+
if (oldest) this.processedContinuityCommands.delete(oldest);
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
try {
|
|
1145
|
+
if (this.config.debug) {
|
|
1146
|
+
this.logStderr(`[CONTINUITY] Processing ${command.type} command`);
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
const response = await this.continuity.handleCommand(this.config.name, command);
|
|
1150
|
+
|
|
1151
|
+
// If there's a response (e.g., from load or search), inject it
|
|
1152
|
+
if (response) {
|
|
1153
|
+
this.messageQueue.push({
|
|
1154
|
+
from: 'system',
|
|
1155
|
+
body: response,
|
|
1156
|
+
messageId: `continuity-${Date.now()}`,
|
|
1157
|
+
});
|
|
1158
|
+
this.checkForInjectionOpportunity();
|
|
1159
|
+
}
|
|
1160
|
+
} catch (err: any) {
|
|
1161
|
+
this.logStderr(`[CONTINUITY] Error: ${err.message}`, true);
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
/**
|
|
1166
|
+
* Parse [[SESSION_END]] blocks from output and close session explicitly.
|
|
1167
|
+
* Agents output this to mark their work session as complete:
|
|
1168
|
+
*
|
|
1169
|
+
* [[SESSION_END]]
|
|
1170
|
+
* {"summary": "Completed auth module", "completedTasks": ["login", "logout"]}
|
|
1171
|
+
* [[/SESSION_END]]
|
|
1172
|
+
*
|
|
1173
|
+
* Also stores the data for use in autoSave to populate handoff (fixes empty handoff issue).
|
|
1174
|
+
*/
|
|
1175
|
+
private parseSessionEndAndClose(content: string): void {
|
|
1176
|
+
if (this.sessionEndProcessed) return; // Only process once per session
|
|
1177
|
+
|
|
1178
|
+
const sessionEnd = parseSessionEndFromOutput(content);
|
|
1179
|
+
if (!sessionEnd) return;
|
|
1180
|
+
|
|
1181
|
+
// Store SESSION_END data for use in autoSave (fixes empty handoff issue)
|
|
1182
|
+
this.sessionEndData = sessionEnd;
|
|
1183
|
+
|
|
1184
|
+
// Get session ID from client connection - if not available yet, don't set flag
|
|
1185
|
+
// so we can retry when sessionId becomes available
|
|
1186
|
+
const sessionId = this.client.currentSessionId;
|
|
1187
|
+
if (!sessionId) {
|
|
1188
|
+
this.logStderr('Cannot close session: no session ID yet, will retry');
|
|
1189
|
+
return;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
this.sessionEndProcessed = true;
|
|
1193
|
+
|
|
1194
|
+
// Wait for storage to be ready before attempting to close session
|
|
1195
|
+
this.storageReady.then(ready => {
|
|
1196
|
+
if (!ready || !this.storage) {
|
|
1197
|
+
this.logStderr('Cannot close session: storage not initialized');
|
|
1198
|
+
return;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
this.storage.endSession(sessionId, {
|
|
1202
|
+
summary: sessionEnd.summary,
|
|
1203
|
+
closedBy: 'agent',
|
|
1204
|
+
}).then(() => {
|
|
1205
|
+
this.logStderr(`Session closed by agent: ${sessionEnd.summary || 'complete'}`);
|
|
1206
|
+
}).catch(err => {
|
|
1207
|
+
this.logStderr(`Failed to close session: ${err.message}`, true);
|
|
1208
|
+
});
|
|
1209
|
+
});
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
/**
|
|
1213
|
+
* Execute spawn via API (if dashboardPort set) or callback.
|
|
1214
|
+
* After spawning, waits for the agent to come online and sends the task via relay.
|
|
1215
|
+
*/
|
|
1216
|
+
protected override async executeSpawn(name: string, cli: string, task: string): Promise<void> {
|
|
1217
|
+
let spawned = false;
|
|
1218
|
+
|
|
1219
|
+
if (this.config.dashboardPort) {
|
|
1220
|
+
// Use dashboard API for spawning (works from any context, no terminal required)
|
|
1221
|
+
try {
|
|
1222
|
+
const response = await fetch(`http://localhost:${this.config.dashboardPort}/api/spawn`, {
|
|
1223
|
+
method: 'POST',
|
|
1224
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1225
|
+
body: JSON.stringify({ name, cli }), // No task - we send it after agent is online
|
|
1226
|
+
});
|
|
1227
|
+
const result = await response.json() as { success: boolean; error?: string };
|
|
1228
|
+
if (result.success) {
|
|
1229
|
+
this.logStderr(`Spawned ${name} via API`);
|
|
1230
|
+
spawned = true;
|
|
1231
|
+
} else {
|
|
1232
|
+
this.logStderr(`Spawn failed: ${result.error}`, true);
|
|
1233
|
+
}
|
|
1234
|
+
} catch (err: any) {
|
|
1235
|
+
this.logStderr(`Spawn API call failed: ${err.message}`, true);
|
|
1236
|
+
}
|
|
1237
|
+
} else if (this.config.onSpawn) {
|
|
1238
|
+
// Fall back to callback
|
|
1239
|
+
try {
|
|
1240
|
+
await this.config.onSpawn(name, cli, task);
|
|
1241
|
+
spawned = true;
|
|
1242
|
+
} catch (err: any) {
|
|
1243
|
+
this.logStderr(`Spawn failed: ${err.message}`, true);
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
// If spawn succeeded and we have a task, wait for agent to come online and send it
|
|
1248
|
+
if (spawned && task && task.trim() && this.config.dashboardPort) {
|
|
1249
|
+
await this.waitAndSendTask(name, task);
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
/**
|
|
1254
|
+
* Wait for a spawned agent to come online, then send the task via relay.
|
|
1255
|
+
* Uses the wrapper's own relay client so the message comes "from" this agent,
|
|
1256
|
+
* not from the dashboard's relay client.
|
|
1257
|
+
*/
|
|
1258
|
+
private async waitAndSendTask(agentName: string, task: string): Promise<void> {
|
|
1259
|
+
const maxWaitMs = 30000;
|
|
1260
|
+
const pollIntervalMs = 500;
|
|
1261
|
+
const startTime = Date.now();
|
|
1262
|
+
|
|
1263
|
+
this.logStderr(`Waiting for ${agentName} to come online...`);
|
|
1264
|
+
|
|
1265
|
+
// Poll for agent to be online using dedicated status endpoint
|
|
1266
|
+
while (Date.now() - startTime < maxWaitMs) {
|
|
1267
|
+
try {
|
|
1268
|
+
const response = await fetch(`http://localhost:${this.config.dashboardPort}/api/agents/${encodeURIComponent(agentName)}/online`);
|
|
1269
|
+
const data = await response.json() as { name: string; online: boolean };
|
|
1270
|
+
|
|
1271
|
+
if (data.online) {
|
|
1272
|
+
this.logStderr(`${agentName} is online, sending task...`);
|
|
1273
|
+
|
|
1274
|
+
// Send task directly via our relay client (not dashboard API)
|
|
1275
|
+
// This ensures the message comes "from" this agent, not from _DashboardUI
|
|
1276
|
+
if (this.client.state === 'READY') {
|
|
1277
|
+
const sent = this.client.sendMessage(agentName, task, 'message');
|
|
1278
|
+
if (sent) {
|
|
1279
|
+
this.logStderr(`Task sent to ${agentName}`);
|
|
1280
|
+
} else {
|
|
1281
|
+
this.logStderr(`Failed to send task to ${agentName}: sendMessage returned false`, true);
|
|
1282
|
+
}
|
|
1283
|
+
} else {
|
|
1284
|
+
this.logStderr(`Failed to send task to ${agentName}: relay client not ready (state: ${this.client.state})`, true);
|
|
1285
|
+
}
|
|
1286
|
+
return;
|
|
1287
|
+
}
|
|
1288
|
+
} catch (_err: unknown) {
|
|
1289
|
+
// Ignore poll errors, keep trying
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
await sleep(pollIntervalMs);
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
this.logStderr(`Timeout waiting for ${agentName} to come online`, true);
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
/**
|
|
1299
|
+
* Execute release via API (if dashboardPort set) or callback
|
|
1300
|
+
*/
|
|
1301
|
+
protected override async executeRelease(name: string): Promise<void> {
|
|
1302
|
+
if (this.config.dashboardPort) {
|
|
1303
|
+
// Use dashboard API for release (works from any context, no terminal required)
|
|
1304
|
+
try {
|
|
1305
|
+
const response = await fetch(`http://localhost:${this.config.dashboardPort}/api/spawned/${encodeURIComponent(name)}`, {
|
|
1306
|
+
method: 'DELETE',
|
|
1307
|
+
});
|
|
1308
|
+
const result = await response.json() as { success: boolean; error?: string };
|
|
1309
|
+
if (result.success) {
|
|
1310
|
+
this.logStderr(`Released ${name} via API`);
|
|
1311
|
+
} else {
|
|
1312
|
+
this.logStderr(`Release failed: ${result.error}`, true);
|
|
1313
|
+
}
|
|
1314
|
+
} catch (err: any) {
|
|
1315
|
+
this.logStderr(`Release API call failed: ${err.message}`, true);
|
|
1316
|
+
}
|
|
1317
|
+
} else if (this.config.onRelease) {
|
|
1318
|
+
// Fall back to callback
|
|
1319
|
+
try {
|
|
1320
|
+
await this.config.onRelease(name);
|
|
1321
|
+
} catch (err: any) {
|
|
1322
|
+
this.logStderr(`Release failed: ${err.message}`, true);
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
/**
|
|
1328
|
+
* Parse ->relay:spawn and ->relay:release commands from output.
|
|
1329
|
+
* Supports two formats:
|
|
1330
|
+
* Single-line: ->relay:spawn WorkerName cli "task description"
|
|
1331
|
+
* Multi-line (fenced): ->relay:spawn WorkerName cli <<<
|
|
1332
|
+
* task description here
|
|
1333
|
+
* can span multiple lines>>>
|
|
1334
|
+
* ->relay:release WorkerName
|
|
1335
|
+
*/
|
|
1336
|
+
protected override parseSpawnReleaseCommands(content: string): void {
|
|
1337
|
+
// Only process if we have API or callbacks configured
|
|
1338
|
+
const canSpawn = this.config.dashboardPort || this.config.onSpawn;
|
|
1339
|
+
const canRelease = this.config.dashboardPort || this.config.onRelease;
|
|
1340
|
+
|
|
1341
|
+
// Debug: Log spawn capability status
|
|
1342
|
+
if (content.includes('->relay:spawn')) {
|
|
1343
|
+
this.logStderr(`[spawn-debug] canSpawn=${!!canSpawn} dashboardPort=${this.config.dashboardPort} hasOnSpawn=${!!this.config.onSpawn}`);
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
if (!canSpawn && !canRelease) return;
|
|
1347
|
+
|
|
1348
|
+
const lines = content.split('\n');
|
|
1349
|
+
|
|
1350
|
+
// Pattern to strip common line prefixes (bullets, prompts, etc.)
|
|
1351
|
+
// Must include ● (U+25CF BLACK CIRCLE) used by Claude's TUI
|
|
1352
|
+
const linePrefixPattern = /^(?:[>$%#→➜›»●•◦‣⁃\-*⏺◆◇○□■│┃┆┇┊┋╎╏✦]\s*)+/;
|
|
1353
|
+
|
|
1354
|
+
for (const line of lines) {
|
|
1355
|
+
let trimmed = line.trim();
|
|
1356
|
+
|
|
1357
|
+
// Strip common line prefixes (bullets, prompts) before checking for commands
|
|
1358
|
+
trimmed = trimmed.replace(linePrefixPattern, '');
|
|
1359
|
+
|
|
1360
|
+
// Fix for over-stripping: the linePrefixPattern includes - and > characters,
|
|
1361
|
+
// which can accidentally strip the -> from ->relay:spawn, leaving just relay:spawn.
|
|
1362
|
+
// If we detect this happened, restore the -> prefix.
|
|
1363
|
+
if (/^(relay|thinking|continuity):/.test(trimmed)) {
|
|
1364
|
+
trimmed = '->' + trimmed;
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
// If we're in fenced spawn mode, accumulate lines until we see >>>
|
|
1368
|
+
if (this.pendingFencedSpawn) {
|
|
1369
|
+
// Check for fence close (>>> at end of line or on its own line)
|
|
1370
|
+
const closeIdx = trimmed.indexOf('>>>');
|
|
1371
|
+
if (closeIdx !== -1) {
|
|
1372
|
+
// Add content before >>> to task
|
|
1373
|
+
const contentBeforeClose = trimmed.substring(0, closeIdx);
|
|
1374
|
+
if (contentBeforeClose) {
|
|
1375
|
+
this.pendingFencedSpawn.taskLines.push(contentBeforeClose);
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
// Execute the spawn with accumulated task
|
|
1379
|
+
const { name, cli, taskLines } = this.pendingFencedSpawn;
|
|
1380
|
+
const taskStr = taskLines.join('\n').trim();
|
|
1381
|
+
const spawnKey = `${name}:${cli}`;
|
|
1382
|
+
|
|
1383
|
+
if (!this.processedSpawnCommands.has(spawnKey)) {
|
|
1384
|
+
this.processedSpawnCommands.add(spawnKey);
|
|
1385
|
+
if (taskStr) {
|
|
1386
|
+
this.logStderr(`Spawn command (fenced): ${name} (${cli}) - "${taskStr.substring(0, 50)}..."`);
|
|
1387
|
+
} else {
|
|
1388
|
+
this.logStderr(`Spawn command (fenced): ${name} (${cli}) - no task`);
|
|
1389
|
+
}
|
|
1390
|
+
this.executeSpawn(name, cli, taskStr);
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
this.pendingFencedSpawn = null;
|
|
1394
|
+
} else {
|
|
1395
|
+
// Accumulate line as part of task
|
|
1396
|
+
this.pendingFencedSpawn.taskLines.push(line);
|
|
1397
|
+
}
|
|
1398
|
+
continue;
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
// Check for fenced spawn start: ->relay:spawn Name [cli] <<< (CLI optional, defaults to 'claude')
|
|
1402
|
+
// Prefixes are stripped above, so we just look for the command at start of line
|
|
1403
|
+
const fencedSpawnMatch = trimmed.match(/^->relay:spawn\s+(\S+)(?:\s+(\S+))?\s+<<<(.*)$/);
|
|
1404
|
+
if (fencedSpawnMatch && canSpawn) {
|
|
1405
|
+
const [, name, cliOrUndefined, inlineContent] = fencedSpawnMatch;
|
|
1406
|
+
const cli = cliOrUndefined || 'claude';
|
|
1407
|
+
|
|
1408
|
+
// Validate name
|
|
1409
|
+
if (name.length < 2) {
|
|
1410
|
+
this.logStderr(`Fenced spawn has invalid name, skipping: name=${name}`);
|
|
1411
|
+
continue;
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
// Check if fence closes on same line (e.g., ->relay:spawn Worker cli <<<task>>>)
|
|
1415
|
+
const inlineCloseIdx = inlineContent.indexOf('>>>');
|
|
1416
|
+
if (inlineCloseIdx !== -1) {
|
|
1417
|
+
// Single line fenced: extract task between <<< and >>>
|
|
1418
|
+
const taskStr = inlineContent.substring(0, inlineCloseIdx).trim();
|
|
1419
|
+
const spawnKey = `${name}:${cli}`;
|
|
1420
|
+
|
|
1421
|
+
if (!this.processedSpawnCommands.has(spawnKey)) {
|
|
1422
|
+
this.processedSpawnCommands.add(spawnKey);
|
|
1423
|
+
if (taskStr) {
|
|
1424
|
+
this.logStderr(`Spawn command: ${name} (${cli}) - "${taskStr.substring(0, 50)}..."`);
|
|
1425
|
+
} else {
|
|
1426
|
+
this.logStderr(`Spawn command: ${name} (${cli}) - no task`);
|
|
1427
|
+
}
|
|
1428
|
+
this.executeSpawn(name, cli, taskStr);
|
|
1429
|
+
}
|
|
1430
|
+
} else {
|
|
1431
|
+
// Start multi-line fenced mode
|
|
1432
|
+
this.pendingFencedSpawn = {
|
|
1433
|
+
name,
|
|
1434
|
+
cli,
|
|
1435
|
+
taskLines: inlineContent.trim() ? [inlineContent.trim()] : [],
|
|
1436
|
+
};
|
|
1437
|
+
this.logStderr(`Starting fenced spawn capture: ${name} (${cli})`);
|
|
1438
|
+
}
|
|
1439
|
+
continue;
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
// Match single-line spawn: ->relay:spawn WorkerName [cli] ["task"]
|
|
1443
|
+
// CLI is optional - defaults to 'claude'. Task is also optional.
|
|
1444
|
+
// Prefixes are stripped above, so we just look for the command at start of line
|
|
1445
|
+
const spawnMatch = trimmed.match(/^->relay:spawn\s+(\S+)(?:\s+(\S+))?(?:\s+["'](.+?)["'])?\s*$/);
|
|
1446
|
+
if (spawnMatch && canSpawn) {
|
|
1447
|
+
const [, name, cliOrUndefined, task] = spawnMatch;
|
|
1448
|
+
const cli = cliOrUndefined || 'claude';
|
|
1449
|
+
|
|
1450
|
+
// Validate the parsed values
|
|
1451
|
+
if (cli === '<<<' || cli === '>>>' || name === '<<<' || name === '>>>') {
|
|
1452
|
+
this.logStderr(`Invalid spawn command (fence markers), skipping: name=${name}, cli=${cli}`);
|
|
1453
|
+
continue;
|
|
1454
|
+
}
|
|
1455
|
+
if (name.length < 2) {
|
|
1456
|
+
this.logStderr(`Spawn command has suspiciously short name, skipping: name=${name}`);
|
|
1457
|
+
continue;
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
const taskStr = task || '';
|
|
1461
|
+
const spawnKey = `${name}:${cli}`;
|
|
1462
|
+
|
|
1463
|
+
if (!this.processedSpawnCommands.has(spawnKey)) {
|
|
1464
|
+
this.processedSpawnCommands.add(spawnKey);
|
|
1465
|
+
if (taskStr) {
|
|
1466
|
+
this.logStderr(`Spawn command: ${name} (${cli}) - "${taskStr.substring(0, 50)}..."`);
|
|
1467
|
+
} else {
|
|
1468
|
+
this.logStderr(`Spawn command: ${name} (${cli}) - no task`);
|
|
1469
|
+
}
|
|
1470
|
+
this.executeSpawn(name, cli, taskStr);
|
|
1471
|
+
}
|
|
1472
|
+
continue;
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
// Match ->relay:release WorkerName
|
|
1476
|
+
// Prefixes are stripped above, so we just look for the command at start of line
|
|
1477
|
+
const releaseMatch = trimmed.match(/^->relay:release\s+(\S+)\s*$/);
|
|
1478
|
+
if (releaseMatch && canRelease) {
|
|
1479
|
+
const [, name] = releaseMatch;
|
|
1480
|
+
|
|
1481
|
+
if (!this.processedReleaseCommands.has(name)) {
|
|
1482
|
+
this.processedReleaseCommands.add(name);
|
|
1483
|
+
this.logStderr(`Release command: ${name}`);
|
|
1484
|
+
this.executeRelease(name);
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
/**
|
|
1491
|
+
* Handle incoming message from relay
|
|
1492
|
+
* @param originalTo - The original 'to' field from sender. '*' indicates this was a broadcast message.
|
|
1493
|
+
* Agents should reply to originalTo to maintain channel routing (e.g., respond to #general, not DM).
|
|
1494
|
+
*/
|
|
1495
|
+
protected override handleIncomingMessage(from: string, payload: SendPayload, messageId: string, meta?: SendMeta, originalTo?: string): void {
|
|
1496
|
+
if (this.hasSeenIncoming(messageId)) {
|
|
1497
|
+
this.logStderr(`← ${from}: duplicate delivery (${messageId.substring(0, 8)})`);
|
|
1498
|
+
return;
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
const truncatedBody = payload.body.substring(0, Math.min(DEBUG_LOG_TRUNCATE_LENGTH, payload.body.length));
|
|
1502
|
+
const channelInfo = originalTo === '*' ? ' [broadcast]' : '';
|
|
1503
|
+
this.logStderr(`← ${from}${channelInfo}: ${truncatedBody}...`);
|
|
1504
|
+
|
|
1505
|
+
// Record in trajectory via trail
|
|
1506
|
+
this.trajectory?.message('received', from, this.config.name, payload.body);
|
|
1507
|
+
|
|
1508
|
+
// Queue for injection - include originalTo so we can inform the agent how to route responses
|
|
1509
|
+
this.messageQueue.push({
|
|
1510
|
+
from,
|
|
1511
|
+
body: payload.body,
|
|
1512
|
+
messageId,
|
|
1513
|
+
thread: payload.thread,
|
|
1514
|
+
importance: meta?.importance,
|
|
1515
|
+
data: payload.data,
|
|
1516
|
+
sync: meta?.sync,
|
|
1517
|
+
originalTo,
|
|
1518
|
+
});
|
|
1519
|
+
|
|
1520
|
+
// Write to inbox if enabled
|
|
1521
|
+
if (this.inbox) {
|
|
1522
|
+
this.inbox.addMessage(from, payload.body);
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
// Try to inject
|
|
1526
|
+
this.checkForInjectionOpportunity();
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
/**
|
|
1530
|
+
* Handle incoming channel message from relay.
|
|
1531
|
+
* Channel messages include a channel indicator so the agent knows to reply to the channel.
|
|
1532
|
+
*/
|
|
1533
|
+
protected override handleIncomingChannelMessage(
|
|
1534
|
+
from: string,
|
|
1535
|
+
channel: string,
|
|
1536
|
+
body: string,
|
|
1537
|
+
envelope: import('@agent-relay/protocol/types').Envelope<import('@agent-relay/protocol/channels').ChannelMessagePayload>
|
|
1538
|
+
): void {
|
|
1539
|
+
const messageId = envelope.id;
|
|
1540
|
+
|
|
1541
|
+
if (this.hasSeenIncoming(messageId)) {
|
|
1542
|
+
this.logStderr(`← ${from} [${channel}]: duplicate delivery (${messageId.substring(0, 8)})`);
|
|
1543
|
+
return;
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
const truncatedBody = body.substring(0, Math.min(DEBUG_LOG_TRUNCATE_LENGTH, body.length));
|
|
1547
|
+
this.logStderr(`← ${from} [${channel}]: ${truncatedBody}...`);
|
|
1548
|
+
|
|
1549
|
+
// Record in trajectory via trail
|
|
1550
|
+
this.trajectory?.message('received', from, this.config.name, body);
|
|
1551
|
+
|
|
1552
|
+
// Queue for injection - include channel as originalTo so we can inform the agent how to route responses
|
|
1553
|
+
this.messageQueue.push({
|
|
1554
|
+
from,
|
|
1555
|
+
body,
|
|
1556
|
+
messageId,
|
|
1557
|
+
thread: envelope.payload.thread,
|
|
1558
|
+
data: {
|
|
1559
|
+
_isChannelMessage: true,
|
|
1560
|
+
_channel: channel,
|
|
1561
|
+
_mentions: envelope.payload.mentions,
|
|
1562
|
+
},
|
|
1563
|
+
originalTo: channel, // Set channel as the reply target
|
|
1564
|
+
});
|
|
1565
|
+
|
|
1566
|
+
// Write to inbox if enabled
|
|
1567
|
+
if (this.inbox) {
|
|
1568
|
+
this.inbox.addMessage(from, body);
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
// Try to inject
|
|
1572
|
+
this.checkForInjectionOpportunity();
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
/**
|
|
1576
|
+
* Check if we should inject a message.
|
|
1577
|
+
* Uses UniversalIdleDetector (from BaseWrapper) for robust cross-CLI idle detection.
|
|
1578
|
+
*/
|
|
1579
|
+
private checkForInjectionOpportunity(): void {
|
|
1580
|
+
if (this.messageQueue.length === 0) return;
|
|
1581
|
+
if (this.isInjecting) return;
|
|
1582
|
+
if (!this.running) return;
|
|
1583
|
+
|
|
1584
|
+
// Use universal idle detector for more reliable detection (inherited from BaseWrapper)
|
|
1585
|
+
const idleResult = this.checkIdleForInjection();
|
|
1586
|
+
|
|
1587
|
+
if (!idleResult.isIdle) {
|
|
1588
|
+
// Not idle yet, retry later
|
|
1589
|
+
const retryMs = this.config.injectRetryMs ?? 500;
|
|
1590
|
+
setTimeout(() => this.checkForInjectionOpportunity(), retryMs);
|
|
1591
|
+
return;
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
this.injectNextMessage();
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
/**
|
|
1598
|
+
* Inject message via tmux send-keys.
|
|
1599
|
+
* Uses shared injection logic with tmux-specific callbacks.
|
|
1600
|
+
*/
|
|
1601
|
+
private async injectNextMessage(): Promise<void> {
|
|
1602
|
+
const msg = this.messageQueue.shift();
|
|
1603
|
+
if (!msg) return;
|
|
1604
|
+
|
|
1605
|
+
this.isInjecting = true;
|
|
1606
|
+
|
|
1607
|
+
try {
|
|
1608
|
+
const shortId = msg.messageId.substring(0, 8);
|
|
1609
|
+
|
|
1610
|
+
// Wait for input to be clear before injecting
|
|
1611
|
+
// If input is not clear (human typing), re-queue and try later - never clear forcefully!
|
|
1612
|
+
const waitTimeoutMs = this.config.inputWaitTimeoutMs ?? 5000;
|
|
1613
|
+
const waitPollMs = this.config.inputWaitPollMs ?? 200;
|
|
1614
|
+
const inputClear = await this.waitForClearInput(waitTimeoutMs, waitPollMs);
|
|
1615
|
+
if (!inputClear) {
|
|
1616
|
+
// Input still has text after timeout - DON'T clear forcefully, re-queue instead
|
|
1617
|
+
this.logStderr('Input not clear, re-queuing injection');
|
|
1618
|
+
this.messageQueue.unshift(msg);
|
|
1619
|
+
this.isInjecting = false;
|
|
1620
|
+
setTimeout(() => this.checkForInjectionOpportunity(), this.config.injectRetryMs ?? 1000);
|
|
1621
|
+
return;
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
// Ensure pane output is stable to avoid interleaving with active generation
|
|
1625
|
+
const stablePane = await this.waitForStablePane(
|
|
1626
|
+
this.config.outputStabilityTimeoutMs ?? 2000,
|
|
1627
|
+
this.config.outputStabilityPollMs ?? 200
|
|
1628
|
+
);
|
|
1629
|
+
if (!stablePane) {
|
|
1630
|
+
this.logStderr('Output still active, re-queuing injection');
|
|
1631
|
+
this.messageQueue.unshift(msg);
|
|
1632
|
+
this.isInjecting = false;
|
|
1633
|
+
setTimeout(() => this.checkForInjectionOpportunity(), this.config.injectRetryMs ?? 500);
|
|
1634
|
+
return;
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
// For Gemini: check if we're at a shell prompt ($) vs chat prompt (>)
|
|
1638
|
+
// If at shell prompt, skip injection to avoid shell command execution
|
|
1639
|
+
if (this.cliType === 'gemini') {
|
|
1640
|
+
const lastLine = await this.getLastLine();
|
|
1641
|
+
const cleanLine = stripAnsi(lastLine).trim();
|
|
1642
|
+
if (CLI_QUIRKS.isShellPrompt(cleanLine)) {
|
|
1643
|
+
this.logStderr('Gemini at shell prompt, skipping injection to avoid shell execution');
|
|
1644
|
+
// Re-queue the message for later
|
|
1645
|
+
this.messageQueue.unshift(msg);
|
|
1646
|
+
this.isInjecting = false;
|
|
1647
|
+
setTimeout(() => this.checkForInjectionOpportunity(), 2000);
|
|
1648
|
+
return;
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
// Build injection string using shared utility
|
|
1653
|
+
let injection = buildInjectionString(msg);
|
|
1654
|
+
|
|
1655
|
+
// Gemini-specific: wrap body in backticks to prevent shell keyword interpretation
|
|
1656
|
+
if (this.cliType === 'gemini') {
|
|
1657
|
+
const colonIdx = injection.indexOf(': ');
|
|
1658
|
+
if (colonIdx > 0) {
|
|
1659
|
+
const prefix = injection.substring(0, colonIdx + 2);
|
|
1660
|
+
const body = injection.substring(colonIdx + 2);
|
|
1661
|
+
injection = prefix + CLI_QUIRKS.wrapForGemini(body);
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
// Create callbacks for shared injection logic
|
|
1666
|
+
const callbacks: InjectionCallbacks = {
|
|
1667
|
+
getOutput: async () => {
|
|
1668
|
+
try {
|
|
1669
|
+
const { stdout } = await execAsync(
|
|
1670
|
+
`"${this.tmuxPath}" capture-pane -t ${this.sessionName} -p -S - 2>/dev/null`
|
|
1671
|
+
);
|
|
1672
|
+
return stdout;
|
|
1673
|
+
} catch {
|
|
1674
|
+
return '';
|
|
1675
|
+
}
|
|
1676
|
+
},
|
|
1677
|
+
performInjection: async (inj: string) => {
|
|
1678
|
+
// Use send-keys -l (literal) instead of paste-buffer
|
|
1679
|
+
// paste-buffer causes issues where Claude shows "[Pasted text]" but content doesn't appear
|
|
1680
|
+
await this.sendKeysLiteral(inj);
|
|
1681
|
+
await sleep(INJECTION_CONSTANTS.ENTER_DELAY_MS);
|
|
1682
|
+
await this.sendKeys('Enter');
|
|
1683
|
+
},
|
|
1684
|
+
log: (message: string) => this.logStderr(message),
|
|
1685
|
+
logError: (message: string) => this.logStderr(message, true),
|
|
1686
|
+
getMetrics: () => this.injectionMetrics,
|
|
1687
|
+
};
|
|
1688
|
+
|
|
1689
|
+
// Inject with retry and verification using shared logic
|
|
1690
|
+
const result = await sharedInjectWithRetry(injection, shortId, msg.from, callbacks);
|
|
1691
|
+
|
|
1692
|
+
if (result.success) {
|
|
1693
|
+
this.logStderr(`Injection complete (attempt ${result.attempts})`);
|
|
1694
|
+
// Record success for adaptive throttling
|
|
1695
|
+
this.throttle.recordSuccess();
|
|
1696
|
+
this.sendSyncAck(msg.messageId, msg.sync, 'OK');
|
|
1697
|
+
} else {
|
|
1698
|
+
// All retries failed - log and optionally fall back to inbox
|
|
1699
|
+
this.logStderr(
|
|
1700
|
+
`Message delivery failed after ${result.attempts} attempts: from=${msg.from} id=${shortId}`,
|
|
1701
|
+
true
|
|
1702
|
+
);
|
|
1703
|
+
// Record failure for adaptive throttling
|
|
1704
|
+
this.throttle.recordFailure();
|
|
1705
|
+
|
|
1706
|
+
// Write to inbox as fallback if enabled
|
|
1707
|
+
if (this.inbox) {
|
|
1708
|
+
this.inbox.addMessage(msg.from, msg.body);
|
|
1709
|
+
this.logStderr('Wrote message to inbox as fallback');
|
|
1710
|
+
}
|
|
1711
|
+
this.sendSyncAck(msg.messageId, msg.sync, 'ERROR', { error: 'injection_failed' });
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
} catch (err: any) {
|
|
1715
|
+
this.logStderr(`Injection failed: ${err.message}`, true);
|
|
1716
|
+
// Record failure for adaptive throttling
|
|
1717
|
+
this.throttle.recordFailure();
|
|
1718
|
+
this.sendSyncAck(msg.messageId, msg.sync, 'ERROR', { error: err.message });
|
|
1719
|
+
} finally {
|
|
1720
|
+
this.isInjecting = false;
|
|
1721
|
+
|
|
1722
|
+
// Process next message after adaptive delay (faster when healthy, slower under stress)
|
|
1723
|
+
if (this.messageQueue.length > 0) {
|
|
1724
|
+
const delay = this.throttle.getDelay();
|
|
1725
|
+
setTimeout(() => this.checkForInjectionOpportunity(), delay);
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
private hasSeenIncoming(messageId: string): boolean {
|
|
1731
|
+
if (this.receivedMessageIdSet.has(messageId)) {
|
|
1732
|
+
return true;
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
this.receivedMessageIdSet.add(messageId);
|
|
1736
|
+
this.receivedMessageIdOrder.push(messageId);
|
|
1737
|
+
|
|
1738
|
+
if (this.receivedMessageIdOrder.length > this.MAX_RECEIVED_MESSAGES) {
|
|
1739
|
+
const oldest = this.receivedMessageIdOrder.shift();
|
|
1740
|
+
if (oldest) {
|
|
1741
|
+
this.receivedMessageIdSet.delete(oldest);
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
return false;
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
/**
|
|
1749
|
+
* Send special keys to tmux
|
|
1750
|
+
*/
|
|
1751
|
+
private async sendKeys(keys: string): Promise<void> {
|
|
1752
|
+
const cmd = `"${this.tmuxPath}" send-keys -t ${this.sessionName} ${keys}`;
|
|
1753
|
+
try {
|
|
1754
|
+
await execAsync(cmd);
|
|
1755
|
+
this.logStderr(`[sendKeys] Sent: ${keys}`);
|
|
1756
|
+
} catch (err: any) {
|
|
1757
|
+
this.logStderr(`[sendKeys] Failed to send ${keys}: ${err.message}`, true);
|
|
1758
|
+
throw err;
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
/**
|
|
1763
|
+
* Send literal text to tmux
|
|
1764
|
+
*/
|
|
1765
|
+
private async sendKeysLiteral(text: string): Promise<void> {
|
|
1766
|
+
// Escape for shell and use -l for literal
|
|
1767
|
+
// Must escape: \ " $ ` ! and remove any newlines
|
|
1768
|
+
const escaped = text
|
|
1769
|
+
.replace(/[\r\n]+/g, ' ') // Remove any newlines first
|
|
1770
|
+
.replace(/\\/g, '\\\\')
|
|
1771
|
+
.replace(/"/g, '\\"')
|
|
1772
|
+
.replace(/\$/g, '\\$')
|
|
1773
|
+
.replace(/`/g, '\\`')
|
|
1774
|
+
.replace(/!/g, '\\!');
|
|
1775
|
+
try {
|
|
1776
|
+
await execAsync(`"${this.tmuxPath}" send-keys -t ${this.sessionName} -l "${escaped}"`);
|
|
1777
|
+
this.logStderr(`[sendKeysLiteral] Sent ${text.length} chars`);
|
|
1778
|
+
} catch (err: any) {
|
|
1779
|
+
this.logStderr(`[sendKeysLiteral] Failed: ${err.message}`, true);
|
|
1780
|
+
throw err;
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
/**
|
|
1785
|
+
* Paste text using tmux buffer with optional bracketed paste to avoid interleaving with ongoing output.
|
|
1786
|
+
* Some CLIs (like droid) don't handle bracketed paste sequences properly, so we skip -p for them.
|
|
1787
|
+
*/
|
|
1788
|
+
private async pasteLiteral(text: string): Promise<void> {
|
|
1789
|
+
// Sanitize newlines to keep injection single-line inside paste buffer
|
|
1790
|
+
const sanitized = text.replace(/[\r\n]+/g, ' ');
|
|
1791
|
+
const escaped = sanitized
|
|
1792
|
+
.replace(/\\/g, '\\\\')
|
|
1793
|
+
.replace(/"/g, '\\"')
|
|
1794
|
+
.replace(/\$/g, '\\$')
|
|
1795
|
+
.replace(/`/g, '\\`')
|
|
1796
|
+
.replace(/!/g, '\\!');
|
|
1797
|
+
|
|
1798
|
+
// Set tmux buffer then paste
|
|
1799
|
+
const setBufferCmd = `"${this.tmuxPath}" set-buffer -- "${escaped}"`;
|
|
1800
|
+
await execAsync(setBufferCmd);
|
|
1801
|
+
await execAsync(`"${this.tmuxPath}" paste-buffer -t ${this.sessionName}`);
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
/**
|
|
1805
|
+
* Reset session-specific state for wrapper reuse.
|
|
1806
|
+
* Call this when starting a new session with the same wrapper instance.
|
|
1807
|
+
*/
|
|
1808
|
+
override resetSessionState(): void {
|
|
1809
|
+
super.resetSessionState();
|
|
1810
|
+
// TmuxWrapper-specific state
|
|
1811
|
+
this.lastSummaryHash = '';
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
/**
|
|
1815
|
+
* Get the prompt pattern for the current CLI type.
|
|
1816
|
+
*/
|
|
1817
|
+
private getPromptPattern(): RegExp {
|
|
1818
|
+
return CLI_QUIRKS.getPromptPattern(this.cliType);
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
/**
|
|
1822
|
+
* Capture the last non-empty line from the tmux pane.
|
|
1823
|
+
*/
|
|
1824
|
+
private async getLastLine(): Promise<string> {
|
|
1825
|
+
try {
|
|
1826
|
+
const { stdout } = await execAsync(
|
|
1827
|
+
`"${this.tmuxPath}" capture-pane -t ${this.sessionName} -p -J 2>/dev/null`
|
|
1828
|
+
);
|
|
1829
|
+
const lines = stdout.split('\n').filter(l => l.length > 0);
|
|
1830
|
+
return lines[lines.length - 1] || '';
|
|
1831
|
+
} catch {
|
|
1832
|
+
return '';
|
|
1833
|
+
}
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
/**
|
|
1837
|
+
* Detect if the provided line contains visible user input (beyond the prompt).
|
|
1838
|
+
*/
|
|
1839
|
+
private hasVisibleInput(line: string): boolean {
|
|
1840
|
+
const cleanLine = stripAnsi(line).trimEnd();
|
|
1841
|
+
if (cleanLine === '') return false;
|
|
1842
|
+
|
|
1843
|
+
return !this.getPromptPattern().test(cleanLine);
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
/**
|
|
1847
|
+
* Check if the input line is clear (no user-typed text after the prompt).
|
|
1848
|
+
* Returns true if the last visible line appears to be just a prompt.
|
|
1849
|
+
*/
|
|
1850
|
+
private async isInputClear(lastLine?: string): Promise<boolean> {
|
|
1851
|
+
try {
|
|
1852
|
+
const lineToCheck = lastLine ?? await this.getLastLine();
|
|
1853
|
+
const cleanLine = stripAnsi(lineToCheck).trimEnd();
|
|
1854
|
+
const isClear = this.getPromptPattern().test(cleanLine);
|
|
1855
|
+
|
|
1856
|
+
if (this.config.debug) {
|
|
1857
|
+
const truncatedLine = cleanLine.substring(0, Math.min(DEBUG_LOG_TRUNCATE_LENGTH, cleanLine.length));
|
|
1858
|
+
this.logStderr(`isInputClear: lastLine="${truncatedLine}", clear=${isClear}`);
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
return isClear;
|
|
1862
|
+
} catch {
|
|
1863
|
+
// If we can't capture, assume not clear (safer)
|
|
1864
|
+
return false;
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
/**
|
|
1869
|
+
* Get cursor X position to detect input length.
|
|
1870
|
+
* Returns the cursor column (0-indexed).
|
|
1871
|
+
*/
|
|
1872
|
+
private async getCursorX(): Promise<number> {
|
|
1873
|
+
try {
|
|
1874
|
+
const { stdout } = await execAsync(
|
|
1875
|
+
`"${this.tmuxPath}" display-message -t ${this.sessionName} -p "#{cursor_x}" 2>/dev/null`
|
|
1876
|
+
);
|
|
1877
|
+
return parseInt(stdout.trim(), 10) || 0;
|
|
1878
|
+
} catch {
|
|
1879
|
+
return 0;
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
/**
|
|
1884
|
+
* Wait for the input line to be clear before injecting.
|
|
1885
|
+
* Polls until the input appears empty or timeout is reached.
|
|
1886
|
+
*
|
|
1887
|
+
* @param maxWaitMs Maximum time to wait (default 5000ms)
|
|
1888
|
+
* @param pollIntervalMs How often to check (default 200ms)
|
|
1889
|
+
* @returns true if input became clear, false if timed out
|
|
1890
|
+
*/
|
|
1891
|
+
private async waitForClearInput(maxWaitMs = 5000, pollIntervalMs = 200): Promise<boolean> {
|
|
1892
|
+
const startTime = Date.now();
|
|
1893
|
+
let lastCursorX = -1;
|
|
1894
|
+
let stableCursorCount = 0;
|
|
1895
|
+
|
|
1896
|
+
while (Date.now() - startTime < maxWaitMs) {
|
|
1897
|
+
const lastLine = await this.getLastLine();
|
|
1898
|
+
|
|
1899
|
+
// Check if input line is just a prompt
|
|
1900
|
+
if (await this.isInputClear(lastLine)) {
|
|
1901
|
+
return true;
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
const hasInput = this.hasVisibleInput(lastLine);
|
|
1905
|
+
|
|
1906
|
+
// Also check cursor stability - if cursor is moving, agent is typing
|
|
1907
|
+
const cursorX = await this.getCursorX();
|
|
1908
|
+
if (!hasInput && cursorX === lastCursorX) {
|
|
1909
|
+
stableCursorCount++;
|
|
1910
|
+
// If cursor has been stable for enough polls and at typical prompt position,
|
|
1911
|
+
// the agent might be done but we just can't match the prompt pattern
|
|
1912
|
+
if (stableCursorCount >= STABLE_CURSOR_THRESHOLD && cursorX <= MAX_PROMPT_CURSOR_POSITION) {
|
|
1913
|
+
this.logStderr(`waitForClearInput: cursor stable at x=${cursorX}, assuming clear`);
|
|
1914
|
+
return true;
|
|
1915
|
+
}
|
|
1916
|
+
} else {
|
|
1917
|
+
stableCursorCount = 0;
|
|
1918
|
+
lastCursorX = cursorX;
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
await sleep(pollIntervalMs);
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
this.logStderr(`waitForClearInput: timed out after ${maxWaitMs}ms`);
|
|
1925
|
+
return false;
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
/**
|
|
1929
|
+
* Capture a signature of the current pane content for stability checks.
|
|
1930
|
+
* Uses hash+length to cheaply detect changes without storing full content.
|
|
1931
|
+
*/
|
|
1932
|
+
private async capturePaneSignature(): Promise<string | null> {
|
|
1933
|
+
try {
|
|
1934
|
+
const { stdout } = await execAsync(
|
|
1935
|
+
`"${this.tmuxPath}" capture-pane -t ${this.sessionName} -p -J -S - 2>/dev/null`
|
|
1936
|
+
);
|
|
1937
|
+
const hash = crypto.createHash('sha1').update(stdout).digest('hex');
|
|
1938
|
+
return `${stdout.length}:${hash}`;
|
|
1939
|
+
} catch {
|
|
1940
|
+
return null;
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
/**
|
|
1945
|
+
* Wait for pane output to stabilize before injecting to avoid interleaving with ongoing output.
|
|
1946
|
+
*/
|
|
1947
|
+
private async waitForStablePane(maxWaitMs = 2000, pollIntervalMs = 200, requiredStablePolls = 2): Promise<boolean> {
|
|
1948
|
+
const start = Date.now();
|
|
1949
|
+
let lastSig = await this.capturePaneSignature();
|
|
1950
|
+
if (!lastSig) return false;
|
|
1951
|
+
|
|
1952
|
+
let stableCount = 0;
|
|
1953
|
+
|
|
1954
|
+
while (Date.now() - start < maxWaitMs) {
|
|
1955
|
+
await sleep(pollIntervalMs);
|
|
1956
|
+
const sig = await this.capturePaneSignature();
|
|
1957
|
+
if (!sig) continue;
|
|
1958
|
+
|
|
1959
|
+
if (sig === lastSig) {
|
|
1960
|
+
stableCount++;
|
|
1961
|
+
if (stableCount >= requiredStablePolls) {
|
|
1962
|
+
return true;
|
|
1963
|
+
}
|
|
1964
|
+
} else {
|
|
1965
|
+
stableCount = 0;
|
|
1966
|
+
lastSig = sig;
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
this.logStderr(`waitForStablePane: timed out after ${maxWaitMs}ms`);
|
|
1971
|
+
return false;
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
/**
|
|
1975
|
+
* Stop and cleanup
|
|
1976
|
+
*/
|
|
1977
|
+
stop(): void {
|
|
1978
|
+
if (!this.running) return;
|
|
1979
|
+
this.running = false;
|
|
1980
|
+
this.activityState = 'disconnected';
|
|
1981
|
+
this.stopStuckDetection();
|
|
1982
|
+
|
|
1983
|
+
// Auto-save continuity state before shutdown (fire and forget)
|
|
1984
|
+
// Pass sessionEndData to populate handoff (fixes empty handoff issue)
|
|
1985
|
+
if (this.continuity) {
|
|
1986
|
+
this.continuity.autoSave(this.config.name, 'session_end', this.sessionEndData).catch((err) => {
|
|
1987
|
+
this.logStderr(`[CONTINUITY] Auto-save failed: ${err.message}`, true);
|
|
1988
|
+
});
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
// Reset session state for potential reuse
|
|
1992
|
+
this.resetSessionState();
|
|
1993
|
+
|
|
1994
|
+
// Stop polling
|
|
1995
|
+
if (this.pollTimer) {
|
|
1996
|
+
clearInterval(this.pollTimer);
|
|
1997
|
+
this.pollTimer = undefined;
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
// Kill tmux session
|
|
2001
|
+
try {
|
|
2002
|
+
execSync(`"${this.tmuxPath}" kill-session -t ${this.sessionName} 2>/dev/null`);
|
|
2003
|
+
} catch {
|
|
2004
|
+
// Ignore
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
// Disconnect relay
|
|
2008
|
+
this.client.destroy();
|
|
2009
|
+
}
|
|
2010
|
+
}
|