@oar-sh/oar 0.9.10 → 0.9.12

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 (135) hide show
  1. package/CHANGELOG.md +440 -1
  2. package/README.md +156 -972
  3. package/package.json +13 -13
  4. package/server/README.md +112 -25
  5. package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
  6. package/server/claude-worker/claude-sdk-adapter.mjs +57 -4
  7. package/server/claude-worker/claude-session-process.mjs +217 -11
  8. package/server/claude-worker/claude-session-worker.mjs +26 -1
  9. package/server/claude-worker/task-output-discovery.mjs +126 -0
  10. package/server/copilot-worker/copilot-sdk-session-process.mjs +235 -14
  11. package/server/copilot-worker/copilot-sdk-session-worker.mjs +8 -0
  12. package/server/copilot-worker/copilot-shell-output-mirror.mjs +94 -0
  13. package/server/copilot-worker/copilot-task-settle.mjs +96 -0
  14. package/server/db-schema.mjs +68 -2
  15. package/server/grok-worker/acp-client.mjs +2 -1
  16. package/server/grok-worker/acp-host-services.mjs +46 -6
  17. package/server/grok-worker/grok-background-tasks.mjs +241 -0
  18. package/server/grok-worker/grok-sdk-adapter.mjs +3 -1
  19. package/server/grok-worker/grok-session-worker.mjs +33 -1
  20. package/server/grok-worker/grok-turn-runner.mjs +11 -0
  21. package/server/migrations/0013-previews.mjs +130 -0
  22. package/server/migrations/0014-conversation-groups.mjs +89 -0
  23. package/server/migrations/0015-conversation-group-position.mjs +76 -0
  24. package/server/migrations/0016-share-access-log.mjs +110 -0
  25. package/server/migrations/0017-conversation-history-indexes.mjs +89 -0
  26. package/server/public/app/about-ui.js +73 -0
  27. package/server/public/app/about-view.mjs +234 -0
  28. package/server/public/app/action-confirmations.js +31 -14
  29. package/server/public/app/agent-exchange-fold-view.mjs +40 -101
  30. package/server/public/app/api-client.js +264 -16
  31. package/server/public/app/ask-user-view.js +17 -15
  32. package/server/public/app/background-tasks-view.mjs +285 -16
  33. package/server/public/app/bootstrap.js +253 -37
  34. package/server/public/app/chat-actions-menu-state.mjs +20 -10
  35. package/server/public/app/claude-background-command-limit-options.mjs +21 -0
  36. package/server/public/app/context-menu.mjs +90 -6
  37. package/server/public/app/conversation-group-drag.mjs +191 -0
  38. package/server/public/app/conversation-group-order.mjs +265 -0
  39. package/server/public/app/conversation-group-state.mjs +100 -0
  40. package/server/public/app/conversation-list-drag.mjs +449 -0
  41. package/server/public/app/conversation-payload-cache.mjs +357 -0
  42. package/server/public/app/conversation-turn-state.mjs +81 -0
  43. package/server/public/app/conversation-view.js +567 -94
  44. package/server/public/app/conversation-window-retention.mjs +96 -0
  45. package/server/public/app/cwd-picker.js +1 -1
  46. package/server/public/app/hidden-messages-view.mjs +312 -0
  47. package/server/public/app/host-suspend-ui.js +7 -1
  48. package/server/public/app/journal-view.js +1033 -82
  49. package/server/public/app/preview-cards.mjs +117 -31
  50. package/server/public/app/preview-command.mjs +10 -1
  51. package/server/public/app/relay-board-view.js +19 -6
  52. package/server/public/app/relay-prompt-sanitizer.mjs +11 -4
  53. package/server/public/app/relay-restart-error.mjs +16 -0
  54. package/server/public/app/settings-modal.js +150 -10
  55. package/server/public/app/settings-tabs.js +1 -1
  56. package/server/public/app/share-format.mjs +49 -0
  57. package/server/public/app/share-preview-marks.mjs +167 -0
  58. package/server/public/app/share-state.js +109 -0
  59. package/server/public/app/shared-conversations-settings.js +480 -0
  60. package/server/public/app/socket-handlers.js +138 -20
  61. package/server/public/app/store.js +121 -30
  62. package/server/public/app/subagent-row.mjs +3 -3
  63. package/server/public/app/subagent-viewer.mjs +37 -7
  64. package/server/public/app/task-console-render.mjs +237 -0
  65. package/server/public/app/task-console-watch.mjs +42 -0
  66. package/server/public/app/task-console.mjs +630 -0
  67. package/server/public/app/task-output-text.mjs +234 -0
  68. package/server/public/app/thinking-follow.js +0 -4
  69. package/server/public/app/transcript-island.mjs +73 -0
  70. package/server/public/app/transcript-jump-bottom-view.mjs +230 -26
  71. package/server/public/app/transcript-jump-bottom.mjs +170 -22
  72. package/server/public/app/transcript-reading-anchor.mjs +256 -0
  73. package/server/public/app/transcript-reload-freshness.mjs +17 -1
  74. package/server/public/app/transcript-viewport-anchor.mjs +159 -0
  75. package/server/public/index.html +625 -21
  76. package/server/relay-tools.md +3 -1
  77. package/server/relay.mjs +2 -1
  78. package/server/repositories/conversation-group-repository.mjs +444 -0
  79. package/server/repositories/message-repository.mjs +9 -0
  80. package/server/repositories/question-repository.mjs +4 -0
  81. package/server/repositories/session-repository.mjs +12 -37
  82. package/server/repositories/share-repository.mjs +322 -0
  83. package/server/repositories/share-visibility-statements.mjs +10 -0
  84. package/server/routes/about-routes.mjs +15 -0
  85. package/server/routes/cache-routes.mjs +4 -0
  86. package/server/routes/conversation-group-routes.mjs +171 -0
  87. package/server/routes/messages-routes.mjs +433 -68
  88. package/server/routes/preview-routes.mjs +9 -1
  89. package/server/routes/sessions-routes.mjs +901 -253
  90. package/server/server-runtime.mjs +392 -99
  91. package/server/services/about-service.mjs +461 -0
  92. package/server/services/background-task-store.mjs +365 -0
  93. package/server/services/background-work-view.mjs +228 -0
  94. package/server/services/cache-rebuild-service.mjs +30 -0
  95. package/server/services/cli-process-runner.mjs +4 -21
  96. package/server/services/conversation-history-page.mjs +199 -0
  97. package/server/services/delete-archive-service.mjs +4 -1
  98. package/server/services/detached-worker-watch-service.mjs +150 -0
  99. package/server/services/hidden-message-list.mjs +21 -0
  100. package/server/services/host-suspend-command.mjs +2 -1
  101. package/server/services/message-pin-list.mjs +1 -1
  102. package/server/services/preview-health-probe.mjs +8 -1
  103. package/server/services/preview-proxy-server.mjs +8 -4
  104. package/server/services/preview-registry-service.mjs +676 -26
  105. package/server/services/preview-static-handler.mjs +40 -7
  106. package/server/services/preview-store.mjs +274 -0
  107. package/server/services/rate-limit-service.mjs +12 -1
  108. package/server/services/relay-auth.mjs +183 -0
  109. package/server/services/relay-cli-launcher-service.mjs +3 -1
  110. package/server/services/relay-event-service.mjs +17 -0
  111. package/server/services/relay-prompt-sanitizer.mjs +11 -4
  112. package/server/services/remote-relay-dispatcher.mjs +187 -7
  113. package/server/services/request-boundary.mjs +183 -0
  114. package/server/services/session-activity-tracker.mjs +231 -19
  115. package/server/services/session-worker-launch-service.mjs +190 -6
  116. package/server/services/session-worker-process-service.mjs +10 -6
  117. package/server/services/share-access-log-service.mjs +123 -0
  118. package/server/services/share-path-redaction.mjs +424 -0
  119. package/server/services/status-event-service.mjs +7 -3
  120. package/server/services/task-output-rooms.mjs +259 -0
  121. package/server/services/task-output-service.mjs +562 -0
  122. package/server/services/tunnel-worker-path-guard.mjs +24 -41
  123. package/server/services/windows-autostart-service.mjs +6 -1
  124. package/server/services/windows-boot-autostart-service.mjs +207 -23
  125. package/shared/claude-background-command-limit.mjs +83 -0
  126. package/shared/preview-tool-core.mjs +15 -5
  127. package/shared/remote-relay-contract.mjs +35 -3
  128. package/shared/share-path-privacy.mjs +30 -0
  129. package/shared/task-output-text.mjs +737 -0
  130. package/shared/windows-system-tools.mjs +106 -0
  131. package/shared/worker-runtime/background-task-digest.mjs +30 -0
  132. package/shared/worker-runtime/heartbeat.mjs +24 -0
  133. package/shared/worker-runtime/kept-posts.mjs +83 -0
  134. package/shared/worker-runtime/task-output-spool.mjs +181 -0
  135. package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
