@oar-sh/oar 0.9.10 → 0.9.11

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 (104) hide show
  1. package/CHANGELOG.md +314 -1
  2. package/README.md +156 -972
  3. package/package.json +13 -13
  4. package/server/README.md +95 -22
  5. package/server/claude-worker/claude-session-process.mjs +89 -11
  6. package/server/claude-worker/claude-session-worker.mjs +9 -1
  7. package/server/copilot-worker/copilot-sdk-session-process.mjs +76 -11
  8. package/server/copilot-worker/copilot-sdk-session-worker.mjs +6 -0
  9. package/server/db-schema.mjs +59 -2
  10. package/server/grok-worker/acp-client.mjs +2 -1
  11. package/server/grok-worker/acp-host-services.mjs +4 -3
  12. package/server/migrations/0013-previews.mjs +130 -0
  13. package/server/migrations/0014-conversation-groups.mjs +89 -0
  14. package/server/migrations/0015-conversation-group-position.mjs +76 -0
  15. package/server/migrations/0016-share-access-log.mjs +110 -0
  16. package/server/public/app/about-ui.js +73 -0
  17. package/server/public/app/about-view.mjs +234 -0
  18. package/server/public/app/action-confirmations.js +31 -14
  19. package/server/public/app/agent-exchange-fold-view.mjs +40 -101
  20. package/server/public/app/api-client.js +251 -16
  21. package/server/public/app/ask-user-view.js +17 -15
  22. package/server/public/app/bootstrap.js +158 -32
  23. package/server/public/app/chat-actions-menu-state.mjs +20 -10
  24. package/server/public/app/context-menu.mjs +90 -6
  25. package/server/public/app/conversation-group-drag.mjs +191 -0
  26. package/server/public/app/conversation-group-order.mjs +265 -0
  27. package/server/public/app/conversation-group-state.mjs +100 -0
  28. package/server/public/app/conversation-list-drag.mjs +449 -0
  29. package/server/public/app/conversation-turn-state.mjs +81 -0
  30. package/server/public/app/conversation-view.js +136 -93
  31. package/server/public/app/cwd-picker.js +1 -1
  32. package/server/public/app/hidden-messages-view.mjs +312 -0
  33. package/server/public/app/host-suspend-ui.js +7 -1
  34. package/server/public/app/journal-view.js +787 -22
  35. package/server/public/app/preview-cards.mjs +117 -31
  36. package/server/public/app/preview-command.mjs +10 -1
  37. package/server/public/app/relay-board-view.js +19 -6
  38. package/server/public/app/relay-restart-error.mjs +16 -0
  39. package/server/public/app/settings-modal.js +40 -8
  40. package/server/public/app/settings-tabs.js +1 -1
  41. package/server/public/app/share-format.mjs +49 -0
  42. package/server/public/app/share-preview-marks.mjs +167 -0
  43. package/server/public/app/share-state.js +109 -0
  44. package/server/public/app/shared-conversations-settings.js +480 -0
  45. package/server/public/app/socket-handlers.js +80 -14
  46. package/server/public/app/store.js +96 -30
  47. package/server/public/app/subagent-row.mjs +3 -3
  48. package/server/public/app/subagent-viewer.mjs +30 -6
  49. package/server/public/app/thinking-follow.js +0 -4
  50. package/server/public/app/transcript-jump-bottom-view.mjs +194 -20
  51. package/server/public/app/transcript-jump-bottom.mjs +132 -21
  52. package/server/public/app/transcript-viewport-anchor.mjs +140 -0
  53. package/server/public/index.html +386 -15
  54. package/server/relay-tools.md +2 -1
  55. package/server/relay.mjs +2 -1
  56. package/server/repositories/conversation-group-repository.mjs +444 -0
  57. package/server/repositories/message-repository.mjs +1 -0
  58. package/server/repositories/session-repository.mjs +12 -37
  59. package/server/repositories/share-repository.mjs +322 -0
  60. package/server/repositories/share-visibility-statements.mjs +10 -0
  61. package/server/routes/about-routes.mjs +15 -0
  62. package/server/routes/cache-routes.mjs +4 -0
  63. package/server/routes/conversation-group-routes.mjs +171 -0
  64. package/server/routes/messages-routes.mjs +184 -65
  65. package/server/routes/preview-routes.mjs +9 -1
  66. package/server/routes/sessions-routes.mjs +771 -193
  67. package/server/server-runtime.mjs +316 -100
  68. package/server/services/about-service.mjs +461 -0
  69. package/server/services/background-task-store.mjs +128 -0
  70. package/server/services/background-work-view.mjs +228 -0
  71. package/server/services/cache-rebuild-service.mjs +30 -0
  72. package/server/services/delete-archive-service.mjs +4 -1
  73. package/server/services/detached-worker-watch-service.mjs +150 -0
  74. package/server/services/hidden-message-list.mjs +21 -0
  75. package/server/services/host-suspend-command.mjs +2 -1
  76. package/server/services/message-pin-list.mjs +1 -1
  77. package/server/services/preview-health-probe.mjs +8 -1
  78. package/server/services/preview-proxy-server.mjs +8 -4
  79. package/server/services/preview-registry-service.mjs +676 -26
  80. package/server/services/preview-static-handler.mjs +40 -7
  81. package/server/services/preview-store.mjs +274 -0
  82. package/server/services/rate-limit-service.mjs +12 -1
  83. package/server/services/relay-auth.mjs +183 -0
  84. package/server/services/relay-cli-launcher-service.mjs +3 -1
  85. package/server/services/relay-event-service.mjs +17 -0
  86. package/server/services/remote-relay-dispatcher.mjs +57 -5
  87. package/server/services/request-boundary.mjs +183 -0
  88. package/server/services/session-activity-tracker.mjs +227 -19
  89. package/server/services/session-worker-launch-service.mjs +190 -6
  90. package/server/services/session-worker-process-service.mjs +10 -6
  91. package/server/services/share-access-log-service.mjs +123 -0
  92. package/server/services/share-path-redaction.mjs +424 -0
  93. package/server/services/status-event-service.mjs +7 -3
  94. package/server/services/tunnel-worker-path-guard.mjs +24 -41
  95. package/server/services/windows-autostart-service.mjs +6 -1
  96. package/server/services/windows-boot-autostart-service.mjs +207 -23
  97. package/shared/preview-tool-core.mjs +15 -5
  98. package/shared/remote-relay-contract.mjs +3 -1
  99. package/shared/share-path-privacy.mjs +30 -0
  100. package/shared/windows-system-tools.mjs +106 -0
  101. package/shared/worker-runtime/background-task-digest.mjs +30 -0
  102. package/shared/worker-runtime/heartbeat.mjs +24 -0
  103. package/shared/worker-runtime/kept-posts.mjs +83 -0
  104. package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
