@agentuity/coder 1.0.39 → 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.
Files changed (49) hide show
  1. package/dist/client.d.ts +2 -2
  2. package/dist/client.d.ts.map +1 -1
  3. package/dist/client.js +2 -0
  4. package/dist/client.js.map +1 -1
  5. package/dist/hub-overlay-state.d.ts +30 -0
  6. package/dist/hub-overlay-state.d.ts.map +1 -0
  7. package/dist/hub-overlay-state.js +68 -0
  8. package/dist/hub-overlay-state.js.map +1 -0
  9. package/dist/hub-overlay.d.ts +41 -2
  10. package/dist/hub-overlay.d.ts.map +1 -1
  11. package/dist/hub-overlay.js +667 -115
  12. package/dist/hub-overlay.js.map +1 -1
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +19 -34
  15. package/dist/index.js.map +1 -1
  16. package/dist/native-remote-ui-context.d.ts +5 -0
  17. package/dist/native-remote-ui-context.d.ts.map +1 -0
  18. package/dist/native-remote-ui-context.js +30 -0
  19. package/dist/native-remote-ui-context.js.map +1 -0
  20. package/dist/protocol.d.ts +210 -38
  21. package/dist/protocol.d.ts.map +1 -1
  22. package/dist/protocol.js +2 -1
  23. package/dist/protocol.js.map +1 -1
  24. package/dist/remote-lifecycle.d.ts +61 -0
  25. package/dist/remote-lifecycle.d.ts.map +1 -0
  26. package/dist/remote-lifecycle.js +190 -0
  27. package/dist/remote-lifecycle.js.map +1 -0
  28. package/dist/remote-session.d.ts +15 -0
  29. package/dist/remote-session.d.ts.map +1 -1
  30. package/dist/remote-session.js +240 -35
  31. package/dist/remote-session.js.map +1 -1
  32. package/dist/remote-tui.d.ts.map +1 -1
  33. package/dist/remote-tui.js +95 -12
  34. package/dist/remote-tui.js.map +1 -1
  35. package/dist/remote-ui-handler.d.ts +5 -0
  36. package/dist/remote-ui-handler.d.ts.map +1 -0
  37. package/dist/remote-ui-handler.js +53 -0
  38. package/dist/remote-ui-handler.js.map +1 -0
  39. package/package.json +5 -5
  40. package/src/client.ts +5 -3
  41. package/src/hub-overlay-state.ts +117 -0
  42. package/src/hub-overlay.ts +974 -138
  43. package/src/index.ts +19 -50
  44. package/src/native-remote-ui-context.ts +41 -0
  45. package/src/protocol.ts +270 -56
  46. package/src/remote-lifecycle.ts +270 -0
  47. package/src/remote-session.ts +293 -38
  48. package/src/remote-tui.ts +129 -13
  49. package/src/remote-ui-handler.ts +86 -0
@@ -1,11 +1,35 @@
1
1
  import { getMarkdownTheme } from '@mariozechner/pi-coding-agent';
2
2
  import { matchesKey, Markdown as MdComponent } from '@mariozechner/pi-tui';
3
+ import { buildProjectionFromEntries, normalizeStreamProjection, shouldReplaceStreamProjection, } from "./hub-overlay-state.js";
3
4
  import { truncateToWidth } from "./renderers.js";