@@ -9,15 +9,19 @@
9
9
  // and drops on a backed-up connection; a missed event shows up as a hole in
10
10
  // the seq numbers and is fetched again, and so is the end of a run. Full-screen on
11
11
  // phones; Escape, ×, a click outside and the system back close it.
12
+ //
13
+ // A shared view (and the owner's "Preview as shared") opens it read-only: the
14
+ // record comes filtered from the share's sub-agent route, without a socket, so
15
+ // a running agent's record is read again every few seconds; no Stop.
12
16
 
13
- import { apiFetch, cancelSubagentRun } from './api-client.js';
17
+ import { apiFetch, cancelSubagentRun, loadSharedSubagentRecord } from './api-client.js';
14
18
  import { renderMarkdownPreview } from './router.js';
15
19
  import { currentConvId, getSubagentRun, IS_SHARED_VIEW, upsertSubagentRun } from './store.js';
16
20
  import { createViewerHistory } from './viewer-history.mjs';
17
21
  import { getSocket } from './socket-handlers.js';
18
22
  import { createSubagentViewerWatch } from './subagent-viewer-watch.mjs';
19
23
  import { formatTaskModelLabel, formatTaskTokens } from './task-row-format.mjs';
20
- import { getConversationBackgroundTasks } from './background-tasks-view.mjs';
24
+ import { getConversationBackgroundTasks, setSubagentStepCounter } from './background-tasks-view.mjs';
21
25
  import { formatRunDuration, isRunningSubagentStatus, subagentRowHtml, subagentStatusLabel } from './subagent-row.mjs';
