@oar-sh/oar 0.9.5 → 0.9.7

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 (119) hide show
  1. package/.github/extensions/web-relay/polling/polling-loop.mjs +12 -32
  2. package/.github/extensions/web-relay/skills/question-bridge.mjs +14 -39
  3. package/CHANGELOG.md +340 -0
  4. package/README.md +196 -26
  5. package/package.json +2 -1
  6. package/server/README.md +174 -7
  7. package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +340 -0
  8. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +1325 -0
  9. package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +155 -0
  10. package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
  11. package/server/claude-worker/claude-sdk-adapter.mjs +18 -2
  12. package/server/claude-worker/claude-session-process.mjs +151 -1
  13. package/server/claude-worker/claude-session-worker.mjs +6 -0
  14. package/server/claude-worker/claude-turn-failure.mjs +92 -0
  15. package/server/claude-worker/claude-turn-publisher.mjs +52 -3
  16. package/server/claude-worker/sdk-message-normalizer.mjs +13 -1
  17. package/server/copilot-worker/copilot-model-switch.mjs +29 -3
  18. package/server/copilot-worker/copilot-question-bridge.mjs +2 -4
  19. package/server/copilot-worker/copilot-runtime-tree.mjs +91 -0
  20. package/server/copilot-worker/copilot-sdk-adapter.mjs +48 -8
  21. package/server/copilot-worker/copilot-sdk-session-process.mjs +897 -166
  22. package/server/cursor-worker/cursor-sdk-adapter.mjs +80 -13
  23. package/server/cursor-worker/cursor-turn-runner.mjs +5 -2
  24. package/server/db-schema.mjs +24 -0
  25. package/server/grok-worker/acp-client.mjs +68 -12
  26. package/server/grok-worker/grok-sdk-adapter.mjs +4 -0
  27. package/server/grok-worker/grok-turn-runner.mjs +6 -2
  28. package/server/migrations/0006-workspace-root-settings.mjs +67 -0
  29. package/server/migrations/0007-claude-cloud.mjs +82 -0
  30. package/server/public/app/agent-sessions-settings-ui.js +197 -0
  31. package/server/public/app/agent-sessions-settings.mjs +134 -0
  32. package/server/public/app/api-client.js +160 -6
  33. package/server/public/app/attachments-view.js +20 -1
  34. package/server/public/app/attribution-control.mjs +46 -0
  35. package/server/public/app/bootstrap.js +347 -31
  36. package/server/public/app/claude-cloud-conversation-ui.js +110 -0
  37. package/server/public/app/claude-cloud-settings-ui.js +374 -0
  38. package/server/public/app/claude-cloud-ui.mjs +611 -0
  39. package/server/public/app/composer-control-state.mjs +15 -3
  40. package/server/public/app/context-menu.mjs +140 -0
  41. package/server/public/app/context-tier-options.mjs +4 -0
  42. package/server/public/app/conversation-origin-ui.js +27 -0
  43. package/server/public/app/conversation-provider-indicator.mjs +22 -4
  44. package/server/public/app/conversation-view.js +178 -8
  45. package/server/public/app/cwd-picker.js +12 -0
  46. package/server/public/app/journal-view.js +704 -28
  47. package/server/public/app/markdown-strikethrough.mjs +31 -0
  48. package/server/public/app/math-dollars.mjs +89 -0
  49. package/server/public/app/model-selector-options.mjs +1 -1
  50. package/server/public/app/plan-usage-view.mjs +36 -8
  51. package/server/public/app/relay-board-inline.mjs +70 -0
  52. package/server/public/app/relay-board-view.js +157 -61
  53. package/server/public/app/relay-error-ctas.mjs +28 -5
  54. package/server/public/app/remote-relay-origin-view.mjs +120 -4
  55. package/server/public/app/router.js +49 -12
  56. package/server/public/app/settings-modal.js +33 -2
  57. package/server/public/app/settings-tabs.js +1 -1
  58. package/server/public/app/socket-handlers.js +60 -0
  59. package/server/public/app/store.js +4 -1
  60. package/server/public/app/subagent-fold.mjs +61 -0
  61. package/server/public/app/usage-limit-ui.js +225 -0
  62. package/server/public/index.html +471 -8
  63. package/server/relay-tools.md +13 -1
  64. package/server/repositories/message-repository.mjs +10 -1
  65. package/server/repositories/question-repository.mjs +1 -0
  66. package/server/repositories/remote-relay-repository.mjs +72 -1
  67. package/server/repositories/session-repository.mjs +47 -1
  68. package/server/routes/claude-cloud-routes.mjs +140 -0
  69. package/server/routes/git-routes.mjs +37 -0
  70. package/server/routes/messages-routes.mjs +202 -26
  71. package/server/routes/relay-board-routes.mjs +56 -2
  72. package/server/routes/remote-relay-routes.mjs +15 -1
  73. package/server/routes/sessions-routes.mjs +538 -72
  74. package/server/server-runtime.mjs +291 -27
  75. package/server/services/claude-account-usage-service.mjs +176 -0
  76. package/server/services/claude-cloud-repo-service.mjs +408 -0
  77. package/server/services/claude-cloud-session-service.mjs +326 -0
  78. package/server/services/claude-cloud-settings-service.mjs +269 -0
  79. package/server/services/git-remote-service.mjs +115 -0
  80. package/server/services/plan-usage-claude-cloud.mjs +211 -0
  81. package/server/services/plan-usage-claude.mjs +202 -40
  82. package/server/services/plan-usage-contract.mjs +39 -3
  83. package/server/services/plan-usage-service.mjs +82 -2
  84. package/server/services/preview-proxy-server.mjs +64 -36
  85. package/server/services/process-list-service.mjs +122 -0
  86. package/server/services/provider-reasoning-effort.mjs +3 -0
  87. package/server/services/relay-console-log.mjs +199 -0
  88. package/server/services/remote-relay-caller-context.mjs +78 -19
  89. package/server/services/remote-relay-client.mjs +9 -0
  90. package/server/services/remote-relay-dispatcher.mjs +451 -39
  91. package/server/services/remote-relay-inbound.mjs +83 -1
  92. package/server/services/remote-relay-loopback.mjs +245 -0
  93. package/server/services/remote-relay-registry-service.mjs +52 -0
  94. package/server/services/session-worker-launch-service.mjs +100 -15
  95. package/server/services/session-worker-process-service.mjs +410 -138
  96. package/server/services/session-worker-registry-service.mjs +1 -1
  97. package/server/services/session-worker-stop-service.mjs +24 -13
  98. package/server/services/session-worker-supervisor-service.mjs +89 -2
  99. package/server/services/usage-limit-pause-service.mjs +443 -0
  100. package/server/services/worker-launch-note-service.mjs +151 -0
  101. package/shared/ask-user-bridge.mjs +14 -39
  102. package/shared/claude-attribution.mjs +117 -0
  103. package/shared/claude-cloud/api-client.mjs +809 -0
  104. package/shared/claude-cloud/base-url.mjs +47 -0
  105. package/shared/claude-cloud/credentials.mjs +221 -0
  106. package/shared/claude-cloud/repo-url.mjs +111 -0
  107. package/shared/claude-usage-limit.mjs +257 -0
  108. package/shared/control-poller.mjs +69 -20
  109. package/shared/failure-note-text.mjs +38 -0
  110. package/shared/provider-routing.mjs +4 -2
  111. package/shared/question-wait.mjs +108 -0
  112. package/shared/remote-relay-contract.mjs +167 -22
  113. package/shared/remote-relay-tool-core.mjs +11 -8
  114. package/shared/steer-settle-failure.mjs +17 -0
  115. package/shared/steer-settle-markers.mjs +8 -0
  116. package/shared/worker-runtime/process-tree.mjs +483 -0
  117. package/shared/worker-runtime/relay-errors.mjs +31 -0
  118. package/shared/worker-runtime/send-and-wait-errors.mjs +4 -4
  119. package/shared/worker-runtime/turn-liveness.mjs +249 -0