4
5
  const ANSI_RE = /\x1b\[[0-9;]*m/g;
5
6
  const POLL_MS = 4_000;
6
7
  const REQUEST_TIMEOUT_MS = 5_000;
8
+ const HISTORY_FETCH_FAILURE_COOLDOWN_MS = 15_000;
7
9
  const MAX_FEED_ITEMS = 80;
8
10
  const STREAM_SESSION_LIMIT = 8;
11
+ class HubRequestError extends Error {
12
+ _tag = 'HubRequestError';
13
+ path;
14
+ status;
15
+ statusText;
16
+ body;
17
+ plainArgs;
18
+ constructor(args) {
19
+ super(args.message, args.cause === undefined ? undefined : { cause: args.cause });
20
+ this.name = 'HubRequestError';
21
+ this.path = args.path;
22
+ this.status = args.status;
23
+ this.statusText = args.statusText;
24
+ this.body = args.body;
25
+ this.plainArgs = {
26
+ path: args.path,
27
+ status: args.status,
28
+ statusText: args.statusText,
29
+ body: args.body,
30
+ };
31
+ }
32
+ }
9
33
  function visibleWidth(text) {
10
34
  return text.replace(ANSI_RE, '').replace(/\t/g, ' ').length;
11
35
  }
@@ -48,8 +72,8 @@ function formatClock(ms) {
48
72
  const d = new Date(ms);
49
73
  return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
50
74
  }
51
- function formatRelative(isoDate) {
52
- const ts = Date.parse(isoDate);
75
+ function formatRelative(value) {
76
+ const ts = typeof value === 'number' ? value : Date.parse(value);
53
77
  if (Number.isNaN(ts))
54
78
  return '-';
55
79
  const seconds = Math.max(0, Math.floor((Date.now() - ts) / 1000));
@@ -64,6 +88,15 @@ function formatRelative(isoDate) {
64
88
  const days = Math.floor(hours / 24);
65
89
  return `${days}d ago`;
66
90
  }
91
+ function formatElapsedCompact(ms) {
92
+ if (ms < 1_000)
93
+ return `${ms}ms`;
94
+ if (ms < 60_000)
95
+ return `${Math.round(ms / 1_000)}s`;
96
+ if (ms < 3_600_000)
97
+ return `${Math.round(ms / 60_000)}m`;
98
+ return `${Math.round(ms / 3_600_000)}h`;
99
+ }
67
100
  function shortId(id) {
68
101
  if (id.length <= 12)
69
102
  return id;
@@ -249,14 +282,27 @@ export class HubOverlay {
249
282
  cachedTodos = null;
250
283
  feed = [];
251
284
  sessionFeed = new Map();
285
+ sessionHistoryFeed = new Map();
252
286
  sessionBuffers = new Map();
253
287
  taskBuffers = new Map();
254
- hydratedSessions = new Set();
288
+ streamSources = new Map();
289
+ replayLoadedSessions = new Set();
290
+ replayInFlight = new Set();
291
+ replayRequestControllers = new Map();
292
+ replayRequestGenerations = new Map();
293
+ replayFailureUntil = new Map();
294
+ eventHistoryLoadedSessions = new Set();
295
+ eventHistoryInFlight = new Set();
296
+ eventHistoryRequestControllers = new Map();
297
+ eventHistoryRequestGenerations = new Map();
298
+ eventHistoryFailureUntil = new Map();
255
299
  previousDigests = new Map();
256
300
  loadingList = true;
257
301
  loadingDetail = false;
258
302
  listError = '';
259
303
  detailError = '';
304
+ detailTodoRequestId = 0;
305
+ resumeBusySessionId = null;
260
306
  lastUpdatedAt = 0;
261
307
  listInFlight = false;
262
308
  detailInFlight = false;
@@ -409,6 +455,14 @@ export class HubOverlay {
409
455
  stream.controller.abort();
410
456
  }
411
457
  this.sseControllers.clear();
458
+ for (const controller of this.replayRequestControllers.values()) {
459
+ controller.abort();
460
+ }
461
+ this.replayRequestControllers.clear();
462
+ for (const controller of this.eventHistoryRequestControllers.values()) {
463
+ controller.abort();
464
+ }
465
+ this.eventHistoryRequestControllers.clear();
412
466
  }
413
467
  requestRender() {
414
468
  try {
@@ -418,6 +472,52 @@ export class HubOverlay {
418
472
  // Best effort render invalidation.
419
473
  }
420
474
  }
475
+ nextRequestGeneration(generations, sessionId) {
476
+ const next = (generations.get(sessionId) ?? 0) + 1;
477
+ generations.set(sessionId, next);
478
+ return next;
479
+ }
480
+ invalidateSessionRequest(generations, controllers, inFlight, sessionId) {
481
+ this.nextRequestGeneration(generations, sessionId);
482
+ inFlight.delete(sessionId);
483
+ const controller = controllers.get(sessionId);
484
+ if (controller)
485
+ controller.abort();
486
+ controllers.delete(sessionId);
487
+ }
488
+ isCurrentSessionRequest(generations, controllers, sessionId, generation, controller) {
489
+ return (!controller.signal.aborted &&
490
+ generations.get(sessionId) === generation &&
491
+ controllers.get(sessionId) === controller);
492
+ }
493
+ isFailureCoolingDown(failureUntil, sessionId) {
494
+ const until = failureUntil.get(sessionId);
495
+ if (!until)
496
+ return false;
497
+ if (until <= Date.now()) {
498
+ failureUntil.delete(sessionId);
499
+ return false;
500
+ }
501
+ return true;
502
+ }
503
+ markFailureCooldown(failureUntil, sessionId) {
504
+ failureUntil.set(sessionId, Date.now() + HISTORY_FETCH_FAILURE_COOLDOWN_MS);
505
+ }
506
+ clearReplayFailureCooldown(sessionId) {
507
+ this.replayFailureUntil.delete(sessionId);
508
+ }
509
+ clearEventHistoryFailureCooldown(sessionId) {
510
+ this.eventHistoryFailureUntil.delete(sessionId);
511
+ }
512
+ resetHistoricalSessionState(sessionId) {
513
+ this.invalidateSessionRequest(this.replayRequestGenerations, this.replayRequestControllers, this.replayInFlight, sessionId);
514
+ this.invalidateSessionRequest(this.eventHistoryRequestGenerations, this.eventHistoryRequestControllers, this.eventHistoryInFlight, sessionId);
515
+ this.replayLoadedSessions.delete(sessionId);
516
+ this.eventHistoryLoadedSessions.delete(sessionId);
517
+ this.sessionHistoryFeed.delete(sessionId);
518
+ this.clearReplayFailureCooldown(sessionId);
519
+ this.clearEventHistoryFailureCooldown(sessionId);
520
+ }
421
521
  close() {
422
522
  this.dispose();
423
523
  this.done(undefined);
@@ -460,6 +560,12 @@ export class HubOverlay {
460
560
  }
461
561
  handleDetailInput(data) {
462
562
  const tasks = this.getDetailTasks();
563
+ if ((matchesKey(data, 'w') || data.toLowerCase() === 'w') && this.detailSessionId) {
564
+ if (this.canResumeSession(this.detail)) {
565
+ void this.resumeSession(this.detailSessionId);
566
+ }
567
+ return;
568
+ }
463
569
  if (matchesKey(data, 'up')) {
464
570
  if (tasks.length > 0) {
465
571
  this.selectedTaskIndex = (this.selectedTaskIndex - 1 + tasks.length) % tasks.length;
@@ -708,9 +814,53 @@ export class HubOverlay {
708
814
  }
709
815
  return buffer;
710
816
  }
817
+ getStreamSource(sessionId) {
818
+ return this.streamSources.get(sessionId) ?? 'none';
819
+ }
820
+ setStreamSource(sessionId, source) {
821
+ this.streamSources.set(sessionId, source);
822
+ }
823
+ clearTaskBuffers(sessionId) {
824
+ for (const key of this.taskBuffers.keys()) {
825
+ if (key.startsWith(`${sessionId}:`)) {
826
+ this.taskBuffers.delete(key);
827
+ }
828
+ }
829
+ }
830
+ replaceStreamProjection(sessionId, projectionRaw, source) {
831
+ if (!projectionRaw)
832
+ return false;
833
+ const currentSource = this.getStreamSource(sessionId);
834
+ if (!shouldReplaceStreamProjection(currentSource, source))
835
+ return false;
836
+ const projection = normalizeStreamProjection(projectionRaw);
837
+ const sessionBuffer = this.getSessionBuffer(sessionId);
838
+ sessionBuffer.output = projection.output;
839
+ sessionBuffer.thinking = projection.thinking;
840
+ this.clearTaskBuffers(sessionId);
841
+ for (const [taskId, block] of Object.entries(projection.tasks)) {
842
+ const taskBuffer = this.getTaskBuffer(sessionId, taskId);
843
+ taskBuffer.output = block.output;
844
+ taskBuffer.thinking = block.thinking;
845
+ }
846
+ this.setStreamSource(sessionId, source);
847
+ return true;
848
+ }
849
+ applyReplayEntries(sessionId, entries) {
850
+ const projection = buildProjectionFromEntries(entries);
851
+ this.clearReplayFailureCooldown(sessionId);
852
+ if (this.replaceStreamProjection(sessionId, projection, 'replay')) {
853
+ this.replayLoadedSessions.add(sessionId);
854
+ }
855
+ }
711
856
  appendBufferText(sessionId, kind, chunk, taskId) {
712
857
  if (!chunk)
713
858
  return;
859
+ this.clearReplayFailureCooldown(sessionId);
860
+ if (this.replayInFlight.has(sessionId)) {
861
+ this.invalidateSessionRequest(this.replayRequestGenerations, this.replayRequestControllers, this.replayInFlight, sessionId);
862
+ }
863
+ this.setStreamSource(sessionId, 'live');
714
864
  // Session stream is lead/top-level only; task-scoped output lives in task buffers.
715
865
  if (!taskId) {
716
866
  const sessionBuffer = this.getSessionBuffer(sessionId);
@@ -769,21 +919,62 @@ export class HubOverlay {
769
919
  }
770
920
  return ` ${tab('1', 'List', active === 'list')}${divider}${tab('2', 'Feed', active === 'feed')}`;
771
921
  }
772
- async fetchJson(path, timeoutMs = REQUEST_TIMEOUT_MS) {
922
+ async fetchJson(path, timeoutMs = REQUEST_TIMEOUT_MS, init) {
773
923
  const controller = new AbortController();
774
924
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
775
925
  try {
776
926
  // TODO: Remove/Change when we get Agentuity service level auth enabled, this is just temporary
777
927
  const apiKey = process.env.AGENTUITY_CODER_API_KEY;
778
- const headers = { accept: 'application/json' };
928
+ const headers = {
929
+ accept: 'application/json',
930
+ ...(init?.headers && typeof init.headers === 'object'
931
+ ? init.headers
932
+ : {}),
933
+ };
779
934
  if (apiKey)
780
935
  headers['x-agentuity-auth-api-key'] = apiKey;
936
+ const signal = init?.signal
937
+ ? AbortSignal.any([controller.signal, init.signal])
938
+ : controller.signal;
781
939
  const response = await fetch(`${this.baseUrl}${path}`, {
940
+ ...init,
782
941
  headers,
783
- signal: controller.signal,
942
+ signal,
784
943
  });
785
944
  if (!response.ok) {
786
- throw new Error(`Hub returned ${response.status}`);
945
+ let message = `Hub returned ${response.status}`;
946
+ let rawBody = '';
947
+ try {
948
+ const text = await response.text();
949
+ if (text) {
950
+ rawBody = text;
951
+ try {
952
+ const parsed = JSON.parse(text);
953
+ if (typeof parsed.error === 'string' && parsed.error.trim()) {
954
+ message = parsed.error;
955
+ }
956
+ else if (typeof parsed.message === 'string' && parsed.message.trim()) {
957
+ message = parsed.message;
958
+ }
959
+ else {
960
+ message = text;
961
+ }
962
+ }
963
+ catch {
964
+ message = text;
965
+ }
966
+ }
967
+ }
968
+ catch {
969
+ // Fall back to the status-only message.
970
+ }
971
+ throw new HubRequestError({
972
+ message,
973
+ path,
974
+ status: response.status,
975
+ statusText: response.statusText || undefined,
976
+ body: rawBody || undefined,
977
+ });
787
978
  }
788
979
  return (await response.json());
789
980
  }
@@ -791,6 +982,143 @@ export class HubOverlay {
791
982
  clearTimeout(timeout);
792
983
  }
793
984
  }
985
+ getSessionSummary(sessionId) {
986
+ return this.sessions.find((item) => item.sessionId === sessionId);
987
+ }
988
+ isRuntimeAvailable(sessionId) {
989
+ const detailRuntime = this.detail?.sessionId === sessionId ? this.detail.runtimeAvailable : undefined;
990
+ if (typeof detailRuntime === 'boolean')
991
+ return detailRuntime;
992
+ const session = this.getSessionSummary(sessionId);
993
+ if (typeof session?.runtimeAvailable === 'boolean')
994
+ return session.runtimeAvailable;
995
+ if (session?.historyOnly)
996
+ return false;
997
+ if (session?.bucket === 'history')
998
+ return false;
999
+ return true;
1000
+ }
1001
+ isHistoryOnly(sessionId) {
1002
+ if (this.detail?.sessionId === sessionId && typeof this.detail.historyOnly === 'boolean') {
1003
+ return this.detail.historyOnly;
1004
+ }
1005
+ return this.getSessionSummary(sessionId)?.historyOnly === true;
1006
+ }
1007
+ canResumeSession(detail) {
1008
+ if (!detail)
1009
+ return false;
1010
+ return (detail.mode === 'sandbox' &&
1011
+ detail.bucket === 'paused' &&
1012
+ detail.historyOnly !== true &&
1013
+ detail.runtimeAvailable === false);
1014
+ }
1015
+ isArchivedStatus(status) {
1016
+ return status === 'archived' || status === 'shutdown' || status === 'stopped';
1017
+ }
1018
+ getStatusTone(status) {
1019
+ if (status === 'active' || status === 'running' || status === 'connected') {
1020
+ return 'success';
1021
+ }
1022
+ if (status === 'error' || status === 'failed' || status === 'stopped') {
1023
+ return 'error';
1024
+ }
1025
+ if (status === 'archived' || status === 'shutdown') {
1026
+ return 'muted';
1027
+ }
1028
+ return 'warning';
1029
+ }
1030
+ getConnectionState(input) {
1031
+ const isLocalSession = input.mode === 'tui';
1032
+ const isArchived = this.isArchivedStatus(input.status);
1033
+ const bucket = input.bucket ?? (input.historyOnly ? 'history' : 'running');
1034
+ const runtimeAvailable = input.runtimeAvailable !== false;
1035
+ const controlAvailable = input.controlAvailable ?? runtimeAvailable;
1036
+ const historyOnly = input.historyOnly === true || bucket === 'history';
1037
+ const canWake = !isLocalSession && !isArchived && bucket === 'paused' && historyOnly !== true;
1038
+ if (historyOnly)
1039
+ return { label: 'History only', tone: 'warning', controlAvailable };
1040
+ if (isLocalSession && bucket === 'provisioning')
1041
+ return { label: 'Starting', tone: 'warning', controlAvailable };
1042
+ if (isLocalSession && !isArchived)
1043
+ return { label: 'View only', tone: 'muted', controlAvailable };
1044
+ if (bucket === 'provisioning')
1045
+ return { label: 'Starting', tone: 'warning', controlAvailable };
1046
+ if (canWake) {
1047
+ return {
1048
+ label: this.resumeBusySessionId === input.sessionId ? 'Waking' : 'Paused',
1049
+ tone: this.resumeBusySessionId === input.sessionId ? 'accent' : 'warning',
1050
+ controlAvailable,
1051
+ };
1052
+ }
1053
+ if (!isArchived && runtimeAvailable && !controlAvailable) {
1054
+ return { label: 'Read only', tone: 'muted', controlAvailable };
1055
+ }
1056
+ if (!isArchived && runtimeAvailable)
1057
+ return { label: 'Live', tone: 'success', controlAvailable };
1058
+ if (isArchived)
1059
+ return { label: 'Archived', tone: 'muted', controlAvailable };
1060
+ return { label: 'Disconnected', tone: 'warning', controlAvailable };
1061
+ }
1062
+ formatTagSummary(tags, maxCount = 4) {
1063
+ if (!tags || tags.length === 0)
1064
+ return null;
1065
+ const visible = tags.slice(0, maxCount);
1066
+ const remainder = tags.length - visible.length;
1067
+ return remainder > 0 ? `${visible.join(', ')} +${remainder}` : visible.join(', ');
1068
+ }
1069
+ getSessionEventEntries(sessionId) {
1070
+ if (!this.isRuntimeAvailable(sessionId) || this.isHistoryOnly(sessionId)) {
1071
+ return this.sessionHistoryFeed.get(sessionId) ?? [];
1072
+ }
1073
+ return this.sessionFeed.get(sessionId) ?? [];
1074
+ }
1075
+ async refreshTodos(sessionId) {
1076
+ const requestId = ++this.detailTodoRequestId;
1077
+ try {
1078
+ const todosResponse = await this.fetchJson(`/api/hub/session/${encodeURIComponent(sessionId)}/todos?includeTerminal=true&includeSync=true&limit=30`, 15_000).catch((err) => {
1079
+ const isAbort = err?.name === 'AbortError' || err?.message?.includes('aborted');
1080
+ return {
1081
+ _fetchError: true,
1082
+ message: isAbort ? 'Todos loading...' : err?.message || 'Failed to load todos',
1083
+ };
1084
+ });
1085
+ if (this.disposed ||
1086
+ this.detail?.sessionId !== sessionId ||
1087
+ requestId !== this.detailTodoRequestId) {
1088
+ return;
1089
+ }
1090
+ if (todosResponse._fetchError) {
1091
+ if (this.cachedTodos && this.cachedTodos.sessionId === sessionId) {
1092
+ this.detail.todos = this.cachedTodos.todos;
1093
+ this.detail.todoSummary = this.cachedTodos.summary;
1094
+ }
1095
+ this.detail.todosUnavailable = todosResponse.message;
1096
+ this.requestRender();
1097
+ return;
1098
+ }
1099
+ this.detail.todos = Array.isArray(todosResponse.todos) ? todosResponse.todos : [];
1100
+ this.detail.todoSummary =
1101
+ todosResponse.summary && typeof todosResponse.summary === 'object'
1102
+ ? todosResponse.summary
1103
+ : undefined;
1104
+ this.detail.todosUnavailable = todosResponse.unavailable
1105
+ ? typeof todosResponse.message === 'string'
1106
+ ? todosResponse.message
1107
+ : 'Task service unavailable'
1108
+ : undefined;
1109
+ if (this.detail.todos.length > 0) {
1110
+ this.cachedTodos = {
1111
+ todos: this.detail.todos,
1112
+ summary: this.detail.todoSummary,
1113
+ sessionId,
1114
+ };
1115
+ }
1116
+ this.requestRender();
1117
+ }
1118
+ catch {
1119
+ // Best-effort todos loading.
1120
+ }
1121
+ }
794
1122
  async refreshList(initial = false) {
795
1123
  if (this.disposed || this.listInFlight)
796
1124
  return;
@@ -835,46 +1163,21 @@ export class HubOverlay {
835
1163
  this.requestRender();
836
1164
  }
837
1165
  try {
838
- const [detail, todosResponse] = await Promise.all([
839
- this.fetchJson(`/api/hub/session/${encodeURIComponent(sessionId)}`),
840
- this.fetchJson(`/api/hub/session/${encodeURIComponent(sessionId)}/todos?includeTerminal=true&includeSync=true&limit=30`, 15_000).catch((err) => {
841
- const isAbort = err?.name === 'AbortError' || err?.message?.includes('aborted');
842
- return {
843
- _fetchError: true,
844
- message: isAbort ? 'Todos loading\u2026' : err?.message || 'Failed to load todos',
845
- };
846
- }),
847
- ]);
848
- if (todosResponse?._fetchError) {
849
- // Use cached todos if available, show loading indicator
850
- if (this.cachedTodos && this.cachedTodos.sessionId === sessionId) {
851
- detail.todos = this.cachedTodos.todos;
852
- detail.todoSummary = this.cachedTodos.summary;
853
- detail.todosUnavailable = todosResponse.message;
854
- }
855
- else {
856
- detail.todosUnavailable = todosResponse.message;
857
- }
1166
+ const detail = await this.fetchJson(`/api/hub/session/${encodeURIComponent(sessionId)}`);
1167
+ if (this.cachedTodos && this.cachedTodos.sessionId === sessionId) {
1168
+ detail.todos = this.cachedTodos.todos;
1169
+ detail.todoSummary = this.cachedTodos.summary;
1170
+ detail.todosUnavailable = 'Loading todos...';
858
1171
  }
859
- else if (todosResponse) {
860
- detail.todos = Array.isArray(todosResponse.todos) ? todosResponse.todos : [];
861
- detail.todoSummary =
862
- todosResponse.summary && typeof todosResponse.summary === 'object'
863
- ? todosResponse.summary
864
- : undefined;
865
- detail.todosUnavailable = todosResponse.unavailable
866
- ? typeof todosResponse.message === 'string'
867
- ? todosResponse.message
868
- : 'Task service unavailable'
869
- : undefined;
870
- // Cache successful todo data
871
- if (detail.todos && detail.todos.length > 0) {
872
- this.cachedTodos = { todos: detail.todos, summary: detail.todoSummary, sessionId };
873
- }
1172
+ else {
1173
+ detail.todosUnavailable = 'Loading todos...';
874
1174
  }
875
1175
  this.detail = detail;
876
1176
  this.detailSessionId = sessionId;
877
- this.applyStreamProjection(sessionId, detail.stream);
1177
+ this.replaceStreamProjection(sessionId, detail.stream, 'snapshot');
1178
+ if (detail.runtimeAvailable !== false && detail.historyOnly !== true) {
1179
+ this.resetHistoricalSessionState(sessionId);
1180
+ }
878
1181
  const taskCount = detail.tasks?.length ?? 0;
879
1182
  if (taskCount === 0) {
880
1183
  this.selectedTaskIndex = 0;
@@ -886,6 +1189,11 @@ export class HubOverlay {
886
1189
  this.detailError = '';
887
1190
  this.lastUpdatedAt = Date.now();
888
1191
  this.requestRender();
1192
+ void this.refreshTodos(sessionId);
1193
+ if (detail.runtimeAvailable === false || detail.historyOnly === true) {
1194
+ void this.refreshReplay(sessionId);
1195
+ void this.refreshEventHistory(sessionId);
1196
+ }
889
1197
  }
890
1198
  catch (err) {
891
1199
  this.loadingDetail = false;
@@ -896,6 +1204,127 @@ export class HubOverlay {
896
1204
  this.detailInFlight = false;
897
1205
  }
898
1206
  }
1207
+ async refreshReplay(sessionId) {
1208
+ if (this.isFailureCoolingDown(this.replayFailureUntil, sessionId))
1209
+ return;
1210
+ if (this.replayLoadedSessions.has(sessionId) || this.replayInFlight.has(sessionId))
1211
+ return;
1212
+ const controller = new AbortController();
1213
+ const generation = this.nextRequestGeneration(this.replayRequestGenerations, sessionId);
1214
+ this.replayInFlight.add(sessionId);
1215
+ this.replayRequestControllers.set(sessionId, controller);
1216
+ try {
1217
+ const data = await this.fetchJson(`/api/hub/session/${encodeURIComponent(sessionId)}/replay`, 15_000, { signal: controller.signal });
1218
+ if (!this.isCurrentSessionRequest(this.replayRequestGenerations, this.replayRequestControllers, sessionId, generation, controller)) {
1219
+ return;
1220
+ }
1221
+ this.applyReplayEntries(sessionId, Array.isArray(data.entries) ? data.entries : []);
1222
+ this.replayLoadedSessions.add(sessionId);
1223
+ this.requestRender();
1224
+ }
1225
+ catch (err) {
1226
+ if (controller.signal.aborted ||
1227
+ !this.isCurrentSessionRequest(this.replayRequestGenerations, this.replayRequestControllers, sessionId, generation, controller)) {
1228
+ return;
1229
+ }
1230
+ this.markFailureCooldown(this.replayFailureUntil, sessionId);
1231
+ this.pushFeed(`${this.getSessionLabel(sessionId)}: replay unavailable (${err instanceof Error ? err.message : String(err)})`, sessionId);
1232
+ this.requestRender();
1233
+ }
1234
+ finally {
1235
+ if (this.isCurrentSessionRequest(this.replayRequestGenerations, this.replayRequestControllers, sessionId, generation, controller)) {
1236
+ this.replayInFlight.delete(sessionId);
1237
+ this.replayRequestControllers.delete(sessionId);
1238
+ }
1239
+ }
1240
+ }
1241
+ formatHistoryEventLine(sessionId, event) {
1242
+ const payload = event.payload && typeof event.payload === 'object'
1243
+ ? event.payload
1244
+ : undefined;
1245
+ return (this.formatEventFeedLine(sessionId, event.event, payload) ??
1246
+ this.formatStreamLine(event.event, payload) ??
1247
+ `${event.event}${event.agent ? ` ${event.agent}` : ''}`);
1248
+ }
1249
+ async refreshEventHistory(sessionId) {
1250
+ if (this.isFailureCoolingDown(this.eventHistoryFailureUntil, sessionId)) {
1251
+ return;
1252
+ }
1253
+ if (this.eventHistoryLoadedSessions.has(sessionId) ||
1254
+ this.eventHistoryInFlight.has(sessionId)) {
1255
+ return;
1256
+ }
1257
+ const controller = new AbortController();
1258
+ const generation = this.nextRequestGeneration(this.eventHistoryRequestGenerations, sessionId);
1259
+ this.eventHistoryInFlight.add(sessionId);
1260
+ this.eventHistoryRequestControllers.set(sessionId, controller);
1261
+ try {
1262
+ const data = await this.fetchJson(`/api/hub/session/${encodeURIComponent(sessionId)}/events/history?limit=100`, 15_000, { signal: controller.signal });
1263
+ if (!this.isCurrentSessionRequest(this.eventHistoryRequestGenerations, this.eventHistoryRequestControllers, sessionId, generation, controller)) {
1264
+ return;
1265
+ }
1266
+ const entries = (data.events ?? [])
1267
+ .map((event) => {
1268
+ const text = this.formatHistoryEventLine(sessionId, event);
1269
+ if (!text)
1270
+ return null;
1271
+ return {
1272
+ at: Number.isNaN(Date.parse(event.occurredAt))
1273
+ ? Date.now()
1274
+ : Date.parse(event.occurredAt),
1275
+ sessionId,
1276
+ text,
1277
+ };
1278
+ })
1279
+ .filter((entry) => entry !== null)
1280
+ .sort((a, b) => b.at - a.at);
1281
+ this.sessionHistoryFeed.set(sessionId, entries);
1282
+ this.clearEventHistoryFailureCooldown(sessionId);
1283
+ this.eventHistoryLoadedSessions.add(sessionId);
1284
+ this.requestRender();
1285
+ }
1286
+ catch (err) {
1287
+ if (controller.signal.aborted ||
1288
+ !this.isCurrentSessionRequest(this.eventHistoryRequestGenerations, this.eventHistoryRequestControllers, sessionId, generation, controller)) {
1289
+ return;
1290
+ }
1291
+ this.markFailureCooldown(this.eventHistoryFailureUntil, sessionId);
1292
+ this.pushFeed(`${this.getSessionLabel(sessionId)}: event history unavailable (${err instanceof Error ? err.message : String(err)})`, sessionId);
1293
+ this.requestRender();
1294
+ }
1295
+ finally {
1296
+ if (this.isCurrentSessionRequest(this.eventHistoryRequestGenerations, this.eventHistoryRequestControllers, sessionId, generation, controller)) {
1297
+ this.eventHistoryInFlight.delete(sessionId);
1298
+ this.eventHistoryRequestControllers.delete(sessionId);
1299
+ }
1300
+ }
1301
+ }
1302
+ async resumeSession(sessionId) {
1303
+ if (this.resumeBusySessionId)
1304
+ return;
1305
+ this.resumeBusySessionId = sessionId;
1306
+ this.pushFeed(`${this.getSessionLabel(sessionId)}: resume requested`, sessionId);
1307
+ this.requestRender();
1308
+ try {
1309
+ const response = await this.fetchJson(`/api/hub/session/${encodeURIComponent(sessionId)}/resume`, 15_000, { method: 'POST' });
1310
+ if (response.resumed !== true) {
1311
+ throw new Error(response.error || 'Hub declined to resume the session');
1312
+ }
1313
+ this.resetHistoricalSessionState(sessionId);
1314
+ await this.refreshList();
1315
+ await this.refreshDetail(sessionId);
1316
+ }
1317
+ catch (err) {
1318
+ const message = err instanceof Error ? err.message : String(err);
1319
+ this.detailError = message;
1320
+ this.pushFeed(`${this.getSessionLabel(sessionId)}: resume failed (${message})`, sessionId);
1321
+ this.requestRender();
1322
+ }
1323
+ finally {
1324
+ this.resumeBusySessionId = null;
1325
+ this.requestRender();
1326
+ }
1327
+ }
899
1328
  updateFeedFromList(sessions) {
900
1329
  if (this.previousDigests.size === 0) {
901
1330
  if (sessions.length > 0) {
@@ -952,9 +1381,14 @@ export class HubOverlay {
952
1381
  return;
953
1382
  const desiredModes = new Map();
954
1383
  for (const session of sessions.slice(0, STREAM_SESSION_LIMIT)) {
1384
+ if (session.runtimeAvailable === false || session.historyOnly === true)
1385
+ continue;
955
1386
  desiredModes.set(session.sessionId, 'summary');
956
1387
  }
957
- if (this.detailSessionId && this.isSessionContext()) {
1388
+ if (this.detailSessionId &&
1389
+ this.isSessionContext() &&
1390
+ this.isRuntimeAvailable(this.detailSessionId) &&
1391
+ !this.isHistoryOnly(this.detailSessionId)) {
958
1392
  desiredModes.set(this.detailSessionId, 'full');
959
1393
  }
960
1394
  for (const [sessionId, stream] of this.sseControllers) {
@@ -1113,52 +1547,16 @@ export class HubOverlay {
1113
1547
  }
1114
1548
  }
1115
1549
  applyHydration(sessionId, eventData) {
1116
- if (this.hydratedSessions.has(sessionId))
1117
- return;
1118
1550
  if (!eventData || typeof eventData !== 'object')
1119
1551
  return;
1120
1552
  const payload = eventData;
1121
- const loadedFromProjection = this.applyStreamProjection(sessionId, payload.stream);
1553
+ const loadedFromProjection = this.replaceStreamProjection(sessionId, payload.stream, 'hydration');
1122
1554
  if (!loadedFromProjection) {
1123
- const entries = Array.isArray(payload.entries) ? payload.entries : [];
1124
- for (const rawEntry of entries) {
1125
- if (!rawEntry || typeof rawEntry !== 'object')
1126
- continue;
1127
- const entry = rawEntry;
1128
- const type = typeof entry.type === 'string' ? entry.type : '';
1129
- const content = typeof entry.content === 'string' ? entry.content : '';
1130
- const taskId = typeof entry.taskId === 'string' ? entry.taskId : undefined;
1131
- if (!content)
1132
- continue;
1133
- if (type === 'message' || type === 'task_result') {
1134
- this.appendBufferText(sessionId, 'output', content + '\n\n', taskId);
1135
- }
1136
- else if (type === 'thinking') {
1137
- this.appendBufferText(sessionId, 'thinking', content + '\n\n', taskId);
1138
- }
1139
- }
1140
- }
1141
- this.hydratedSessions.add(sessionId);
1142
- }
1143
- applyStreamProjection(sessionId, streamRaw) {
1144
- if (!streamRaw || typeof streamRaw !== 'object')
1145
- return false;
1146
- const stream = streamRaw;
1147
- const sessionBuffer = this.getSessionBuffer(sessionId);
1148
- sessionBuffer.output = typeof stream.output === 'string' ? stream.output : '';
1149
- sessionBuffer.thinking = typeof stream.thinking === 'string' ? stream.thinking : '';
1150
- const taskStreams = stream.tasks && typeof stream.tasks === 'object'
1151
- ? stream.tasks
1152
- : {};
1153
- for (const [taskId, rawBlock] of Object.entries(taskStreams)) {
1154
- if (!rawBlock || typeof rawBlock !== 'object')
1155
- continue;
1156
- const block = rawBlock;
1157
- const taskBuffer = this.getTaskBuffer(sessionId, taskId);
1158
- taskBuffer.output = typeof block.output === 'string' ? block.output : '';
1159
- taskBuffer.thinking = typeof block.thinking === 'string' ? block.thinking : '';
1555
+ const entries = Array.isArray(payload.entries)
1556
+ ? payload.entries.filter((entry) => !!entry && typeof entry === 'object')
1557
+ : [];
1558
+ this.replaceStreamProjection(sessionId, buildProjectionFromEntries(entries), 'hydration');
1160
1559
  }
1161
- return true;
1162
1560
  }
1163
1561
  captureStreamContent(sessionId, eventName, eventData, taskId) {
1164
1562
  const data = eventData && typeof eventData === 'object'
@@ -1269,7 +1667,7 @@ export class HubOverlay {
1269
1667
  }
1270
1668
  getFeedEntries(scope) {
1271
1669
  if (scope === 'session' && this.detailSessionId) {
1272
- return this.sessionFeed.get(this.detailSessionId) ?? [];
1670
+ return this.getSessionEventEntries(this.detailSessionId);
1273
1671
  }
1274
1672
  return this.feed;
1275
1673
  }
@@ -1457,7 +1855,21 @@ export class HubOverlay {
1457
1855
  const updated = this.lastUpdatedAt
1458
1856
  ? `${formatClock(this.lastUpdatedAt)} updated`
1459
1857
  : 'not updated';
1460
- body.push(this.contentLine(this.theme.fg('muted', ` Active: ${this.sessions.length} sessions ${updated}`), inner));
1858
+ const liveCount = this.sessions.filter((session) => {
1859
+ const connection = this.getConnectionState({
1860
+ sessionId: session.sessionId,
1861
+ mode: session.mode,
1862
+ status: session.status,
1863
+ bucket: session.bucket,
1864
+ runtimeAvailable: session.runtimeAvailable,
1865
+ controlAvailable: session.controlAvailable,
1866
+ historyOnly: session.historyOnly,
1867
+ });
1868
+ return connection.label === 'Live';
1869
+ }).length;
1870
+ const pausedCount = this.sessions.filter((session) => session.bucket === 'paused' && session.historyOnly !== true).length;
1871
+ const historyCount = this.sessions.filter((session) => session.historyOnly === true || session.bucket === 'history').length;
1872
+ body.push(this.contentLine(this.theme.fg('muted', ` Active: ${this.sessions.length} sessions Live:${liveCount} Paused:${pausedCount} History:${historyCount} ${updated}`), inner));
1461
1873
  }
1462
1874
  body.push(this.contentLine(this.theme.fg('dim', ` ${hLine(Math.max(0, inner - 2))}`), inner));
1463
1875
  body.push(this.contentLine(this.theme.bold(' Teams / Observers'), inner));
@@ -1476,17 +1888,31 @@ export class HubOverlay {
1476
1888
  const marker = selected ? this.theme.fg('accent', '›') : ' ';
1477
1889
  const label = session.label || shortId(session.sessionId);
1478
1890
  const name = selected ? this.theme.bold(label) : label;
1479
- const statusColor = session.status === 'running'
1480
- ? 'success'
1481
- : session.status === 'error' || session.status === 'failed'
1482
- ? 'error'
1483
- : 'warning';
1484
- const status = this.theme.fg(statusColor, session.status);
1891
+ const status = this.theme.fg(this.getStatusTone(session.status), session.status);
1892
+ const connection = this.getConnectionState({
1893
+ sessionId: session.sessionId,
1894
+ mode: session.mode,
1895
+ status: session.status,
1896
+ bucket: session.bucket,
1897
+ runtimeAvailable: session.runtimeAvailable,
1898
+ controlAvailable: session.controlAvailable,
1899
+ historyOnly: session.historyOnly,
1900
+ });
1901
+ const connectionLabel = this.theme.fg(connection.tone, connection.label);
1485
1902
  const self = this.currentSessionId === session.sessionId
1486
1903
  ? this.theme.fg('accent', ' (this)')
1487
1904
  : '';
1488
- const metrics = this.theme.fg('muted', `obs:${session.observerCount} agents:${session.subAgentCount} tasks:${session.taskCount} ${formatRelative(session.createdAt)}`);
1489
- body.push(this.contentLine(` ${marker} ${name}${self} ${status} ${session.mode} ${metrics}`, inner));
1905
+ const tagSummary = this.formatTagSummary(session.tags, 2);
1906
+ const metricsParts = [
1907
+ `obs:${session.observerCount}`,
1908
+ `agents:${session.subAgentCount}`,
1909
+ `tasks:${session.taskCount}`,
1910
+ formatRelative(session.createdAt),
1911
+ session.defaultAgent ? `agent:${session.defaultAgent}` : undefined,
1912
+ tagSummary ? `tags:${tagSummary}` : undefined,
1913
+ ].filter((part) => !!part);
1914
+ const metrics = this.theme.fg('muted', metricsParts.join(' '));
1915
+ body.push(this.contentLine(` ${marker} ${name}${self} ${status} ${session.mode} ${connectionLabel} ${metrics}`, inner));
1490
1916
  }
1491
1917
  if (end < this.sessions.length) {
1492
1918
  body.push(this.contentLine(this.theme.fg('dim', ` ↓ ${this.sessions.length - end} more below`), inner));
@@ -1504,11 +1930,12 @@ export class HubOverlay {
1504
1930
  renderDetailScreen(width, maxLines) {
1505
1931
  const inner = Math.max(0, width - 2);
1506
1932
  const lines = [];
1507
- const title = this.detail?.label || this.detailSessionId || 'Hub Session';
1933
+ const title = this.detail?.label ||
1934
+ (this.detailSessionId ? shortId(this.detailSessionId) : 'Hub Session');
1508
1935
  const headerRows = 3;
1509
1936
  const footerRows = 2;
1510
1937
  const contentBudget = Math.max(5, maxLines - headerRows - footerRows);
1511
- lines.push(buildTopBorder(width, `Session ${shortId(title)}`));
1938
+ lines.push(buildTopBorder(width, `Session ${title}`));
1512
1939
  lines.push(this.contentLine('', inner));
1513
1940
  lines.push(this.contentLine(this.buildTopTabs('detail', true), inner));
1514
1941
  const body = [];
@@ -1526,17 +1953,67 @@ export class HubOverlay {
1526
1953
  const participants = session.participants ?? [];
1527
1954
  const tasks = session.tasks ?? [];
1528
1955
  const activityEntries = Object.entries(session.agentActivity ?? {});
1956
+ const connection = this.getConnectionState({
1957
+ sessionId: session.sessionId,
1958
+ mode: session.mode,
1959
+ status: session.status,
1960
+ bucket: session.bucket,
1961
+ runtimeAvailable: session.runtimeAvailable,
1962
+ controlAvailable: session.controlAvailable,
1963
+ historyOnly: session.historyOnly,
1964
+ });
1965
+ const inactiveTasks = session.diagnostics?.inactiveRunningTasks ?? [];
1966
+ const inactiveTaskById = new Map(inactiveTasks.map((item) => [item.taskId, item]));
1967
+ const pushWrappedLine = (text, tone = 'muted') => {
1968
+ for (const wrapped of wrapText(text, Math.max(12, inner - 4))) {
1969
+ body.push(this.contentLine(this.theme.fg(tone, ` ${wrapped}`), inner));
1970
+ }
1971
+ };
1529
1972
  body.push(this.contentLine(this.theme.bold(' Overview'), inner));
1530
1973
  body.push(this.contentLine(this.theme.fg('muted', ` ID: ${session.sessionId}`), inner));
1531
- body.push(this.contentLine(this.theme.fg('muted', ` Status: ${session.status} Mode: ${session.mode}`), inner));
1974
+ body.push(this.contentLine(` Status: ${this.theme.fg(this.getStatusTone(session.status), session.status)} Mode: ${session.mode} Bucket: ${session.bucket ?? '-'}`, inner));
1975
+ body.push(this.contentLine(` Connection: ${this.theme.fg(connection.tone, connection.label)} Runtime: ${session.runtimeAvailable === false ? 'offline' : 'ready'} Control: ${connection.controlAvailable ? 'enabled' : 'read-only'}`, inner));
1532
1976
  body.push(this.contentLine(this.theme.fg('muted', ` Created: ${formatRelative(session.createdAt)}`), inner));
1533
1977
  body.push(this.contentLine(this.theme.fg('muted', ` Participants: ${participants.length} Tasks: ${tasks.length} Active agents: ${activityEntries.length}`), inner));
1978
+ if (session.defaultAgent) {
1979
+ body.push(this.contentLine(this.theme.fg('muted', ` Default agent: ${session.defaultAgent}`), inner));
1980
+ }
1981
+ const tagSummary = this.formatTagSummary(session.tags);
1982
+ if (tagSummary) {
1983
+ pushWrappedLine(`Tags: ${tagSummary}`);
1984
+ }
1985
+ if (session.streamId || session.streamUrl) {
1986
+ const streamState = session.streamId
1987
+ ? shortId(session.streamId)
1988
+ : session.streamUrl
1989
+ ? 'attached'
1990
+ : 'none';
1991
+ body.push(this.contentLine(this.theme.fg('muted', ` Stream: ${streamState}`), inner));
1992
+ }
1534
1993
  if (session.context?.branch) {
1535
1994
  body.push(this.contentLine(this.theme.fg('muted', ` Branch: ${session.context.branch}`), inner));
1536
1995
  }
1537
1996
  if (session.context?.workingDirectory) {
1538
1997
  body.push(this.contentLine(this.theme.fg('muted', ` CWD: ${session.context.workingDirectory}`), inner));
1539
1998
  }
1999
+ if (session.task) {
2000
+ body.push(this.contentLine(this.theme.fg('dim', ` ${hLine(Math.max(0, inner - 2))}`), inner));
2001
+ body.push(this.contentLine(this.theme.bold(' Root Task'), inner));
2002
+ for (const wrapped of wrapText(session.task, Math.max(12, inner - 4))) {
2003
+ body.push(this.contentLine(` ${wrapped}`, inner));
2004
+ }
2005
+ }
2006
+ if (session.error) {
2007
+ body.push(this.contentLine(this.theme.fg('dim', ` ${hLine(Math.max(0, inner - 2))}`), inner));
2008
+ body.push(this.contentLine(this.theme.bold(' Error'), inner));
2009
+ pushWrappedLine(session.error, 'error');
2010
+ }
2011
+ if (this.canResumeSession(session)) {
2012
+ const resumeMessage = this.resumeBusySessionId === session.sessionId
2013
+ ? 'Waking sandbox session...'
2014
+ : 'Press [w] to wake this paused sandbox session.';
2015
+ pushWrappedLine(resumeMessage, this.resumeBusySessionId === session.sessionId ? 'muted' : 'warning');
2016
+ }
1540
2017
  body.push(this.contentLine(this.theme.fg('dim', ` ${hLine(Math.max(0, inner - 2))}`), inner));
1541
2018
  body.push(this.contentLine(this.theme.bold(' Tasks'), inner));
1542
2019
  body.push(this.contentLine(this.theme.fg('dim', ' Use ↑ and ↓ to move, Enter to open task view'), inner));
@@ -1554,14 +2031,22 @@ export class HubOverlay {
1554
2031
  ? 'success'
1555
2032
  : task.status === 'failed'
1556
2033
  ? 'error'
1557
- : 'warning';
2034
+ : task.status === 'running'
2035
+ ? 'accent'
2036
+ : 'warning';
1558
2037
  const status = this.theme.fg(statusColor, task.status);
2038
+ const inactive = inactiveTaskById.get(task.taskId);
1559
2039
  const prompt = task.prompt
1560
2040
  ? truncateToWidth(toSingleLine(task.prompt), Math.max(16, inner - 34))
1561
2041
  : '';
1562
- const duration = typeof task.duration === 'number' ? ` ${task.duration}ms` : '';
2042
+ const duration = typeof task.duration === 'number'
2043
+ ? ` ${formatElapsedCompact(task.duration)}`
2044
+ : '';
2045
+ const idle = inactive
2046
+ ? ` ${this.theme.fg('warning', `idle ${formatElapsedCompact(inactive.inactivityMs)}`)}`
2047
+ : '';
1563
2048
  const marker = selected ? this.theme.fg('accent', '›') : ' ';
1564
- body.push(this.contentLine(`${marker} ${shortId(task.taskId).padEnd(12)} ${task.agent.padEnd(9)} ${status}${duration} ${prompt}`, inner));
2049
+ body.push(this.contentLine(`${marker} ${shortId(task.taskId).padEnd(12)} ${task.agent.padEnd(9)} ${status}${duration}${idle} ${prompt}`, inner));
1565
2050
  }
1566
2051
  }
1567
2052
  body.push(this.contentLine(this.theme.fg('dim', ` ${hLine(Math.max(0, inner - 2))}`), inner));
@@ -1617,6 +2102,17 @@ export class HubOverlay {
1617
2102
  body.push(this.contentLine(` ${participant.id.padEnd(12)} ${participant.role.padEnd(9)} ${(participant.transport || 'ws').padEnd(3)} ${when}${idle}`, inner));
1618
2103
  }
1619
2104
  }
2105
+ if (inactiveTasks.length > 0) {
2106
+ body.push(this.contentLine(this.theme.fg('dim', ` ${hLine(Math.max(0, inner - 2))}`), inner));
2107
+ body.push(this.contentLine(this.theme.bold(' Diagnostics'), inner));
2108
+ body.push(this.contentLine(this.theme.fg('warning', ` ${inactiveTasks.length} running task${inactiveTasks.length === 1 ? '' : 's'} without recent activity`), inner));
2109
+ for (const item of inactiveTasks.slice(0, 8)) {
2110
+ const lastSeen = item.lastActivityAt
2111
+ ? ` last ${formatRelative(item.lastActivityAt)}`
2112
+ : '';
2113
+ body.push(this.contentLine(this.theme.fg('warning', ` ${shortId(item.taskId).padEnd(12)} ${item.agent.padEnd(10)} idle ${formatElapsedCompact(item.inactivityMs)} started ${formatRelative(item.startedAt)}${lastSeen}`), inner));
2114
+ }
2115
+ }
1620
2116
  body.push(this.contentLine(this.theme.fg('dim', ` ${hLine(Math.max(0, inner - 2))}`), inner));
1621
2117
  body.push(this.contentLine(this.theme.bold(' Agent Activity'), inner));
1622
2118
  if (activityEntries.length === 0) {
@@ -1624,12 +2120,28 @@ export class HubOverlay {
1624
2120
  }
1625
2121
  else {
1626
2122
  for (const [agent, info] of activityEntries.slice(0, 15)) {
1627
- const tool = info.currentTool ? ` ${info.currentTool}` : '';
1628
- const calls = typeof info.toolCallCount === 'number'
1629
- ? this.theme.fg('dim', ` (${info.toolCallCount} calls)`)
1630
- : '';
1631
2123
  const status = info.status || 'idle';
1632
- body.push(this.contentLine(` ${agent.padEnd(12)} ${status}${tool}${calls}`, inner));
2124
+ const statusTone = status === 'completed'
2125
+ ? 'success'
2126
+ : status === 'failed'
2127
+ ? 'error'
2128
+ : status === 'tool_start' || status === 'running'
2129
+ ? 'accent'
2130
+ : 'warning';
2131
+ const tool = info.currentTool ? ` tool:${info.currentTool}` : '';
2132
+ const calls = typeof info.toolCallCount === 'number' ? ` calls:${info.toolCallCount}` : '';
2133
+ const lastSeen = info.lastActivity !== undefined && info.lastActivity !== null
2134
+ ? ` last:${formatRelative(info.lastActivity)}`
2135
+ : '';
2136
+ const totalElapsed = typeof info.totalElapsed === 'number'
2137
+ ? ` total:${formatElapsedCompact(info.totalElapsed)}`
2138
+ : '';
2139
+ body.push(this.contentLine(` ${agent.padEnd(12)} ${this.theme.fg(statusTone, status)}${this.theme.fg('dim', `${tool}${calls}${lastSeen}${totalElapsed}`)}`, inner));
2140
+ if (info.currentToolArgs) {
2141
+ for (const wrapped of wrapText(toSingleLine(info.currentToolArgs), Math.max(12, inner - 6))) {
2142
+ body.push(this.contentLine(this.theme.fg('dim', ` ${wrapped}`), inner));
2143
+ }
2144
+ }
1633
2145
  }
1634
2146
  }
1635
2147
  }
@@ -1645,7 +2157,8 @@ export class HubOverlay {
1645
2157
  const scrollInfo = this.detailMaxScroll > 0
1646
2158
  ? this.theme.fg('dim', ` scroll ${this.detailScrollOffset}/${this.detailMaxScroll}`)
1647
2159
  : this.theme.fg('dim', ' scroll 0/0');
1648
- lines.push(this.contentLine(`${scrollInfo} ${this.theme.fg('dim', '[↑↓] Task [j/k] Scroll [Enter] Open [r] Refresh [Esc] Back')}`, inner));
2160
+ const wakeHint = this.canResumeSession(this.detail) ? ' [w] Wake' : '';
2161
+ lines.push(this.contentLine(`${scrollInfo} ${this.theme.fg('dim', `[↑↓] Task [j/k] Scroll [Enter] Open${wakeHint} [r] Refresh [Esc] Back`)}`, inner));
1649
2162
  lines.push(buildBottomBorder(width));
1650
2163
  return lines.slice(0, maxLines);
1651
2164
  }
@@ -1656,25 +2169,41 @@ export class HubOverlay {
1656
2169
  const footerRows = 2;
1657
2170
  const contentBudget = Math.max(5, maxLines - headerRows - footerRows);
1658
2171
  const scoped = this.feedScope === 'session' && !!this.detailSessionId;
2172
+ const sessionId = scoped ? this.detailSessionId : null;
2173
+ const historySession = sessionId
2174
+ ? !this.isRuntimeAvailable(sessionId) || this.isHistoryOnly(sessionId)
2175
+ : false;
1659
2176
  const title = scoped
1660
- ? `${this.feedViewMode === 'events' ? 'Session Events' : 'Session Feed'} ${shortId(this.getSessionLabel(this.detailSessionId))}`
2177
+ ? `${this.feedViewMode === 'events' ? 'Session Events' : 'Session Feed'} ${this.getSessionLabel(sessionId)}`
1661
2178
  : 'Global Feed';
1662
2179
  const entryBudget = Math.max(1, contentBudget - 2);
1663
- const sessionBuffer = scoped && this.detailSessionId ? this.sessionBuffers.get(this.detailSessionId) : undefined;
2180
+ const sessionBuffer = sessionId ? this.sessionBuffers.get(sessionId) : undefined;
1664
2181
  lines.push(buildTopBorder(width, title));
1665
2182
  lines.push(this.contentLine('', inner));
1666
2183
  lines.push(this.contentLine(this.buildTopTabs(scoped ? (this.feedViewMode === 'events' ? 'events' : 'feed') : 'feed', scoped), inner));
1667
2184
  lines.push(this.contentLine(this.theme.fg('muted', scoped
1668
2185
  ? this.feedViewMode === 'stream'
1669
- ? ' Streaming rendered session output (sub-agent style) — [v] task stream'
1670
- : ' Streaming full session events'
2186
+ ? historySession
2187
+ ? ' Rehydrated session output from stored replay — [v] task stream'
2188
+ : ' Live rendered session output (sub-agent style) — [v] task stream'
2189
+ : historySession
2190
+ ? ' Stored session events from history'
2191
+ : ' Live full session events'
1671
2192
  : ' Streaming event summaries across all sessions'), inner));
1672
2193
  lines.push(this.contentLine(this.theme.fg('dim', ` ${hLine(Math.max(0, inner - 2))}`), inner));
1673
2194
  let contentLines = [];
1674
2195
  if (scoped && this.feedViewMode === 'stream') {
1675
2196
  contentLines = this.renderStreamLines(sessionBuffer?.output ?? '', sessionBuffer?.thinking ?? '', this.showFeedThinking, Math.max(12, inner - 4));
1676
2197
  if (contentLines.length === 0) {
1677
- contentLines = [this.theme.fg('dim', '(no streamed output yet)')];
2198
+ if (sessionId && historySession && this.replayInFlight.has(sessionId)) {
2199
+ contentLines = [this.theme.fg('dim', '(loading replay...)')];
2200
+ }
2201
+ else if (sessionId && historySession && this.replayLoadedSessions.has(sessionId)) {
2202
+ contentLines = [this.theme.fg('dim', '(no replay output available)')];
2203
+ }
2204
+ else {
2205
+ contentLines = [this.theme.fg('dim', '(no streamed output yet)')];
2206
+ }
1678
2207
  }
1679
2208
  }
1680
2209
  else {
@@ -1683,7 +2212,19 @@ export class HubOverlay {
1683
2212
  .reverse()
1684
2213
  .map((entry) => `${this.theme.fg('dim', formatClock(entry.at))} ${entry.text}`);
1685
2214
  if (contentLines.length === 0) {
1686
- contentLines = [this.theme.fg('dim', '(no feed items yet)')];
2215
+ if (sessionId && historySession && this.eventHistoryInFlight.has(sessionId)) {
2216
+ contentLines = [this.theme.fg('dim', '(loading event history...)')];
2217
+ }
2218
+ else if (sessionId &&
2219
+ historySession &&
2220
+ this.eventHistoryLoadedSessions.has(sessionId)) {
2221
+ contentLines = [this.theme.fg('dim', '(no stored events available)')];
2222
+ }
2223
+ else {
2224
+ contentLines = [
2225
+ this.theme.fg('dim', scoped ? '(no session feed items yet)' : '(no feed items yet)'),
2226
+ ];
2227
+ }
1687
2228
  }
1688
2229
  }
1689
2230
  this.feedMaxScroll = Math.max(0, contentLines.length - entryBudget);
@@ -1728,12 +2269,16 @@ export class HubOverlay {
1728
2269
  body.push(this.contentLine(this.theme.fg('dim', ' No task selected'), inner));
1729
2270
  }
1730
2271
  else {
2272
+ const historySession = this.detailSessionId
2273
+ ? !this.isRuntimeAvailable(this.detailSessionId) ||
2274
+ this.isHistoryOnly(this.detailSessionId)
2275
+ : false;
1731
2276
  body.push(this.contentLine(this.theme.bold(' Task Overview'), inner));
1732
2277
  body.push(this.contentLine(this.theme.fg('muted', ` Task ID: ${selected.taskId}`), inner));
1733
2278
  body.push(this.contentLine(this.theme.fg('muted', ` Agent: ${selected.agent}`), inner));
1734
2279
  body.push(this.contentLine(this.theme.fg('muted', ` Status: ${selected.status}`), inner));
1735
2280
  if (typeof selected.duration === 'number') {
1736
- body.push(this.contentLine(this.theme.fg('muted', ` Duration: ${selected.duration}ms`), inner));
2281
+ body.push(this.contentLine(this.theme.fg('muted', ` Duration: ${formatElapsedCompact(selected.duration)}`), inner));
1737
2282
  }
1738
2283
  if (selected.startedAt) {
1739
2284
  body.push(this.contentLine(this.theme.fg('muted', ` Started: ${selected.startedAt}`), inner));
@@ -1754,7 +2299,14 @@ export class HubOverlay {
1754
2299
  : undefined;
1755
2300
  const rendered = this.renderStreamLines(taskBuffer?.output ?? '', taskBuffer?.thinking ?? '', this.showTaskThinking, Math.max(12, inner - 4));
1756
2301
  if (rendered.length === 0) {
1757
- body.push(this.contentLine(this.theme.fg('dim', ' (no task output yet)'), inner));
2302
+ if (historySession &&
2303
+ this.detailSessionId &&
2304
+ this.replayInFlight.has(this.detailSessionId)) {
2305
+ body.push(this.contentLine(this.theme.fg('dim', ' (loading replay...)'), inner));
2306
+ }
2307
+ else {
2308
+ body.push(this.contentLine(this.theme.fg('dim', ' (no task output yet)'), inner));
2309
+ }
1758
2310
  }
1759
2311
  else {
1760
2312
  for (const line of rendered) {