22
26
  import {
23
27
  createTimelineState,
@@ -30,6 +34,7 @@ import {
30
34
  const PAGE_LIMIT = 500;
31
35
  const MAX_PAGES = 10;
32
36
  const STOP_PENDING_MS = 15_000;
37
+ const SHARED_POLL_MS = 3_000;
33
38
 
34
39
  const viewer = {
35
40
  open: false,
@@ -44,6 +49,7 @@ const viewer = {
44
49
  stopping: false,
45
50
  stopTimer: null,
46
51
  tickTimer: null,
52
+ sharedPollTimer: null,
47
53
  openFolds: new Set(),
48
54
  };
49
55
 
@@ -146,7 +152,7 @@ function buildEntryNode(entry) {
146
152
  renderMarkdown,
147
153
  children: viewer.children,
148
154
  openDetails: viewer.openFolds.has(entry.seq),
149
- clickable: !IS_SHARED_VIEW,
155
+ clickable: true,
150
156
  });
151
157
  return holder.firstElementChild;
152
158
  }
@@ -215,6 +221,21 @@ function applyRunHeader(header) {
215
221
  }
216
222
  renderHeader();
217
223
  syncTicker();
224
+ syncSharedPoll();
225
+ }
226
+
227
+ // Without a socket a running record is read again while it is on screen.
228
+ function syncSharedPoll() {
229
+ const want = IS_SHARED_VIEW && viewer.open && runIsRunning(viewer.run);
230
+ if (want && !viewer.sharedPollTimer) {
231
+ viewer.sharedPollTimer = setInterval(() => {
232
+ if (!viewer.loading && document.visibilityState !== 'hidden') void fetchRecord();
233
+ }, SHARED_POLL_MS);
234
+ }
235
+ if (!want && viewer.sharedPollTimer) {
236
+ clearInterval(viewer.sharedPollTimer);
237
+ viewer.sharedPollTimer = null;
238
+ }
218
239
  }
219
240
 
