@agentuity/coder 1.0.40 → 1.0.41

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.
@@ -1,5 +1,14 @@
1
1
  import { type Theme, getMarkdownTheme } from '@mariozechner/pi-coding-agent';
2
2
  import { matchesKey, Markdown as MdComponent } from '@mariozechner/pi-tui';
3
+ import {
4
+ buildProjectionFromEntries,
5
+ normalizeStreamProjection,
6
+ shouldReplaceStreamProjection,
7
+ type ConversationEntryLike,
8
+ type StreamBuffer,
9
+ type StreamProjection,
10
+ type StreamProjectionSource,
11
+ } from './hub-overlay-state.ts';
3
12
  import { truncateToWidth } from './renderers.ts';
4
13
 
5
14
  interface Component {
@@ -26,6 +35,20 @@ interface HubSessionSummary {
26
35
  taskCount: number;
27
36
  participantCount: number;
28
37
  createdAt: string;
38
+ bucket?: 'running' | 'paused' | 'provisioning' | 'history';
39
+ runtimeAvailable?: boolean;
40
+ controlAvailable?: boolean;
41
+ historyOnly?: boolean;
42
+ tags?: string[];
43
+ skills?: HubSessionSkillRef[];
44
+ defaultAgent?: string;
45
+ }
46
+
47
+ interface HubSessionSkillRef {
48
+ skillId: string;
49
+ repo: string;
50
+ name?: string;
51
+ url?: string;
29
52
  }
30
53
 
31
54
  interface HubParticipant {
@@ -72,16 +95,40 @@ type AgentActivity = Record<
72
95
  status?: string;
73
96
  currentTool?: string;
74
97
  toolCallCount?: number;
75
- lastActivity?: string;
98
+ lastActivity?: string | number;
99
+ currentToolArgs?: string;
100
+ totalElapsed?: number;
76
101
  }
77
102
  >;
78
103
 
104
+ interface HubSessionDiagnostics {
105
+ inactiveRunningTasks?: Array<{
106
+ taskId: string;
107
+ agent: string;
108
+ inactivityMs: number;
109
+ startedAt: string;
110
+ lastActivityAt?: string;
111
+ }>;
112
+ }
113
+
79
114
  interface HubSessionDetail {
80
115
  sessionId: string;
81
116
  label?: string;
82
117
  status: string;
83
118
  createdAt: string;
84
119
  mode: string;
120
+ task?: string;
121
+ error?: string;
122
+ streamId?: string | null;
123
+ streamUrl?: string | null;
124
+ tags?: string[];
125
+ skills?: HubSessionSkillRef[];
126
+ defaultAgent?: string;
127
+ bucket?: 'running' | 'paused' | 'provisioning' | 'history';
128
+ runtimeAvailable?: boolean;
129
+ controlAvailable?: boolean;
130
+ historyOnly?: boolean;
131
+ diagnostics?: HubSessionDiagnostics;
85
132
  context?: {
86
133
  branch?: string;
87
134
  workingDirectory?: string;
@@ -92,11 +139,7 @@ interface HubSessionDetail {
92
139
  todoSummary?: HubTodoSummary;
93
140
  todosUnavailable?: string;
94
141
  agentActivity?: AgentActivity;
95
- stream?: {
96
- output?: string;
97
- thinking?: string;
98
- tasks?: Record<string, { output?: string; thinking?: string }>;
99
- };
142
+ stream?: StreamProjection;
100
143
  }
101
144
 
102
145
  interface HubTodoListResponse {
@@ -114,6 +157,27 @@ interface HubListResponse {
114
157
  };
115
158
  }
116
159
 
160
+ interface HubReplayResponse {
161
+ sessionId: string;
162
+ entries?: ConversationEntryLike[];
163
+ }
164
+
165
+ interface HubEventHistoryItem {
166
+ id: number;
167
+ event: string;
168
+ category?: string;
169
+ agent?: string;
170
+ taskId?: string;
171
+ payload?: unknown;
172
+ occurredAt: string;
173
+ ingestedAt?: string;
174
+ }
175
+
176
+ interface HubEventHistoryResponse {
177
+ sessionId: string;
178
+ events?: HubEventHistoryItem[];
179
+ }
180
+
117
181
  interface FeedEntry {
118
182
  at: number;
119
183
  sessionId?: string;
@@ -132,11 +196,6 @@ interface SessionStreamState {
132
196
  mode: 'summary' | 'full';
133
197
  }
134
198
 
135
- interface StreamBuffer {
136
- output: string;
137
- thinking: string;
138
- }
139
-
140
199
  interface HubOverlayOptions {
141
200
  baseUrl: string;
142
201
  currentSessionId?: string;
@@ -150,9 +209,46 @@ type ScreenMode = 'list' | 'detail' | 'feed' | 'task';
150
209
  const ANSI_RE = /\x1b\[[0-9;]*m/g;
151
210
  const POLL_MS = 4_000;
152
211
  const REQUEST_TIMEOUT_MS = 5_000;
212
+ const HISTORY_FETCH_FAILURE_COOLDOWN_MS = 15_000;
153
213
  const MAX_FEED_ITEMS = 80;
154
214
  const STREAM_SESSION_LIMIT = 8;
155
215
 
216
+ class HubRequestError extends Error {
217
+ public readonly _tag = 'HubRequestError';
218
+ public readonly path: string;
219
+ public readonly status: number;
220
+ public readonly statusText?: string;
221
+ public readonly body?: string;
222
+ public readonly plainArgs: {
223
+ path: string;
224
+ status: number;
225
+ statusText?: string;
226
+ body?: string;
227
+ };
228
+
229
+ constructor(args: {
230
+ message: string;
231
+ path: string;
232
+ status: number;
233
+ statusText?: string;
234
+ body?: string;
235
+ cause?: unknown;
236
+ }) {
237
+ super(args.message, args.cause === undefined ? undefined : { cause: args.cause });
238
+ this.name = 'HubRequestError';
239
+ this.path = args.path;
240
+ this.status = args.status;
241
+ this.statusText = args.statusText;
242
+ this.body = args.body;
243
+ this.plainArgs = {
244
+ path: args.path,
245
+ status: args.status,
246
+ statusText: args.statusText,
247
+ body: args.body,
248
+ };
249
+ }
250
+ }
251
+
156
252
  function visibleWidth(text: string): number {
157
253
  return text.replace(ANSI_RE, '').replace(/\t/g, ' ').length;
158
254
  }
@@ -195,8 +291,8 @@ function formatClock(ms: number): string {
195
291
  return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
196
292
  }
197
293
 
198
- function formatRelative(isoDate: string): string {
199
- const ts = Date.parse(isoDate);
294
+ function formatRelative(value: string | number): string {
295
+ const ts = typeof value === 'number' ? value : Date.parse(value);
200
296
  if (Number.isNaN(ts)) return '-';
201
297
  const seconds = Math.max(0, Math.floor((Date.now() - ts) / 1000));
202
298
  if (seconds < 60) return `${seconds}s ago`;
@@ -208,6 +304,13 @@ function formatRelative(isoDate: string): string {
208
304
  return `${days}d ago`;
209
305
  }
210
306
 
307
+ function formatElapsedCompact(ms: number): string {
308
+ if (ms < 1_000) return `${ms}ms`;
309
+ if (ms < 60_000) return `${Math.round(ms / 1_000)}s`;
310
+ if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m`;
311
+ return `${Math.round(ms / 3_600_000)}h`;
312
+ }
313
+
211
314
  function shortId(id: string): string {
212
315
  if (id.length <= 12) return id;
213
316
  return id.slice(0, 12);
@@ -411,15 +514,28 @@ export class HubOverlay implements Component, Focusable {
411
514
  private cachedTodos: { todos: any[]; summary: any; sessionId: string } | null = null;
412
515
  private feed: FeedEntry[] = [];
413
516
  private sessionFeed = new Map<string, FeedEntry[]>();
517
+ private sessionHistoryFeed = new Map<string, FeedEntry[]>();
414
518
  private sessionBuffers = new Map<string, StreamBuffer>();
415
519
  private taskBuffers = new Map<string, StreamBuffer>();
416
- private hydratedSessions = new Set<string>();
520
+ private streamSources = new Map<string, StreamProjectionSource>();
521
+ private replayLoadedSessions = new Set<string>();
522
+ private replayInFlight = new Set<string>();
523
+ private replayRequestControllers = new Map<string, AbortController>();
524
+ private replayRequestGenerations = new Map<string, number>();
525
+ private replayFailureUntil = new Map<string, number>();
526
+ private eventHistoryLoadedSessions = new Set<string>();
527
+ private eventHistoryInFlight = new Set<string>();
528
+ private eventHistoryRequestControllers = new Map<string, AbortController>();
529
+ private eventHistoryRequestGenerations = new Map<string, number>();
530
+ private eventHistoryFailureUntil = new Map<string, number>();
417
531
  private previousDigests = new Map<string, SessionDigest>();
418
532
 
419
533
  private loadingList = true;
420
534
  private loadingDetail = false;
421
535
  private listError = '';
422
536
  private detailError = '';
537
+ private detailTodoRequestId = 0;
538
+ private resumeBusySessionId: string | null = null;
423
539
  private lastUpdatedAt = 0;
424
540
  private listInFlight = false;
425
541
  private detailInFlight = false;
@@ -590,6 +706,14 @@ export class HubOverlay implements Component, Focusable {
590
706
  stream.controller.abort();
591
707
  }
592
708
  this.sseControllers.clear();
709
+ for (const controller of this.replayRequestControllers.values()) {
710
+ controller.abort();
711
+ }
712
+ this.replayRequestControllers.clear();
713
+ for (const controller of this.eventHistoryRequestControllers.values()) {
714
+ controller.abort();
715
+ }
716
+ this.eventHistoryRequestControllers.clear();
593
717
  }
594
718
 
595
719
  private requestRender(): void {
@@ -600,6 +724,81 @@ export class HubOverlay implements Component, Focusable {
600
724
  }
601
725
  }
602
726
 
727
+ private nextRequestGeneration(generations: Map<string, number>, sessionId: string): number {
728
+ const next = (generations.get(sessionId) ?? 0) + 1;
729
+ generations.set(sessionId, next);
730
+ return next;
731
+ }
732
+
733
+ private invalidateSessionRequest(
734
+ generations: Map<string, number>,
735
+ controllers: Map<string, AbortController>,
736
+ inFlight: Set<string>,
737
+ sessionId: string
738
+ ): void {
739
+ this.nextRequestGeneration(generations, sessionId);
740
+ inFlight.delete(sessionId);
741
+ const controller = controllers.get(sessionId);
742
+ if (controller) controller.abort();
743
+ controllers.delete(sessionId);
744
+ }
745
+
746
+ private isCurrentSessionRequest(
747
+ generations: Map<string, number>,
748
+ controllers: Map<string, AbortController>,
749
+ sessionId: string,
750
+ generation: number,
751
+ controller: AbortController
752
+ ): boolean {
753
+ return (
754
+ !controller.signal.aborted &&
755
+ generations.get(sessionId) === generation &&
756
+ controllers.get(sessionId) === controller
757
+ );
758
+ }
759
+
760
+ private isFailureCoolingDown(failureUntil: Map<string, number>, sessionId: string): boolean {
761
+ const until = failureUntil.get(sessionId);
762
+ if (!until) return false;
763
+ if (until <= Date.now()) {
764
+ failureUntil.delete(sessionId);
765
+ return false;
766
+ }
767
+ return true;
768
+ }
769
+
770
+ private markFailureCooldown(failureUntil: Map<string, number>, sessionId: string): void {
771
+ failureUntil.set(sessionId, Date.now() + HISTORY_FETCH_FAILURE_COOLDOWN_MS);
772
+ }
773
+
774
+ private clearReplayFailureCooldown(sessionId: string): void {
775
+ this.replayFailureUntil.delete(sessionId);
776
+ }
777
+
778
+ private clearEventHistoryFailureCooldown(sessionId: string): void {
779
+ this.eventHistoryFailureUntil.delete(sessionId);
780
+ }
781
+
782
+ private resetHistoricalSessionState(sessionId: string): void {
783
+ this.invalidateSessionRequest(
784
+ this.replayRequestGenerations,
785
+ this.replayRequestControllers,
786
+ this.replayInFlight,
787
+ sessionId
788
+ );
789
+ this.invalidateSessionRequest(
790
+ this.eventHistoryRequestGenerations,
791
+ this.eventHistoryRequestControllers,
792
+ this.eventHistoryInFlight,
793
+ sessionId
794
+ );
795
+ this.replayLoadedSessions.delete(sessionId);
796
+ this.eventHistoryLoadedSessions.delete(sessionId);
797
+ this.sessionHistoryFeed.delete(sessionId);
798
+ this.clearReplayFailureCooldown(sessionId);
799
+ this.clearEventHistoryFailureCooldown(sessionId);
800
+ }
801
+
603
802
  private close(): void {
604
803
  this.dispose();
605
804
  this.done(undefined);
@@ -650,6 +849,13 @@ export class HubOverlay implements Component, Focusable {
650
849
  private handleDetailInput(data: string): void {
651
850
  const tasks = this.getDetailTasks();
652
851
 
852
+ if ((matchesKey(data, 'w') || data.toLowerCase() === 'w') && this.detailSessionId) {
853
+ if (this.canResumeSession(this.detail)) {
854
+ void this.resumeSession(this.detailSessionId);
855
+ }
856
+ return;
857
+ }
858
+
653
859
  if (matchesKey(data, 'up')) {
654
860
  if (tasks.length > 0) {
655
861
  this.selectedTaskIndex = (this.selectedTaskIndex - 1 + tasks.length) % tasks.length;
@@ -935,6 +1141,55 @@ export class HubOverlay implements Component, Focusable {
935
1141
  return buffer;
936
1142
  }
937
1143
 
1144
+ private getStreamSource(sessionId: string): StreamProjectionSource {
1145
+ return this.streamSources.get(sessionId) ?? 'none';
1146
+ }
1147
+
1148
+ private setStreamSource(sessionId: string, source: StreamProjectionSource): void {
1149
+ this.streamSources.set(sessionId, source);
1150
+ }
1151
+
1152
+ private clearTaskBuffers(sessionId: string): void {
1153
+ for (const key of this.taskBuffers.keys()) {
1154
+ if (key.startsWith(`${sessionId}:`)) {
1155
+ this.taskBuffers.delete(key);
1156
+ }
1157
+ }
1158
+ }
1159
+
1160
+ private replaceStreamProjection(
1161
+ sessionId: string,
1162
+ projectionRaw: StreamProjection | null | undefined,
1163
+ source: Exclude<StreamProjectionSource, 'live'>
1164
+ ): boolean {
1165
+ if (!projectionRaw) return false;
1166
+ const currentSource = this.getStreamSource(sessionId);
1167
+ if (!shouldReplaceStreamProjection(currentSource, source)) return false;
1168
+
1169
+ const projection = normalizeStreamProjection(projectionRaw);
1170
+ const sessionBuffer = this.getSessionBuffer(sessionId);
1171
+ sessionBuffer.output = projection.output;
1172
+ sessionBuffer.thinking = projection.thinking;
1173
+
1174
+ this.clearTaskBuffers(sessionId);
1175
+ for (const [taskId, block] of Object.entries(projection.tasks)) {
1176
+ const taskBuffer = this.getTaskBuffer(sessionId, taskId);
1177
+ taskBuffer.output = block.output;
1178
+ taskBuffer.thinking = block.thinking;
1179
+ }
1180
+
1181
+ this.setStreamSource(sessionId, source);
1182
+ return true;
1183
+ }
1184
+
1185
+ private applyReplayEntries(sessionId: string, entries: ConversationEntryLike[]): void {
1186
+ const projection = buildProjectionFromEntries(entries);
1187
+ this.clearReplayFailureCooldown(sessionId);
1188
+ if (this.replaceStreamProjection(sessionId, projection, 'replay')) {
1189
+ this.replayLoadedSessions.add(sessionId);
1190
+ }
1191
+ }
1192
+
938
1193
  private appendBufferText(
939
1194
  sessionId: string,
940
1195
  kind: 'output' | 'thinking',
@@ -942,6 +1197,16 @@ export class HubOverlay implements Component, Focusable {
942
1197
  taskId?: string
943
1198
  ): void {
944
1199
  if (!chunk) return;
1200
+ this.clearReplayFailureCooldown(sessionId);
1201
+ if (this.replayInFlight.has(sessionId)) {
1202
+ this.invalidateSessionRequest(
1203
+ this.replayRequestGenerations,
1204
+ this.replayRequestControllers,
1205
+ this.replayInFlight,
1206
+ sessionId
1207
+ );
1208
+ }
1209
+ this.setStreamSource(sessionId, 'live');
945
1210
 
946
1211
  // Session stream is lead/top-level only; task-scoped output lives in task buffers.
947
1212
  if (!taskId) {
@@ -1013,20 +1278,61 @@ export class HubOverlay implements Component, Focusable {
1013
1278
  return ` ${tab('1', 'List', active === 'list')}${divider}${tab('2', 'Feed', active === 'feed')}`;
1014
1279
  }
1015
1280
 
1016
- private async fetchJson<T>(path: string, timeoutMs = REQUEST_TIMEOUT_MS): Promise<T> {
1281
+ private async fetchJson<T>(
1282
+ path: string,
1283
+ timeoutMs = REQUEST_TIMEOUT_MS,
1284
+ init?: RequestInit
1285
+ ): Promise<T> {
1017
1286
  const controller = new AbortController();
1018
1287
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
1019
1288
  try {
1020
1289
  // TODO: Remove/Change when we get Agentuity service level auth enabled, this is just temporary
1021
1290
  const apiKey = process.env.AGENTUITY_CODER_API_KEY;
1022
- const headers: Record<string, string> = { accept: 'application/json' };
1291
+ const headers: Record<string, string> = {
1292
+ accept: 'application/json',
1293
+ ...(init?.headers && typeof init.headers === 'object'
1294
+ ? (init.headers as Record<string, string>)
1295
+ : {}),
1296
+ };
1023
1297
  if (apiKey) headers['x-agentuity-auth-api-key'] = apiKey;
1298
+ const signal = init?.signal
1299
+ ? AbortSignal.any([controller.signal, init.signal])
1300
+ : controller.signal;
1024
1301
  const response = await fetch(`${this.baseUrl}${path}`, {
1302
+ ...init,
1025
1303
  headers,
1026
- signal: controller.signal,
1304
+ signal,
1027
1305
  });
1028
1306
  if (!response.ok) {
1029
- throw new Error(`Hub returned ${response.status}`);
1307
+ let message = `Hub returned ${response.status}`;
1308
+ let rawBody = '';
1309
+ try {
1310
+ const text = await response.text();
1311
+ if (text) {
1312
+ rawBody = text;
1313
+ try {
1314
+ const parsed = JSON.parse(text) as { error?: unknown; message?: unknown };
1315
+ if (typeof parsed.error === 'string' && parsed.error.trim()) {
1316
+ message = parsed.error;
1317
+ } else if (typeof parsed.message === 'string' && parsed.message.trim()) {
1318
+ message = parsed.message;
1319
+ } else {
1320
+ message = text;
1321
+ }
1322
+ } catch {
1323
+ message = text;
1324
+ }
1325
+ }
1326
+ } catch {
1327
+ // Fall back to the status-only message.
1328
+ }
1329
+ throw new HubRequestError({
1330
+ message,
1331
+ path,
1332
+ status: response.status,
1333
+ statusText: response.statusText || undefined,
1334
+ body: rawBody || undefined,
1335
+ });
1030
1336
  }
1031
1337
  return (await response.json()) as T;
1032
1338
  } finally {
@@ -1034,6 +1340,171 @@ export class HubOverlay implements Component, Focusable {
1034
1340
  }
1035
1341
  }
1036
1342
 
1343
+ private getSessionSummary(sessionId: string): HubSessionSummary | undefined {
1344
+ return this.sessions.find((item) => item.sessionId === sessionId);
1345
+ }
1346
+
1347
+ private isRuntimeAvailable(sessionId: string): boolean {
1348
+ const detailRuntime =
1349
+ this.detail?.sessionId === sessionId ? this.detail.runtimeAvailable : undefined;
1350
+ if (typeof detailRuntime === 'boolean') return detailRuntime;
1351
+
1352
+ const session = this.getSessionSummary(sessionId);
1353
+ if (typeof session?.runtimeAvailable === 'boolean') return session.runtimeAvailable;
1354
+ if (session?.historyOnly) return false;
1355
+ if (session?.bucket === 'history') return false;
1356
+ return true;
1357
+ }
1358
+
1359
+ private isHistoryOnly(sessionId: string): boolean {
1360
+ if (this.detail?.sessionId === sessionId && typeof this.detail.historyOnly === 'boolean') {
1361
+ return this.detail.historyOnly;
1362
+ }
1363
+ return this.getSessionSummary(sessionId)?.historyOnly === true;
1364
+ }
1365
+
1366
+ private canResumeSession(detail: HubSessionDetail | null): boolean {
1367
+ if (!detail) return false;
1368
+ return (
1369
+ detail.mode === 'sandbox' &&
1370
+ detail.bucket === 'paused' &&
1371
+ detail.historyOnly !== true &&
1372
+ detail.runtimeAvailable === false
1373
+ );
1374
+ }
1375
+
1376
+ private isArchivedStatus(status: string): boolean {
1377
+ return status === 'archived' || status === 'shutdown' || status === 'stopped';
1378
+ }
1379
+
1380
+ private getStatusTone(status: string): 'success' | 'warning' | 'error' | 'muted' {
1381
+ if (status === 'active' || status === 'running' || status === 'connected') {
1382
+ return 'success';
1383
+ }
1384
+ if (status === 'error' || status === 'failed' || status === 'stopped') {
1385
+ return 'error';
1386
+ }
1387
+ if (status === 'archived' || status === 'shutdown') {
1388
+ return 'muted';
1389
+ }
1390
+ return 'warning';
1391
+ }
1392
+
1393
+ private getConnectionState(input: {
1394
+ sessionId: string;
1395
+ mode: string;
1396
+ status: string;
1397
+ bucket?: 'running' | 'paused' | 'provisioning' | 'history';
1398
+ runtimeAvailable?: boolean;
1399
+ controlAvailable?: boolean;
1400
+ historyOnly?: boolean;
1401
+ }): {
1402
+ label: string;
1403
+ tone: 'success' | 'warning' | 'error' | 'accent' | 'muted';
1404
+ controlAvailable: boolean;
1405
+ } {
1406
+ const isLocalSession = input.mode === 'tui';
1407
+ const isArchived = this.isArchivedStatus(input.status);
1408
+ const bucket = input.bucket ?? (input.historyOnly ? 'history' : 'running');
1409
+ const runtimeAvailable = input.runtimeAvailable !== false;
1410
+ const controlAvailable = input.controlAvailable ?? runtimeAvailable;
1411
+ const historyOnly = input.historyOnly === true || bucket === 'history';
1412
+ const canWake = !isLocalSession && !isArchived && bucket === 'paused' && historyOnly !== true;
1413
+
1414
+ if (historyOnly) return { label: 'History only', tone: 'warning', controlAvailable };
1415
+ if (isLocalSession && bucket === 'provisioning')
1416
+ return { label: 'Starting', tone: 'warning', controlAvailable };
1417
+ if (isLocalSession && !isArchived)
1418
+ return { label: 'View only', tone: 'muted', controlAvailable };
1419
+ if (bucket === 'provisioning')
1420
+ return { label: 'Starting', tone: 'warning', controlAvailable };
1421
+ if (canWake) {
1422
+ return {
1423
+ label: this.resumeBusySessionId === input.sessionId ? 'Waking' : 'Paused',
1424
+ tone: this.resumeBusySessionId === input.sessionId ? 'accent' : 'warning',
1425
+ controlAvailable,
1426
+ };
1427
+ }
1428
+ if (!isArchived && runtimeAvailable && !controlAvailable) {
1429
+ return { label: 'Read only', tone: 'muted', controlAvailable };
1430
+ }
1431
+ if (!isArchived && runtimeAvailable)
1432
+ return { label: 'Live', tone: 'success', controlAvailable };
1433
+ if (isArchived) return { label: 'Archived', tone: 'muted', controlAvailable };
1434
+ return { label: 'Disconnected', tone: 'warning', controlAvailable };
1435
+ }
1436
+
1437
+ private formatTagSummary(tags: string[] | undefined, maxCount = 4): string | null {
1438
+ if (!tags || tags.length === 0) return null;
1439
+ const visible = tags.slice(0, maxCount);
1440
+ const remainder = tags.length - visible.length;
1441
+ return remainder > 0 ? `${visible.join(', ')} +${remainder}` : visible.join(', ');
1442
+ }
1443
+
1444
+ private getSessionEventEntries(sessionId: string): FeedEntry[] {
1445
+ if (!this.isRuntimeAvailable(sessionId) || this.isHistoryOnly(sessionId)) {
1446
+ return this.sessionHistoryFeed.get(sessionId) ?? [];
1447
+ }
1448
+ return this.sessionFeed.get(sessionId) ?? [];
1449
+ }
1450
+
1451
+ private async refreshTodos(sessionId: string): Promise<void> {
1452
+ const requestId = ++this.detailTodoRequestId;
1453
+ try {
1454
+ const todosResponse = await this.fetchJson<HubTodoListResponse>(
1455
+ `/api/hub/session/${encodeURIComponent(sessionId)}/todos?includeTerminal=true&includeSync=true&limit=30`,
1456
+ 15_000
1457
+ ).catch((err: any) => {
1458
+ const isAbort = err?.name === 'AbortError' || err?.message?.includes('aborted');
1459
+ return {
1460
+ _fetchError: true,
1461
+ message: isAbort ? 'Todos loading...' : err?.message || 'Failed to load todos',
1462
+ } as HubTodoListResponse & { _fetchError: true };
1463
+ });
1464
+
1465
+ if (
1466
+ this.disposed ||
1467
+ this.detail?.sessionId !== sessionId ||
1468
+ requestId !== this.detailTodoRequestId
1469
+ ) {
1470
+ return;
1471
+ }
1472
+
1473
+ if ((todosResponse as HubTodoListResponse & { _fetchError?: boolean })._fetchError) {
1474
+ if (this.cachedTodos && this.cachedTodos.sessionId === sessionId) {
1475
+ this.detail.todos = this.cachedTodos.todos;
1476
+ this.detail.todoSummary = this.cachedTodos.summary;
1477
+ }
1478
+ this.detail.todosUnavailable = todosResponse.message;
1479
+ this.requestRender();
1480
+ return;
1481
+ }
1482
+
1483
+ this.detail.todos = Array.isArray(todosResponse.todos) ? todosResponse.todos : [];
1484
+ this.detail.todoSummary =
1485
+ todosResponse.summary && typeof todosResponse.summary === 'object'
1486
+ ? todosResponse.summary
1487
+ : undefined;
1488
+ this.detail.todosUnavailable = todosResponse.unavailable
1489
+ ? typeof todosResponse.message === 'string'
1490
+ ? todosResponse.message
1491
+ : 'Task service unavailable'
1492
+ : undefined;
1493
+
1494
+ if (this.detail.todos.length > 0) {
1495
+ this.cachedTodos = {
1496
+ todos: this.detail.todos,
1497
+ summary: this.detail.todoSummary,
1498
+ sessionId,
1499
+ };
1500
+ }
1501
+
1502
+ this.requestRender();
1503
+ } catch {
1504
+ // Best-effort todos loading.
1505
+ }
1506
+ }
1507
+
1037
1508
  private async refreshList(initial = false): Promise<void> {
1038
1509
  if (this.disposed || this.listInFlight) return;
1039
1510
  this.listInFlight = true;
@@ -1078,47 +1549,24 @@ export class HubOverlay implements Component, Focusable {
1078
1549
  }
1079
1550
 
1080
1551
  try {
1081
- const [detail, todosResponse] = await Promise.all([
1082
- this.fetchJson<HubSessionDetail>(`/api/hub/session/${encodeURIComponent(sessionId)}`),
1083
- this.fetchJson<HubTodoListResponse>(
1084
- `/api/hub/session/${encodeURIComponent(sessionId)}/todos?includeTerminal=true&includeSync=true&limit=30`,
1085
- 15_000
1086
- ).catch((err: any) => {
1087
- const isAbort = err?.name === 'AbortError' || err?.message?.includes('aborted');
1088
- return {
1089
- _fetchError: true,
1090
- message: isAbort ? 'Todos loading\u2026' : err?.message || 'Failed to load todos',
1091
- } as any;
1092
- }),
1093
- ]);
1094
- if (todosResponse?._fetchError) {
1095
- // Use cached todos if available, show loading indicator
1096
- if (this.cachedTodos && this.cachedTodos.sessionId === sessionId) {
1097
- detail.todos = this.cachedTodos.todos;
1098
- detail.todoSummary = this.cachedTodos.summary;
1099
- detail.todosUnavailable = todosResponse.message;
1100
- } else {
1101
- detail.todosUnavailable = todosResponse.message;
1102
- }
1103
- } else if (todosResponse) {
1104
- detail.todos = Array.isArray(todosResponse.todos) ? todosResponse.todos : [];
1105
- detail.todoSummary =
1106
- todosResponse.summary && typeof todosResponse.summary === 'object'
1107
- ? todosResponse.summary
1108
- : undefined;
1109
- detail.todosUnavailable = todosResponse.unavailable
1110
- ? typeof todosResponse.message === 'string'
1111
- ? todosResponse.message
1112
- : 'Task service unavailable'
1113
- : undefined;
1114
- // Cache successful todo data
1115
- if (detail.todos && detail.todos.length > 0) {
1116
- this.cachedTodos = { todos: detail.todos, summary: detail.todoSummary, sessionId };
1117
- }
1552
+ const detail = await this.fetchJson<HubSessionDetail>(
1553
+ `/api/hub/session/${encodeURIComponent(sessionId)}`
1554
+ );
1555
+
1556
+ if (this.cachedTodos && this.cachedTodos.sessionId === sessionId) {
1557
+ detail.todos = this.cachedTodos.todos;
1558
+ detail.todoSummary = this.cachedTodos.summary;
1559
+ detail.todosUnavailable = 'Loading todos...';
1560
+ } else {
1561
+ detail.todosUnavailable = 'Loading todos...';
1118
1562
  }
1563
+
1119
1564
  this.detail = detail;
1120
1565
  this.detailSessionId = sessionId;
1121
- this.applyStreamProjection(sessionId, detail.stream);
1566
+ this.replaceStreamProjection(sessionId, detail.stream, 'snapshot');
1567
+ if (detail.runtimeAvailable !== false && detail.historyOnly !== true) {
1568
+ this.resetHistoricalSessionState(sessionId);
1569
+ }
1122
1570
  const taskCount = detail.tasks?.length ?? 0;
1123
1571
  if (taskCount === 0) {
1124
1572
  this.selectedTaskIndex = 0;
@@ -1129,6 +1577,11 @@ export class HubOverlay implements Component, Focusable {
1129
1577
  this.detailError = '';
1130
1578
  this.lastUpdatedAt = Date.now();
1131
1579
  this.requestRender();
1580
+ void this.refreshTodos(sessionId);
1581
+ if (detail.runtimeAvailable === false || detail.historyOnly === true) {
1582
+ void this.refreshReplay(sessionId);
1583
+ void this.refreshEventHistory(sessionId);
1584
+ }
1132
1585
  } catch (err) {
1133
1586
  this.loadingDetail = false;
1134
1587
  this.detailError = err instanceof Error ? err.message : String(err);
@@ -1138,6 +1591,193 @@ export class HubOverlay implements Component, Focusable {
1138
1591
  }
1139
1592
  }
1140
1593
 
1594
+ private async refreshReplay(sessionId: string): Promise<void> {
1595
+ if (this.isFailureCoolingDown(this.replayFailureUntil, sessionId)) return;
1596
+ if (this.replayLoadedSessions.has(sessionId) || this.replayInFlight.has(sessionId)) return;
1597
+ const controller = new AbortController();
1598
+ const generation = this.nextRequestGeneration(this.replayRequestGenerations, sessionId);
1599
+ this.replayInFlight.add(sessionId);
1600
+ this.replayRequestControllers.set(sessionId, controller);
1601
+ try {
1602
+ const data = await this.fetchJson<HubReplayResponse>(
1603
+ `/api/hub/session/${encodeURIComponent(sessionId)}/replay`,
1604
+ 15_000,
1605
+ { signal: controller.signal }
1606
+ );
1607
+ if (
1608
+ !this.isCurrentSessionRequest(
1609
+ this.replayRequestGenerations,
1610
+ this.replayRequestControllers,
1611
+ sessionId,
1612
+ generation,
1613
+ controller
1614
+ )
1615
+ ) {
1616
+ return;
1617
+ }
1618
+ this.applyReplayEntries(sessionId, Array.isArray(data.entries) ? data.entries : []);
1619
+ this.replayLoadedSessions.add(sessionId);
1620
+ this.requestRender();
1621
+ } catch (err) {
1622
+ if (
1623
+ controller.signal.aborted ||
1624
+ !this.isCurrentSessionRequest(
1625
+ this.replayRequestGenerations,
1626
+ this.replayRequestControllers,
1627
+ sessionId,
1628
+ generation,
1629
+ controller
1630
+ )
1631
+ ) {
1632
+ return;
1633
+ }
1634
+ this.markFailureCooldown(this.replayFailureUntil, sessionId);
1635
+ this.pushFeed(
1636
+ `${this.getSessionLabel(sessionId)}: replay unavailable (${err instanceof Error ? err.message : String(err)})`,
1637
+ sessionId
1638
+ );
1639
+ this.requestRender();
1640
+ } finally {
1641
+ if (
1642
+ this.isCurrentSessionRequest(
1643
+ this.replayRequestGenerations,
1644
+ this.replayRequestControllers,
1645
+ sessionId,
1646
+ generation,
1647
+ controller
1648
+ )
1649
+ ) {
1650
+ this.replayInFlight.delete(sessionId);
1651
+ this.replayRequestControllers.delete(sessionId);
1652
+ }
1653
+ }
1654
+ }
1655
+
1656
+ private formatHistoryEventLine(sessionId: string, event: HubEventHistoryItem): string | null {
1657
+ const payload =
1658
+ event.payload && typeof event.payload === 'object'
1659
+ ? (event.payload as Record<string, unknown>)
1660
+ : undefined;
1661
+ return (
1662
+ this.formatEventFeedLine(sessionId, event.event, payload) ??
1663
+ this.formatStreamLine(event.event, payload) ??
1664
+ `${event.event}${event.agent ? ` ${event.agent}` : ''}`
1665
+ );
1666
+ }
1667
+
1668
+ private async refreshEventHistory(sessionId: string): Promise<void> {
1669
+ if (this.isFailureCoolingDown(this.eventHistoryFailureUntil, sessionId)) {
1670
+ return;
1671
+ }
1672
+ if (
1673
+ this.eventHistoryLoadedSessions.has(sessionId) ||
1674
+ this.eventHistoryInFlight.has(sessionId)
1675
+ ) {
1676
+ return;
1677
+ }
1678
+ const controller = new AbortController();
1679
+ const generation = this.nextRequestGeneration(this.eventHistoryRequestGenerations, sessionId);
1680
+ this.eventHistoryInFlight.add(sessionId);
1681
+ this.eventHistoryRequestControllers.set(sessionId, controller);
1682
+ try {
1683
+ const data = await this.fetchJson<HubEventHistoryResponse>(
1684
+ `/api/hub/session/${encodeURIComponent(sessionId)}/events/history?limit=100`,
1685
+ 15_000,
1686
+ { signal: controller.signal }
1687
+ );
1688
+ if (
1689
+ !this.isCurrentSessionRequest(
1690
+ this.eventHistoryRequestGenerations,
1691
+ this.eventHistoryRequestControllers,
1692
+ sessionId,
1693
+ generation,
1694
+ controller
1695
+ )
1696
+ ) {
1697
+ return;
1698
+ }
1699
+ const entries = (data.events ?? [])
1700
+ .map((event): FeedEntry | null => {
1701
+ const text = this.formatHistoryEventLine(sessionId, event);
1702
+ if (!text) return null;
1703
+ return {
1704
+ at: Number.isNaN(Date.parse(event.occurredAt))
1705
+ ? Date.now()
1706
+ : Date.parse(event.occurredAt),
1707
+ sessionId,
1708
+ text,
1709
+ };
1710
+ })
1711
+ .filter((entry): entry is FeedEntry => entry !== null)
1712
+ .sort((a, b) => b.at - a.at);
1713
+ this.sessionHistoryFeed.set(sessionId, entries);
1714
+ this.clearEventHistoryFailureCooldown(sessionId);
1715
+ this.eventHistoryLoadedSessions.add(sessionId);
1716
+ this.requestRender();
1717
+ } catch (err) {
1718
+ if (
1719
+ controller.signal.aborted ||
1720
+ !this.isCurrentSessionRequest(
1721
+ this.eventHistoryRequestGenerations,
1722
+ this.eventHistoryRequestControllers,
1723
+ sessionId,
1724
+ generation,
1725
+ controller
1726
+ )
1727
+ ) {
1728
+ return;
1729
+ }
1730
+ this.markFailureCooldown(this.eventHistoryFailureUntil, sessionId);
1731
+ this.pushFeed(
1732
+ `${this.getSessionLabel(sessionId)}: event history unavailable (${err instanceof Error ? err.message : String(err)})`,
1733
+ sessionId
1734
+ );
1735
+ this.requestRender();
1736
+ } finally {
1737
+ if (
1738
+ this.isCurrentSessionRequest(
1739
+ this.eventHistoryRequestGenerations,
1740
+ this.eventHistoryRequestControllers,
1741
+ sessionId,
1742
+ generation,
1743
+ controller
1744
+ )
1745
+ ) {
1746
+ this.eventHistoryInFlight.delete(sessionId);
1747
+ this.eventHistoryRequestControllers.delete(sessionId);
1748
+ }
1749
+ }
1750
+ }
1751
+
1752
+ private async resumeSession(sessionId: string): Promise<void> {
1753
+ if (this.resumeBusySessionId) return;
1754
+ this.resumeBusySessionId = sessionId;
1755
+ this.pushFeed(`${this.getSessionLabel(sessionId)}: resume requested`, sessionId);
1756
+ this.requestRender();
1757
+
1758
+ try {
1759
+ const response = await this.fetchJson<{ resumed?: boolean; error?: string }>(
1760
+ `/api/hub/session/${encodeURIComponent(sessionId)}/resume`,
1761
+ 15_000,
1762
+ { method: 'POST' }
1763
+ );
1764
+ if (response.resumed !== true) {
1765
+ throw new Error(response.error || 'Hub declined to resume the session');
1766
+ }
1767
+ this.resetHistoricalSessionState(sessionId);
1768
+ await this.refreshList();
1769
+ await this.refreshDetail(sessionId);
1770
+ } catch (err) {
1771
+ const message = err instanceof Error ? err.message : String(err);
1772
+ this.detailError = message;
1773
+ this.pushFeed(`${this.getSessionLabel(sessionId)}: resume failed (${message})`, sessionId);
1774
+ this.requestRender();
1775
+ } finally {
1776
+ this.resumeBusySessionId = null;
1777
+ this.requestRender();
1778
+ }
1779
+ }
1780
+
1141
1781
  private updateFeedFromList(sessions: HubSessionSummary[]): void {
1142
1782
  if (this.previousDigests.size === 0) {
1143
1783
  if (sessions.length > 0) {
@@ -1209,9 +1849,15 @@ export class HubOverlay implements Component, Focusable {
1209
1849
  if (this.disposed) return;
1210
1850
  const desiredModes = new Map<string, 'summary' | 'full'>();
1211
1851
  for (const session of sessions.slice(0, STREAM_SESSION_LIMIT)) {
1852
+ if (session.runtimeAvailable === false || session.historyOnly === true) continue;
1212
1853
  desiredModes.set(session.sessionId, 'summary');
1213
1854
  }
1214
- if (this.detailSessionId && this.isSessionContext()) {
1855
+ if (
1856
+ this.detailSessionId &&
1857
+ this.isSessionContext() &&
1858
+ this.isRuntimeAvailable(this.detailSessionId) &&
1859
+ !this.isHistoryOnly(this.detailSessionId)
1860
+ ) {
1215
1861
  desiredModes.set(this.detailSessionId, 'full');
1216
1862
  }
1217
1863
 
@@ -1395,50 +2041,22 @@ export class HubOverlay implements Component, Focusable {
1395
2041
  }
1396
2042
 
1397
2043
  private applyHydration(sessionId: string, eventData: unknown): void {
1398
- if (this.hydratedSessions.has(sessionId)) return;
1399
2044
  if (!eventData || typeof eventData !== 'object') return;
1400
2045
  const payload = eventData as Record<string, unknown>;
1401
- const loadedFromProjection = this.applyStreamProjection(sessionId, payload.stream);
2046
+ const loadedFromProjection = this.replaceStreamProjection(
2047
+ sessionId,
2048
+ payload.stream as StreamProjection | undefined,
2049
+ 'hydration'
2050
+ );
1402
2051
 
1403
2052
  if (!loadedFromProjection) {
1404
- const entries = Array.isArray(payload.entries) ? payload.entries : [];
1405
- for (const rawEntry of entries) {
1406
- if (!rawEntry || typeof rawEntry !== 'object') continue;
1407
- const entry = rawEntry as Record<string, unknown>;
1408
- const type = typeof entry.type === 'string' ? entry.type : '';
1409
- const content = typeof entry.content === 'string' ? entry.content : '';
1410
- const taskId = typeof entry.taskId === 'string' ? entry.taskId : undefined;
1411
-
1412
- if (!content) continue;
1413
- if (type === 'message' || type === 'task_result') {
1414
- this.appendBufferText(sessionId, 'output', content + '\n\n', taskId);
1415
- } else if (type === 'thinking') {
1416
- this.appendBufferText(sessionId, 'thinking', content + '\n\n', taskId);
1417
- }
1418
- }
1419
- }
1420
-
1421
- this.hydratedSessions.add(sessionId);
1422
- }
1423
-
1424
- private applyStreamProjection(sessionId: string, streamRaw: unknown): boolean {
1425
- if (!streamRaw || typeof streamRaw !== 'object') return false;
1426
- const stream = streamRaw as Record<string, unknown>;
1427
- const sessionBuffer = this.getSessionBuffer(sessionId);
1428
- sessionBuffer.output = typeof stream.output === 'string' ? stream.output : '';
1429
- sessionBuffer.thinking = typeof stream.thinking === 'string' ? stream.thinking : '';
1430
- const taskStreams =
1431
- stream.tasks && typeof stream.tasks === 'object'
1432
- ? (stream.tasks as Record<string, unknown>)
1433
- : {};
1434
- for (const [taskId, rawBlock] of Object.entries(taskStreams)) {
1435
- if (!rawBlock || typeof rawBlock !== 'object') continue;
1436
- const block = rawBlock as Record<string, unknown>;
1437
- const taskBuffer = this.getTaskBuffer(sessionId, taskId);
1438
- taskBuffer.output = typeof block.output === 'string' ? block.output : '';
1439
- taskBuffer.thinking = typeof block.thinking === 'string' ? block.thinking : '';
2053
+ const entries = Array.isArray(payload.entries)
2054
+ ? payload.entries.filter(
2055
+ (entry): entry is ConversationEntryLike => !!entry && typeof entry === 'object'
2056
+ )
2057
+ : [];
2058
+ this.replaceStreamProjection(sessionId, buildProjectionFromEntries(entries), 'hydration');
1440
2059
  }
1441
- return true;
1442
2060
  }
1443
2061
 
1444
2062
  private captureStreamContent(
@@ -1570,7 +2188,7 @@ export class HubOverlay implements Component, Focusable {
1570
2188
 
1571
2189
  private getFeedEntries(scope: 'global' | 'session'): FeedEntry[] {
1572
2190
  if (scope === 'session' && this.detailSessionId) {
1573
- return this.sessionFeed.get(this.detailSessionId) ?? [];
2191
+ return this.getSessionEventEntries(this.detailSessionId);
1574
2192
  }
1575
2193
  return this.feed;
1576
2194
  }
@@ -1777,9 +2395,30 @@ export class HubOverlay implements Component, Focusable {
1777
2395
  const updated = this.lastUpdatedAt
1778
2396
  ? `${formatClock(this.lastUpdatedAt)} updated`
1779
2397
  : 'not updated';
2398
+ const liveCount = this.sessions.filter((session) => {
2399
+ const connection = this.getConnectionState({
2400
+ sessionId: session.sessionId,
2401
+ mode: session.mode,
2402
+ status: session.status,
2403
+ bucket: session.bucket,
2404
+ runtimeAvailable: session.runtimeAvailable,
2405
+ controlAvailable: session.controlAvailable,
2406
+ historyOnly: session.historyOnly,
2407
+ });
2408
+ return connection.label === 'Live';
2409
+ }).length;
2410
+ const pausedCount = this.sessions.filter(
2411
+ (session) => session.bucket === 'paused' && session.historyOnly !== true
2412
+ ).length;
2413
+ const historyCount = this.sessions.filter(
2414
+ (session) => session.historyOnly === true || session.bucket === 'history'
2415
+ ).length;
1780
2416
  body.push(
1781
2417
  this.contentLine(
1782
- this.theme.fg('muted', ` Active: ${this.sessions.length} sessions ${updated}`),
2418
+ this.theme.fg(
2419
+ 'muted',
2420
+ ` Active: ${this.sessions.length} sessions Live:${liveCount} Paused:${pausedCount} History:${historyCount} ${updated}`
2421
+ ),
1783
2422
  inner
1784
2423
  )
1785
2424
  );
@@ -1803,27 +2442,34 @@ export class HubOverlay implements Component, Focusable {
1803
2442
  const marker = selected ? this.theme.fg('accent', '›') : ' ';
1804
2443
  const label = session.label || shortId(session.sessionId);
1805
2444
  const name = selected ? this.theme.bold(label) : label;
1806
- const statusColor =
1807
- session.status === 'running'
1808
- ? 'success'
1809
- : session.status === 'error' || session.status === 'failed'
1810
- ? 'error'
1811
- : 'warning';
1812
- const status = this.theme.fg(
1813
- statusColor as 'success' | 'error' | 'warning',
1814
- session.status
1815
- );
2445
+ const status = this.theme.fg(this.getStatusTone(session.status), session.status);
2446
+ const connection = this.getConnectionState({
2447
+ sessionId: session.sessionId,
2448
+ mode: session.mode,
2449
+ status: session.status,
2450
+ bucket: session.bucket,
2451
+ runtimeAvailable: session.runtimeAvailable,
2452
+ controlAvailable: session.controlAvailable,
2453
+ historyOnly: session.historyOnly,
2454
+ });
2455
+ const connectionLabel = this.theme.fg(connection.tone, connection.label);
1816
2456
  const self =
1817
2457
  this.currentSessionId === session.sessionId
1818
2458
  ? this.theme.fg('accent', ' (this)')
1819
2459
  : '';
1820
- const metrics = this.theme.fg(
1821
- 'muted',
1822
- `obs:${session.observerCount} agents:${session.subAgentCount} tasks:${session.taskCount} ${formatRelative(session.createdAt)}`
1823
- );
2460
+ const tagSummary = this.formatTagSummary(session.tags, 2);
2461
+ const metricsParts = [
2462
+ `obs:${session.observerCount}`,
2463
+ `agents:${session.subAgentCount}`,
2464
+ `tasks:${session.taskCount}`,
2465
+ formatRelative(session.createdAt),
2466
+ session.defaultAgent ? `agent:${session.defaultAgent}` : undefined,
2467
+ tagSummary ? `tags:${tagSummary}` : undefined,
2468
+ ].filter((part): part is string => !!part);
2469
+ const metrics = this.theme.fg('muted', metricsParts.join(' '));
1824
2470
  body.push(
1825
2471
  this.contentLine(
1826
- ` ${marker} ${name}${self} ${status} ${session.mode} ${metrics}`,
2472
+ ` ${marker} ${name}${self} ${status} ${session.mode} ${connectionLabel} ${metrics}`,
1827
2473
  inner
1828
2474
  )
1829
2475
  );
@@ -1857,12 +2503,14 @@ export class HubOverlay implements Component, Focusable {
1857
2503
  private renderDetailScreen(width: number, maxLines: number): string[] {
1858
2504
  const inner = Math.max(0, width - 2);
1859
2505
  const lines: string[] = [];
1860
- const title = this.detail?.label || this.detailSessionId || 'Hub Session';
2506
+ const title =
2507
+ this.detail?.label ||
2508
+ (this.detailSessionId ? shortId(this.detailSessionId) : 'Hub Session');
1861
2509
  const headerRows = 3;
1862
2510
  const footerRows = 2;
1863
2511
  const contentBudget = Math.max(5, maxLines - headerRows - footerRows);
1864
2512
 
1865
- lines.push(buildTopBorder(width, `Session ${shortId(title)}`));
2513
+ lines.push(buildTopBorder(width, `Session ${title}`));
1866
2514
  lines.push(this.contentLine('', inner));
1867
2515
  lines.push(this.contentLine(this.buildTopTabs('detail', true), inner));
1868
2516
 
@@ -1878,12 +2526,41 @@ export class HubOverlay implements Component, Focusable {
1878
2526
  const participants = session.participants ?? [];
1879
2527
  const tasks = session.tasks ?? [];
1880
2528
  const activityEntries = Object.entries(session.agentActivity ?? {});
2529
+ const connection = this.getConnectionState({
2530
+ sessionId: session.sessionId,
2531
+ mode: session.mode,
2532
+ status: session.status,
2533
+ bucket: session.bucket,
2534
+ runtimeAvailable: session.runtimeAvailable,
2535
+ controlAvailable: session.controlAvailable,
2536
+ historyOnly: session.historyOnly,
2537
+ });
2538
+ const inactiveTasks = session.diagnostics?.inactiveRunningTasks ?? [];
2539
+ const inactiveTaskById = new Map(
2540
+ inactiveTasks.map((item) => [item.taskId, item] as const)
2541
+ );
2542
+ const pushWrappedLine = (
2543
+ text: string,
2544
+ tone: 'muted' | 'warning' | 'error' | 'dim' = 'muted'
2545
+ ): void => {
2546
+ for (const wrapped of wrapText(text, Math.max(12, inner - 4))) {
2547
+ body.push(this.contentLine(this.theme.fg(tone, ` ${wrapped}`), inner));
2548
+ }
2549
+ };
1881
2550
 
1882
2551
  body.push(this.contentLine(this.theme.bold(' Overview'), inner));
1883
2552
  body.push(this.contentLine(this.theme.fg('muted', ` ID: ${session.sessionId}`), inner));
1884
2553
  body.push(
1885
2554
  this.contentLine(
1886
- this.theme.fg('muted', ` Status: ${session.status} Mode: ${session.mode}`),
2555
+ ` Status: ${this.theme.fg(this.getStatusTone(session.status), session.status)} Mode: ${session.mode} Bucket: ${session.bucket ?? '-'}`,
2556
+ inner
2557
+ )
2558
+ );
2559
+ body.push(
2560
+ this.contentLine(
2561
+ ` Connection: ${this.theme.fg(connection.tone, connection.label)} Runtime: ${
2562
+ session.runtimeAvailable === false ? 'offline' : 'ready'
2563
+ } Control: ${connection.controlAvailable ? 'enabled' : 'read-only'}`,
1887
2564
  inner
1888
2565
  )
1889
2566
  );
@@ -1902,6 +2579,26 @@ export class HubOverlay implements Component, Focusable {
1902
2579
  inner
1903
2580
  )
1904
2581
  );
2582
+ if (session.defaultAgent) {
2583
+ body.push(
2584
+ this.contentLine(
2585
+ this.theme.fg('muted', ` Default agent: ${session.defaultAgent}`),
2586
+ inner
2587
+ )
2588
+ );
2589
+ }
2590
+ const tagSummary = this.formatTagSummary(session.tags);
2591
+ if (tagSummary) {
2592
+ pushWrappedLine(`Tags: ${tagSummary}`);
2593
+ }
2594
+ if (session.streamId || session.streamUrl) {
2595
+ const streamState = session.streamId
2596
+ ? shortId(session.streamId)
2597
+ : session.streamUrl
2598
+ ? 'attached'
2599
+ : 'none';
2600
+ body.push(this.contentLine(this.theme.fg('muted', ` Stream: ${streamState}`), inner));
2601
+ }
1905
2602
  if (session.context?.branch) {
1906
2603
  body.push(
1907
2604
  this.contentLine(
@@ -1918,6 +2615,32 @@ export class HubOverlay implements Component, Focusable {
1918
2615
  )
1919
2616
  );
1920
2617
  }
2618
+ if (session.task) {
2619
+ body.push(
2620
+ this.contentLine(this.theme.fg('dim', ` ${hLine(Math.max(0, inner - 2))}`), inner)
2621
+ );
2622
+ body.push(this.contentLine(this.theme.bold(' Root Task'), inner));
2623
+ for (const wrapped of wrapText(session.task, Math.max(12, inner - 4))) {
2624
+ body.push(this.contentLine(` ${wrapped}`, inner));
2625
+ }
2626
+ }
2627
+ if (session.error) {
2628
+ body.push(
2629
+ this.contentLine(this.theme.fg('dim', ` ${hLine(Math.max(0, inner - 2))}`), inner)
2630
+ );
2631
+ body.push(this.contentLine(this.theme.bold(' Error'), inner));
2632
+ pushWrappedLine(session.error, 'error');
2633
+ }
2634
+ if (this.canResumeSession(session)) {
2635
+ const resumeMessage =
2636
+ this.resumeBusySessionId === session.sessionId
2637
+ ? 'Waking sandbox session...'
2638
+ : 'Press [w] to wake this paused sandbox session.';
2639
+ pushWrappedLine(
2640
+ resumeMessage,
2641
+ this.resumeBusySessionId === session.sessionId ? 'muted' : 'warning'
2642
+ );
2643
+ }
1921
2644
  body.push(
1922
2645
  this.contentLine(this.theme.fg('dim', ` ${hLine(Math.max(0, inner - 2))}`), inner)
1923
2646
  );
@@ -1942,19 +2665,28 @@ export class HubOverlay implements Component, Focusable {
1942
2665
  ? 'success'
1943
2666
  : task.status === 'failed'
1944
2667
  ? 'error'
1945
- : 'warning';
1946
- const status = this.theme.fg(
1947
- statusColor as 'success' | 'error' | 'warning',
1948
- task.status
1949
- );
2668
+ : task.status === 'running'
2669
+ ? 'accent'
2670
+ : 'warning';
2671
+ const status = this.theme.fg(statusColor, task.status);
2672
+ const inactive = inactiveTaskById.get(task.taskId);
1950
2673
  const prompt = task.prompt
1951
2674
  ? truncateToWidth(toSingleLine(task.prompt), Math.max(16, inner - 34))
1952
2675
  : '';
1953
- const duration = typeof task.duration === 'number' ? ` ${task.duration}ms` : '';
2676
+ const duration =
2677
+ typeof task.duration === 'number'
2678
+ ? ` ${formatElapsedCompact(task.duration)}`
2679
+ : '';
2680
+ const idle = inactive
2681
+ ? ` ${this.theme.fg(
2682
+ 'warning',
2683
+ `idle ${formatElapsedCompact(inactive.inactivityMs)}`
2684
+ )}`
2685
+ : '';
1954
2686
  const marker = selected ? this.theme.fg('accent', '›') : ' ';
1955
2687
  body.push(
1956
2688
  this.contentLine(
1957
- `${marker} ${shortId(task.taskId).padEnd(12)} ${task.agent.padEnd(9)} ${status}${duration} ${prompt}`,
2689
+ `${marker} ${shortId(task.taskId).padEnd(12)} ${task.agent.padEnd(9)} ${status}${duration}${idle} ${prompt}`,
1958
2690
  inner
1959
2691
  )
1960
2692
  );
@@ -2058,6 +2790,36 @@ export class HubOverlay implements Component, Focusable {
2058
2790
  }
2059
2791
  }
2060
2792
 
2793
+ if (inactiveTasks.length > 0) {
2794
+ body.push(
2795
+ this.contentLine(this.theme.fg('dim', ` ${hLine(Math.max(0, inner - 2))}`), inner)
2796
+ );
2797
+ body.push(this.contentLine(this.theme.bold(' Diagnostics'), inner));
2798
+ body.push(
2799
+ this.contentLine(
2800
+ this.theme.fg(
2801
+ 'warning',
2802
+ ` ${inactiveTasks.length} running task${inactiveTasks.length === 1 ? '' : 's'} without recent activity`
2803
+ ),
2804
+ inner
2805
+ )
2806
+ );
2807
+ for (const item of inactiveTasks.slice(0, 8)) {
2808
+ const lastSeen = item.lastActivityAt
2809
+ ? ` last ${formatRelative(item.lastActivityAt)}`
2810
+ : '';
2811
+ body.push(
2812
+ this.contentLine(
2813
+ this.theme.fg(
2814
+ 'warning',
2815
+ ` ${shortId(item.taskId).padEnd(12)} ${item.agent.padEnd(10)} idle ${formatElapsedCompact(item.inactivityMs)} started ${formatRelative(item.startedAt)}${lastSeen}`
2816
+ ),
2817
+ inner
2818
+ )
2819
+ );
2820
+ }
2821
+ }
2822
+
2061
2823
  body.push(
2062
2824
  this.contentLine(this.theme.fg('dim', ` ${hLine(Math.max(0, inner - 2))}`), inner)
2063
2825
  );
@@ -2066,13 +2828,43 @@ export class HubOverlay implements Component, Focusable {
2066
2828
  body.push(this.contentLine(this.theme.fg('dim', ' (none)'), inner));
2067
2829
  } else {
2068
2830
  for (const [agent, info] of activityEntries.slice(0, 15)) {
2069
- const tool = info.currentTool ? ` ${info.currentTool}` : '';
2831
+ const status = info.status || 'idle';
2832
+ const statusTone =
2833
+ status === 'completed'
2834
+ ? 'success'
2835
+ : status === 'failed'
2836
+ ? 'error'
2837
+ : status === 'tool_start' || status === 'running'
2838
+ ? 'accent'
2839
+ : 'warning';
2840
+ const tool = info.currentTool ? ` tool:${info.currentTool}` : '';
2070
2841
  const calls =
2071
- typeof info.toolCallCount === 'number'
2072
- ? this.theme.fg('dim', ` (${info.toolCallCount} calls)`)
2842
+ typeof info.toolCallCount === 'number' ? ` calls:${info.toolCallCount}` : '';
2843
+ const lastSeen =
2844
+ info.lastActivity !== undefined && info.lastActivity !== null
2845
+ ? ` last:${formatRelative(info.lastActivity)}`
2073
2846
  : '';
2074
- const status = info.status || 'idle';
2075
- body.push(this.contentLine(` ${agent.padEnd(12)} ${status}${tool}${calls}`, inner));
2847
+ const totalElapsed =
2848
+ typeof info.totalElapsed === 'number'
2849
+ ? ` total:${formatElapsedCompact(info.totalElapsed)}`
2850
+ : '';
2851
+ body.push(
2852
+ this.contentLine(
2853
+ ` ${agent.padEnd(12)} ${this.theme.fg(statusTone, status)}${this.theme.fg(
2854
+ 'dim',
2855
+ `${tool}${calls}${lastSeen}${totalElapsed}`
2856
+ )}`,
2857
+ inner
2858
+ )
2859
+ );
2860
+ if (info.currentToolArgs) {
2861
+ for (const wrapped of wrapText(
2862
+ toSingleLine(info.currentToolArgs),
2863
+ Math.max(12, inner - 6)
2864
+ )) {
2865
+ body.push(this.contentLine(this.theme.fg('dim', ` ${wrapped}`), inner));
2866
+ }
2867
+ }
2076
2868
  }
2077
2869
  }
2078
2870
  }
@@ -2094,9 +2886,10 @@ export class HubOverlay implements Component, Focusable {
2094
2886
  this.detailMaxScroll > 0
2095
2887
  ? this.theme.fg('dim', ` scroll ${this.detailScrollOffset}/${this.detailMaxScroll}`)
2096
2888
  : this.theme.fg('dim', ' scroll 0/0');
2889
+ const wakeHint = this.canResumeSession(this.detail) ? ' [w] Wake' : '';
2097
2890
  lines.push(
2098
2891
  this.contentLine(
2099
- `${scrollInfo} ${this.theme.fg('dim', '[↑↓] Task [j/k] Scroll [Enter] Open [r] Refresh [Esc] Back')}`,
2892
+ `${scrollInfo} ${this.theme.fg('dim', `[↑↓] Task [j/k] Scroll [Enter] Open${wakeHint} [r] Refresh [Esc] Back`)}`,
2100
2893
  inner
2101
2894
  )
2102
2895
  );
@@ -2111,12 +2904,15 @@ export class HubOverlay implements Component, Focusable {
2111
2904
  const footerRows = 2;
2112
2905
  const contentBudget = Math.max(5, maxLines - headerRows - footerRows);
2113
2906
  const scoped = this.feedScope === 'session' && !!this.detailSessionId;
2907
+ const sessionId = scoped ? this.detailSessionId! : null;
2908
+ const historySession = sessionId
2909
+ ? !this.isRuntimeAvailable(sessionId) || this.isHistoryOnly(sessionId)
2910
+ : false;
2114
2911
  const title = scoped
2115
- ? `${this.feedViewMode === 'events' ? 'Session Events' : 'Session Feed'} ${shortId(this.getSessionLabel(this.detailSessionId!))}`
2912
+ ? `${this.feedViewMode === 'events' ? 'Session Events' : 'Session Feed'} ${this.getSessionLabel(sessionId!)}`
2116
2913
  : 'Global Feed';
2117
2914
  const entryBudget = Math.max(1, contentBudget - 2);
2118
- const sessionBuffer =
2119
- scoped && this.detailSessionId ? this.sessionBuffers.get(this.detailSessionId) : undefined;
2915
+ const sessionBuffer = sessionId ? this.sessionBuffers.get(sessionId) : undefined;
2120
2916
 
2121
2917
  lines.push(buildTopBorder(width, title));
2122
2918
  lines.push(this.contentLine('', inner));
@@ -2135,8 +2931,12 @@ export class HubOverlay implements Component, Focusable {
2135
2931
  'muted',
2136
2932
  scoped
2137
2933
  ? this.feedViewMode === 'stream'
2138
- ? ' Streaming rendered session output (sub-agent style) — [v] task stream'
2139
- : ' Streaming full session events'
2934
+ ? historySession
2935
+ ? ' Rehydrated session output from stored replay — [v] task stream'
2936
+ : ' Live rendered session output (sub-agent style) — [v] task stream'
2937
+ : historySession
2938
+ ? ' Stored session events from history'
2939
+ : ' Live full session events'
2140
2940
  : ' Streaming event summaries across all sessions'
2141
2941
  ),
2142
2942
  inner
@@ -2155,7 +2955,13 @@ export class HubOverlay implements Component, Focusable {
2155
2955
  Math.max(12, inner - 4)
2156
2956
  );
2157
2957
  if (contentLines.length === 0) {
2158
- contentLines = [this.theme.fg('dim', '(no streamed output yet)')];
2958
+ if (sessionId && historySession && this.replayInFlight.has(sessionId)) {
2959
+ contentLines = [this.theme.fg('dim', '(loading replay...)')];
2960
+ } else if (sessionId && historySession && this.replayLoadedSessions.has(sessionId)) {
2961
+ contentLines = [this.theme.fg('dim', '(no replay output available)')];
2962
+ } else {
2963
+ contentLines = [this.theme.fg('dim', '(no streamed output yet)')];
2964
+ }
2159
2965
  }
2160
2966
  } else {
2161
2967
  const entries = this.getFeedEntries(scoped ? 'session' : 'global');
@@ -2163,7 +2969,22 @@ export class HubOverlay implements Component, Focusable {
2163
2969
  .reverse()
2164
2970
  .map((entry) => `${this.theme.fg('dim', formatClock(entry.at))} ${entry.text}`);
2165
2971
  if (contentLines.length === 0) {
2166
- contentLines = [this.theme.fg('dim', '(no feed items yet)')];
2972
+ if (sessionId && historySession && this.eventHistoryInFlight.has(sessionId)) {
2973
+ contentLines = [this.theme.fg('dim', '(loading event history...)')];
2974
+ } else if (
2975
+ sessionId &&
2976
+ historySession &&
2977
+ this.eventHistoryLoadedSessions.has(sessionId)
2978
+ ) {
2979
+ contentLines = [this.theme.fg('dim', '(no stored events available)')];
2980
+ } else {
2981
+ contentLines = [
2982
+ this.theme.fg(
2983
+ 'dim',
2984
+ scoped ? '(no session feed items yet)' : '(no feed items yet)'
2985
+ ),
2986
+ ];
2987
+ }
2167
2988
  }
2168
2989
  }
2169
2990
 
@@ -2223,6 +3044,10 @@ export class HubOverlay implements Component, Focusable {
2223
3044
  if (!selected) {
2224
3045
  body.push(this.contentLine(this.theme.fg('dim', ' No task selected'), inner));
2225
3046
  } else {
3047
+ const historySession = this.detailSessionId
3048
+ ? !this.isRuntimeAvailable(this.detailSessionId) ||
3049
+ this.isHistoryOnly(this.detailSessionId)
3050
+ : false;
2226
3051
  body.push(this.contentLine(this.theme.bold(' Task Overview'), inner));
2227
3052
  body.push(
2228
3053
  this.contentLine(this.theme.fg('muted', ` Task ID: ${selected.taskId}`), inner)
@@ -2231,7 +3056,10 @@ export class HubOverlay implements Component, Focusable {
2231
3056
  body.push(this.contentLine(this.theme.fg('muted', ` Status: ${selected.status}`), inner));
2232
3057
  if (typeof selected.duration === 'number') {
2233
3058
  body.push(
2234
- this.contentLine(this.theme.fg('muted', ` Duration: ${selected.duration}ms`), inner)
3059
+ this.contentLine(
3060
+ this.theme.fg('muted', ` Duration: ${formatElapsedCompact(selected.duration)}`),
3061
+ inner
3062
+ )
2235
3063
  );
2236
3064
  }
2237
3065
  if (selected.startedAt) {
@@ -2275,7 +3103,15 @@ export class HubOverlay implements Component, Focusable {
2275
3103
  Math.max(12, inner - 4)
2276
3104
  );
2277
3105
  if (rendered.length === 0) {
2278
- body.push(this.contentLine(this.theme.fg('dim', ' (no task output yet)'), inner));
3106
+ if (
3107
+ historySession &&
3108
+ this.detailSessionId &&
3109
+ this.replayInFlight.has(this.detailSessionId)
3110
+ ) {
3111
+ body.push(this.contentLine(this.theme.fg('dim', ' (loading replay...)'), inner));
3112
+ } else {
3113
+ body.push(this.contentLine(this.theme.fg('dim', ' (no task output yet)'), inner));
3114
+ }
2279
3115
  } else {
2280
3116
  for (const line of rendered) {
2281
3117
  body.push(this.contentLine(` ${line}`, inner));