@@ -0,0 +1,234 @@
1
+ // The About OAR modal, as HTML from a GET /api/about payload. No DOM here;
2
+ // about-ui.js opens the modal and wires it.
3
+
4
+ export const OAR_REPO_URL = 'https://github.com/oar-sh/oar';
5
+ export const OAR_SITE_URL = 'https://oar.sh';
6
+ export const OAR_RELEASES_URL = 'https://github.com/oar-sh/oar/releases';
7
+
8
+ const STATE_LABELS = Object.freeze({
9
+ online: 'online',
10
+ offline: 'offline',
11
+ idle: 'idle',
12
+ });
13
+
14
+ const USAGE_LABELS = Object.freeze({
15
+ github: 'GitHub Copilot',
16
+ cursor: 'Cursor',
17
+ grok: 'Grok',
18
+ 'claude-cloud': 'Claude Cloud',
19
+ });
20
+
21
+ function esc(value) {
22
+ return String(value ?? '')
23
+ .replace(/&/g, '&')
24
+ .replace(/</g, '&lt;')
25
+ .replace(/>/g, '&gt;')
26
+ .replace(/"/g, '&quot;')
27
+ .replace(/'/g, '&#39;');
28
+ }
29
+
30
+ function finite(value) {
31
+ const number = Number(value);
32
+ return Number.isFinite(number) ? number : null;
33
+ }
34
+
35
+ export function aboutMenuLabel(version) {
36
+ const text = String(version || '').trim();
37
+ return text && text !== '0.0.0' ? `ℹ️ About OAR · ${text}` : 'ℹ️ About OAR';
38
+ }
39
+
40
+ export function formatCount(value) {
41
+ const number = finite(value);
42
+ return number === null ? '–' : Math.round(number).toLocaleString('en-US');
43
+ }
44
+
45
+ /** Tokens and other large amounts: 1 234, 12.3 k, 4.56 M. */
46
+ export function formatAmount(value) {
47
+ const number = finite(value);
48
+ if (number === null) return '–';
49
+ if (Math.abs(number) >= 1e9) return `${(number / 1e9).toFixed(2)} B`;
50
+ if (Math.abs(number) >= 1e6) return `${(number / 1e6).toFixed(2)} M`;
51
+ if (Math.abs(number) >= 1e4) return `${(number / 1e3).toFixed(1)} k`;
52
+ return Number.isInteger(number) ? number.toLocaleString('en-US') : number.toFixed(2);
53
+ }
54
+
55
+ export function formatUsd(value) {
56
+ const number = finite(value);
57
+ return number === null ? '–' : `USD ${number.toFixed(2)}`;
58
+ }
59
+
60
+ export function formatBytes(value) {
61
+ const number = finite(value);
62
+ if (number === null || number < 0) return '–';
63
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
64
+ let size = number;
65
+ let unit = 0;
66
+ while (size >= 1024 && unit < units.length - 1) {
67
+ size /= 1024;
68
+ unit += 1;
69
+ }
70
+ return unit === 0 ? `${Math.round(size)} B` : `${size.toFixed(size >= 100 ? 0 : 1)} ${units[unit]}`;
71
+ }
72
+
73
+ export function formatUptime(seconds) {
74
+ const total = finite(seconds);
75
+ if (total === null || total < 0) return '–';
76
+ const days = Math.floor(total / 86_400);
77
+ const hours = Math.floor((total % 86_400) / 3_600);
78
+ const minutes = Math.floor((total % 3_600) / 60);
79
+ if (days > 0) return `${days} d ${hours} h`;
80
+ if (hours > 0) return `${hours} h ${minutes} min`;
81
+ if (minutes > 0) return `${minutes} min`;
82
+ return `${Math.floor(total)} s`;
83
+ }
84
+
85
+ function defaultFormatDateTime(iso) {
86
+ const at = Date.parse(iso);
87
+ if (!Number.isFinite(at)) return '–';
88
+ return new Date(at).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
89
+ }
90
+
91
+ function defaultFormatDate(iso) {
92
+ const at = Date.parse(iso);
93
+ if (!Number.isFinite(at)) return '–';
94
+ return new Date(at).toLocaleDateString(undefined, { dateStyle: 'medium' });
95
+ }
96
+
97
+ function row(label, value, { hint = '', title = '' } = {}) {
98
+ return `<tr><td><div class="plan-usage-detail-label"><span>${esc(label)}</span>${hint ? `<span class="plan-usage-detail-hint">${esc(hint)}</span>` : ''}</div></td>`
99
+ + `<td class="plan-usage-detail-value"${title ? ` title="${esc(title)}"` : ''}>${value}</td></tr>`;
100
+ }
101
+
102
+ function section(id, title, inner, { subtitle = '' } = {}) {
103
+ return `<section class="plan-usage-card about-section" data-about-section="${esc(id)}">`
104
+ + `<div class="plan-usage-card-head"><div class="plan-usage-card-titles"><span class="plan-usage-card-title">${esc(title)}</span>`
105
+ + `${subtitle ? `<span class="plan-usage-account">${esc(subtitle)}</span>` : ''}</div></div>${inner}</section>`;
106
+ }
107
+
108
+ function table(rows) {
109
+ return rows.length ? `<table class="plan-usage-detail-table"><tbody>${rows.join('')}</tbody></table>` : '';
110
+ }
111
+
112
+ function note(text) {
113
+ return `<div class="plan-usage-detail-note">${text}</div>`;
114
+ }
115
+
116
+ function externalLink(href, text) {
117
+ return `<a class="plan-usage-link" href="${esc(href)}" target="_blank" rel="noopener">${esc(text)}</a>`;
118
+ }
119
+
120
+ function providerDetails(provider) {
121
+ const parts = [];
122
+ if (provider.engine === 'extension') parts.push('extension engine');
123
+ if (provider.engine === 'sdk') parts.push('SDK engine');
124
+ if (provider.cliVersion) parts.push(`CLI ${provider.cliVersion}`);
125
+ if (provider.sdkVersion) parts.push(`SDK ${provider.sdkVersion}`);
126
+ return parts.join(' · ');
127
+ }
128
+
129
+ function providerState(provider) {
130
+ const state = STATE_LABELS[provider.state] ? provider.state : 'idle';
131
+ const workers = finite(provider.runningWorkers) || 0;
132
+ const count = workers > 0 ? ` · ${workers} ${workers === 1 ? 'worker' : 'workers'}` : '';
133
+ return `<span class="about-state" data-state="${esc(state)}">${esc(STATE_LABELS[state])}</span>${esc(count)}`;
134
+ }
135
+
136
+ function usageRows(id, usage) {
137
+ if (id === 'github') {
138
+ return [row('Premium requests', esc(formatAmount(usage.premiumRequests)), { hint: `over ${formatCount(usage.turns)} replies` })];
139
+ }
140
+ if (id === 'cursor') {
141
+ return [
142
+ row('Tokens', esc(formatAmount(usage.totalTokens)), {
143
+ hint: `${formatAmount(usage.inputTokens)} in · ${formatAmount(usage.outputTokens)} out · ${formatAmount((finite(usage.cacheReadTokens) || 0) + (finite(usage.cacheWriteTokens) || 0))} cache`,
144
+ }),
145
+ row('Cost', esc(formatUsd(usage.rawCostUsd)), { hint: `model price over ${formatCount(usage.runs)} runs; charged ${formatUsd(usage.chargedUsd)}` }),
146
+ ];
147
+ }
148
+ if (id === 'grok') {
149
+ return [
150
+ row('Tokens', esc(formatAmount(usage.totalTokens)), { hint: `${formatAmount(usage.inputTokens)} in · ${formatAmount(usage.outputTokens)} out` }),
151
+ row('Cost', esc(formatUsd(usage.costUsd)), { hint: `over ${formatCount(usage.turns)} turns` }),
152
+ ];
153
+ }
154
+ if (id === 'claude-cloud') {
155
+ return [row('Cost', esc(formatUsd(usage.costUsd)), { hint: `over ${formatCount(usage.sessions)} cloud sessions` })];
156
+ }
157
+ return [];
158
+ }
159
+
160
+ function buildUpdateSection(update, formatDateTime) {
161
+ if (update.checkEnabled) {
162
+ if (update.available && update.latestVersion) {
163
+ return section('update', 'Updates', note(`Latest: ${esc(update.latestVersion)} · ${externalLink(update.notesUrl || OAR_RELEASES_URL, 'release notes')}`));
164
+ }
165
+ const checked = update.lastCheckedAt ? `, checked ${esc(formatDateTime(update.lastCheckedAt))}` : '';
166
+ return section('update', 'Updates', note(update.lastCheckedAt ? `Up to date${checked}.` : 'Not checked yet.'));
167
+ }
168
+ if (update.checkKilled) {
169
+ return section('update', 'Updates', note('Update checks are turned off on this relay (OAR_NO_UPDATE_CHECK).'));
170
+ }
171
+ return section('update', 'Updates', note(
172
+ 'Update checks are off. Turn on <button type="button" class="plan-usage-link about-inline-button" data-about-open-settings>Settings › General › Check for updates automatically</button> to see the latest version here.',
173
+ ));
174
+ }
175
+
176
+ /**
177
+ * @param {object} about the GET /api/about payload
178
+ * @param {object} [options]
179
+ * @param {(iso: string) => string} [options.formatDateTime]
180
+ * @param {(iso: string) => string} [options.formatDate]
181
+ */
182
+ export function buildAboutModalHtml(about = {}, {
183
+ formatDateTime = defaultFormatDateTime,
184
+ formatDate = defaultFormatDate,
185
+ } = {}) {
186
+ const sections = [];
187
+
188
+ const commit = String(about.commit || '').trim();
189
+ const install = about.installMethod === 'git-checkout' ? 'git checkout' : (about.installMethod === 'npm-global' ? 'npm package' : '');
190
+ sections.push(section('build', 'OAR', table([
191
+ row('Version', esc(about.version || '–'), install ? { hint: install } : {}),
192
+ ...(commit ? [row('Commit', `<code>${esc(commit.slice(0, 7))}</code>`, { title: commit })] : []),
193
+ row('Started', esc(about.startedAt ? formatDateTime(about.startedAt) : '–')),
194
+ row('Uptime', esc(formatUptime(about.uptimeSeconds))),
195
+ ])));
196
+
197
+ const providers = Array.isArray(about.providers) ? about.providers : [];
198
+ sections.push(section('providers', 'Providers', providers.length
199
+ ? table(providers.map((provider) => row(provider.label || provider.id, providerState(provider), { hint: providerDetails(provider) })))
200
+ + (providers.some((provider) => provider.state === 'idle')
201
+ ? note('Idle: on, with no worker running right now; one starts with the next turn.')
202
+ : '')
203
+ : note('No provider is turned on.')));
204
+
205
+ const stats = about.stats || {};
206
+ const turnsByProvider = Array.isArray(stats.turnsByProvider) ? stats.turnsByProvider : [];
207
+ const archived = finite(stats.archivedConversations) || 0;
208
+ sections.push(section('activity', 'Activity', table([
209
+ row('Conversations', esc(formatCount(stats.conversations)), archived ? { hint: `${formatCount(archived)} archived` } : {}),
210
+ row('Turns', esc(formatCount(stats.turns)), { hint: 'replies written by an agent' }),
211
+ row('Messages', esc(formatCount(stats.messages))),
212
+ ...turnsByProvider.map((entry) => row(`Turns on ${entry.label || entry.id}`, esc(formatCount(entry.turns)))),
213
+ ]), { subtitle: stats.firstConversationAt ? `since ${formatDate(stats.firstConversationAt)}` : '' }));
214
+
215
+ const usage = about.usage && typeof about.usage === 'object' ? about.usage : {};
216
+ const usageIds = Object.keys(USAGE_LABELS).filter((id) => usage[id]);
217
+ sections.push(section('usage', 'Usage', (usageIds.length
218
+ ? usageIds.map((id) => `<div class="about-usage-provider">${esc(USAGE_LABELS[id])}</div>${table(usageRows(id, usage[id]))}`).join('')
219
+ : note('No provider has reported usage yet.'))
220
+ + note('What the providers report and this relay keeps: Copilot premium requests per reply, Cursor and Grok tokens and cost per billing cycle, Claude Cloud cost per session. Claude Code and OpenAI turns keep no usage here.')));
221
+
222
+ const relay = about.relay || {};
223
+ sections.push(section('relay', 'Relay', table([
224
+ row('Uptime', esc(formatUptime(relay.uptimeSeconds))),
225
+ row('Database', esc(formatBytes(relay.dbBytes)), { hint: `${formatBytes(relay.dbFileBytes)} + ${formatBytes(relay.walBytes)} WAL` }),
226
+ row('Running workers', esc(formatCount(relay.runningWorkers))),
227
+ ])));
228
+
229
+ sections.push(buildUpdateSection(about.update || {}, formatDateTime));
230
+
231
+ sections.push(`<div class="about-links" data-about-section="links">${externalLink(OAR_REPO_URL, 'github.com/oar-sh/oar')}${externalLink(OAR_SITE_URL, 'oar.sh')}</div>`);
232
+
233
+ return `<div class="about-modal">${sections.join('')}</div>`;
234
+ }
@@ -19,6 +19,7 @@ import {
19
19
  refreshWorkspaceRootHints,
20
20
  } from './api-client.js';
21
21
  import { isSuspendHostActionVisible } from './settings-modal.js';
22
+ import { describeRelayRestartError } from './relay-restart-error.mjs';
22
23
  import {
23
24
  applyHostSuspendState,
24
25
  describeHostSuspendBlocker,
@@ -311,10 +312,17 @@ export async function confirmEmptyQueue() {
311
312
  emptyQueueInFlight = true;
312
313
  setSummaryModalLoading(true);
313
314
  try {
314
- const result = await requestQueueEmpty({
315
- reason: 'manual-empty-queue',
316
- requestedBy: 'localhost-api',
317
- });
315
+ let result = null;
316
+ try {
317
+ result = await requestQueueEmpty({
318
+ reason: 'manual-empty-queue',
319
+ requestedBy: 'localhost-api',
320
+ });
321
+ } catch (error) {
322
+ closeSummaryModal();
323
+ alert(`Failed to empty queue: ${error?.message || 'unknown error'}`);
324
+ return;
325
+ }
318
326
  closeSummaryModal();
319
327
  if (!result?.ok) {
320
328
  alert('Failed to empty queue');
@@ -334,23 +342,32 @@ export async function confirmEmptyQueue() {
334
342
  }
335
343
  }
336
344
 
345
+ /** A failed restart request: a refusal as an alert, no answer as a notice. */
346
+ export function showRelayRestartRequestError(error) {
347
+ const { kind, text } = describeRelayRestartError(error);
348
+ if (kind === 'refused') alert(text);
349
+ else showTransientRelayNotice(text, 9000);
350
+ }
351
+
337
352
  export async function confirmRestartWebRelay() {
338
353
  if (restartRelayInFlight) return;
339
354
  restartRelayInFlight = true;
340
355
  setSummaryModalLoading(true);
341
356
  try {
342
- const result = await requestRelayRestart({
343
- reason: 'manual-restart',
344
- requestedBy: 'localhost-api',
345
- restart: true,
346
- });
347
- closeSummaryModal();
348
- // Restart can close the connection before the browser receives JSON.
349
- if (!result) {
350
- showTransientRelayNotice('Relay restart requested. Connection may briefly drop while it restarts.', 7000);
357
+ let result = null;
358
+ try {
359
+ result = await requestRelayRestart({
360
+ reason: 'manual-restart',
361
+ requestedBy: 'localhost-api',
362
+ restart: true,
363
+ });
364
+ } catch (error) {
365
+ closeSummaryModal();
366
+ showRelayRestartRequestError(error);
351
367
  return;
352
368
  }
353
- if (!result.ok) {
369
+ closeSummaryModal();
370
+ if (!result?.ok) {
354
371
  alert('Failed to queue relay restart');
355
372
  return;
356
373
  }
@@ -1,36 +1,33 @@
1
1
  // Agent-to-agent exchanges in the transcript: a prompt another agent sent this
2
- // session (another relay's agent, or an agent on this relay) and this
3
- // session's reply to it fold into ONE row, "note from win-test · report
4
- // builder · replied", which opens to both as they render otherwise.
2
+ // session (another relay's agent, or an agent on this relay) folds into ONE
3
+ // row, "note from win-test · report builder · answered below", which opens to
4
+ // the prompt as it renders otherwise.
5
5
  //
6
6
  // DOM-free on purpose, like relay-event-view.mjs: conversation-view.js reads
7
7
  // the rows off the transcript, plans with this module and applies the plan.
8
8
  // Presentation only: the transcript data is never touched.
9
9
  //
10
- // Nothing addressed to the user is folded away. A turn folds only when the
11
- // agent's prompt started it alone:
12
- // - a message steered into the turn (handed off: the reply settled
13
- // 'absorbed'; folded in or stopped with it: the steer's own marker) keeps
14
- // it unfolded, and so does an agent's prompt that was itself steered into a
15
- // turn;
16
- // - so does a question or approval card of the turn that is still open;
17
- // - and, while the turn runs, a message of the user's with no answer yet: it
18
- // may be steered into the turn.
19
- // The last two pass, so a turn shown for them is reported `transient`: the
20
- // caller keeps it shown while the page lives instead of folding it away under
21
- // the user once the card is answered.
10
+ // Nothing addressed to the user is folded away. The fold covers the agent's
11
+ // prompt and nothing else: this session's reply to it can carry a line for
12
+ // the user, so the reply, the live bubble of the running turn and every card
13
+ // of the turn always show, as they do for a message of the user's own. The
14
+ // row still says how the turn stands.
15
+ //
16
+ // One prompt is left as it is: an agent's prompt that was steered into a
17
+ // running turn. It renders as part of that turn's flow (steered-turn-merge.mjs:
18
+ // the dashed bubble, the marker attached under it), and a row in its place
19
+ // would break that flow.
22
20
 
23
21
  import { isRelayEventMessage } from './relay-event-view.mjs';
24
22
  import { relayErrorCodeFromText } from './relay-error-ctas.mjs';
25
23
  import { isLocalAgentOrigin, localAgentOriginModel, normalizeRemoteOrigin } from './remote-relay-origin-view.mjs';
26
24
 
27
25
  export const AGENT_EXCHANGE_HEAD_CLASS = 'agent-exchange-head';
28
- export const AGENT_EXCHANGE_MEMBER_CLASS = 'agent-exchange-member';
29
26
  export const AGENT_EXCHANGE_CLOSED_CLASS = 'agent-exchange-closed';
30
27
 
31
28
  export const AGENT_EXCHANGE_STATUS_TEXT = Object.freeze({
32
29
  working: 'working…',
33
- replied: 'replied',
30
+ replied: 'answered below',
34
31
  failed: 'failed',
35
32
  stopped: 'stopped',
36
33
  none: 'no reply yet',
@@ -41,9 +38,6 @@ const STOPPED_ERROR_CODE = 'turn-aborted';
41
38
  // A steer's settle markers (steered-turn-merge.mjs): the message was consumed
42
39
  // by another turn, folded into it or stopped with it.
43
40
  const STEER_MARKER_KINDS = new Set(['folded', 'stopped']);
44
- const ABSORBED_KIND = 'absorbed';
45
- const ANSWERED_ELSEWHERE_KIND = 'answered-elsewhere';
46
- const TRANSIENT_REASONS = new Set(['open-card', 'user-waiting']);
47
41
 
48
42
  function defaultEscape(value) {
49
43
  return String(value ?? '')
@@ -58,10 +52,6 @@ function oneLine(value) {
58
52
  return String(value ?? '').replace(/\s+/g, ' ').trim();
59
53
  }
60
54
 
61
- function idSet(values) {
62
- return new Set(Array.from(values || [], (value) => String(value ?? '').trim()).filter(Boolean));
63
- }
64
-
65
55
  /** True for an `origin` that names an agent: one on this relay or another relay's. */
66
56
  export function isAgentOrigin(origin) {
67
57
  return isLocalAgentOrigin(origin) || !!normalizeRemoteOrigin(origin);
@@ -87,12 +77,10 @@ export function replyOutcome(text) {
87
77
  /**
88
78
  * What the plan needs of one message, built when its row is: an agent
89
79
  * prompt's origin, an assistant row's kind, the prompt it answers and how it
90
- * ended. `parseTimestamp` reads the message's timestamp (the caller's
91
- * tolerant parser; Date.parse by default).
80
+ * ended.
92
81
  */
93
- export function agentExchangeRowOf(message, { id = message?.id, parseTimestamp = Date.parse } = {}) {
82
+ export function agentExchangeRowOf(message, { id = message?.id } = {}) {
94
83
  const role = message?.role === 'assistant' ? 'assistant' : (message?.role === 'user' ? 'user' : '');
95
- const timestampMs = Number(parseTimestamp(message?.timestamp));
96
84
  return {
97
85
  id: String(id ?? '').trim(),
98
86
  role,
@@ -100,15 +88,10 @@ export function agentExchangeRowOf(message, { id = message?.id, parseTimestamp =
100
88
  origin: isAgentPromptMessage(message) ? message.origin : null,
101
89
  kind: String(message?.kind || '').trim(),
102
90
  sourceMessageId: role === 'assistant' ? String(message?.sourceMessageId || '').trim() : '',
103
- timestampMs: Number.isFinite(timestampMs) && timestampMs > 0 ? timestampMs : 0,
104
91
  outcome: role === 'assistant' ? replyOutcome(message?.text) : '',
105
92
  };
106
93
  }
107
94
 
108
- function isUsersOwnMessage(row) {
109
- return row.role === 'user' && !row.origin && !row.relayEvent;
110
- }
111
-
112
95
  /**
113
96
  * The plan for a transcript: one entry per agent prompt, in transcript order.
114
97
  *
@@ -116,96 +99,52 @@ function isUsersOwnMessage(row) {
116
99
  * `steered` for a user row steered-turn-merge.mjs classed as steered), and
117
100
  * the live bubble of a running turn as `{ id: <its message id>, role:
118
101
  * 'assistant', live: true }`. `runningIds` names turns that run without a
119
- * live bubble on the page, `openCardIds` the turns with an open question or
120
- * approval card, `keptShownIds` those the caller keeps shown.
102
+ * live bubble on the page.
121
103
  *
122
- * Each entry: `{ promptId, prompt, members, status, fold, reasons,
123
- * transient }`. `members` are the reply rows and the live bubble, the rows a
124
- * folded exchange hides; `fold` is false while any of `reasons` holds.
104
+ * Each entry: `{ promptId, prompt, status, fold }`. The prompt's row is the
105
+ * only one a fold covers; the replies and the live bubble are read for the
106
+ * status alone. `fold` is false for a prompt steered into a running turn.
125
107
  */
126
- export function planAgentExchangeFolds(rows, { runningIds = [], openCardIds = [], keptShownIds = [] } = {}) {
108
+ export function planAgentExchangeFolds(rows, { runningIds = [] } = {}) {
127
109
  const list = (Array.isArray(rows) ? rows : []).filter((row) => row && String(row.id || '').trim());
128
- const running = idSet(runningIds);
129
- const openCards = idSet(openCardIds);
130
- const keptShown = idSet(keptShownIds);
110
+ const running = new Set(Array.from(runningIds || [], (value) => String(value ?? '').trim()).filter(Boolean));
131
111
 
132
- // One turn per user-side row (the user's, an agent's, a relay event), with
133
- // its replies and its live bubble.
134
112
  const turns = [];
135
113
  const turnById = new Map();
136
- list.forEach((row, index) => {
137
- if (row.role !== 'user' || turnById.has(row.id)) return;
138
- const turn = { prompt: row, index, replies: [], live: null };
114
+ for (const row of list) {
115
+ if (row.role !== 'user' || !row.origin || turnById.has(row.id)) continue;
116
+ const turn = { prompt: row, replies: [], live: false };
139
117
  turns.push(turn);
140
118
  turnById.set(row.id, turn);
141
- });
119
+ }
142
120
  for (const row of list) {
143
121
  if (row.role !== 'assistant') continue;
144
122
  const turn = turnById.get(row.live ? row.id : row.sourceMessageId);
145
123
  if (!turn) continue;
146
- if (row.live) turn.live = row;
124
+ if (row.live) turn.live = true;
147
125
  else turn.replies.push(row);
148
126
  }
149
- for (const turn of turns) {
150
- turn.running = !!turn.live || running.has(turn.prompt.id);
151
- turn.markers = turn.replies.filter((row) => STEER_MARKER_KINDS.has(row.kind));
152
- turn.answers = turn.replies.filter((row) => !STEER_MARKER_KINDS.has(row.kind) && row.kind !== ANSWERED_ELSEWHERE_KIND);
153
- // When the turn ended (its last answer was written); still open while it
154
- // runs; null for a turn that has not run (queued, cancelled).
155
- turn.end = turn.answers.length
156
- ? Math.max(0, ...turn.answers.map((row) => Number(row.timestampMs) || 0))
157
- : (turn.running ? Infinity : null);
158
- }
159
-
160
- // A steer folded into a turn (or stopped with it) settles as a marker under
161
- // its own message; the turn it joined is the one running when it was sent:
162
- // sent before it, ended after it, and of those the first to end (a
163
- // session runs one turn at a time).
164
- const steeredInto = new Set();
165
- for (const turn of turns) {
166
- const sentAt = Number(turn.prompt.timestampMs) || 0;
167
- if (!turn.markers.length || !sentAt) continue;
168
- let host = null;
169
- for (const other of turns) {
170
- const startedAt = Number(other.prompt.timestampMs) || 0;
171
- if (other === turn || other.end === null || !startedAt) continue;
172
- if (startedAt > sentAt || other.end < sentAt) continue;
173
- if (!host || other.end < host.end || (other.end === host.end && startedAt > host.prompt.timestampMs)) host = other;
174
- }
175
- if (host) steeredInto.add(host);
176
- }
177
127
 
178
- return turns.filter((turn) => !!turn.prompt.origin).map((turn) => {
179
- const reasons = [];
180
- if (turn.prompt.steered || turn.markers.length) reasons.push('steered');
181
- if (turn.replies.some((row) => row.kind === ABSORBED_KIND) || steeredInto.has(turn)) reasons.push('steered-in');
182
- // Its answer is on a background-continuation row further down: there is
183
- // no reply here to fold with it.
184
- if (turn.replies.some((row) => row.kind === ANSWERED_ELSEWHERE_KIND)) reasons.push('answered-elsewhere');
185
- if (openCards.has(turn.prompt.id)) reasons.push('open-card');
186
- if (turn.running && !turn.answers.length && turns.some((other) => other.index > turn.index
187
- && isUsersOwnMessage(other.prompt)
188
- && !other.replies.length
189
- && !other.running)) {
190
- reasons.push('user-waiting');
191
- }
192
- if (keptShown.has(turn.prompt.id)) reasons.push('kept-shown');
193
- const last = turn.answers[turn.answers.length - 1] || null;
194
- const status = last ? (last.outcome || 'replied') : (turn.running ? 'working' : 'none');
128
+ return turns.map((turn) => {
129
+ const markers = turn.replies.filter((row) => STEER_MARKER_KINDS.has(row.kind));
130
+ // An answer, or the marker that the answer is on a continuation row
131
+ // further down.
132
+ const answers = turn.replies.filter((row) => !STEER_MARKER_KINDS.has(row.kind));
133
+ const last = answers[answers.length - 1] || null;
134
+ let status = 'none';
135
+ if (last) status = last.outcome || 'replied';
136
+ else if (turn.live || running.has(turn.prompt.id)) status = 'working';
195
137
  return {
196
138
  promptId: turn.prompt.id,
197
139
  prompt: turn.prompt,
198
- members: turn.live ? [...turn.replies, turn.live] : turn.replies.slice(),
199
140
  status,
200
- fold: reasons.length === 0,
201
- reasons,
202
- transient: reasons.some((reason) => TRANSIENT_REASONS.has(reason)),
141
+ fold: !turn.prompt.steered && !markers.length,
203
142
  };
204
143
  });
205
144
  }
206
145
 
207
146
  /**
208
- * Whether a folded exchange shows open. `choice` is the user's own toggle of
147
+ * Whether a folded prompt shows open. `choice` is the user's own toggle of
209
148
  * it (true opened, false closed, undefined none) and always wins; without
210
149
  * one it is open while the turn works and closes by itself once it is
211
150
  * replied, failed or stopped.
@@ -234,8 +173,8 @@ export function agentExchangeSummary(origin, status, { conversations = null } =
234
173
 
235
174
  /**
236
175
  * The folded row's markup: a <details> whose summary is the whole row. Its
237
- * body stays empty — the prompt and the reply are the transcript rows below
238
- * it, shown while it is open.
176
+ * body stays empty — the prompt's bubble is its sibling in the prompt's own
177
+ * node, shown while it is open.
239
178
  */
240
179
  export function renderAgentExchangeRowHtml(summary, { escHtml = defaultEscape, timeText = '', open = false } = {}) {
241
180
  return `<details class="agent-exchange-row agent-exchange-${escHtml(summary?.status || 'none')}"${open ? ' open' : ''}>`