taskplane 0.22.18 → 0.23.1

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.
@@ -55,6 +55,52 @@ function formatCost(usd) {
55
55
  return `$${usd.toFixed(2)}`;
56
56
  }
57
57
 
58
+ /**
59
+ * TP-107: Check if a lane has a live agent via the Runtime V2 registry.
60
+ * Returns true/false if registry data is available, null if no V2 data.
61
+ */
62
+ function isLaneAliveV2(laneNumber) {
63
+ if (!currentData || !currentData.runtimeRegistry || !currentData.runtimeRegistry.agents) return null;
64
+ const agents = Object.values(currentData.runtimeRegistry.agents);
65
+ const laneAgents = agents.filter(a => a.laneNumber === laneNumber);
66
+ if (laneAgents.length === 0) return null;
67
+ return laneAgents.some(a => a.status === 'running' || a.status === 'spawning');
68
+ }
69
+
70
+ /**
71
+ * TP-107: Merge Runtime V2 lane snapshot data onto legacy lane state.
72
+ * V2 fields take precedence when present; legacy fields are preserved as fallback.
73
+ */
74
+ function mergeV2LaneSnapshot(legacyLs, v2snap) {
75
+ const base = legacyLs ? { ...legacyLs } : {};
76
+ // Overlay V2 fields from nested worker snapshot onto flat legacy shape.
77
+ // RuntimeLaneSnapshot has worker: { status, elapsedMs, toolCalls, contextPct, ... }
78
+ const w = v2snap.worker;
79
+ if (w) {
80
+ // Map V2 agent status to legacy dashboard status strings
81
+ if (w.status) {
82
+ const statusMap = { running: 'running', spawning: 'running', exited: 'done', crashed: 'error', killed: 'error', timed_out: 'error', wrapping_up: 'running' };
83
+ base.workerStatus = statusMap[w.status] || w.status;
84
+ }
85
+ if (w.elapsedMs != null) base.workerElapsed = w.elapsedMs;
86
+ if (w.contextPct != null) base.workerContextPct = w.contextPct;
87
+ if (w.toolCalls != null) base.workerToolCount = w.toolCalls;
88
+ if (w.lastTool) base.workerLastTool = w.lastTool;
89
+ if (w.costUsd != null) base.workerCostUsd = w.costUsd;
90
+ if (w.inputTokens != null) base.workerInputTokens = w.inputTokens;
91
+ if (w.outputTokens != null) base.workerOutputTokens = w.outputTokens;
92
+ if (w.cacheReadTokens != null) base.workerCacheReadTokens = w.cacheReadTokens;
93
+ if (w.cacheWriteTokens != null) base.workerCacheWriteTokens = w.cacheWriteTokens;
94
+ }
95
+ if (v2snap.taskId) base.taskId = v2snap.taskId;
96
+ if (v2snap.batchId) base.batchId = v2snap.batchId;
97
+ // Enrich progress display from V2 snapshot
98
+ if (v2snap.progress) {
99
+ base._v2Progress = v2snap.progress;
100
+ }
101
+ return base;
102
+ }
103
+
58
104
  /** Build a compact token summary string from lane state sidecar data.
59
105
  * Display: ↑total_input ↓output (cost)
60
106
  * Anthropic splits input into: uncached `input` + `cacheRead`.
@@ -376,21 +422,49 @@ function renderSummary(batch) {
376
422
  let elapsedStr = `elapsed: ${formatDuration(elapsed)}`;
377
423
  if (batch.updatedAt) elapsedStr += ` · updated: ${relativeTime(batch.updatedAt)}`;
378
424
 
379
- // Aggregate tokens across all active lane states
425
+ // Aggregate tokens/cost for summary.
426
+ // Runtime V2 snapshots are authoritative when present; legacy lane-state sidecars are fallback.
380
427
  const laneStates = currentData?.laneStates || {};
381
- let batchInput = 0, batchOutput = 0, batchCacheRead = 0, batchCacheWrite = 0, batchCostFromLanes = 0;
382
- for (const ls of Object.values(laneStates)) {
383
- batchInput += ls.workerInputTokens || 0;
384
- batchOutput += ls.workerOutputTokens || 0;
385
- batchCacheRead += ls.workerCacheReadTokens || 0;
386
- batchCacheWrite += ls.workerCacheWriteTokens || 0;
387
- batchCostFromLanes += ls.workerCostUsd || 0;
388
- }
389
- // Use server-computed batchTotalCost (includes telemetry for uncovered lanes);
390
- // fallback to lane-state-only sum for backward compatibility (pre-telemetry server)
391
- const batchCost = (currentData?.batchTotalCost != null && currentData.batchTotalCost > 0)
392
- ? currentData.batchTotalCost
393
- : batchCostFromLanes;
428
+ const runtimeLaneSnapshots = currentData?.runtimeLaneSnapshots || {};
429
+ const v2Snaps = Object.values(runtimeLaneSnapshots);
430
+
431
+ let batchInput = 0, batchOutput = 0, batchCacheRead = 0, batchCacheWrite = 0, batchCostFromSnapshots = 0;
432
+
433
+ if (v2Snaps.length > 0) {
434
+ for (const snap of v2Snaps) {
435
+ const w = snap?.worker || {};
436
+ batchInput += w.inputTokens || 0;
437
+ batchOutput += w.outputTokens || 0;
438
+ batchCacheRead += w.cacheReadTokens || 0;
439
+ batchCacheWrite += w.cacheWriteTokens || 0;
440
+ batchCostFromSnapshots += w.costUsd || 0;
441
+
442
+ const r = snap?.reviewer || null;
443
+ if (r) {
444
+ batchInput += r.inputTokens || 0;
445
+ batchOutput += r.outputTokens || 0;
446
+ batchCacheRead += r.cacheReadTokens || 0;
447
+ batchCacheWrite += r.cacheWriteTokens || 0;
448
+ batchCostFromSnapshots += r.costUsd || 0;
449
+ }
450
+ }
451
+ } else {
452
+ // Legacy fallback
453
+ for (const ls of Object.values(laneStates)) {
454
+ batchInput += ls.workerInputTokens || 0;
455
+ batchOutput += ls.workerOutputTokens || 0;
456
+ batchCacheRead += ls.workerCacheReadTokens || 0;
457
+ batchCacheWrite += ls.workerCacheWriteTokens || 0;
458
+ batchCostFromSnapshots += ls.workerCostUsd || 0;
459
+ }
460
+ }
461
+
462
+ // Keep server-computed cost as fallback for uncovered early-start lanes.
463
+ const batchCost = batchCostFromSnapshots > 0
464
+ ? batchCostFromSnapshots
465
+ : ((currentData?.batchTotalCost != null && currentData.batchTotalCost > 0)
466
+ ? currentData.batchTotalCost
467
+ : 0);
394
468
  const batchTotalIn = batchInput + batchCacheRead;
395
469
  if (batchTotalIn > 0 || batchOutput > 0) {
396
470
  let tokenStr = ` · tokens: ↑${formatTokens(batchTotalIn)} ↓${formatTokens(batchOutput)}`;
@@ -428,6 +502,8 @@ function renderLanesTasks(batch, tmuxSessions) {
428
502
  const tmuxSet = new Set(tmuxSessions || []);
429
503
  const laneStates = currentData?.laneStates || {};
430
504
  const telemetry = currentData?.telemetry || {};
505
+ // TP-107: V2 lane snapshots take precedence over legacy lane states when present
506
+ const v2Snapshots = currentData?.runtimeLaneSnapshots || {};
431
507
  const showRepos = knownRepos.length >= 2;
432
508
  let html = "";
433
509
 
@@ -440,7 +516,9 @@ function renderLanesTasks(batch, tmuxSessions) {
440
516
  if (!laneMatchesRepo) continue;
441
517
  }
442
518
 
443
- const alive = tmuxSet.has(lane.tmuxSessionName);
519
+ // TP-107: check V2 registry for liveness first, fall back to tmux
520
+ const v2Alive = isLaneAliveV2(lane.laneNumber);
521
+ const alive = v2Alive !== null ? v2Alive : tmuxSet.has(lane.tmuxSessionName);
444
522
  const tmuxCmd = `tmux attach -t ${lane.tmuxSessionName}`;
445
523
 
446
524
  // Lane header
@@ -475,7 +553,10 @@ function renderLanesTasks(batch, tmuxSessions) {
475
553
  }
476
554
 
477
555
  // Get lane state and telemetry for worker stats
478
- const ls = laneStates[lane.tmuxSessionName] || null;
556
+ // TP-107: V2 lane snapshots take precedence when present
557
+ const v2snap = v2Snapshots[lane.laneNumber] || null;
558
+ const legacyLs = laneStates[lane.tmuxSessionName] || null;
559
+ const ls = v2snap ? mergeV2LaneSnapshot(legacyLs, v2snap) : legacyLs;
479
560
  const tel = telemetry[lane.tmuxSessionName] || null;
480
561
 
481
562
  for (const task of laneTasks) {
@@ -829,6 +910,164 @@ function renderMergeAgents(batch, tmuxSessions) {
829
910
  $mergeBody.innerHTML = html;
830
911
  }
831
912
 
913
+ // ─── Render: Runtime V2 Agents (TP-107) ─────────────────────────────────────
914
+
915
+ function renderAgentsPanel(registry) {
916
+ const $panel = document.getElementById('agents-panel');
917
+ const $body = document.getElementById('agents-body');
918
+ if (!$panel || !$body) return;
919
+
920
+ if (!registry || !registry.agents || Object.keys(registry.agents).length === 0) {
921
+ $panel.style.display = 'none';
922
+ return;
923
+ }
924
+
925
+ $panel.style.display = '';
926
+ const agents = Object.values(registry.agents);
927
+ let html = '<div class="agents-grid">';
928
+
929
+ for (const agent of agents) {
930
+ const isTerminal = ['exited', 'crashed', 'timed_out', 'killed'].includes(agent.status);
931
+ const statusClass = isTerminal ? 'agent-terminal' : 'agent-live';
932
+ const icon = isTerminal ? '\u{1F534}' : '\u{1F7E2}';
933
+ const elapsed = agent.startedAt ? Math.round((Date.now() - agent.startedAt) / 1000) : 0;
934
+ const elapsedStr = elapsed > 0 ? formatDuration(elapsed * 1000) : '';
935
+
936
+ html += `<div class="agent-card ${statusClass}">`;
937
+ html += `<div class="agent-header">${icon} <strong>${escapeHtml(agent.agentId)}</strong></div>`;
938
+ html += `<div class="agent-meta">`;
939
+ html += `<span class="agent-badge">${escapeHtml(agent.role)}</span>`;
940
+ if (agent.laneNumber != null) html += `<span class="agent-badge">lane ${agent.laneNumber}</span>`;
941
+ if (agent.taskId) html += `<span class="agent-badge">${escapeHtml(agent.taskId)}</span>`;
942
+ html += `<span class="agent-badge agent-status-${agent.status}">${escapeHtml(agent.status)}</span>`;
943
+ if (elapsedStr && !isTerminal) html += `<span class="agent-badge">${elapsedStr}</span>`;
944
+ html += `</div>`;
945
+ html += `</div>`;
946
+ }
947
+
948
+ html += '</div>';
949
+ $body.innerHTML = html;
950
+ }
951
+
952
+ // ─── Render: Mailbox Messages (TP-107) ──────────────────────────────────────
953
+
954
+ function renderMessagesPanel(mailbox) {
955
+ const $panel = document.getElementById('messages-panel');
956
+ const $body = document.getElementById('messages-body');
957
+ if (!$panel || !$body) return;
958
+
959
+ // TP-093: event-authoritative model — prefer audit events, fallback to directory scan
960
+ const auditEvents = mailbox?.auditEvents || [];
961
+ const dirMessages = mailbox?.messages || [];
962
+ const hasData = auditEvents.length > 0 || dirMessages.length > 0;
963
+
964
+ if (!mailbox || !hasData) {
965
+ $panel.style.display = 'none';
966
+ return;
967
+ }
968
+
969
+ $panel.style.display = '';
970
+ let html = '<div class="messages-list">';
971
+
972
+ if (auditEvents.length > 0) {
973
+ // Primary: render from audit event stream (authoritative, durable)
974
+ for (const evt of auditEvents) {
975
+ html += renderMailboxAuditEvent(evt);
976
+ }
977
+ } else {
978
+ // Fallback: render from directory scan (legacy compatibility)
979
+ for (const msg of dirMessages) {
980
+ html += renderMailboxDirMessage(msg);
981
+ }
982
+ }
983
+
984
+ html += '</div>';
985
+ $body.innerHTML = html;
986
+ }
987
+
988
+ /** Render a single mailbox audit event (events.jsonl row). */
989
+ function renderMailboxAuditEvent(evt) {
990
+ const ts = evt.ts ? new Date(evt.ts).toLocaleTimeString() : '';
991
+ const type = evt.type || '';
992
+
993
+ let direction = '';
994
+ let statusBadge = '';
995
+ let typeBadge = '';
996
+ let preview = '';
997
+
998
+ if (type === 'message_sent') {
999
+ const isBroadcast = evt.broadcast;
1000
+ direction = isBroadcast ? '\u2192 all (broadcast)' : `\u2192 ${escapeHtml(evt.to || '')}`;
1001
+ statusBadge = '<span class="msg-badge msg-delivered">sent</span>';
1002
+ typeBadge = `<span class="msg-badge msg-type">${escapeHtml(evt.messageType || '')}</span>`;
1003
+ preview = evt.contentPreview || '';
1004
+ } else if (type === 'message_delivered') {
1005
+ direction = `\u2192 ${escapeHtml(evt.to || '')}`;
1006
+ statusBadge = evt.broadcast
1007
+ ? '<span class="msg-badge msg-delivered">broadcast delivered</span>'
1008
+ : '<span class="msg-badge msg-delivered">delivered</span>';
1009
+ typeBadge = evt.messageType ? `<span class="msg-badge msg-type">${escapeHtml(evt.messageType)}</span>` : '';
1010
+ preview = evt.contentPreview || '';
1011
+ } else if (type === 'message_replied' || type === 'message_escalated') {
1012
+ direction = `\u2190 ${escapeHtml(evt.from || '')}`;
1013
+ statusBadge = type === 'message_escalated'
1014
+ ? '<span class="msg-badge msg-reply">escalation</span>'
1015
+ : '<span class="msg-badge msg-reply">reply</span>';
1016
+ typeBadge = evt.messageType ? `<span class="msg-badge msg-type">${escapeHtml(evt.messageType)}</span>` : '';
1017
+ preview = evt.contentPreview || '';
1018
+ } else if (type === 'message_rate_limited') {
1019
+ direction = `\u2192 ${escapeHtml(evt.to || '')}`;
1020
+ statusBadge = '<span class="msg-badge msg-rate-limited">rate limited</span>';
1021
+ const waitSec = evt.retryAfterMs ? Math.ceil(evt.retryAfterMs / 1000) : '?';
1022
+ preview = `${evt.reason || 'Rate limited'} (retry in ${waitSec}s)`;
1023
+ } else {
1024
+ // Unknown event type — render generically
1025
+ direction = evt.from ? `${escapeHtml(evt.from)}` : '';
1026
+ preview = JSON.stringify(evt).slice(0, 120);
1027
+ }
1028
+
1029
+ return `<div class="message-row">`
1030
+ + `<span class="msg-time">${escapeHtml(ts)}</span>`
1031
+ + `<span class="msg-direction">${direction}</span>`
1032
+ + typeBadge
1033
+ + statusBadge
1034
+ + `<span class="msg-preview">${escapeHtml(preview)}</span>`
1035
+ + `</div>`;
1036
+ }
1037
+
1038
+ /** Render a single directory-scanned message (legacy fallback). */
1039
+ function renderMailboxDirMessage(msg) {
1040
+ const ts = msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString() : '';
1041
+ // TP-093: for broadcast per-agent ack markers, show recipient identity instead of "_broadcast"
1042
+ let direction;
1043
+ if (msg.to === 'supervisor') {
1044
+ direction = '\u2190 supervisor';
1045
+ } else if (msg._isBroadcast && msg._agentDir && msg._agentDir !== '_broadcast') {
1046
+ direction = `\u2192 ${escapeHtml(msg._agentDir)} (broadcast)`;
1047
+ } else {
1048
+ direction = `\u2192 ${escapeHtml(msg.to || msg._agentDir || '')}`;
1049
+ }
1050
+ let statusBadge;
1051
+ if (msg._status === 'pending') statusBadge = '<span class="msg-badge msg-pending">pending</span>';
1052
+ else if (msg._status === 'delivered') statusBadge = '<span class="msg-badge msg-delivered">delivered</span>';
1053
+ else if (msg._status === 'reply') statusBadge = '<span class="msg-badge msg-reply">reply</span>';
1054
+ else if (msg._status === 'reply-acked') statusBadge = '<span class="msg-badge msg-delivered">reply (acked)</span>';
1055
+ else statusBadge = '';
1056
+ const typeBadge = `<span class="msg-badge msg-type">${escapeHtml(msg.type || '')}</span>`;
1057
+ const preview = (msg.content || '').slice(0, 120);
1058
+ const broadcastTag = msg._isBroadcast ? ' <span class="msg-badge msg-type">broadcast</span>' : '';
1059
+
1060
+ return `<div class="message-row">`
1061
+ + `<span class="msg-time">${escapeHtml(ts)}</span>`
1062
+ + `<span class="msg-direction">${direction}</span>`
1063
+ + typeBadge
1064
+ + statusBadge
1065
+ + broadcastTag
1066
+ + `<span class="msg-preview">${escapeHtml(preview)}</span>`
1067
+ + `</div>`;
1068
+ }
1069
+
1070
+
832
1071
  // ─── Render: Errors ─────────────────────────────────────────────────────────