220
241
  /**
@@ -231,7 +252,9 @@ async function fetchRecord({ fromStart = false } = {}) {
231
252
  renderStatus();
232
253
  try {
233
254
  for (let page = 0; page < MAX_PAGES; page += 1) {
234
- const data = await apiFetch(`/api/conversation/${encodeURIComponent(conversationId)}/subagent/${encodeURIComponent(runId)}?after=${after}&limit=${PAGE_LIMIT}`);
255
+ const data = IS_SHARED_VIEW
256
+ ? await loadSharedSubagentRecord(runId, { after, limit: PAGE_LIMIT })
257
+ : await apiFetch(`/api/conversation/${encodeURIComponent(conversationId)}/subagent/${encodeURIComponent(runId)}?after=${after}&limit=${PAGE_LIMIT}`);
235
258
  if (token !== viewer.fetchToken || !viewer.open || viewer.state?.runId !== runId) return;
236
259
  if (!data) {
237
260
  viewer.error = viewer.state.entries.size ? '' : 'This agent\'s record could not be loaded.';
@@ -272,7 +295,7 @@ async function loadRun(runId) {
272
295
  renderHeader();
273
296
  renderStatus();
274
297
  // Joined before the read, so nothing sent in between is lost.
275
- await viewerWatch.watch(viewer.conversationId, id);
298
+ if (!IS_SHARED_VIEW) await viewerWatch.watch(viewer.conversationId, id);
276
299
  if (!viewer.open || viewer.state?.runId !== id) return;
277
300
  await fetchRecord({ fromStart: true });
278
301
  // A finished record reads from the top; a running one follows its end.
@@ -317,6 +340,7 @@ export function closeSubagentViewer({ fromHistory = false } = {}) {
317
340
  viewer.children = new Map();
318
341
  clearTimeout(viewer.stopTimer);
319
342
  syncTicker();
343
+ syncSharedPoll();
320
344
  if (!fromHistory) viewerHistory?.closed();
321
345
  }
322
346
 
@@ -338,7 +362,7 @@ async function openParent() {
338
362
 
339
363
  async function stopRun() {
340
364
  const run = viewer.run;
341
- if (!run || viewer.stopping || !runIsRunning(run)) return;
365
+ if (IS_SHARED_VIEW || !run || viewer.stopping || !runIsRunning(run)) return;
342
366
  viewer.stopping = true;
343
367
  renderHeader();
344
368
  const task = liveTaskFor(run);
@@ -464,6 +488,12 @@ export function syncSubagentRowClock() {
464
488
  export function initSubagentViewer() {
465
489
  if (initialized) return;
466
490
  initialized = true;
491
+ // A sub-agent row without the worker's `steps` counts the run's activity
492
+ // lines, the number its reply bubble shows.
493
+ setSubagentStepCounter((runId) => {
494
+ const activities = getSubagentRun(runId)?.activities;
495
+ return Array.isArray(activities) ? activities.length : null;
496
+ });
467
497
  viewerHistory = createViewerHistory({
468
498
  history: window.history,
469
499
  mark: 'oarSubagentViewer',
@@ -475,7 +505,7 @@ export function initSubagentViewer() {
475
505
  // (a child row) opens that child in place.
476
506
  document.addEventListener('click', (event) => {
477
507
  const trigger = event.target?.closest?.('[data-subagent-open]');
478
- if (!trigger || IS_SHARED_VIEW) return;
508
+ if (!trigger) return;
479
509
  event.preventDefault();
480
510
  event.stopPropagation();
481
511
  const runId = trigger.getAttribute('data-subagent-open');
@@ -0,0 +1,237 @@
1
+ // Pure pieces of the task console and the task rows: the pass/fail chip, the
2
+ // stall hint, the ✓/✘ of an ended task, the console's line rows and the
3
+ // search for the first failure. DOM nodes are built with createElement and
4
+ // textContent only: every string here comes from a worker's output.
5
+
6
+ import { ansiToSegments, stallMinutes } from './task-output-text.mjs';
7
+
8
+ // Mirrors the stall setting's bounds in shared/task-output-text.mjs.
9
+ export const DEFAULT_TASK_STALL_MINUTES = 10;
10
+ export const TASK_STALL_MINUTES_MIN = 1;
11
+ export const TASK_STALL_MINUTES_MAX = 120;
12
+
13
+ /** The minutes after which a quiet task is marked, from a payload value. */
14
+ export function normalizeStallThreshold(value) {
15
+ const minutes = Number(value);
16
+ if (value === null || value === undefined || value === '' || !Number.isFinite(minutes)) return DEFAULT_TASK_STALL_MINUTES;
17
+ return Math.min(TASK_STALL_MINUTES_MAX, Math.max(TASK_STALL_MINUTES_MIN, Math.round(minutes)));
18
+ }
19
+
20
+ function count(value) {
21
+ const number = Number(value);
22
+ return Number.isFinite(number) && number >= 0 ? Math.floor(number) : 0;
23
+ }
24
+
25
+ /**
26
+ * The chip for a test runner's counts: "✓ 412 ✘ 2", "✓ 38". Null when the
27
+ * health names no counts. `failed` marks the red variant.
28
+ */
29
+ export function runnerChip(health) {
30
+ const counts = health?.counts;
31
+ if (!counts || typeof counts !== 'object') return null;
32
+ const passed = count(counts.passed);
33
+ const failed = count(counts.failed);
34
+ const skipped = count(counts.skipped);
35
+ if (!passed && !failed && !skipped) return null;
36
+ const text = failed ? `✓ ${passed} ✘ ${failed}` : `✓ ${passed}`;
37
+ const runner = String(health?.runner || '').trim();
38
+ const titleParts = [`${passed} passed`, `${failed} failed`];
39
+ if (skipped) titleParts.push(`${skipped} skipped`);
40
+ return {
41
+ text,
42
+ failed: failed > 0,
43
+ title: `${runner ? `${runner}: ` : ''}${titleParts.join(', ')}`,
44
+ };
45
+ }
46
+
47
+ /**
48
+ * "no output for 12 min" once a live task has been quiet a minute or more;
49
+ * `warn` from the threshold on. Null while output is fresh or unknown.
50
+ */
51
+ export function stallHint(lastOutputAt, thresholdMinutes, now = Date.now()) {
52
+ const minutes = stallMinutes(lastOutputAt, now);
53
+ if (minutes == null || minutes < 1) return null;
54
+ return {
55
+ minutes,
56
+ text: `no output for ${minutes} min`,
57
+ warn: minutes >= normalizeStallThreshold(thresholdMinutes),
58
+ };
59
+ }
60
+
61
+ /** ✓ or ✘ for an ended task, from its status and exit code. */
62
+ export function endedOutcome(task) {
63
+ const status = String(task?.status || '').trim().toLowerCase();
64
+ const exitCode = task?.exitCode;
65
+ const hasExit = exitCode !== null && exitCode !== undefined && exitCode !== '' && Number.isFinite(Number(exitCode));
66
+ const failed = status === 'failed' || (hasExit && Number(exitCode) !== 0);
67
+ const stopped = status === 'stopped' || status === 'killed' || status === 'cancelled';
68
+ const label = failed
69
+ ? (hasExit ? `failed, exit code ${Number(exitCode)}` : 'failed')
70
+ : stopped ? 'stopped' : status === 'completed' || (hasExit && Number(exitCode) === 0) ? 'completed' : 'ended';
71
+ return {
72
+ icon: failed ? '✘' : stopped ? '■' : '✓',
73
+ ok: !failed && !stopped,
74
+ failed,
75
+ label,
76
+ };
77
+ }
78
+
79
+ /** "22 steps", "1 step", or '' when unknown. */
80
+ export function stepsLabel(steps) {
81
+ const number = Number(steps);
82
+ if (!Number.isFinite(number) || number < 0) return '';
83
+ const whole = Math.floor(number);
84
+ return `${whole} step${whole === 1 ? '' : 's'}`;
85
+ }
86
+
87
+ /** Whether a row opens the console: shell and Monitor tasks with output. */
88
+ export function taskHasConsole(task) {
89
+ const kind = String(task?.outputKind || '').trim();
90
+ if (kind === 'none') return false;
91
+ if (kind) return true;
92
+ if (String(task?.outputPath || '').trim()) return true;
93
+ const type = String(task?.taskType || '').trim();
94
+ return type === 'local_bash' || type === 'monitor';
95
+ }
96
+
97
+ export function taskOutputUnavailable(task) {
98
+ return String(task?.outputKind || '').trim() === 'none';
99
+ }
100
+
101
+ // ─── Line rows ───────────────────────────────────────────────────────────────
102
+
103
+ function segmentClass(segment) {
104
+ const classes = [];
105
+ if (Number.isInteger(segment.fg)) classes.push(`tc-fg-${segment.fg}`);
106
+ if (Number.isInteger(segment.bg)) classes.push(`tc-bg-${segment.bg}`);
107
+ if (segment.bold) classes.push('tc-bold');
108
+ if (segment.dim) classes.push('tc-dim');
109
+ if (segment.italic) classes.push('tc-italic');
110
+ if (segment.underline) classes.push('tc-underline');
111
+ return classes.join(' ');
112
+ }
113
+
114
+ function segmentsOf(line) {
115
+ if (Array.isArray(line?.segments)) return line.segments;
116
+ if (Array.isArray(line?.ansi)) return line.ansi;
117
+ return ansiToSegments(String(line?.text ?? ''));
118
+ }
119
+
120
+ /** Plain text of a line, without its colour codes. */
121
+ export function lineText(line) {
122
+ return segmentsOf(line).map((segment) => String(segment?.text ?? '')).join('');
123
+ }
124
+
125
+ /**
126
+ * One `.tc-line` row: SGR spans for the text, then "×N" for a folded repeat.
127
+ * `partial` marks the line still being written.
128
+ */
129
+ export function buildLineNode(doc, line, { partial = false } = {}) {
130
+ const row = doc.createElement('div');
131
+ row.className = partial ? 'tc-line tc-line-partial' : 'tc-line';
132
+ const segments = segmentsOf(line);
133
+ if (!segments.length) row.appendChild(doc.createTextNode('​'));
134
+ for (const segment of segments) {
135
+ const text = String(segment?.text ?? '');
136
+ if (!text) continue;
137
+ const className = segmentClass(segment);
138
+ if (!className) {
139
+ row.appendChild(doc.createTextNode(text));
140
+ continue;
141
+ }
142
+ const span = doc.createElement('span');
143
+ span.className = className;
144
+ span.textContent = text;
145
+ row.appendChild(span);
146
+ }
147
+ const repeats = count(line?.count);
148
+ if (repeats > 1) {
149
+ const badge = doc.createElement('span');
150
+ badge.className = 'tc-line-count';
151
+ badge.textContent = `×${repeats}`;
152
+ row.appendChild(badge);
153
+ }
154
+ return row;
155
+ }
156
+
157
+ // The failure lines of the runners the relay parses (vitest, jest, node:test,
158
+ // Playwright, pytest, turbo), as shared/task-output-text.mjs finds them: the
159
+ // per-test marks and the detailed reports. The jump lands on the earliest.
160
+ const FAILURE_LINE_PATTERNS = [
161
+ /^\s*(?:[✗✘×✕✖]|\[FAIL\])\s+(?!failing tests:)/,
162
+ /^\s*FAIL\s/,
163
+ /^\s{2}\d+\) /,
164
+ /^\s*●\s/,
165
+ /^_{3,} .+ _{3,}$/,
166
+ /\s(?:FAILED|ERROR)(?:\s+\[\s*\d+%\])?\s*$/,
167
+ /^not ok \d+ /,
168
+ /command finished with error/i,
169
+ ];
170
+
171
+ /** Index of the first line that reads as a failing test, or -1. */
172
+ export function findFirstFailureIndex(lines) {
173
+ const list = Array.isArray(lines) ? lines : [];
174
+ for (let index = 0; index < list.length; index += 1) {
175
+ const text = lineText(list[index]);
176
+ if (FAILURE_LINE_PATTERNS.some((pattern) => pattern.test(text))) return index;
177
+ }
178
+ return -1;
179
+ }
180
+
181
+ /**
182
+ * The console header's meta line: "Shell · 4m 12s · failed, exit code 1".
183
+ * The stall hint goes beside it in its own element (it turns amber).
184
+ */
185
+ export function consoleMetaParts({ task, elapsed, ended, status, exitCode, kind }) {
186
+ const parts = [];
187
+ const type = String(task?.taskType || '').trim();
188
+ parts.push(type === 'monitor' ? 'Monitor' : type === 'local_bash' ? 'Shell' : (type || 'Task'));
189
+ if (elapsed) parts.push(elapsed);
190
+ if (ended) parts.push(endedOutcome({ status, exitCode }).label);
191
+ else parts.push('running');
192
+ if (kind === 'snapshot') parts.push('last 8 KB, read again every 3 s');
193
+ if (kind === 'spool' && task?.mirrored) parts.push("output mirrored from the runtime's recent window");
194
+ return parts;
195
+ }
196
+
197
+ // ─── Live chunks ─────────────────────────────────────────────────────────────
198
+
199
+ /** Where the file stands after a chunk: its end offset, else the file size. */
200
+ export function chunkEndOffset(chunk) {
201
+ for (const key of ['endOffset', 'nextOffset', 'size']) {
202
+ const value = Number(chunk?.[key]);
203
+ if (chunk?.[key] !== undefined && chunk?.[key] !== null && Number.isFinite(value)) return value;
204
+ }
205
+ return NaN;
206
+ }
207
+
208
+ /**
209
+ * What a `task_output_chunk` does to a view that holds the file up to
210
+ * `nextOffset` (of a file `size` bytes long): 'apply' when it continues
211
+ * exactly there, 'ignore' when it only repeats bytes already shown, and
212
+ * 'refetch' for a gap, an overlap, or a file that got shorter (the relay's
213
+ * `reset`, or a smaller size).
214
+ */
215
+ export function chunkAction({ nextOffset, size }, chunk) {
216
+ if (chunk?.reset === true) return 'refetch';
217
+ const offset = Number(chunk?.offset);
218
+ if (!Number.isFinite(offset)) return 'ignore';
219
+ const chunkSize = Number(chunk?.size);
220
+ if (Number.isFinite(chunkSize) && Number.isFinite(Number(size)) && chunkSize < Number(size)) return 'refetch';
221
+ if (offset === Number(nextOffset)) return 'apply';
222
+ const end = chunkEndOffset(chunk);
223
+ if (offset < Number(nextOffset) && Number.isFinite(end) && end <= Number(nextOffset)) return 'ignore';
224
+ return 'refetch';
225
+ }
226
+
227
+ /** Bytes a modal may hold before "Load earlier" stops paging back. */
228
+ export const TASK_CONSOLE_MAX_LOADED_BYTES = 5 * 1024 * 1024;
229
+
230
+ /** The "Load earlier" button's state. */
231
+ export function loadEarlierState({ hasMoreBefore, loadedBytes, loading = false }) {
232
+ if (!hasMoreBefore) return { hidden: true, disabled: true, text: '' };
233
+ if (Number(loadedBytes) >= TASK_CONSOLE_MAX_LOADED_BYTES) {
234
+ return { hidden: false, disabled: true, text: 'older output not loaded' };
235
+ }
236
+ return { hidden: false, disabled: !!loading, text: loading ? 'Loading…' : 'Load earlier' };
237
+ }
@@ -0,0 +1,42 @@
1
+ // Tells the relay which task's output the console shows, so its new bytes
2
+ // arrive live on this page only (`task_output_watch` / `_unwatch`, one room
3
+ // per socket). The console waits for the watch's answer before it reads the
4
+ // tail: bytes written between the read and the join would otherwise be lost
5
+ // until the next gap shows them. The relay's rooms may or may not survive a
6
+ // reconnect, so the console watches again on every connect.
7
+
8
+ const WATCH_TIMEOUT_MS = 3000;
9
+
10
+ export function createTaskConsoleWatch({ getSocket, timeoutMs = WATCH_TIMEOUT_MS } = {}) {
11
+ let watched = null;
12
+
13
+ async function watch(conversationId, taskId) {
14
+ watched = { conversationId: String(conversationId || ''), taskId: String(taskId || '') };
15
+ const socket = getSocket?.();
16
+ if (!socket?.connected || !watched.conversationId || !watched.taskId) return { ok: false, code: 'offline' };
17
+ let result;
18
+ try {
19
+ result = await socket.timeout(timeoutMs).emitWithAck('task_output_watch', {
20
+ conversationId: watched.conversationId,
21
+ taskId: watched.taskId,
22
+ });
23
+ } catch {
24
+ return { ok: false, code: 'timeout' };
25
+ }
26
+ return result && typeof result === 'object' ? result : { ok: false, code: 'bad-answer' };
27
+ }
28
+
29
+ function unwatch() {
30
+ if (!watched) return;
31
+ watched = null;
32
+ const socket = getSocket?.();
33
+ if (socket?.connected) socket.emit('task_output_unwatch', {});
34
+ }
35
+
36
+ return {
37
+ watch,
38
+ unwatch,
39
+ /** The task the relay should be sending, or null. */
40
+ current: () => (watched ? { ...watched } : null),
41
+ };
42
+ }