@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.
Files changed (115) hide show
  1. package/dist/__fixtures__/claude-outputs.d.ts +49 -0
  2. package/dist/__fixtures__/claude-outputs.d.ts.map +1 -0
  3. package/dist/__fixtures__/claude-outputs.js +443 -0
  4. package/dist/__fixtures__/claude-outputs.js.map +1 -0
  5. package/dist/__fixtures__/codex-outputs.d.ts +9 -0
  6. package/dist/__fixtures__/codex-outputs.d.ts.map +1 -0
  7. package/dist/__fixtures__/codex-outputs.js +94 -0
  8. package/dist/__fixtures__/codex-outputs.js.map +1 -0
  9. package/dist/__fixtures__/gemini-outputs.d.ts +19 -0
  10. package/dist/__fixtures__/gemini-outputs.d.ts.map +1 -0
  11. package/dist/__fixtures__/gemini-outputs.js +144 -0
  12. package/dist/__fixtures__/gemini-outputs.js.map +1 -0
  13. package/dist/__fixtures__/index.d.ts +68 -0
  14. package/dist/__fixtures__/index.d.ts.map +1 -0
  15. package/dist/__fixtures__/index.js +44 -0
  16. package/dist/__fixtures__/index.js.map +1 -0
  17. package/dist/auth-detection.d.ts +49 -0
  18. package/dist/auth-detection.d.ts.map +1 -0
  19. package/dist/auth-detection.js +199 -0
  20. package/dist/auth-detection.js.map +1 -0
  21. package/dist/base-wrapper.d.ts +225 -0
  22. package/dist/base-wrapper.d.ts.map +1 -0
  23. package/dist/base-wrapper.js +572 -0
  24. package/dist/base-wrapper.js.map +1 -0
  25. package/dist/client.d.ts +254 -0
  26. package/dist/client.d.ts.map +1 -0
  27. package/dist/client.js +801 -0
  28. package/dist/client.js.map +1 -0
  29. package/dist/id-generator.d.ts +35 -0
  30. package/dist/id-generator.d.ts.map +1 -0
  31. package/dist/id-generator.js +60 -0
  32. package/dist/id-generator.js.map +1 -0
  33. package/dist/idle-detector.d.ts +110 -0
  34. package/dist/idle-detector.d.ts.map +1 -0
  35. package/dist/idle-detector.js +304 -0
  36. package/dist/idle-detector.js.map +1 -0
  37. package/dist/inbox.d.ts +37 -0
  38. package/dist/inbox.d.ts.map +1 -0
  39. package/dist/inbox.js +73 -0
  40. package/dist/inbox.js.map +1 -0
  41. package/dist/index.d.ts +37 -0
  42. package/dist/index.d.ts.map +1 -0
  43. package/dist/index.js +47 -0
  44. package/dist/index.js.map +1 -0
  45. package/dist/parser.d.ts +236 -0
  46. package/dist/parser.d.ts.map +1 -0
  47. package/dist/parser.js +1238 -0
  48. package/dist/parser.js.map +1 -0
  49. package/dist/prompt-composer.d.ts +67 -0
  50. package/dist/prompt-composer.d.ts.map +1 -0
  51. package/dist/prompt-composer.js +168 -0
  52. package/dist/prompt-composer.js.map +1 -0
  53. package/dist/relay-pty-orchestrator.d.ts +407 -0
  54. package/dist/relay-pty-orchestrator.d.ts.map +1 -0
  55. package/dist/relay-pty-orchestrator.js +1885 -0
  56. package/dist/relay-pty-orchestrator.js.map +1 -0
  57. package/dist/shared.d.ts +201 -0
  58. package/dist/shared.d.ts.map +1 -0
  59. package/dist/shared.js +341 -0
  60. package/dist/shared.js.map +1 -0
  61. package/dist/stuck-detector.d.ts +161 -0
  62. package/dist/stuck-detector.d.ts.map +1 -0
  63. package/dist/stuck-detector.js +402 -0
  64. package/dist/stuck-detector.js.map +1 -0
  65. package/dist/tmux-resolver.d.ts +55 -0
  66. package/dist/tmux-resolver.d.ts.map +1 -0
  67. package/dist/tmux-resolver.js +175 -0
  68. package/dist/tmux-resolver.js.map +1 -0
  69. package/dist/tmux-wrapper.d.ts +345 -0
  70. package/dist/tmux-wrapper.d.ts.map +1 -0
  71. package/dist/tmux-wrapper.js +1747 -0
  72. package/dist/tmux-wrapper.js.map +1 -0
  73. package/dist/trajectory-integration.d.ts +292 -0
  74. package/dist/trajectory-integration.d.ts.map +1 -0
  75. package/dist/trajectory-integration.js +979 -0
  76. package/dist/trajectory-integration.js.map +1 -0
  77. package/dist/wrapper-types.d.ts +41 -0
  78. package/dist/wrapper-types.d.ts.map +1 -0
  79. package/dist/wrapper-types.js +7 -0
  80. package/dist/wrapper-types.js.map +1 -0
  81. package/package.json +63 -0
  82. package/src/__fixtures__/claude-outputs.ts +471 -0
  83. package/src/__fixtures__/codex-outputs.ts +99 -0
  84. package/src/__fixtures__/gemini-outputs.ts +151 -0
  85. package/src/__fixtures__/index.ts +47 -0
  86. package/src/auth-detection.ts +244 -0
  87. package/src/base-wrapper.test.ts +540 -0
  88. package/src/base-wrapper.ts +741 -0
  89. package/src/client.test.ts +262 -0
  90. package/src/client.ts +984 -0
  91. package/src/id-generator.test.ts +71 -0
  92. package/src/id-generator.ts +69 -0
  93. package/src/idle-detector.test.ts +390 -0
  94. package/src/idle-detector.ts +370 -0
  95. package/src/inbox.test.ts +233 -0
  96. package/src/inbox.ts +89 -0
  97. package/src/index.ts +170 -0
  98. package/src/parser.regression.test.ts +251 -0
  99. package/src/parser.test.ts +1359 -0
  100. package/src/parser.ts +1477 -0
  101. package/src/prompt-composer.test.ts +219 -0
  102. package/src/prompt-composer.ts +231 -0
  103. package/src/relay-pty-orchestrator.test.ts +1027 -0
  104. package/src/relay-pty-orchestrator.ts +2270 -0
  105. package/src/shared.test.ts +221 -0
  106. package/src/shared.ts +454 -0
  107. package/src/stuck-detector.test.ts +303 -0
  108. package/src/stuck-detector.ts +511 -0
  109. package/src/tmux-resolver.test.ts +104 -0
  110. package/src/tmux-resolver.ts +207 -0
  111. package/src/tmux-wrapper.test.ts +316 -0
  112. package/src/tmux-wrapper.ts +2010 -0
  113. package/src/trajectory-detection.test.ts +151 -0
  114. package/src/trajectory-integration.ts +1261 -0
  115. 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
+ }