@oar-sh/oar 0.9.6 → 0.9.8

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 (105) hide show
  1. package/CHANGELOG.md +285 -0
  2. package/README.md +222 -40
  3. package/bin/oar.js +320 -18
  4. package/package.json +1 -1
  5. package/server/README.md +174 -7
  6. package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +340 -0
  7. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +1325 -0
  8. package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +155 -0
  9. package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
  10. package/server/claude-worker/claude-sdk-adapter.mjs +18 -13
  11. package/server/claude-worker/claude-session-process.mjs +225 -24
  12. package/server/claude-worker/claude-session-worker.mjs +6 -0
  13. package/server/claude-worker/sdk-message-normalizer.mjs +28 -0
  14. package/server/db-schema.mjs +17 -0
  15. package/server/migrations/0006-workspace-root-settings.mjs +67 -0
  16. package/server/migrations/0007-claude-cloud.mjs +82 -0
  17. package/server/public/app/activity-replay-state.mjs +68 -5
  18. package/server/public/app/agent-sessions-settings-ui.js +197 -0
  19. package/server/public/app/agent-sessions-settings.mjs +134 -0
  20. package/server/public/app/api-client.js +149 -6
  21. package/server/public/app/attachments-view.js +20 -1
  22. package/server/public/app/attribution-control.mjs +46 -0
  23. package/server/public/app/bootstrap.js +468 -88
  24. package/server/public/app/claude-cloud-conversation-ui.js +110 -0
  25. package/server/public/app/claude-cloud-settings-ui.js +374 -0
  26. package/server/public/app/claude-cloud-ui.mjs +611 -0
  27. package/server/public/app/composer-control-state.mjs +11 -0
  28. package/server/public/app/context-menu.mjs +140 -0
  29. package/server/public/app/context-tier-options.mjs +4 -0
  30. package/server/public/app/context-usage-view.mjs +127 -8
  31. package/server/public/app/conversation-origin-ui.js +27 -0
  32. package/server/public/app/conversation-provider-indicator.mjs +22 -4
  33. package/server/public/app/conversation-view.js +145 -11
  34. package/server/public/app/cwd-picker.js +12 -0
  35. package/server/public/app/journal-view.js +698 -28
  36. package/server/public/app/math-dollars.mjs +89 -0
  37. package/server/public/app/model-selector-options.mjs +1 -1
  38. package/server/public/app/plan-usage-view.mjs +36 -8
  39. package/server/public/app/pwa-install.js +5 -0
  40. package/server/public/app/relay-error-ctas.mjs +16 -2
  41. package/server/public/app/remote-relay-origin-view.mjs +120 -4
  42. package/server/public/app/router.js +49 -12
  43. package/server/public/app/scrollbar-hover.mjs +78 -0
  44. package/server/public/app/settings-modal.js +37 -3
  45. package/server/public/app/settings-tabs.js +1 -1
  46. package/server/public/app/socket-handlers.js +53 -1
  47. package/server/public/app/store.js +26 -0
  48. package/server/public/app/transcript-separators.mjs +48 -11
  49. package/server/public/app-icon-192.png +0 -0
  50. package/server/public/app-icon-512.png +0 -0
  51. package/server/public/app-icon-light-192.png +0 -0
  52. package/server/public/app-icon-light-512.png +0 -0
  53. package/server/public/app-icon-light-maskable-512.png +0 -0
  54. package/server/public/app-icon-light.svg +4 -0
  55. package/server/public/app-icon-maskable-512.png +0 -0
  56. package/server/public/app-icon.svg +3 -3
  57. package/server/public/favicon.ico +0 -0
  58. package/server/public/index.html +564 -20
  59. package/server/public/manifest.webmanifest +9 -3
  60. package/server/public/sw.js +6 -1
  61. package/server/relay-tools.md +13 -1
  62. package/server/repositories/remote-relay-repository.mjs +42 -1
  63. package/server/repositories/session-repository.mjs +47 -1
  64. package/server/routes/claude-cloud-routes.mjs +140 -0
  65. package/server/routes/git-routes.mjs +37 -0
  66. package/server/routes/messages-routes.mjs +123 -16
  67. package/server/routes/remote-relay-routes.mjs +15 -1
  68. package/server/routes/sessions-routes.mjs +517 -74
  69. package/server/server-runtime.mjs +268 -33
  70. package/server/services/claude-account-usage-service.mjs +176 -0
  71. package/server/services/claude-cloud-repo-service.mjs +408 -0
  72. package/server/services/claude-cloud-session-service.mjs +326 -0
  73. package/server/services/claude-cloud-settings-service.mjs +269 -0
  74. package/server/services/claude-context-usage.mjs +51 -3
  75. package/server/services/git-remote-service.mjs +115 -0
  76. package/server/services/oar-cli-helpers.mjs +67 -1
  77. package/server/services/plan-usage-claude-cloud.mjs +211 -0
  78. package/server/services/plan-usage-claude.mjs +202 -40
  79. package/server/services/plan-usage-contract.mjs +39 -3
  80. package/server/services/plan-usage-service.mjs +82 -2
  81. package/server/services/provider-reasoning-effort.mjs +3 -0
  82. package/server/services/pwa-manifest-theme.mjs +30 -0
  83. package/server/services/remote-relay-caller-context.mjs +49 -10
  84. package/server/services/remote-relay-client.mjs +9 -0
  85. package/server/services/remote-relay-dispatcher.mjs +383 -35
  86. package/server/services/remote-relay-inbound.mjs +49 -2
  87. package/server/services/remote-relay-loopback.mjs +245 -0
  88. package/server/services/remote-relay-registry-service.mjs +52 -0
  89. package/server/services/session-worker-launch-service.mjs +100 -15
  90. package/server/services/session-worker-process-service.mjs +348 -129
  91. package/server/services/session-worker-registry-service.mjs +18 -1
  92. package/server/services/session-worker-stop-service.mjs +24 -13
  93. package/server/services/session-worker-supervisor-service.mjs +56 -1
  94. package/server/services/update-install-service.mjs +9 -3
  95. package/server/services/usage-limit-pause-service.mjs +3 -1
  96. package/server/services/worker-launch-note-service.mjs +151 -0
  97. package/shared/claude-attribution.mjs +117 -0
  98. package/shared/claude-cloud/api-client.mjs +809 -0
  99. package/shared/claude-cloud/base-url.mjs +47 -0
  100. package/shared/claude-cloud/credentials.mjs +221 -0
  101. package/shared/claude-cloud/repo-url.mjs +111 -0
  102. package/shared/provider-routing.mjs +4 -2
  103. package/shared/remote-relay-contract.mjs +153 -22
  104. package/shared/remote-relay-tool-core.mjs +11 -8
  105. package/shared/steer-settle-failure.mjs +1 -0
