@agentuity/coder 1.0.37 → 1.0.39

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/src/remote-tui.ts CHANGED
@@ -221,13 +221,116 @@ export async function runRemoteTui(options: {
221
221
  let seenMessageStart = false;
222
222
  let seenAgentStart = false;
223
223
 
224
+ // Dedup guard: some events may arrive twice via different broadcast paths
225
+ // (rpc_event envelope + direct lifecycle broadcast). Track recent live events
226
+ // by type+timestamp to skip duplicates.
227
+ const recentEventKeys = new Set<string>();
228
+ const DEDUP_WINDOW_MS = 100;
229
+
230
+ // InteractiveMode adds a new assistant component on every assistant message_start.
231
+ // Track active/completed remote messages so normal terminal events and late duplicates
232
+ // do not spawn extra components.
233
+ let assistantStreamActive = false;
234
+ const recentCompletedAssistantMessageKeys: string[] = [];
235
+ const completedAssistantMessageKeySet = new Set<string>();
236
+
237
+ function rememberCompletedAssistantMessage(key: string): void {
238
+ if (completedAssistantMessageKeySet.has(key)) return;
239
+ recentCompletedAssistantMessageKeys.push(key);
240
+ completedAssistantMessageKeySet.add(key);
241
+ if (recentCompletedAssistantMessageKeys.length > 32) {
242
+ const expired = recentCompletedAssistantMessageKeys.shift();
243
+ if (expired) completedAssistantMessageKeySet.delete(expired);
244
+ }
245
+ }
246
+
247
+ function emitRemoteUserPrompt(text: string, timestamp: number): void {
248
+ const userMessage = {
249
+ role: 'user' as const,
250
+ content: [{ type: 'text' as const, text }],
251
+ timestamp,
252
+ };
253
+ const syntheticEvents = [
254
+ { type: 'message_start', message: userMessage },
255
+ { type: 'message_end', message: userMessage },
256
+ ] as RpcEvent[];
257
+
258
+ if (!interactiveModeReady) {
259
+ eventBuffer.push(...syntheticEvents);
260
+ log('Buffered synthetic user_prompt events (InteractiveMode not ready)');
261
+ return;
262
+ }
263
+
264
+ for (const event of syntheticEvents) {
265
+ agent.emit(event);
266
+ }
267
+ }
268
+
224
269
  remote.onEvent((rpcEvent: RpcEvent) => {
225
270
  const source = (rpcEvent as any)._source ?? 'unknown';
271
+ const isReplay =
272
+ source === 'replay' ||
273
+ (rpcEvent as any).replay === true ||
274
+ (rpcEvent as any).isReplay === true;
226
275
  log(`Event received: ${rpcEvent.type} (source=${source})`);
227
276
 
228
277
  // session_hydration is handled separately below — skip it here
229
278
  if (rpcEvent.type === 'session_hydration') return;
230
279
 
280
+ // Remote prompts from other controllers are broadcast as user_prompt.
281
+ // Convert them to synthetic user message lifecycle events so InteractiveMode
282
+ // renders them like locally-entered prompts. Replays are covered by hydration.
283
+ if (rpcEvent.type === 'user_prompt') {
284
+ if (isReplay) {
285
+ log('Skipping replay user_prompt');
286
+ return;
287
+ }
288
+
289
+ const promptText =
290
+ typeof (rpcEvent as any).content === 'string'
291
+ ? (rpcEvent as any).content
292
+ : typeof (rpcEvent as any).text === 'string'
293
+ ? (rpcEvent as any).text
294
+ : '';
295
+ if (!promptText.trim()) {
296
+ log('Skipping empty user_prompt');
297
+ return;
298
+ }
299
+
300
+ const promptTimestamp =
301
+ typeof (rpcEvent as any).timestamp === 'number'
302
+ ? (rpcEvent as any).timestamp
303
+ : Date.now();
304
+ log('Rendering live user_prompt as synthetic user message');
305
+ emitRemoteUserPrompt(promptText, promptTimestamp);
306
+ return;
307
+ }
308
+
309
+ // Skip user-role message events — the TUI already shows user messages
310
+ // via InteractiveMode input or the synthetic user_prompt path above.
311
+ // Pi emits message_start/end for both user and assistant messages; without
312
+ // this guard the same prompt can appear twice.
313
+ if (rpcEvent.type === 'message_start' || rpcEvent.type === 'message_end') {
314
+ const msg = (rpcEvent as any).message;
315
+ if (msg?.role === 'user') {
316
+ log(`Skipping ${rpcEvent.type} (role=user) — handled locally`);
317
+ return;
318
+ }
319
+ }
320
+
321
+ // Dedup: skip if we already processed the same event type + timestamp recently
322
+ // Replays still check the cache, but they never populate it.
323
+ const ts = (rpcEvent as any).timestamp ?? 0;
324
+ const dedupKey = `${rpcEvent.type}:${ts}`;
325
+ if (recentEventKeys.has(dedupKey)) {
326
+ log(`Dedup: skipping duplicate ${rpcEvent.type} (ts=${ts})`);
327
+ return;
328
+ }
329
+ if (!isReplay && ts > 0) {
330
+ recentEventKeys.add(dedupKey);
331
+ setTimeout(() => recentEventKeys.delete(dedupKey), DEDUP_WINDOW_MS);
332
+ }
333
+
231
334
  // Skip duplicate agent_start if we already emitted a synthetic one
232
335
  if (rpcEvent.type === 'agent_start' && syntheticAgentStartEmitted) {
233
336
  syntheticAgentStartEmitted = false;
@@ -237,11 +340,48 @@ export async function runRemoteTui(options: {
237
340
  }
238
341
 
239
342
  // Skip replay events — these are historical from Durable Stream
240
- if (source === 'replay') {
343
+ if (isReplay) {
241
344
  log(`Skipping replay event: ${rpcEvent.type}`);
242
345
  return;
243
346
  }
244
347
 
348
+ const hadSeenAgentStart = seenAgentStart;
349
+ const hadSeenMessageStart = seenMessageStart;
350
+ const assistantMessageKey = getRemoteAssistantMessageKey(rpcEvent);
351
+ if (
352
+ assistantMessageKey &&
353
+ (rpcEvent.type === 'message_start' || rpcEvent.type === 'message_end') &&
354
+ completedAssistantMessageKeySet.has(assistantMessageKey)
355
+ ) {
356
+ log(`Dedup: skipping repeated assistant ${rpcEvent.type}`);
357
+ return;
358
+ }
359
+
360
+ // State-based dedup for assistant message streaming.
361
+ // Prevents duplicate AssistantMessageComponent from being added to the DOM.
362
+ if (rpcEvent.type === 'message_start') {
363
+ const msg = (rpcEvent as any).message;
364
+ if (msg?.role === 'assistant') {
365
+ if (assistantStreamActive) {
366
+ log(`Dedup: skipping duplicate assistant message_start (stream already active)`);
367
+ return;
368
+ }
369
+ assistantStreamActive = true;
370
+ }
371
+ }
372
+ if (rpcEvent.type === 'message_end') {
373
+ const msg = (rpcEvent as any).message;
374
+ if (msg?.role === 'assistant') {
375
+ assistantStreamActive = false;
376
+ if (assistantMessageKey) {
377
+ rememberCompletedAssistantMessage(assistantMessageKey);
378
+ }
379
+ }
380
+ }
381
+ if (rpcEvent.type === 'agent_end') {
382
+ assistantStreamActive = false;
383
+ }
384
+
245
385
  // Track streaming lifecycle events so we can inject synthetics when
246
386
  // we attach mid-stream (controller connected after agent already started).
247
387
  if (rpcEvent.type === 'agent_start') seenAgentStart = true;
@@ -270,12 +410,13 @@ export async function runRemoteTui(options: {
270
410
  // This happens when the controller connects mid-stream or after the
271
411
  // agent finishes — the broadcast of agent_start/message_start occurred
272
412
  // before the controller WebSocket was registered.
413
+ let injectedSyntheticMessageStart = false;
273
414
  if (
274
415
  (rpcEvent.type === 'message_update' || rpcEvent.type === 'message_end') &&
275
- !seenMessageStart
416
+ !hadSeenMessageStart
276
417
  ) {
277
418
  log(`Live ${rpcEvent.type} without prior message_start — injecting synthetics`);
278
- if (!seenAgentStart) {
419
+ if (!hadSeenAgentStart) {
279
420
  agent.emit({ type: 'agent_start', agentName: 'lead', timestamp: Date.now() } as any);
280
421
  seenAgentStart = true;
281
422
  }
@@ -299,10 +440,17 @@ export async function runRemoteTui(options: {
299
440
  },
300
441
  } as any);
301
442
  seenMessageStart = true;
443
+ assistantStreamActive = true;
444
+ injectedSyntheticMessageStart = true;
302
445
  }
303
446
 
304
447
  // Emit to subscribers — InteractiveMode.handleEvent processes this
305
448
  agent.emit(rpcEvent);
449
+ if (injectedSyntheticMessageStart && rpcEvent.type === 'message_end') {
450
+ seenMessageStart = false;
451
+ seenAgentStart = hadSeenAgentStart;
452
+ assistantStreamActive = false;
453
+ }
306
454
 
307
455
  // Resolve running prompt when agent finishes
308
456
  if (rpcEvent.type === 'agent_end') {
@@ -330,8 +478,10 @@ export async function runRemoteTui(options: {
330
478
  resolveHydration = resolve;
331
479
  });
332
480
 
481
+ let hydrationCount = 0;
333
482
  remote.onEvent((event: RpcEvent) => {
334
483
  if (event.type !== 'session_hydration') return;
484
+ hydrationCount++;
335
485
 
336
486
  const entries = (event as any).entries as
337
487
  | Array<{
@@ -345,6 +495,19 @@ export async function runRemoteTui(options: {
345
495
  // Extract task text from hydration (Hub includes session.sandbox?.task)
346
496
  const hydrationTask = (event as any).task as string | undefined;
347
497
 
498
+ // On reconnect (2nd+ hydration), clear SM to prevent duplicate accumulation.
499
+ // agent.replaceMessages() already replaces state.messages, but SM only appends.
500
+ if (hydrationCount > 1) {
501
+ log(`Re-hydration #${hydrationCount} — clearing SessionManager to prevent duplicates`);
502
+ try {
503
+ if (typeof (sm as any).clear === 'function') {
504
+ (sm as any).clear();
505
+ }
506
+ } catch (err) {
507
+ log(`SM clear error (non-fatal): ${err}`);
508
+ }
509
+ }
510
+
348
511
  if (!entries?.length) {
349
512
  log('Received session_hydration with no entries');
350
513
  // Even with no entries, inject task as user message if available
@@ -366,7 +529,7 @@ export async function runRemoteTui(options: {
366
529
  return;
367
530
  }
368
531
 
369
- log(`Hydrating ${entries.length} entries`);
532
+ log(`Hydrating ${entries.length} entries (hydration #${hydrationCount})`);
370
533
  const agentMessages: any[] = [];
371
534
 
372
535
  // If we have a task and no user_prompt entry, inject the task as the first user message
@@ -545,6 +708,7 @@ export async function runRemoteTui(options: {
545
708
  } as any);
546
709
  seenAgentStart = true;
547
710
  seenMessageStart = true;
711
+ assistantStreamActive = true;
548
712
 
549
713
  // Remove any agent_start/message_start from buffer since we already emitted them
550
714
  eventBuffer = eventBuffer.filter(
@@ -627,7 +791,10 @@ function updateAgentState(agent: any, event: RpcEvent): void {
627
791
 
628
792
  case 'message_end':
629
793
  state.streamMessage = null;
630
- state.messages = [...state.messages, (event as any).message];
794
+ // NOTE: Do NOT push to state.messages here.
795
+ // AgentSession._handleAgentEvent already persists via sessionManager.appendMessage().
796
+ // Pushing here causes state.messages to accumulate duplicates with SM,
797
+ // leading to visual duplicates if rebuildChatFromMessages() is ever triggered.
631
798
  break;
632
799
 
633
800
  case 'tool_execution_start': {
@@ -705,3 +872,33 @@ function extractMessageText(msg: any): string {
705
872
 
706
873
  return '';
707
874
  }
875
+
876
+ function getRemoteAssistantMessageKey(event: RpcEvent): string | undefined {
877
+ if (event.type !== 'message_start' && event.type !== 'message_end') return undefined;
878
+
879
+ const message = (event as any).message;
880
+ if (!message || typeof message !== 'object' || message.role !== 'assistant') {
881
+ return undefined;
882
+ }
883
+
884
+ const messageId = typeof message.id === 'string' ? message.id : '';
885
+ if (messageId) return `id:${messageId}`;
886
+
887
+ const timestamp =
888
+ typeof message.timestamp === 'number'
889
+ ? String(message.timestamp)
890
+ : typeof message.timestamp === 'string'
891
+ ? message.timestamp
892
+ : typeof (event as any).timestamp === 'number'
893
+ ? String((event as any).timestamp)
894
+ : typeof (event as any).timestamp === 'string'
895
+ ? (event as any).timestamp
896
+ : '';
897
+ if (timestamp) return `ts:${timestamp}`;
898
+
899
+ const text = extractMessageText(message);
900
+ if (!text) return undefined;
901
+
902
+ const stopReason = typeof message.stopReason === 'string' ? message.stopReason : '';
903
+ return `text:${stopReason}|${text}`;
904
+ }
package/src/renderers.ts CHANGED
@@ -465,18 +465,18 @@ function sessionTodoListRenderers(): ToolRenderers {
465
465
  let text = theme.fg('success', `${count} todo${count === 1 ? '' : 's'}`);
466
466
  text += theme.fg(
467
467
  'dim',
468
- ` o:${Number(summary['open'] ?? 0)} ip:${Number(summary['in_progress'] ?? 0)} d:${Number(summary['done'] ?? 0)} c:${Number(summary['closed'] ?? 0)} x:${Number(summary['cancelled'] ?? 0)}`
468
+ ` o:${Number(summary['open'] ?? 0)} ip:${Number(summary['in_progress'] ?? 0)} d:${Number(summary['done'] ?? 0) + Number(summary['closed'] ?? 0)} x:${Number(summary['cancelled'] ?? 0)}`
469
469
  );
470
470
 
471
471
  if (expanded && todos.length > 0) {
472
472
  const lines = todos.slice(0, 20).map((todo) => {
473
473
  const status = String(todo['status'] ?? 'open');
474
474
  const marker =
475
- status === 'done'
475
+ status === 'done' || status === 'closed'
476
476
  ? theme.fg('success', '✓')
477
477
  : status === 'in_progress'
478
478
  ? theme.fg('accent', '●')
479
- : status === 'cancelled' || status === 'closed'
479
+ : status === 'cancelled'
480
480
  ? theme.fg('error', 'x')
481
481
  : theme.fg('warning', '○');
482
482
  const id = truncate(String(todo['id'] ?? ''), 18);