@agentuity/coder 1.0.37 → 1.0.38
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/hub-overlay.js +3 -3
- package/dist/hub-overlay.js.map +1 -1
- package/dist/remote-tui.d.ts.map +1 -1
- package/dist/remote-tui.js +183 -5
- package/dist/remote-tui.js.map +1 -1
- package/dist/renderers.js +3 -3
- package/dist/renderers.js.map +1 -1
- package/package.json +1 -1
- package/src/hub-overlay.ts +3 -3
- package/src/remote-tui.ts +202 -5
- package/src/renderers.ts +3 -3
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 (
|
|
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
|
-
!
|
|
416
|
+
!hadSeenMessageStart
|
|
276
417
|
) {
|
|
277
418
|
log(`Live ${rpcEvent.type} without prior message_start — injecting synthetics`);
|
|
278
|
-
if (!
|
|
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
|
-
|
|
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)
|
|
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'
|
|
479
|
+
: status === 'cancelled'
|
|
480
480
|
? theme.fg('error', 'x')
|
|
481
481
|
: theme.fg('warning', '○');
|
|
482
482
|
const id = truncate(String(todo['id'] ?? ''), 18);
|