@@ -0,0 +1,82 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Migration: storage for Claude Cloud conversations (provider `claude-cloud`).
5
+ *
6
+ * 1. `runtime_sessions.claude_cloud_session_id` — the cloud session a
7
+ * conversation runs in, reported by its worker after the first message
8
+ * (the per-provider native id, next to `claude_native_session_id`,
9
+ * `cursor_agent_id` and `grok_native_session_id`).
10
+ * 2. `runtime_sessions.claude_cloud_last_sequence` — the last event sequence
11
+ * the worker has handled: where it resumes the event stream after a restart.
12
+ * 3. `runtime_sessions.claude_cloud_cost_usd` — what the session has cost so
13
+ * far, as the cloud reports it.
14
+ * 4. `conversations.cloud_source_json` — what the session clones and where it
15
+ * is shown: `{ repoUrl, branch, environmentId, sessionUrl,
16
+ * pushedBranches: [{ branch, at }] }`.
17
+ *
18
+ * Additive and idempotent. Safe to run on every boot.
19
+ *
20
+ * Usage: node server/migrations/0007-claude-cloud.mjs [path/to/copilot.db]
21
+ */
22
+
23
+ import { fileURLToPath } from 'url';
24
+ import path from 'path';
25
+ import process from 'process';
26
+ import Database from 'better-sqlite3';
27
+
28
+ const COLUMNS = Object.freeze([
29
+ Object.freeze({ table: 'runtime_sessions', name: 'claude_cloud_session_id', type: 'TEXT' }),
30
+ Object.freeze({ table: 'runtime_sessions', name: 'claude_cloud_last_sequence', type: 'TEXT' }),
31
+ Object.freeze({ table: 'runtime_sessions', name: 'claude_cloud_cost_usd', type: 'REAL' }),
32
+ Object.freeze({ table: 'conversations', name: 'cloud_source_json', type: 'TEXT' }),
33
+ ]);
34
+
35
+ function tableExists(db, name) {
36
+ return !!db
37
+ .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
38
+ .get(name);
39
+ }
40
+
41
+ function columnNames(db, table) {
42
+ return new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((column) => String(column.name)));
43
+ }
44
+
45
+ export function migrateClaudeCloud(db) {
46
+ let columnsAdded = 0;
47
+ for (const column of COLUMNS) {
48
+ if (!tableExists(db, column.table)) continue;
49
+ if (columnNames(db, column.table).has(column.name)) continue;
50
+ db.exec(`ALTER TABLE ${column.table} ADD COLUMN ${column.name} ${column.type}`);
51
+ columnsAdded += 1;
52
+ }
53
+ return { applied: columnsAdded > 0, columnsAdded };
54
+ }
55
+
56
+ function defaultDbPath() {
57
+ const here = path.dirname(fileURLToPath(import.meta.url));
58
+ return path.resolve(here, '..', 'data', 'copilot.db');
59
+ }
60
+
61
+ export function migrate(dbPath, options = {}) {
62
+ const db = new Database(dbPath, options);
63
+ try {
64
+ return migrateClaudeCloud(db);
65
+ } finally {
66
+ db.close();
67
+ }
68
+ }
69
+
70
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
71
+ if (isMain) {
72
+ const dbPath = process.argv[2] ? path.resolve(process.argv[2]) : defaultDbPath();
73
+ try {
74
+ const result = migrate(dbPath);
75
+ console.log(`Migration 0007 on ${dbPath}`);
76
+ console.log(result.applied ? ` claude cloud columns added: ${result.columnsAdded}` : ' skipped: nothing to change');
77
+ process.exit(0);
78
+ } catch (err) {
79
+ console.error(`Migration 0007 failed: ${err?.message || err}`);
80
+ process.exit(1);
81
+ }
82
+ }
@@ -18,10 +18,44 @@ export function normalizeRelayActivityEntry(item) {
18
18
  // A compaction boundary is published as an ordinary activity row carrying
19
19
  // structured metadata. The transcript promotes it to a full-width break row
20
20
  // (and drops it from the bubble's tool-activity list), so both sides ask here.
21
+ // Every compaction entry answers here, whatever its state: the live bubble
22
+ // never shows any of them as prose.
21
23
  export function isCompactBoundaryActivityEntry(item) {
22
24
  return normalizeRelayActivityEntry(item)?.metadata?.kind === 'compact_boundary';
23
25
  }
24
26
 
27
+ // The live list of one turn keeps its newest ordinary lines only, but every
28
+ // structured row a line of the transcript is built from: the turn's
29
+ // compaction entries and the session-restart note would otherwise scroll out
30
+ // of a busy turn and take their line with them until the next reload.
31
+ export const LIVE_ACTIVITY_CAP = 24;
32
+ function isKeptLiveActivityEntry(item) {
33
+ const kind = normalizeRelayActivityEntry(item)?.metadata?.kind;
34
+ return kind === 'compact_boundary' || kind === 'compact_window_respawn';
35
+ }
36
+ export function capLiveActivityEntries(items, cap = LIVE_ACTIVITY_CAP) {
37
+ const list = Array.isArray(items) ? items : [];
38
+ let ordinary = list.reduce((count, item) => count + (isKeptLiveActivityEntry(item) ? 0 : 1), 0);
39
+ if (ordinary <= cap) return list;
40
+ return list.filter((item) => {
41
+ if (isKeptLiveActivityEntry(item)) return true;
42
+ ordinary -= 1;
43
+ return ordinary < cap;
44
+ });
45
+ }
46
+
47
+ // A compaction is published in up to two steps on the same message: a
48
+ // `pending` entry when it starts, then either the boundary (no `state`, token
49
+ // counts) or a `cancelled` entry when it ended without a result.
50
+ // → 'pending' | 'cancelled' | 'boundary' | null (not a compaction entry).
51
+ export function compactionEntryState(item) {
52
+ const metadata = normalizeRelayActivityEntry(item)?.metadata;
53
+ if (metadata?.kind !== 'compact_boundary') return null;
54
+ const state = String(metadata.state || '').trim().toLowerCase();
55
+ if (state === 'pending' || state === 'cancelled') return state;
56
+ return 'boundary';
57
+ }
58
+
25
59
  function toTokenCount(value) {
26
60
  // `Number(null)` is 0, not NaN: without this an omitted post_tokens (every
27
61
  // real auto-compaction) would read as a compaction down to zero tokens.
@@ -34,19 +68,36 @@ function toTokenCount(value) {
34
68
  // than once only the LAST boundary is promoted. This returns that entry by
35
69
  // identity, so the render path can keep the others visible as prose instead
36
70
  // of dropping them (they would otherwise vanish from the transcript).
37
- export function promotedCompactBoundaryEntry(items) {
71
+ //
72
+ // The last compaction entry decides: a `pending` one is promoted only while
73
+ // the message is still `processing` (a finished or failed message never shows
74
+ // a dangling start). A dangling start or a `cancelled` end draws no line of
75
+ // its own, so the line falls back to an earlier boundary of the same message
76
+ // when there is one.
77
+ export function promotedCompactBoundaryEntry(items, { processing = false } = {}) {
38
78
  const list = Array.isArray(items) ? items : [];
79
+ let sawLast = false;
39
80
  for (let index = list.length - 1; index >= 0; index -= 1) {
40
- if (isCompactBoundaryActivityEntry(list[index])) return list[index];
81
+ const state = compactionEntryState(list[index]);
82
+ if (!state) continue;
83
+ if (!sawLast) {
84
+ sawLast = true;
85
+ if (state === 'pending' && processing) return list[index];
86
+ }
87
+ if (state === 'boundary') return list[index];
41
88
  }
42
89
  return null;
43
90
  }
44
91
 
45
92
  // The last compaction recorded against one message's activities, as
46
- // { preTokens, postTokens } (either may be null when the SDK omitted it).
47
- export function compactBoundaryFromActivities(items) {
48
- const promoted = promotedCompactBoundaryEntry(items);
93
+ // { preTokens, postTokens } (either may be null when the SDK omitted it), or
94
+ // { pending: true, preTokens: null, postTokens: null } while it runs.
95
+ export function compactBoundaryFromActivities(items, options = {}) {
96
+ const promoted = promotedCompactBoundaryEntry(items, options);
49
97
  if (!promoted) return null;
98
+ if (compactionEntryState(promoted) === 'pending') {
99
+ return { pending: true, preTokens: null, postTokens: null };
100
+ }
50
101
  const metadata = normalizeRelayActivityEntry(promoted).metadata;
51
102
  return {
52
103
  preTokens: toTokenCount(metadata.preTokens),
@@ -54,6 +105,18 @@ export function compactBoundaryFromActivities(items) {
54
105
  };
55
106
  }
56
107
 
108
+ // Activity rows the message bubble lists as prose: everything except the
109
+ // promoted boundary (it is the break row) and every pending/cancelled step
110
+ // (transient state, never prose).
111
+ export function visibleActivityEntries(items, promoted) {
112
+ const list = Array.isArray(items) ? items : [];
113
+ return list.filter((item) => {
114
+ if (item === promoted) return false;
115
+ const state = compactionEntryState(item);
116
+ return state !== 'pending' && state !== 'cancelled';
117
+ });
118
+ }
119
+
57
120
  // Head-cap that never drops structured rows. Activity lists are capped from
58
121
  // the front (the start of a turn is the interesting part), but a
59
122
  // metadata-bearing row carries transcript structure rather than prose, and a
@@ -0,0 +1,197 @@
1
+ // Settings → Relays → Agent sessions: the switch that lets agents start and use
2
+ // sessions on this relay through the remote_relay tool, and the slider for the
3
+ // longest wait of one tool call. The decisions live in
4
+ // agent-sessions-settings.mjs; this file only moves them into the markup in
5
+ // index.html. The static controls use inline handlers that bootstrap.js
6
+ // exposes on window.
7
+ //
8
+ // The section ships hidden. It shows once the relay answered with settings and
9
+ // stays hidden on a relay that does not know the route (404).
10
+
11
+ import { loadAgentSessionsSettings, updateAgentSessionsSettings } from './api-client.js';
12
+ import {
13
+ agentSessionsSavedText,
14
+ agentSessionsViewModel,
15
+ clampWaitSeconds,
16
+ classifyAgentSessionsLoad,
17
+ formatWaitMinutesLabel,
18
+ normalizeAgentSessionsSettings,
19
+ } from './agent-sessions-settings.mjs';
20
+
21
+ // null until the first successful read: "never answered" is not "off".
22
+ let settingsState = null;
23
+ let unsupported = false;
24
+ let loadFailed = false;
25
+ let saveInFlight = false;
26
+ // What is being saved, drawn over the stored state until the relay answers so
27
+ // the switch and the thumb stay where the user put them.
28
+ let pendingChange = null;
29
+ // A value from elsewhere arrived while the slider had focus; drawn on blur.
30
+ let sliderRenderDeferred = false;
31
+ let sliderBlurBound = false;
32
+
33
+ function el(id) {
34
+ return document.getElementById(id);
35
+ }
36
+
37
+ export function getAgentSessionsSettings() {
38
+ return settingsState;
39
+ }
40
+
41
+ function setStatusLine(state, text) {
42
+ const line = el('agent-sessions-status');
43
+ if (!line) return;
44
+ line.textContent = text || '';
45
+ line.hidden = !text;
46
+ if (state) line.dataset.state = state;
47
+ else delete line.dataset.state;
48
+ }
49
+
50
+ function setSliderLabel(text) {
51
+ const label = el('agent-sessions-max-wait-value');
52
+ if (label) label.textContent = text;
53
+ // The value is in seconds; a screen reader hears the minutes.
54
+ el('agent-sessions-max-wait-slider')?.setAttribute('aria-valuetext', text);
55
+ }
56
+
57
+ function bindSliderBlur(slider) {
58
+ if (sliderBlurBound) return;
59
+ sliderBlurBound = true;
60
+ slider.addEventListener('blur', () => {
61
+ if (sliderRenderDeferred) renderAgentSessionsSection({ forceSlider: true });
62
+ });
63
+ }
64
+
65
+ /**
66
+ * Idempotent. A value that came from elsewhere (socket, refresh) never moves
67
+ * the thumb under the user's finger: it waits until focus leaves the slider.
68
+ * `forceSlider` is for this page's own saves.
69
+ */
70
+ export function renderAgentSessionsSection({ forceSlider = false } = {}) {
71
+ const section = el('agent-sessions-section');
72
+ if (!section) return;
73
+ const view = agentSessionsViewModel({
74
+ settings: settingsState && pendingChange ? { ...settingsState, ...pendingChange } : settingsState,
75
+ unsupported,
76
+ loadFailed,
77
+ saving: saveInFlight,
78
+ });
79
+ section.hidden = view.hidden;
80
+
81
+ const toggle = el('agent-sessions-enabled-toggle');
82
+ if (toggle) {
83
+ toggle.checked = view.toggle.checked;
84
+ toggle.disabled = view.toggle.disabled;
85
+ }
86
+
87
+ const slider = el('agent-sessions-max-wait-slider');
88
+ if (slider) {
89
+ bindSliderBlur(slider);
90
+ slider.disabled = view.slider.disabled;
91
+ const held = !forceSlider && !view.slider.disabled && document.activeElement === slider;
92
+ sliderRenderDeferred = held;
93
+ if (!held) {
94
+ // Range first: a value outside the old range would be clamped by the input.
95
+ slider.min = String(view.slider.min);
96
+ slider.max = String(view.slider.max);
97
+ slider.step = String(view.slider.step);
98
+ slider.value = String(view.slider.value);
99
+ setSliderLabel(view.slider.label);
100
+ }
101
+ }
102
+
103
+ const cap = el('agent-sessions-cap');
104
+ if (cap) {
105
+ cap.textContent = view.capText;
106
+ cap.hidden = !view.capText;
107
+ }
108
+ }
109
+
110
+ // Single entry point for every payload (GET, POST answer, socket event).
111
+ export function applyAgentSessionsSettingsState(payload, { forceSlider = false } = {}) {
112
+ if (!payload || typeof payload !== 'object') return settingsState;
113
+ settingsState = normalizeAgentSessionsSettings(payload, settingsState);
114
+ unsupported = false;
115
+ loadFailed = false;
116
+ renderAgentSessionsSection({ forceSlider });
117
+ return settingsState;
118
+ }
119
+
120
+ /** Called when the settings modal opens, and for a socket event without a body. */
121
+ export async function refreshAgentSessionsSection() {
122
+ if (!saveInFlight) setStatusLine('', '');
123
+ renderAgentSessionsSection();
124
+ const result = await loadAgentSessionsSettings();
125
+ const outcome = classifyAgentSessionsLoad(result);
126
+ if (outcome === 'loaded') return applyAgentSessionsSettingsState(result);
127
+ if (outcome === 'unsupported') {
128
+ unsupported = true;
129
+ settingsState = null;
130
+ } else if (!settingsState) {
131
+ // Keep what an earlier read said: one failed refresh is not "gone".
132
+ loadFailed = true;
133
+ setStatusLine('error', 'Could not load the agent session settings from this relay.');
134
+ }
135
+ renderAgentSessionsSection();
136
+ return settingsState;
137
+ }
138
+
139
+ async function saveAgentSessionsChange(change, patch, failureText) {
140
+ saveInFlight = true;
141
+ pendingChange = patch;
142
+ setStatusLine('saved', 'Saving…');
143
+ renderAgentSessionsSection({ forceSlider: true });
144
+ let result = null;
145
+ try {
146
+ result = await updateAgentSessionsSettings(patch);
147
+ } finally {
148
+ saveInFlight = false;
149
+ pendingChange = null;
150
+ }
151
+ if (result?.ok) {
152
+ // The relay's answer is the truth (it may have clamped the wait).
153
+ applyAgentSessionsSettingsState({ ...patch, ...result }, { forceSlider: true });
154
+ setStatusLine('active', agentSessionsSavedText(change, settingsState));
155
+ } else {
156
+ // Back to what the relay has; the line says why.
157
+ setStatusLine('error', result?.error || failureText);
158
+ renderAgentSessionsSection({ forceSlider: true });
159
+ }
160
+ }
161
+
162
+ export async function toggleAgentSessions(checked) {
163
+ if (saveInFlight || !settingsState) {
164
+ renderAgentSessionsSection();
165
+ return;
166
+ }
167
+ await saveAgentSessionsChange('enabled', { enabled: checked === true }, 'Failed to update the setting.');
168
+ }
169
+
170
+ /** Live label feedback while dragging; nothing is saved until change fires. */
171
+ export function previewAgentSessionsMaxWait(value) {
172
+ const seconds = clampWaitSeconds(value, settingsState?.limits, settingsState?.maxWaitSeconds);
173
+ setSliderLabel(formatWaitMinutesLabel(seconds));
174
+ }
175
+
176
+ export async function saveAgentSessionsMaxWait(value) {
177
+ if (saveInFlight || !settingsState) {
178
+ renderAgentSessionsSection({ forceSlider: true });
179
+ return;
180
+ }
181
+ const seconds = clampWaitSeconds(value, settingsState.limits, settingsState.maxWaitSeconds);
182
+ if (seconds === settingsState.maxWaitSeconds) {
183
+ renderAgentSessionsSection({ forceSlider: true });
184
+ return;
185
+ }
186
+ await saveAgentSessionsChange('maxWaitSeconds', { maxWaitSeconds: seconds }, 'Failed to save the longest wait.');
187
+ }
188
+
189
+ /** Test hook. */
190
+ export function resetAgentSessionsSettingsForTests() {
191
+ settingsState = null;
192
+ unsupported = false;
193
+ loadFailed = false;
194
+ saveInFlight = false;
195
+ pendingChange = null;
196
+ sliderRenderDeferred = false;
197
+ }
@@ -0,0 +1,134 @@
1
+ // Pure helpers for Settings → Relays → Agent sessions: whether agents may
2
+ // start and use sessions on this relay, and the longest time one remote_relay
3
+ // call may wait. DOM-free on purpose, like claude-cloud-ui.mjs: the section
4
+ // (agent-sessions-settings-ui.js) owns the elements, this module the decisions.
5
+ //
6
+ // One payload shape everywhere — GET and POST /api/settings/agent-sessions and
7
+ // the `agent_sessions_settings_updated` socket event:
8
+ // { enabled, maxWaitSeconds, limits: { minWaitSeconds, maxWaitSeconds, stepSeconds }, maxActiveSessions }
9
+ // The relay stores seconds; the slider works in seconds too and only its label
10
+ // speaks minutes.
11
+
12
+ export const AGENT_SESSIONS_SOCKET_EVENT = 'agent_sessions_settings_updated';
13
+
14
+ // What the relay enforces when it says nothing else; the markup starts there.
15
+ export const AGENT_SESSIONS_DEFAULT_WAIT_SECONDS = 600;
16
+ export const AGENT_SESSIONS_DEFAULT_LIMITS = Object.freeze({
17
+ minWaitSeconds: 120,
18
+ maxWaitSeconds: 3600,
19
+ stepSeconds: 60,
20
+ });
21
+
22
+ function positiveNumber(value) {
23
+ // No coercion of null, '' or true into a number.
24
+ if (typeof value !== 'number' && (typeof value !== 'string' || !value.trim())) return null;
25
+ const number = Number(value);
26
+ return Number.isFinite(number) && number > 0 ? number : null;
27
+ }
28
+
29
+ /** The slider's range and step in seconds; anything unusable falls back. */
30
+ export function normalizeAgentSessionsLimits(raw, fallback = AGENT_SESSIONS_DEFAULT_LIMITS) {
31
+ const minWaitSeconds = positiveNumber(raw?.minWaitSeconds) ?? fallback.minWaitSeconds;
32
+ const maxCandidate = positiveNumber(raw?.maxWaitSeconds) ?? fallback.maxWaitSeconds;
33
+ return {
34
+ minWaitSeconds,
35
+ maxWaitSeconds: Math.max(minWaitSeconds, maxCandidate),
36
+ stepSeconds: positiveNumber(raw?.stepSeconds) ?? fallback.stepSeconds,
37
+ };
38
+ }
39
+
40
+ /**
41
+ * A wait the relay will accept: inside the limits and on a step counted from
42
+ * the minimum. Something that is not a number becomes `fallback`, clamped the
43
+ * same way.
44
+ */
45
+ export function clampWaitSeconds(value, limits = AGENT_SESSIONS_DEFAULT_LIMITS, fallback = AGENT_SESSIONS_DEFAULT_WAIT_SECONDS) {
46
+ const { minWaitSeconds: min, maxWaitSeconds: max, stepSeconds: step } = normalizeAgentSessionsLimits(limits);
47
+ const wanted = positiveNumber(value) ?? positiveNumber(fallback) ?? min;
48
+ const bounded = Math.min(max, Math.max(min, wanted));
49
+ const stepped = min + Math.round((bounded - min) / step) * step;
50
+ // The last step may overshoot a maximum that is not on the grid.
51
+ return stepped > max ? min + Math.floor((max - min) / step) * step : stepped;
52
+ }
53
+
54
+ /** 600 → "10 min", 150 → "2.5 min"; '' for something that is not a duration. */
55
+ export function formatWaitMinutesLabel(seconds) {
56
+ const value = positiveNumber(seconds);
57
+ if (value === null) return '';
58
+ return `${Math.round((value / 60) * 10) / 10} min`;
59
+ }
60
+
61
+ /**
62
+ * The section's state from a payload. A field the payload does not carry keeps
63
+ * what `previous` had (the first payload falls back to "off" and the defaults).
64
+ */
65
+ export function normalizeAgentSessionsSettings(payload, previous = null) {
66
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return previous;
67
+ const limits = payload.limits && typeof payload.limits === 'object'
68
+ ? normalizeAgentSessionsLimits(payload.limits)
69
+ : (previous?.limits || { ...AGENT_SESSIONS_DEFAULT_LIMITS });
70
+ const maxActive = positiveNumber(payload.maxActiveSessions);
71
+ return {
72
+ enabled: typeof payload.enabled === 'boolean' ? payload.enabled : previous?.enabled === true,
73
+ maxWaitSeconds: positiveNumber(payload.maxWaitSeconds)
74
+ ?? previous?.maxWaitSeconds
75
+ ?? AGENT_SESSIONS_DEFAULT_WAIT_SECONDS,
76
+ limits,
77
+ maxActiveSessions: maxActive !== null ? Math.floor(maxActive) : (previous?.maxActiveSessions ?? null),
78
+ };
79
+ }
80
+
81
+ /**
82
+ * What an answer to the GET means for the section:
83
+ * 'loaded' — the settings; show the section.
84
+ * 'unsupported' — an older relay without the route (404, or a 200 that is not
85
+ * the settings); hide the section.
86
+ * 'failed' — the relay could not be asked (offline, paused, 5xx); keep
87
+ * what is shown.
88
+ */
89
+ export function classifyAgentSessionsLoad(result) {
90
+ if (!result || typeof result !== 'object') return 'failed';
91
+ if (result.ok === true) return typeof result.enabled === 'boolean' ? 'loaded' : 'unsupported';
92
+ return Number(result.status) === 404 ? 'unsupported' : 'failed';
93
+ }
94
+
95
+ /**
96
+ * Everything the section draws, from its state. `settings` is null until the
97
+ * relay answered; `unsupported` hides the section for good; `loadFailed` shows
98
+ * it switched off with a note when the very first read failed.
99
+ */
100
+ export function agentSessionsViewModel({ settings = null, unsupported = false, loadFailed = false, saving = false } = {}) {
101
+ const known = !!settings && !unsupported;
102
+ const limits = normalizeAgentSessionsLimits(settings?.limits);
103
+ const value = positiveNumber(settings?.maxWaitSeconds) ?? AGENT_SESSIONS_DEFAULT_WAIT_SECONDS;
104
+ const cap = positiveNumber(settings?.maxActiveSessions);
105
+ return {
106
+ hidden: unsupported || (!settings && !loadFailed),
107
+ toggle: {
108
+ checked: known && settings.enabled === true,
109
+ disabled: !known || saving,
110
+ },
111
+ slider: {
112
+ min: limits.minWaitSeconds,
113
+ max: limits.maxWaitSeconds,
114
+ step: limits.stepSeconds,
115
+ value,
116
+ label: formatWaitMinutesLabel(value),
117
+ // Not tied to the switch: the limit also governs waits on paired relays.
118
+ disabled: !known || saving,
119
+ },
120
+ capText: known && cap !== null
121
+ ? `At most ${Math.floor(cap)} of them work at the same time per conversation.`
122
+ : '',
123
+ };
124
+ }
125
+
126
+ /** The status line after a save the relay accepted. */
127
+ export function agentSessionsSavedText(change, settings) {
128
+ if (change === 'enabled') {
129
+ return settings?.enabled === true
130
+ ? 'Agents may start and use sessions on this relay now.'
131
+ : 'Agents can no longer start or use sessions on this relay.';
132
+ }
133
+ return `Agents may wait up to ${formatWaitMinutesLabel(settings?.maxWaitSeconds)} per tool call.`;
134
+ }