@@ -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
+ }
@@ -273,11 +273,13 @@ export async function updateClaudeSettings({
273
273
  enabled = undefined,
274
274
  model = undefined,
275
275
  enabledModels = undefined,
276
+ attributionMode = undefined,
276
277
  } = {}) {
277
278
  const payload = {};
278
279
  if (typeof enabled === 'boolean') payload.enabled = enabled;
279
280
  if (typeof model === 'string' && model.trim()) payload.model = model.trim();
280
281
  if (Array.isArray(enabledModels)) payload.enabledModels = enabledModels;
282
+ if (typeof attributionMode === 'string' && attributionMode.trim()) payload.attributionMode = attributionMode.trim();
281
283
  return settingsRequest('/api/settings/claude', payload, 'Failed to update Claude settings');
282
284
  }
283
285
 
@@ -370,6 +372,65 @@ export async function updateGrokSettings({
370
372
  return settingsRequest('/api/settings/grok', payload, 'Failed to update Grok settings');
371
373
  }
372
374
 
375
+ // Claude Cloud (provider `claude-cloud`). A relay without these routes answers
376
+ // 404, and a folder outside the workspace roots is refused: both must read as
377
+ // "nothing to show" (the New Chat option stays hidden, the fields stay empty),
378
+ // so these reads skip apiFetch's error logging.
379
+ async function quietGet(path) {
380
+ if (!networkRequestsEnabled) return null;
381
+ try {
382
+ const response = await fetch(`${BASE}${path}`, {
383
+ signal: requestTimeoutSignal(),
384
+ headers: { 'Content-Type': 'application/json', ...authHeaders() },
385
+ });
386
+ if (!response.ok) return null;
387
+ noteFetchSuccess();
388
+ return await response.json().catch(() => null);
389
+ } catch (error) {
390
+ noteFetchFailure(path, error);
391
+ return null;
392
+ }
393
+ }
394
+
395
+ export async function loadClaudeCloudSettings() {
396
+ return quietGet('/api/settings/claude-cloud');
397
+ }
398
+
399
+ export async function updateClaudeCloudSettings({
400
+ enabled = undefined,
401
+ defaultModel = undefined,
402
+ environmentId = undefined,
403
+ } = {}) {
404
+ const payload = {};
405
+ if (typeof enabled === 'boolean') payload.enabled = enabled;
406
+ if (typeof defaultModel === 'string' && defaultModel.trim()) payload.defaultModel = defaultModel.trim();
407
+ if (typeof environmentId === 'string' && environmentId.trim()) payload.environmentId = environmentId.trim();
408
+ return settingsRequest('/api/settings/claude-cloud', payload, 'Failed to update Claude Cloud settings');
409
+ }
410
+
411
+ // origin + current branch of a folder on the relay host, for the New Chat
412
+ // Repository and Branch fields. null when the relay could not say.
413
+ export async function loadGitRemote(rootPath) {
414
+ const root = String(rootPath || '').trim();
415
+ if (!root) return null;
416
+ return quietGet(`/api/git/remote?root=${encodeURIComponent(root)}`);
417
+ }
418
+
419
+ // The repositories the Claude GitHub app can reach, plus the ones used in
420
+ // earlier cloud chats here, for the New Chat Repository suggestions. null
421
+ // when the relay could not say (the field is then typed, as before).
422
+ export async function loadClaudeCloudRepos({ refresh = false } = {}) {
423
+ return quietGet(`/api/claude-cloud/repos${refresh ? '?refresh=1' : ''}`);
424
+ }
425
+
426
+ // The branches of a GitHub repository as the relay host's git sees them, for
427
+ // the New Chat Branch suggestions. null when the relay could not say.
428
+ export async function loadClaudeCloudBranches(repo) {
429
+ const text = String(repo || '').trim();
430
+ if (!text) return null;
431
+ return quietGet(`/api/claude-cloud/branches?repo=${encodeURIComponent(text)}`);
432
+ }
433
+
373
434
  export async function updateOpenAISettings({
374
435
  apiKey = '',
375
436
  model = 'gpt-4o',
@@ -542,6 +603,23 @@ export async function updateRemoteRelaySettings(patch = {}) {
542
603
  return remoteRelayRequest('/api/settings/remote-relays', { method: 'POST', body });
543
604
  }
544
605
 
606
+ // ─── Agent sessions (Settings → Relays) ──────────────────────────────────────
607
+ // `{ enabled, maxWaitSeconds, limits, maxActiveSessions }`, with `ok` and
608
+ // `status` as above. A relay without the feature answers 404, which the
609
+ // section reads as "do not show me", so the status has to travel with the
610
+ // answer instead of apiFetch's bare null.
611
+
612
+ export async function loadAgentSessionsSettings() {
613
+ return remoteRelayRequest('/api/settings/agent-sessions');
614
+ }
615
+
616
+ export async function updateAgentSessionsSettings(patch = {}) {
617
+ const body = {};
618
+ if (typeof patch.enabled === 'boolean') body.enabled = patch.enabled;
619
+ if (Number.isFinite(patch.maxWaitSeconds)) body.maxWaitSeconds = patch.maxWaitSeconds;
620
+ return remoteRelayRequest('/api/settings/agent-sessions', { method: 'POST', body });
621
+ }
622
+
545
623
  export async function loadUpdateState() {
546
624
  return apiFetch('/api/update/state');
547
625
  }
@@ -607,8 +685,18 @@ export async function relaunchSessionWorkerWithWorkspaceRoot(conversationId, roo
607
685
  });
608
686
  }
609
687
 
610
- export async function loadUsageSummary() {
611
- return apiFetch('/api/usage');
688
+ export async function loadUsageSummary({ providers = null } = {}) {
689
+ // The conversation the modal was opened from: the Claude Cloud card shows
690
+ // that conversation's own cost next to the totals. `providers` names the
691
+ // cards to read live (the rest come from the relay's last live answer);
692
+ // none means everything live.
693
+ const conversationId = String(currentConvId || '').trim();
694
+ const query = new URLSearchParams();
695
+ if (conversationId) query.set('conversationId', conversationId);
696
+ const ids = (Array.isArray(providers) ? providers : []).map((id) => String(id || '').trim()).filter(Boolean);
697
+ if (ids.length) query.set('providers', ids.join(','));
698
+ const text = query.toString();
699
+ return apiFetch(text ? `/api/usage?${text}` : '/api/usage');
612
700
  }
613
701
 
614
702
  export async function loadCursorAllowanceSettings() {
@@ -690,8 +778,9 @@ export async function loadConversations(options = {}) {
690
778
  if (beforeConversationId) params.set('beforeConversationId', beforeConversationId);
691
779
  const beforeUpdatedAt = String(options.beforeUpdatedAt || '').trim();
692
780
  if (beforeUpdatedAt) params.set('beforeUpdatedAt', beforeUpdatedAt);
693
- const includeArchived = String(options.archived || '').trim().toLowerCase();
694
- if (includeArchived === 'true') params.set('archived', 'true');
781
+ // 'true' lists live and archived chats, 'only' the archived ones.
782
+ const archivedMode = String(options.archived || '').trim().toLowerCase();
783
+ if (archivedMode === 'true' || archivedMode === 'only') params.set('archived', archivedMode);
695
784
  const query = params.toString();
696
785
  return apiFetch(`/api/conversations${query ? `?${query}` : ''}`);
697
786
  }
@@ -743,6 +832,39 @@ export async function searchMessages(options = {}) {
743
832
  // Raw fetch instead of apiFetch: a refused delete (the conversation is still
744
833
  // working, or its session would not stop) must surface the server's reason.
745
834
  // The long timeout covers stopping the session and deleting its CLI session.
835
+ // Archive and unarchive answer `{ ok }`, or `{ ok: false, message }` when the
836
+ // relay refuses (a chat still working on a reply is not archived).
837
+ export async function archiveConversation(id) {
838
+ const convId = String(id || '').trim();
839
+ if (!convId) return null;
840
+ return conversationActionRequest(`/api/conversation/${encodeURIComponent(convId)}/archive`);
841
+ }
842
+
843
+ export async function unarchiveConversation(id) {
844
+ const convId = String(id || '').trim();
845
+ if (!convId) return null;
846
+ return conversationActionRequest(`/api/conversation/${encodeURIComponent(convId)}/unarchive`);
847
+ }
848
+
849
+ async function conversationActionRequest(url) {
850
+ if (!areNetworkRequestsEnabled()) return null;
851
+ try {
852
+ const response = await fetch(`${BASE}${url}`, {
853
+ signal: requestTimeoutSignal(LONG_REQUEST_TIMEOUT_MS),
854
+ method: 'POST',
855
+ headers: { 'Content-Type': 'application/json', ...authHeaders() },
856
+ body: '{}',
857
+ });
858
+ const payload = await response.json().catch(() => null);
859
+ if (!response.ok) return { ...(payload || {}), ok: false, status: response.status };
860
+ noteFetchSuccess();
861
+ return payload || { ok: true };
862
+ } catch (error) {
863
+ noteFetchFailure(url, error);
864
+ return null;
865
+ }
866
+ }
867
+
746
868
  export async function deleteConversation(id) {
747
869
  const convId = String(id || '').trim();
748
870
  if (!convId || !areNetworkRequestsEnabled()) return null;
@@ -785,6 +907,23 @@ export async function updateConversationTitle(id, title) {
785
907
  });
786
908
  }
787
909
 
910
+ // The commit attribution a repo folder's Claude sessions use (null = the
911
+ // provider setting). Keyed by the workspace root path.
912
+ export async function loadWorkspaceRootAttribution(path) {
913
+ const root = String(path || '').trim();
914
+ if (!root) return null;
915
+ return apiFetch(`/api/workspace-root/attribution?path=${encodeURIComponent(root)}`);
916
+ }
917
+
918
+ export async function updateWorkspaceRootAttribution(path, attributionMode) {
919
+ const root = String(path || '').trim();
920
+ if (!root) return null;
921
+ return apiFetch('/api/workspace-root/attribution', {
922
+ method: 'POST',
923
+ body: JSON.stringify({ path: root, attributionMode: attributionMode || null }),
924
+ });
925
+ }
926
+
788
927
  export async function updateConversationPreferences(id, preferences = {}) {
789
928
  const convId = String(id || '').trim();
790
929
  if (!convId) return null;
@@ -972,6 +1111,20 @@ export async function cancelHostSuspend(body = {}) {
972
1111
  });
973
1112
  }
974
1113
 
1114
+ export async function resumeUsageLimitPause(conversationId) {
1115
+ return apiFetch('/api/usage-limit/resume', {
1116
+ method: 'POST',
1117
+ body: JSON.stringify({ conversationId }),
1118
+ });
1119
+ }
1120
+
1121
+ export async function cancelUsageLimitPause(conversationId) {
1122
+ return apiFetch('/api/usage-limit/cancel', {
1123
+ method: 'POST',
1124
+ body: JSON.stringify({ conversationId }),
1125
+ });
1126
+ }
1127
+
975
1128
  export async function cancelRelayRestart(body = {}) {
976
1129
  return apiFetch('/api/relay/shutdown/cancel', {
977
1130
  method: 'POST',
@@ -1091,8 +1244,9 @@ export async function answerRelayQuestionStructured(questionId, structuredAnswer
1091
1244
  }
1092
1245
  }
1093
1246
 
1094
- export async function loadRelayBoards(status = 'pending') {
1095
- return apiFetch(`/api/relay-boards?status=${encodeURIComponent(status)}`);
1247
+ export async function loadRelayBoards(status = 'pending', conversationId = '') {
1248
+ const conversation = String(conversationId || '').trim();
1249
+ return apiFetch(`/api/relay-boards?status=${encodeURIComponent(status)}${conversation ? `&conversationId=${encodeURIComponent(conversation)}` : ''}`);
1096
1250
  }
1097
1251
 
1098
1252
  export async function loadRelayBoard(boardId) {
@@ -13,6 +13,7 @@ import {
13
13
  showTransientRelayNotice,
14
14
  getConversationCurrentWorkspaceRootPath,
15
15
  IS_SHARED_VIEW,
16
+ conversations,
16
17
  } from './store.js';
17
18
  import { openAnnotateEditor } from './annotate-editor.js';
18
19
  import {
@@ -61,6 +62,11 @@ import {
61
62
  writeRepoBrowserHiddenPreference,
62
63
  } from './repo-browser-preferences.mjs';
63
64
  import { joinLaunchCwdPath } from './launch-cwd-path.mjs';
65
+ import {
66
+ cloudAttachmentRejectionNotice,
67
+ isClaudeCloudConversation,
68
+ partitionCloudAttachments,
69
+ } from './claude-cloud-ui.mjs';
64
70
 
65
71
  function currentConversationId() {
66
72
  return String(currentConvId || '').trim();
@@ -509,7 +515,7 @@ export async function ingestFiles(files, { source = 'picker' } = {}) {
509
515
  // conversations, and the result must never land in the wrong composer.
510
516
  const ownerConversationId = currentConversationId();
511
517
  const now = new Date();
512
- const prepared = [];
518
+ let prepared = [];
513
519
  for (let index = 0; index < inputFiles.length; index += 1) {
514
520
  prepared.push(await prepareIncomingFile(inputFiles[index], { source, now, index }));
515
521
  }
@@ -519,6 +525,19 @@ export async function ingestFiles(files, { source = 'picker' } = {}) {
519
525
  return [];
520
526
  }
521
527
 
528
+ // A Claude Cloud session takes inline images only; anything else would be
529
+ // refused by its worker with the whole turn. Checked after the re-encode
530
+ // above, which is what brings a large photo under the size limit.
531
+ if (isClaudeCloudConversation(conversations[ownerConversationId])) {
532
+ const { accepted, rejected } = partitionCloudAttachments(prepared);
533
+ if (rejected.length) {
534
+ for (const { attachment } of rejected) releaseAttachmentPreviewUrl(attachment);
535
+ showTransientRelayNotice(cloudAttachmentRejectionNotice(rejected), 7000);
536
+ prepared = accepted;
537
+ if (!prepared.length) return [];
538
+ }
539
+ }
540
+
522
541
  const plan = planAttachmentMerge(selectedAttachments, prepared, MAX_UPLOAD_ATTACHMENTS);
523
542
  const rejected = prepared.filter((item) => !plan.acceptedAdditions.includes(item));
524
543
  for (const item of rejected) {
@@ -0,0 +1,46 @@
1
+ // The commit-attribution row of the 🧠 context modal (Claude sessions only):
2
+ // what this repo folder's commits say, and a select to override the provider
3
+ // setting for the folder — the payload comes from /api/context (`attribution`).
4
+
5
+ const MODE_LABELS = Object.freeze({
6
+ oar: 'OAR — Open Agent Relay (model)',
7
+ vanilla: 'Vanilla — Claude Code\'s own',
8
+ off: 'Off — none',
9
+ });
10
+
11
+ function esc(value) {
12
+ return String(value ?? '')
13
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
14
+ }
15
+
16
+ export function attributionEffectText({ effectiveMode, attributionExample } = {}) {
17
+ if (effectiveMode === 'vanilla') return 'Commits keep Claude Code\'s own attribution.';
18
+ if (effectiveMode === 'off') return 'Commits and pull requests carry no attribution line.';
19
+ const trailer = String(attributionExample || '').trim();
20
+ return trailer ? `Commits end with: ${trailer}` : 'Commits end with the Open Agent Relay trailer.';
21
+ }
22
+
23
+ /**
24
+ * @param {object|null} attribution the `attribution` field of an /api/context response
25
+ * @returns {string} the row's HTML, or '' when the payload has no folder to speak of
26
+ */
27
+ export function renderAttributionControlHtml(attribution) {
28
+ if (!attribution || typeof attribution !== 'object') return '';
29
+ const path = String(attribution.path || '').trim();
30
+ if (!path) return '';
31
+ const folderMode = ['oar', 'vanilla', 'off'].includes(attribution.attributionMode) ? attribution.attributionMode : '';
32
+ const providerMode = ['oar', 'vanilla', 'off'].includes(attribution.providerMode) ? attribution.providerMode : 'oar';
33
+ const options = [
34
+ `<option value=""${folderMode === '' ? ' selected' : ''}>Inherit (provider: ${esc(MODE_LABELS[providerMode].split(' — ')[0])})</option>`,
35
+ ...['oar', 'vanilla', 'off'].map((mode) => `<option value="${mode}"${folderMode === mode ? ' selected' : ''}>${esc(MODE_LABELS[mode])}</option>`),
36
+ ].join('');
37
+ return `
38
+ <div class="ctx-attribution" data-workspace-root="${esc(path)}">
39
+ <div class="ctx-attribution-row">
40
+ <span class="ctx-attribution-label">Commit attribution:</span>
41
+ <select id="ctx-attribution-select" class="ctx-attribution-select" aria-label="Commit attribution for this folder" data-workspace-root="${esc(path)}">${options}</select>
42
+ </div>
43
+ <div class="ctx-attribution-note">For every Claude session in <code>${esc(path)}</code>. ${esc(attributionEffectText(attribution))} A change reaches running sessions with their next message.</div>
44
+ </div>
45
+ `;
46
+ }