833
1072
 
834
1073
  function renderErrors(batch) {
@@ -1156,6 +1395,9 @@ function render(data) {
1156
1395
  renderSupervisor(data);
1157
1396
  renderLanesTasks(batch, tmux);
1158
1397
  renderMergeAgents(batch, tmux);
1398
+ // TP-107: Runtime V2 panels
1399
+ renderAgentsPanel(data.runtimeRegistry);
1400
+ renderMessagesPanel(data.mailbox);
1159
1401
  renderErrors(batch);
1160
1402
 
1161
1403
  const taskCount = (batch.tasks || []).length;
@@ -1217,7 +1459,31 @@ let convRenderedLines = 0;
1217
1459
  // STATUS.md diff-and-skip state
1218
1460
  let lastStatusMdText = "";
1219
1461
 
1220
- // ── Open conversation viewer ────────────────────────────────────────────────
1462
+ // ── Open conversation viewer (TP-107: V2 events preferred, legacy fallback) ──
1463
+
1464
+ /**
1465
+ * Resolve a lane's tmux session name to a Runtime V2 agent ID via the registry.
1466
+ * Returns null if no V2 registry data is available.
1467
+ */
1468
+ function resolveV2AgentId(sessionName) {
1469
+ if (!currentData || !currentData.runtimeRegistry || !currentData.runtimeRegistry.agents) return null;
1470
+ const agents = currentData.runtimeRegistry.agents;
1471
+ // Direct match on agentId
1472
+ if (agents[sessionName]) return sessionName;
1473
+ // Match by tmux session prefix + "-worker" suffix (common V2 naming)
1474
+ const workerKey = sessionName + '-worker';
1475
+ if (agents[workerKey]) return workerKey;
1476
+ // Search by laneNumber match from lane snapshots
1477
+ for (const [id, agent] of Object.entries(agents)) {
1478
+ if (agent.role === 'worker' && agent.laneNumber != null) {
1479
+ const m = sessionName.match(/lane-(\d+)/);
1480
+ if (m && parseInt(m[1]) === agent.laneNumber) return id;
1481
+ }
1482
+ }
1483
+ return null;
1484
+ }
1485
+
1486
+ let viewerV2AgentId = null; // Runtime V2 agent ID for current conversation view
1221
1487
 
1222
1488
  function viewConversation(sessionName) {
1223
1489
  // Toggle off if already viewing this session
@@ -1233,7 +1499,12 @@ function viewConversation(sessionName) {
1233
1499
  autoScrollOn = true;
1234
1500
  convRenderedLines = 0;
1235
1501
 
1236
- $terminalTitle.textContent = `Worker Conversation ${sessionName}`;
1502
+ // TP-107: Resolve V2 agent ID for events endpoint
1503
+ const v2AgentId = resolveV2AgentId(sessionName);
1504
+ viewerV2AgentId = v2AgentId;
1505
+
1506
+ const label = v2AgentId || sessionName;
1507
+ $terminalTitle.textContent = `Worker Conversation — ${label}`;
1237
1508
  $autoScrollText.textContent = 'Follow feed';
1238
1509
  $autoScrollCheckbox.checked = true;
1239
1510
  $terminalPanel.style.display = '';
@@ -1246,9 +1517,21 @@ function viewConversation(sessionName) {
1246
1517
  }
1247
1518
 
1248
1519
  function pollConversation() {
1249
- fetch(`/api/conversation/${encodeURIComponent(viewerTarget)}`)
1250
- .then(r => r.text())
1251
- .then(text => {
1520
+ // TP-107: prefer V2 agent events when available, fallback to legacy conversation
1521
+ const endpoint = viewerV2AgentId
1522
+ ? `/api/agent-events/${encodeURIComponent(viewerV2AgentId)}`
1523
+ : `/api/conversation/${encodeURIComponent(viewerTarget)}`;
1524
+ const isV2 = !!viewerV2AgentId;
1525
+
1526
+ fetch(endpoint)
1527
+ .then(r => isV2 ? r.json() : r.text())
1528
+ .then(data => {
1529
+ if (isV2) {
1530
+ renderV2AgentEvents(data);
1531
+ return;
1532
+ }
1533
+ // Legacy: data is JSONL text
1534
+ const text = data;
1252
1535
  if (!text.trim()) {
1253
1536
  if (convRenderedLines === 0) {
1254
1537
  $terminalBody.innerHTML = '<div class="conv-empty">No conversation events yet…</div>';
@@ -1299,6 +1582,111 @@ function pollConversation() {
1299
1582
  .catch(() => {});
1300
1583
  }
1301
1584
 
1585
+ // ── Runtime V2 agent event renderer (TP-107) ──────────────────────────────
1586
+
1587
+ // Stable cursor for V2 event rendering.
1588
+ // Uses a signature string from the last rendered event so the sliding window
1589
+ // (server caps at 300) doesn't stall when new tail events push older ones out.
1590
+ let v2LastCursor = null; // signature of last rendered event
1591
+ let v2FirstRender = true;
1592
+
1593
+ function v2EventSignature(evt) {
1594
+ return `${evt.ts || 0}:${evt.type || ''}:${JSON.stringify(evt.payload || {}).slice(0, 80)}`;
1595
+ }
1596
+
1597
+ function renderV2AgentEvents(events) {
1598
+ if (!Array.isArray(events) || events.length === 0) {
1599
+ if (v2FirstRender) {
1600
+ $terminalBody.innerHTML = '<div class="conv-empty">No agent events yet…</div>';
1601
+ }
1602
+ return;
1603
+ }
1604
+
1605
+ let container = $terminalBody.querySelector('.conv-stream');
1606
+
1607
+ if (v2FirstRender || !container) {
1608
+ // First load or container missing: full render
1609
+ $terminalBody.innerHTML = '';
1610
+ container = document.createElement('div');
1611
+ container.className = 'conv-stream';
1612
+ $terminalBody.appendChild(container);
1613
+ for (const evt of events) {
1614
+ const html = renderV2Event(evt);
1615
+ if (html) container.insertAdjacentHTML('beforeend', html);
1616
+ }
1617
+ v2LastCursor = v2EventSignature(events[events.length - 1]);
1618
+ v2FirstRender = false;
1619
+ } else {
1620
+ // Incremental: find first unseen event after cursor
1621
+ let cursorIdx = -1;
1622
+ if (v2LastCursor) {
1623
+ for (let i = events.length - 1; i >= 0; i--) {
1624
+ if (v2EventSignature(events[i]) === v2LastCursor) {
1625
+ cursorIdx = i;
1626
+ break;
1627
+ }
1628
+ }
1629
+ }
1630
+
1631
+ if (cursorIdx === -1) {
1632
+ // Cursor not found (rotation/restart): full re-render
1633
+ container.innerHTML = '';
1634
+ for (const evt of events) {
1635
+ const html = renderV2Event(evt);
1636
+ if (html) container.insertAdjacentHTML('beforeend', html);
1637
+ }
1638
+ } else if (cursorIdx < events.length - 1) {
1639
+ // Append only new events after cursor
1640
+ const newEvents = events.slice(cursorIdx + 1);
1641
+ for (const evt of newEvents) {
1642
+ const html = renderV2Event(evt);
1643
+ if (html) container.insertAdjacentHTML('beforeend', html);
1644
+ }
1645
+ } else {
1646
+ // No new events
1647
+ return;
1648
+ }
1649
+
1650
+ v2LastCursor = v2EventSignature(events[events.length - 1]);
1651
+ }
1652
+
1653
+ if (autoScrollOn) {
1654
+ isProgrammaticScroll = true;
1655
+ $terminalBody.scrollTop = $terminalBody.scrollHeight;
1656
+ requestAnimationFrame(() => { isProgrammaticScroll = false; });
1657
+ }
1658
+ }
1659
+
1660
+ function renderV2Event(evt) {
1661
+ const ts = evt.ts ? new Date(evt.ts).toLocaleTimeString() : '';
1662
+ const type = evt.type || 'unknown';
1663
+
1664
+ switch (type) {
1665
+ case 'assistant_message':
1666
+ return `<div class="conv-event conv-assistant"><span class="conv-ts">${escapeHtml(ts)}</span><span class="conv-role">assistant</span><span class="conv-text">${escapeHtml((evt.payload?.text || '').slice(0, 2000))}</span></div>`;
1667
+ case 'prompt_sent':
1668
+ return `<div class="conv-event conv-user"><span class="conv-ts">${escapeHtml(ts)}</span><span class="conv-role">user</span><span class="conv-text">${escapeHtml((evt.payload?.text || '').slice(0, 2000))}</span></div>`;
1669
+ case 'tool_call':
1670
+ return `<div class="conv-event conv-tool"><span class="conv-ts">${escapeHtml(ts)}</span><span class="conv-role">tool</span><span class="conv-text">${escapeHtml(evt.payload?.tool || type)} ${escapeHtml((evt.payload?.path || '').slice(0, 200))}</span></div>`;
1671
+ case 'tool_result':
1672
+ return `<div class="conv-event conv-tool-result"><span class="conv-ts">${escapeHtml(ts)}</span><span class="conv-role">result</span><span class="conv-text">${escapeHtml((evt.payload?.summary || '').slice(0, 500))}</span></div>`;
1673
+ case 'agent_started':
1674
+ return `<div class="conv-event conv-lifecycle"><span class="conv-ts">${escapeHtml(ts)}</span><span class="conv-role">▶</span><span class="conv-text">Agent started (${escapeHtml(evt.role || '')} lane ${evt.laneNumber ?? '?'})</span></div>`;
1675
+ case 'agent_exited':
1676
+ return `<div class="conv-event conv-lifecycle"><span class="conv-ts">${escapeHtml(ts)}</span><span class="conv-role">■</span><span class="conv-text">Agent exited (code ${evt.payload?.exitCode ?? '?'})</span></div>`;
1677
+ case 'agent_crashed':
1678
+ case 'agent_killed':
1679
+ case 'agent_timeout':
1680
+ return `<div class="conv-event conv-lifecycle conv-error"><span class="conv-ts">${escapeHtml(ts)}</span><span class="conv-role">⚠</span><span class="conv-text">${escapeHtml(type)} ${escapeHtml(evt.payload?.reason || '')}</span></div>`;
1681
+ case 'message_delivered':
1682
+ return `<div class="conv-event conv-steer"><span class="conv-ts">${escapeHtml(ts)}</span><span class="conv-role">✉</span><span class="conv-text">Steering: ${escapeHtml((evt.payload?.content || '').slice(0, 500))}</span></div>`;
1683
+ case 'context_pressure':
1684
+ return `<div class="conv-event conv-lifecycle"><span class="conv-ts">${escapeHtml(ts)}</span><span class="conv-role">⚠</span><span class="conv-text">Context pressure: ${evt.payload?.pct ?? '?'}%</span></div>`;
1685
+ default:
1686
+ return `<div class="conv-event conv-lifecycle"><span class="conv-ts">${escapeHtml(ts)}</span><span class="conv-role">•</span><span class="conv-text">${escapeHtml(type)}</span></div>`;
1687
+ }
1688
+ }
1689
+
1302
1690
  // ── Open STATUS.md viewer ───────────────────────────────────────────────────
1303
1691
 
1304
1692
  function viewStatusMd(taskId) {
@@ -1535,8 +1923,11 @@ function closeViewer() {
1535
1923
  }
1536
1924
  viewerMode = null;
1537
1925
  viewerTarget = null;
1926
+ viewerV2AgentId = null;
1538
1927
  autoScrollOn = false;
1539
1928
  convRenderedLines = 0;
1929
+ v2LastCursor = null;
1930
+ v2FirstRender = true;
1540
1931
  lastStatusMdText = '';
1541
1932
  $terminalPanel.style.display = 'none';
1542
1933
  $terminalBody.innerHTML = '';
@@ -87,6 +87,22 @@
87
87
  </div>
88
88
  </div>
89
89
 
90
+ <!-- TP-107: Runtime V2 Agents Panel -->
91
+ <div class="panel" id="agents-panel">
92
+ <div class="panel-header">Agents</div>
93
+ <div class="panel-body" id="agents-body">
94
+ <div class="empty-state">No agent registry data</div>
95
+ </div>
96
+ </div>
97
+
98
+ <!-- TP-107: Mailbox Messages Panel -->
99
+ <div class="panel" id="messages-panel">
100
+ <div class="panel-header">Messages</div>
101
+ <div class="panel-body" id="messages-body">
102
+ <div class="empty-state">No messages</div>
103
+ </div>
104
+ </div>
105
+
90
106
  <!-- Viewer Panel: Conversation + STATUS.md (hidden until activated) -->
91
107
  <div class="panel terminal-panel" id="terminal-panel" style="display:none;">
92
108
  <div class="panel-header">
@@ -1703,3 +1703,108 @@ body {
1703
1703
  color: var(--text-primary);
1704
1704
  margin: 16px 0 8px;
1705
1705
  }
1706
+
1707
+ /* TP-107: Agents Panel */
1708
+ .agents-grid {
1709
+ display: flex;
1710
+ flex-direction: column;
1711
+ gap: 6px;
1712
+ }
1713
+ .agent-card {
1714
+ padding: 8px 12px;
1715
+ border-radius: 6px;
1716
+ background: var(--bg-secondary);
1717
+ border-left: 3px solid var(--green);
1718
+ }
1719
+ .agent-card.agent-terminal {
1720
+ border-left-color: var(--text-faint);
1721
+ opacity: 0.7;
1722
+ }
1723
+ .agent-header {
1724
+ font-size: 0.85rem;
1725
+ margin-bottom: 4px;
1726
+ }
1727
+ .agent-meta {
1728
+ display: flex;
1729
+ flex-wrap: wrap;
1730
+ gap: 4px;
1731
+ }
1732
+ .agent-badge {
1733
+ font-size: 0.7rem;
1734
+ padding: 1px 6px;
1735
+ border-radius: 3px;
1736
+ background: var(--bg-primary);
1737
+ color: var(--text-secondary);
1738
+ }
1739
+ .agent-status-running { color: var(--green); }
1740
+ .agent-status-spawning { color: var(--yellow); }
1741
+ .agent-status-exited { color: var(--text-faint); }
1742
+ .agent-status-crashed { color: var(--red); }
1743
+ .agent-status-killed { color: var(--red); }
1744
+ .agent-status-timed_out { color: var(--yellow); }
1745
+
1746
+ /* TP-107: Messages Panel */
1747
+ .messages-list {
1748
+ display: flex;
1749
+ flex-direction: column;
1750
+ gap: 4px;
1751
+ }
1752
+ .message-row {
1753
+ display: flex;
1754
+ align-items: center;
1755
+ gap: 8px;
1756
+ padding: 4px 8px;
1757
+ font-size: 0.8rem;
1758
+ border-radius: 4px;
1759
+ background: var(--bg-secondary);
1760
+ }
1761
+ .msg-time {
1762
+ color: var(--text-faint);
1763
+ min-width: 60px;
1764
+ }
1765
+ .msg-direction {
1766
+ color: var(--text-secondary);
1767
+ min-width: 100px;
1768
+ }
1769
+ .msg-badge {
1770
+ font-size: 0.65rem;
1771
+ padding: 1px 5px;
1772
+ border-radius: 3px;
1773
+ text-transform: uppercase;
1774
+ }
1775
+ .msg-type {
1776
+ background: var(--bg-primary);
1777
+ color: var(--text-secondary);
1778
+ }
1779
+ .msg-pending {
1780
+ background: var(--yellow);
1781
+ color: var(--bg-primary);
1782
+ }
1783
+ .msg-delivered {
1784
+ background: var(--green);
1785
+ color: var(--bg-primary);
1786
+ }
1787
+ .msg-reply {
1788
+ background: var(--blue);
1789
+ color: var(--bg-primary);
1790
+ }
1791
+ .msg-preview {
1792
+ color: var(--text-primary);
1793
+ overflow: hidden;
1794
+ text-overflow: ellipsis;
1795
+ white-space: nowrap;
1796
+ flex: 1;
1797
+ }
1798
+ .msg-rate-limited {
1799
+ background: var(--red);
1800
+ color: var(--bg-primary);
1801
+ }
1802
+
1803
+ /* TP-107: V2 conversation event styles */
1804
+ .conv-steer {
1805
+ border-left: 3px solid var(--blue);
1806
+ padding-left: 8px;
1807
+ }
1808
+ .conv-error {
1809
+ color: var(--red);
1810
+ }