@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,741 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BaseWrapper - Abstract base class for agent wrappers
|
|
3
|
+
*
|
|
4
|
+
* Provides shared functionality between TmuxWrapper and PtyWrapper:
|
|
5
|
+
* - Message queue management and deduplication
|
|
6
|
+
* - Spawn/release command parsing and execution
|
|
7
|
+
* - Continuity integration (agent ID, summary saving)
|
|
8
|
+
* - Relay command handling
|
|
9
|
+
* - Line joining for multi-line commands
|
|
10
|
+
*
|
|
11
|
+
* Subclasses implement:
|
|
12
|
+
* - start() - Initialize and start the agent process
|
|
13
|
+
* - stop() - Stop the agent process
|
|
14
|
+
* - performInjection() - Inject content into the agent
|
|
15
|
+
* - getCleanOutput() - Get cleaned output for parsing
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { EventEmitter } from 'node:events';
|
|
19
|
+
import { RelayClient } from './client.js';
|
|
20
|
+
import type { ParsedCommand, ParsedSummary } from './parser.js';
|
|
21
|
+
import { isPlaceholderTarget } from './parser.js';
|
|
22
|
+
import type { SendPayload, SendMeta, SpeakOnTrigger, Envelope } from '@agent-relay/protocol/types';
|
|
23
|
+
import type { ChannelMessagePayload } from '@agent-relay/protocol/channels';
|
|
24
|
+
import {
|
|
25
|
+
type QueuedMessage,
|
|
26
|
+
type InjectionMetrics,
|
|
27
|
+
type CliType,
|
|
28
|
+
getDefaultRelayPrefix,
|
|
29
|
+
detectCliType,
|
|
30
|
+
createInjectionMetrics,
|
|
31
|
+
} from './shared.js';
|
|
32
|
+
import {
|
|
33
|
+
DEFAULT_IDLE_BEFORE_INJECT_MS,
|
|
34
|
+
DEFAULT_IDLE_CONFIDENCE_THRESHOLD,
|
|
35
|
+
} from '@agent-relay/config/relay-config';
|
|
36
|
+
import {
|
|
37
|
+
getContinuityManager,
|
|
38
|
+
parseContinuityCommand,
|
|
39
|
+
hasContinuityCommand,
|
|
40
|
+
type ContinuityManager,
|
|
41
|
+
} from '@agent-relay/continuity';
|
|
42
|
+
import { UniversalIdleDetector } from './idle-detector.js';
|
|
43
|
+
import { StuckDetector, type StuckEvent, type StuckReason } from './stuck-detector.js';
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Base configuration shared by all wrapper types
|
|
47
|
+
*/
|
|
48
|
+
export interface BaseWrapperConfig {
|
|
49
|
+
/** Agent name (must be unique) */
|
|
50
|
+
name: string;
|
|
51
|
+
/** Command to execute */
|
|
52
|
+
command: string;
|
|
53
|
+
/** Command arguments */
|
|
54
|
+
args?: string[];
|
|
55
|
+
/** Relay daemon socket path */
|
|
56
|
+
socketPath?: string;
|
|
57
|
+
/** Working directory */
|
|
58
|
+
cwd?: string;
|
|
59
|
+
/** Environment variables */
|
|
60
|
+
env?: Record<string, string>;
|
|
61
|
+
/** Relay prefix pattern (default: '->relay:') */
|
|
62
|
+
relayPrefix?: string;
|
|
63
|
+
/** CLI type (auto-detected if not set) */
|
|
64
|
+
cliType?: CliType;
|
|
65
|
+
/** Dashboard port for spawn/release API */
|
|
66
|
+
dashboardPort?: number;
|
|
67
|
+
/** Callback when spawn command is parsed */
|
|
68
|
+
onSpawn?: (name: string, cli: string, task: string) => Promise<void>;
|
|
69
|
+
/** Callback when release command is parsed */
|
|
70
|
+
onRelease?: (name: string) => Promise<void>;
|
|
71
|
+
/** Agent ID to resume from (for continuity) */
|
|
72
|
+
resumeAgentId?: string;
|
|
73
|
+
/** Stream logs to daemon */
|
|
74
|
+
streamLogs?: boolean;
|
|
75
|
+
/** Task/role description */
|
|
76
|
+
task?: string;
|
|
77
|
+
/** Shadow configuration */
|
|
78
|
+
shadowOf?: string;
|
|
79
|
+
shadowSpeakOn?: SpeakOnTrigger[];
|
|
80
|
+
/** Milliseconds of idle time before injection is allowed (default: 1500) */
|
|
81
|
+
idleBeforeInjectMs?: number;
|
|
82
|
+
/** Confidence threshold for idle detection (0-1, default: 0.7) */
|
|
83
|
+
idleConfidenceThreshold?: number;
|
|
84
|
+
/** Skip initial instruction injection (when using --append-system-prompt) */
|
|
85
|
+
skipInstructions?: boolean;
|
|
86
|
+
/** Skip continuity loading (for spawned agents that don't need session recovery) */
|
|
87
|
+
skipContinuity?: boolean;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Abstract base class for agent wrappers
|
|
92
|
+
*/
|
|
93
|
+
export abstract class BaseWrapper extends EventEmitter {
|
|
94
|
+
protected config: BaseWrapperConfig;
|
|
95
|
+
protected client: RelayClient;
|
|
96
|
+
protected relayPrefix: string;
|
|
97
|
+
protected cliType: CliType;
|
|
98
|
+
protected running = false;
|
|
99
|
+
|
|
100
|
+
// Message queue state
|
|
101
|
+
protected messageQueue: QueuedMessage[] = [];
|
|
102
|
+
protected sentMessageHashes: Set<string> = new Set();
|
|
103
|
+
protected isInjecting = false;
|
|
104
|
+
protected receivedMessageIds: Set<string> = new Set();
|
|
105
|
+
protected injectionMetrics: InjectionMetrics = createInjectionMetrics();
|
|
106
|
+
|
|
107
|
+
// Spawn/release state
|
|
108
|
+
protected processedSpawnCommands: Set<string> = new Set();
|
|
109
|
+
protected processedReleaseCommands: Set<string> = new Set();
|
|
110
|
+
protected pendingFencedSpawn: { name: string; cli: string; taskLines: string[] } | null = null;
|
|
111
|
+
|
|
112
|
+
// Continuity state
|
|
113
|
+
protected continuity?: ContinuityManager;
|
|
114
|
+
protected agentId?: string;
|
|
115
|
+
protected processedContinuityCommands: Set<string> = new Set();
|
|
116
|
+
protected sessionEndProcessed = false;
|
|
117
|
+
protected sessionEndData?: { summary?: string; completedTasks?: string[] };
|
|
118
|
+
protected lastSummaryRawContent = '';
|
|
119
|
+
|
|
120
|
+
// Universal idle detection (shared across all wrapper types)
|
|
121
|
+
protected idleDetector: UniversalIdleDetector;
|
|
122
|
+
|
|
123
|
+
// Stuck detection (extended idle, error loops, output loops)
|
|
124
|
+
protected stuckDetector: StuckDetector;
|
|
125
|
+
|
|
126
|
+
constructor(config: BaseWrapperConfig) {
|
|
127
|
+
super();
|
|
128
|
+
this.config = config;
|
|
129
|
+
this.relayPrefix = config.relayPrefix ?? getDefaultRelayPrefix();
|
|
130
|
+
this.cliType = config.cliType ?? detectCliType(config.command);
|
|
131
|
+
|
|
132
|
+
// Initialize relay client with full config
|
|
133
|
+
this.client = new RelayClient({
|
|
134
|
+
agentName: config.name,
|
|
135
|
+
socketPath: config.socketPath,
|
|
136
|
+
cli: this.cliType,
|
|
137
|
+
task: config.task,
|
|
138
|
+
workingDirectory: config.cwd,
|
|
139
|
+
quiet: true,
|
|
140
|
+
_internal: true, // Suppress deprecation warning for internal wrapper usage
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// Initialize continuity manager (skip for spawned agents that don't need session recovery)
|
|
144
|
+
if (!config.skipContinuity) {
|
|
145
|
+
this.continuity = getContinuityManager({ defaultCli: this.cliType });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Initialize universal idle detector for robust injection timing
|
|
149
|
+
this.idleDetector = new UniversalIdleDetector({
|
|
150
|
+
minSilenceMs: config.idleBeforeInjectMs ?? DEFAULT_IDLE_BEFORE_INJECT_MS,
|
|
151
|
+
confidenceThreshold: config.idleConfidenceThreshold ?? DEFAULT_IDLE_CONFIDENCE_THRESHOLD,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// Initialize stuck detector for extended idle and loop detection
|
|
155
|
+
this.stuckDetector = new StuckDetector();
|
|
156
|
+
this.stuckDetector.on('stuck', (event: StuckEvent) => {
|
|
157
|
+
// Events are emitted for programmatic use - no terminal logging to avoid noise
|
|
158
|
+
this.emit('stuck', event);
|
|
159
|
+
});
|
|
160
|
+
this.stuckDetector.on('unstuck', () => {
|
|
161
|
+
this.emit('unstuck');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// Set up message handler for direct messages
|
|
165
|
+
this.client.onMessage = (from, payload, messageId, meta, originalTo) => {
|
|
166
|
+
this.handleIncomingMessage(from, payload, messageId, meta, originalTo);
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
// Set up channel message handler
|
|
170
|
+
this.client.onChannelMessage = (from, channel, body, envelope) => {
|
|
171
|
+
this.handleIncomingChannelMessage(from, channel, body, envelope);
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// =========================================================================
|
|
176
|
+
// Abstract methods (subclasses must implement)
|
|
177
|
+
// =========================================================================
|
|
178
|
+
|
|
179
|
+
/** Start the agent process */
|
|
180
|
+
abstract start(): Promise<void>;
|
|
181
|
+
|
|
182
|
+
/** Stop the agent process */
|
|
183
|
+
abstract stop(): Promise<void> | void;
|
|
184
|
+
|
|
185
|
+
/** Inject content into the agent */
|
|
186
|
+
protected abstract performInjection(content: string): Promise<void>;
|
|
187
|
+
|
|
188
|
+
/** Get cleaned output for parsing */
|
|
189
|
+
protected abstract getCleanOutput(): string;
|
|
190
|
+
|
|
191
|
+
// =========================================================================
|
|
192
|
+
// Common getters
|
|
193
|
+
// =========================================================================
|
|
194
|
+
|
|
195
|
+
get isRunning(): boolean {
|
|
196
|
+
return this.running;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
get name(): string {
|
|
200
|
+
return this.config.name;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
getAgentId(): string | undefined {
|
|
204
|
+
return this.agentId;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
getInjectionMetrics(): InjectionMetrics & { successRate: number } {
|
|
208
|
+
const total = this.injectionMetrics.total;
|
|
209
|
+
const successes = this.injectionMetrics.successFirstTry + this.injectionMetrics.successWithRetry;
|
|
210
|
+
const successRate = total > 0
|
|
211
|
+
? (successes / total) * 100
|
|
212
|
+
: 100;
|
|
213
|
+
return {
|
|
214
|
+
...this.injectionMetrics,
|
|
215
|
+
successRate,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
get pendingMessageCount(): number {
|
|
220
|
+
return this.messageQueue.length;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// =========================================================================
|
|
224
|
+
// Idle detection (shared across all wrapper types)
|
|
225
|
+
// =========================================================================
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Set the PID for process state inspection (Linux only).
|
|
229
|
+
* Call this after the agent process is started.
|
|
230
|
+
*/
|
|
231
|
+
protected setIdleDetectorPid(pid: number): void {
|
|
232
|
+
this.idleDetector.setPid(pid);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Start stuck detection. Call after the agent process starts.
|
|
237
|
+
*/
|
|
238
|
+
protected startStuckDetection(): void {
|
|
239
|
+
this.stuckDetector.start();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Stop stuck detection. Call when the agent process stops.
|
|
244
|
+
*/
|
|
245
|
+
protected stopStuckDetection(): void {
|
|
246
|
+
this.stuckDetector.stop();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Check if the agent is currently stuck.
|
|
251
|
+
*/
|
|
252
|
+
isStuck(): boolean {
|
|
253
|
+
return this.stuckDetector.getIsStuck();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Get the reason for being stuck (if stuck).
|
|
258
|
+
*/
|
|
259
|
+
getStuckReason(): StuckReason | null {
|
|
260
|
+
return this.stuckDetector.getStuckReason();
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Feed output to the idle and stuck detectors.
|
|
265
|
+
* Call this whenever new output is received from the agent.
|
|
266
|
+
*/
|
|
267
|
+
protected feedIdleDetectorOutput(output: string): void {
|
|
268
|
+
this.idleDetector.onOutput(output);
|
|
269
|
+
this.stuckDetector.onOutput(output);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Check if the agent is idle and ready for injection.
|
|
274
|
+
* Returns idle state with confidence signals.
|
|
275
|
+
*/
|
|
276
|
+
protected checkIdleForInjection(): { isIdle: boolean; confidence: number; signals: Array<{ source: string; confidence: number }> } {
|
|
277
|
+
return this.idleDetector.checkIdle({
|
|
278
|
+
minSilenceMs: this.config.idleBeforeInjectMs ?? 1500,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Wait for the agent to become idle.
|
|
284
|
+
* Returns when idle or after timeout.
|
|
285
|
+
*/
|
|
286
|
+
protected async waitForIdleState(timeoutMs = 30000, pollMs = 200): Promise<{ isIdle: boolean; confidence: number }> {
|
|
287
|
+
return this.idleDetector.waitForIdle(timeoutMs, pollMs);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// =========================================================================
|
|
291
|
+
// Message handling
|
|
292
|
+
// =========================================================================
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Handle incoming message from relay
|
|
296
|
+
*/
|
|
297
|
+
protected handleIncomingMessage(
|
|
298
|
+
from: string,
|
|
299
|
+
payload: SendPayload,
|
|
300
|
+
messageId: string,
|
|
301
|
+
meta?: SendMeta,
|
|
302
|
+
originalTo?: string
|
|
303
|
+
): void {
|
|
304
|
+
// Deduplicate by message ID
|
|
305
|
+
if (this.receivedMessageIds.has(messageId)) return;
|
|
306
|
+
this.receivedMessageIds.add(messageId);
|
|
307
|
+
|
|
308
|
+
// Limit dedup set size
|
|
309
|
+
if (this.receivedMessageIds.size > 1000) {
|
|
310
|
+
const oldest = this.receivedMessageIds.values().next().value;
|
|
311
|
+
if (oldest) this.receivedMessageIds.delete(oldest);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Queue the message
|
|
315
|
+
const queuedMsg: QueuedMessage = {
|
|
316
|
+
from,
|
|
317
|
+
body: payload.body,
|
|
318
|
+
messageId,
|
|
319
|
+
thread: payload.thread,
|
|
320
|
+
importance: meta?.importance,
|
|
321
|
+
data: payload.data,
|
|
322
|
+
sync: meta?.sync,
|
|
323
|
+
originalTo,
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
this.messageQueue.push(queuedMsg);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Send an ACK for a sync message after processing completes.
|
|
331
|
+
* @param messageId - The message ID being acknowledged
|
|
332
|
+
* @param sync - Sync metadata from the original message
|
|
333
|
+
* @param response - Response status: 'OK' for success, 'ERROR' for failure
|
|
334
|
+
* @param responseData - Optional structured response data
|
|
335
|
+
*/
|
|
336
|
+
protected sendSyncAck(messageId: string, sync: SendMeta['sync'] | undefined, response: 'OK' | 'ERROR' | string, responseData?: unknown): void {
|
|
337
|
+
if (!sync?.correlationId) return;
|
|
338
|
+
this.client.sendAck({
|
|
339
|
+
ack_id: messageId,
|
|
340
|
+
seq: 0,
|
|
341
|
+
correlationId: sync.correlationId,
|
|
342
|
+
response,
|
|
343
|
+
responseData,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Handle incoming channel message from relay.
|
|
349
|
+
* Channel messages include a channel indicator so the agent knows to reply to the channel.
|
|
350
|
+
*/
|
|
351
|
+
protected handleIncomingChannelMessage(
|
|
352
|
+
from: string,
|
|
353
|
+
channel: string,
|
|
354
|
+
body: string,
|
|
355
|
+
envelope: Envelope<ChannelMessagePayload>
|
|
356
|
+
): void {
|
|
357
|
+
const messageId = envelope.id;
|
|
358
|
+
|
|
359
|
+
// Deduplicate by message ID
|
|
360
|
+
if (this.receivedMessageIds.has(messageId)) return;
|
|
361
|
+
this.receivedMessageIds.add(messageId);
|
|
362
|
+
|
|
363
|
+
// Limit dedup set size
|
|
364
|
+
if (this.receivedMessageIds.size > 1000) {
|
|
365
|
+
const oldest = this.receivedMessageIds.values().next().value;
|
|
366
|
+
if (oldest) this.receivedMessageIds.delete(oldest);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Queue the message with channel indicator in the body
|
|
370
|
+
// Format: "Relay message from Alice [abc123] [#general]: message body"
|
|
371
|
+
// This lets the agent know to reply to the channel, not the sender
|
|
372
|
+
const queuedMsg: QueuedMessage = {
|
|
373
|
+
from,
|
|
374
|
+
body,
|
|
375
|
+
messageId,
|
|
376
|
+
thread: envelope.payload.thread,
|
|
377
|
+
data: {
|
|
378
|
+
_isChannelMessage: true,
|
|
379
|
+
_channel: channel,
|
|
380
|
+
_mentions: envelope.payload.mentions,
|
|
381
|
+
},
|
|
382
|
+
originalTo: channel, // Set channel as the reply target
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
console.error(`[base-wrapper] Received channel message: from=${from} channel=${channel} id=${messageId.substring(0, 8)}`);
|
|
386
|
+
this.messageQueue.push(queuedMsg);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Send a relay command via the client
|
|
391
|
+
*/
|
|
392
|
+
protected sendRelayCommand(cmd: ParsedCommand): void {
|
|
393
|
+
// Validate target
|
|
394
|
+
if (isPlaceholderTarget(cmd.to)) {
|
|
395
|
+
console.error(`[base-wrapper] Skipped message - placeholder target: ${cmd.to}`);
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Create hash for deduplication (use first 100 chars of body)
|
|
400
|
+
const hash = `${cmd.to}:${cmd.body.substring(0, 100)}`;
|
|
401
|
+
if (this.sentMessageHashes.has(hash)) {
|
|
402
|
+
console.error(`[base-wrapper] Skipped duplicate message to ${cmd.to}`);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
this.sentMessageHashes.add(hash);
|
|
406
|
+
|
|
407
|
+
// Limit hash set size
|
|
408
|
+
if (this.sentMessageHashes.size > 500) {
|
|
409
|
+
const oldest = this.sentMessageHashes.values().next().value;
|
|
410
|
+
if (oldest) this.sentMessageHashes.delete(oldest);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Only send if client ready
|
|
414
|
+
if (this.client.state !== 'READY') {
|
|
415
|
+
console.error(`[base-wrapper] Client not ready (state=${this.client.state}), dropping message to ${cmd.to}`);
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
console.error(`[base-wrapper] sendRelayCommand: to=${cmd.to}, body=${cmd.body.substring(0, 50)}...`);
|
|
420
|
+
|
|
421
|
+
let sendMeta: SendMeta | undefined;
|
|
422
|
+
if (cmd.meta) {
|
|
423
|
+
sendMeta = {
|
|
424
|
+
importance: cmd.meta.importance,
|
|
425
|
+
replyTo: cmd.meta.replyTo,
|
|
426
|
+
requires_ack: cmd.meta.ackRequired,
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Check if target is a channel (starts with #)
|
|
431
|
+
if (cmd.to.startsWith('#')) {
|
|
432
|
+
// Use CHANNEL_MESSAGE protocol for channel targets
|
|
433
|
+
console.error(`[base-wrapper] Sending CHANNEL_MESSAGE to ${cmd.to}`);
|
|
434
|
+
this.client.sendChannelMessage(cmd.to, cmd.body, {
|
|
435
|
+
thread: cmd.thread,
|
|
436
|
+
data: cmd.data,
|
|
437
|
+
});
|
|
438
|
+
} else {
|
|
439
|
+
// Use SEND protocol for direct messages and broadcasts
|
|
440
|
+
if (cmd.sync?.blocking) {
|
|
441
|
+
this.client.sendAndWait(cmd.to, cmd.body, {
|
|
442
|
+
timeoutMs: cmd.sync.timeoutMs,
|
|
443
|
+
kind: cmd.kind,
|
|
444
|
+
data: cmd.data,
|
|
445
|
+
thread: cmd.thread,
|
|
446
|
+
}).catch((err) => {
|
|
447
|
+
console.error(`[base-wrapper] sendAndWait failed for ${cmd.to}: ${err.message}`);
|
|
448
|
+
});
|
|
449
|
+
} else {
|
|
450
|
+
this.client.sendMessage(cmd.to, cmd.body, cmd.kind, cmd.data, cmd.thread, sendMeta);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// =========================================================================
|
|
456
|
+
// Spawn/release handling
|
|
457
|
+
// =========================================================================
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Parse spawn and release commands from output
|
|
461
|
+
*/
|
|
462
|
+
protected parseSpawnReleaseCommands(content: string): void {
|
|
463
|
+
// Single-line spawn: ->relay:spawn Name cli "task"
|
|
464
|
+
const spawnPattern = new RegExp(
|
|
465
|
+
`${this.relayPrefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}spawn\\s+(\\w+)\\s+(\\w+)\\s+"([^"]+)"`
|
|
466
|
+
);
|
|
467
|
+
const spawnMatch = content.match(spawnPattern);
|
|
468
|
+
if (spawnMatch) {
|
|
469
|
+
const [, name, cli, task] = spawnMatch;
|
|
470
|
+
const cmdHash = `spawn:${name}:${cli}:${task}`;
|
|
471
|
+
if (!this.processedSpawnCommands.has(cmdHash)) {
|
|
472
|
+
this.processedSpawnCommands.add(cmdHash);
|
|
473
|
+
this.executeSpawn(name, cli, task);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Fenced spawn: ->relay:spawn Name cli <<<\ntask\n>>>
|
|
478
|
+
const fencedSpawnPattern = new RegExp(
|
|
479
|
+
`${this.relayPrefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}spawn\\s+(\\w+)\\s+(\\w+)\\s*<<<[\\s]*([\\s\\S]*?)>>>`
|
|
480
|
+
);
|
|
481
|
+
const fencedSpawnMatch = content.match(fencedSpawnPattern);
|
|
482
|
+
if (fencedSpawnMatch) {
|
|
483
|
+
const [, name, cli, task] = fencedSpawnMatch;
|
|
484
|
+
const cmdHash = `spawn:${name}:${cli}:${task.trim()}`;
|
|
485
|
+
if (!this.processedSpawnCommands.has(cmdHash)) {
|
|
486
|
+
this.processedSpawnCommands.add(cmdHash);
|
|
487
|
+
this.executeSpawn(name, cli, task.trim());
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Release: ->relay:release Name
|
|
492
|
+
const releasePattern = new RegExp(
|
|
493
|
+
`${this.relayPrefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}release\\s+(\\w+)`
|
|
494
|
+
);
|
|
495
|
+
const releaseMatch = content.match(releasePattern);
|
|
496
|
+
if (releaseMatch) {
|
|
497
|
+
const name = releaseMatch[1];
|
|
498
|
+
const cmdHash = `release:${name}`;
|
|
499
|
+
if (!this.processedReleaseCommands.has(cmdHash)) {
|
|
500
|
+
this.processedReleaseCommands.add(cmdHash);
|
|
501
|
+
this.executeRelease(name);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Execute a spawn command
|
|
508
|
+
*/
|
|
509
|
+
protected async executeSpawn(name: string, cli: string, task: string): Promise<void> {
|
|
510
|
+
// TODO: Re-enable daemon socket spawn when client.spawn() is implemented
|
|
511
|
+
// See: docs/SDK-MIGRATION-PLAN.md for planned implementation
|
|
512
|
+
// For now, go directly to dashboard API or callback
|
|
513
|
+
|
|
514
|
+
// Try dashboard API
|
|
515
|
+
if (this.config.dashboardPort) {
|
|
516
|
+
try {
|
|
517
|
+
const response = await fetch(
|
|
518
|
+
`http://localhost:${this.config.dashboardPort}/api/spawn`,
|
|
519
|
+
{
|
|
520
|
+
method: 'POST',
|
|
521
|
+
headers: { 'Content-Type': 'application/json' },
|
|
522
|
+
body: JSON.stringify({ name, cli, task }),
|
|
523
|
+
}
|
|
524
|
+
);
|
|
525
|
+
if (response.ok) return;
|
|
526
|
+
} catch {
|
|
527
|
+
// Fall through to callback
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// Use callback as final fallback
|
|
532
|
+
if (this.config.onSpawn) {
|
|
533
|
+
await this.config.onSpawn(name, cli, task);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Execute a release command
|
|
539
|
+
*/
|
|
540
|
+
protected async executeRelease(name: string): Promise<void> {
|
|
541
|
+
// TODO: Re-enable daemon socket release when client.release() is implemented
|
|
542
|
+
// See: docs/SDK-MIGRATION-PLAN.md for planned implementation
|
|
543
|
+
// For now, go directly to dashboard API or callback
|
|
544
|
+
|
|
545
|
+
// Try dashboard API as fallback (backwards compatibility)
|
|
546
|
+
if (this.config.dashboardPort) {
|
|
547
|
+
try {
|
|
548
|
+
const response = await fetch(
|
|
549
|
+
`http://localhost:${this.config.dashboardPort}/api/agents/${name}`,
|
|
550
|
+
{ method: 'DELETE' }
|
|
551
|
+
);
|
|
552
|
+
if (response.ok) return;
|
|
553
|
+
} catch {
|
|
554
|
+
// Fall through to callback
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// Use callback as final fallback
|
|
559
|
+
if (this.config.onRelease) {
|
|
560
|
+
await this.config.onRelease(name);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// =========================================================================
|
|
565
|
+
// Continuity handling
|
|
566
|
+
// =========================================================================
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Initialize agent ID for continuity/resume
|
|
570
|
+
*/
|
|
571
|
+
protected async initializeAgentId(): Promise<void> {
|
|
572
|
+
if (!this.continuity) return;
|
|
573
|
+
|
|
574
|
+
try {
|
|
575
|
+
let ledger;
|
|
576
|
+
|
|
577
|
+
// If resuming, try to find previous ledger
|
|
578
|
+
if (this.config.resumeAgentId) {
|
|
579
|
+
ledger = await this.continuity.findLedgerByAgentId(this.config.resumeAgentId);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// Otherwise get or create
|
|
583
|
+
if (!ledger) {
|
|
584
|
+
ledger = await this.continuity.getOrCreateLedger(
|
|
585
|
+
this.config.name,
|
|
586
|
+
this.cliType
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
this.agentId = ledger.agentId;
|
|
591
|
+
} catch (err: any) {
|
|
592
|
+
console.error(`[${this.config.name}] Failed to initialize agent ID: ${err.message}`);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* Parse continuity commands from output
|
|
598
|
+
*/
|
|
599
|
+
protected async parseContinuityCommands(content: string): Promise<void> {
|
|
600
|
+
if (!this.continuity) return;
|
|
601
|
+
if (!hasContinuityCommand(content)) return;
|
|
602
|
+
|
|
603
|
+
const command = parseContinuityCommand(content);
|
|
604
|
+
if (!command) return;
|
|
605
|
+
|
|
606
|
+
// Deduplication
|
|
607
|
+
const cmdHash = `${command.type}:${command.content || command.query || command.item || 'no-content'}`;
|
|
608
|
+
if (command.content && this.processedContinuityCommands.has(cmdHash)) return;
|
|
609
|
+
this.processedContinuityCommands.add(cmdHash);
|
|
610
|
+
|
|
611
|
+
// Limit dedup set size
|
|
612
|
+
if (this.processedContinuityCommands.size > 100) {
|
|
613
|
+
const oldest = this.processedContinuityCommands.values().next().value;
|
|
614
|
+
if (oldest) this.processedContinuityCommands.delete(oldest);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
try {
|
|
618
|
+
const response = await this.continuity.handleCommand(this.config.name, command);
|
|
619
|
+
if (response) {
|
|
620
|
+
// Queue response for injection
|
|
621
|
+
this.messageQueue.push({
|
|
622
|
+
from: 'system',
|
|
623
|
+
body: response,
|
|
624
|
+
messageId: `continuity-${Date.now()}`,
|
|
625
|
+
thread: 'continuity-response',
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
} catch (err: any) {
|
|
629
|
+
console.error(`[${this.config.name}] Continuity command error: ${err.message}`);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Save a parsed summary to the continuity ledger
|
|
635
|
+
*/
|
|
636
|
+
protected async saveSummaryToLedger(summary: ParsedSummary): Promise<void> {
|
|
637
|
+
if (!this.continuity) return;
|
|
638
|
+
|
|
639
|
+
const updates: Record<string, unknown> = {};
|
|
640
|
+
|
|
641
|
+
if (summary.currentTask) {
|
|
642
|
+
updates.currentTask = summary.currentTask;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
if (summary.completedTasks && summary.completedTasks.length > 0) {
|
|
646
|
+
updates.completed = summary.completedTasks;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
if (summary.context) {
|
|
650
|
+
updates.inProgress = [summary.context];
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
if (summary.files && summary.files.length > 0) {
|
|
654
|
+
updates.fileContext = summary.files.map((f: string) => ({ path: f }));
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
if (Object.keys(updates).length > 0) {
|
|
658
|
+
await this.continuity.saveLedger(this.config.name, updates);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* Reset session-specific state for wrapper reuse
|
|
664
|
+
*/
|
|
665
|
+
resetSessionState(): void {
|
|
666
|
+
this.sessionEndProcessed = false;
|
|
667
|
+
this.lastSummaryRawContent = '';
|
|
668
|
+
this.sessionEndData = undefined;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// =========================================================================
|
|
672
|
+
// Utility methods
|
|
673
|
+
// =========================================================================
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* Join continuation lines for multi-line relay/continuity commands.
|
|
677
|
+
* TUIs like Claude Code insert real newlines in output, causing
|
|
678
|
+
* messages to span multiple lines. This joins indented
|
|
679
|
+
* continuation lines back to the command line.
|
|
680
|
+
*/
|
|
681
|
+
protected joinContinuationLines(content: string): string {
|
|
682
|
+
const lines = content.split('\n');
|
|
683
|
+
const result: string[] = [];
|
|
684
|
+
|
|
685
|
+
// Pattern to detect relay OR continuity command line (with optional bullet prefix)
|
|
686
|
+
const escapedPrefix = this.relayPrefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
687
|
+
const commandPattern = new RegExp(
|
|
688
|
+
`^(?:\\s*(?:[>$%#→➜›»●•◦‣⁃\\-*⏺◆◇○□■]\\s*)*)?(?:${escapedPrefix}|->continuity:)`
|
|
689
|
+
);
|
|
690
|
+
// Pattern to detect a continuation line (starts with spaces, no bullet/command)
|
|
691
|
+
const continuationPattern = /^[ \t]+[^>$%#→➜›»●•◦‣⁃\-*⏺◆◇○□■\s]/;
|
|
692
|
+
// Pattern to detect a new block/bullet (stops continuation)
|
|
693
|
+
const newBlockPattern = /^(?:\s*)?[>$%#→➜›»●•◦‣⁃\-*⏺◆◇○□■]/;
|
|
694
|
+
|
|
695
|
+
let i = 0;
|
|
696
|
+
while (i < lines.length) {
|
|
697
|
+
const line = lines[i];
|
|
698
|
+
|
|
699
|
+
// Check if this is a command line
|
|
700
|
+
if (commandPattern.test(line)) {
|
|
701
|
+
let joined = line;
|
|
702
|
+
let j = i + 1;
|
|
703
|
+
|
|
704
|
+
// Look ahead for continuation lines
|
|
705
|
+
while (j < lines.length) {
|
|
706
|
+
const nextLine = lines[j];
|
|
707
|
+
|
|
708
|
+
// Empty line stops continuation
|
|
709
|
+
if (nextLine.trim() === '') break;
|
|
710
|
+
|
|
711
|
+
// New bullet/block stops continuation
|
|
712
|
+
if (newBlockPattern.test(nextLine)) break;
|
|
713
|
+
|
|
714
|
+
// Check if it looks like a continuation (indented text)
|
|
715
|
+
if (continuationPattern.test(nextLine)) {
|
|
716
|
+
// Join with newline to preserve multi-line message content
|
|
717
|
+
joined += '\n' + nextLine.trim();
|
|
718
|
+
j++;
|
|
719
|
+
} else {
|
|
720
|
+
break;
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
result.push(joined);
|
|
725
|
+
i = j; // Skip the lines we joined
|
|
726
|
+
} else {
|
|
727
|
+
result.push(line);
|
|
728
|
+
i++;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
return result.join('\n');
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* Clean up resources
|
|
737
|
+
*/
|
|
738
|
+
protected destroyClient(): void {
|
|
739
|
+
this.client.destroy();
|
|
740
|
+
}
|
|
741
|
+
}
|