@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.
- package/.github/extensions/web-relay/polling/polling-loop.mjs +12 -32
- package/.github/extensions/web-relay/skills/question-bridge.mjs +14 -39
- package/CHANGELOG.md +340 -0
- package/README.md +196 -26
- package/package.json +2 -1
- package/server/README.md +174 -7
- package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +340 -0
- package/server/claude-cloud-worker/claude-cloud-session-process.mjs +1325 -0
- package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +155 -0
- package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +18 -2
- package/server/claude-worker/claude-session-process.mjs +151 -1
- package/server/claude-worker/claude-session-worker.mjs +6 -0
- package/server/claude-worker/claude-turn-failure.mjs +92 -0
- package/server/claude-worker/claude-turn-publisher.mjs +52 -3
- package/server/claude-worker/sdk-message-normalizer.mjs +13 -1
- package/server/copilot-worker/copilot-model-switch.mjs +29 -3
- package/server/copilot-worker/copilot-question-bridge.mjs +2 -4
- package/server/copilot-worker/copilot-runtime-tree.mjs +91 -0
- package/server/copilot-worker/copilot-sdk-adapter.mjs +48 -8
- package/server/copilot-worker/copilot-sdk-session-process.mjs +897 -166
- package/server/cursor-worker/cursor-sdk-adapter.mjs +80 -13
- package/server/cursor-worker/cursor-turn-runner.mjs +5 -2
- package/server/db-schema.mjs +24 -0
- package/server/grok-worker/acp-client.mjs +68 -12
- package/server/grok-worker/grok-sdk-adapter.mjs +4 -0
- package/server/grok-worker/grok-turn-runner.mjs +6 -2
- package/server/migrations/0006-workspace-root-settings.mjs +67 -0
- package/server/migrations/0007-claude-cloud.mjs +82 -0
- package/server/public/app/agent-sessions-settings-ui.js +197 -0
- package/server/public/app/agent-sessions-settings.mjs +134 -0
- package/server/public/app/api-client.js +160 -6
- package/server/public/app/attachments-view.js +20 -1
- package/server/public/app/attribution-control.mjs +46 -0
- package/server/public/app/bootstrap.js +347 -31
- package/server/public/app/claude-cloud-conversation-ui.js +110 -0
- package/server/public/app/claude-cloud-settings-ui.js +374 -0
- package/server/public/app/claude-cloud-ui.mjs +611 -0
- package/server/public/app/composer-control-state.mjs +15 -3
- package/server/public/app/context-menu.mjs +140 -0
- package/server/public/app/context-tier-options.mjs +4 -0
- package/server/public/app/conversation-origin-ui.js +27 -0
- package/server/public/app/conversation-provider-indicator.mjs +22 -4
- package/server/public/app/conversation-view.js +178 -8
- package/server/public/app/cwd-picker.js +12 -0
- package/server/public/app/journal-view.js +704 -28
- package/server/public/app/markdown-strikethrough.mjs +31 -0
- package/server/public/app/math-dollars.mjs +89 -0
- package/server/public/app/model-selector-options.mjs +1 -1
- package/server/public/app/plan-usage-view.mjs +36 -8
- package/server/public/app/relay-board-inline.mjs +70 -0
- package/server/public/app/relay-board-view.js +157 -61
- package/server/public/app/relay-error-ctas.mjs +28 -5
- package/server/public/app/remote-relay-origin-view.mjs +120 -4
- package/server/public/app/router.js +49 -12
- package/server/public/app/settings-modal.js +33 -2
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +60 -0
- package/server/public/app/store.js +4 -1
- package/server/public/app/subagent-fold.mjs +61 -0
- package/server/public/app/usage-limit-ui.js +225 -0
- package/server/public/index.html +471 -8
- package/server/relay-tools.md +13 -1
- package/server/repositories/message-repository.mjs +10 -1
- package/server/repositories/question-repository.mjs +1 -0
- package/server/repositories/remote-relay-repository.mjs +72 -1
- package/server/repositories/session-repository.mjs +47 -1
- package/server/routes/claude-cloud-routes.mjs +140 -0
- package/server/routes/git-routes.mjs +37 -0
- package/server/routes/messages-routes.mjs +202 -26
- package/server/routes/relay-board-routes.mjs +56 -2
- package/server/routes/remote-relay-routes.mjs +15 -1
- package/server/routes/sessions-routes.mjs +538 -72
- package/server/server-runtime.mjs +291 -27
- package/server/services/claude-account-usage-service.mjs +176 -0
- package/server/services/claude-cloud-repo-service.mjs +408 -0
- package/server/services/claude-cloud-session-service.mjs +326 -0
- package/server/services/claude-cloud-settings-service.mjs +269 -0
- package/server/services/git-remote-service.mjs +115 -0
- package/server/services/plan-usage-claude-cloud.mjs +211 -0
- package/server/services/plan-usage-claude.mjs +202 -40
- package/server/services/plan-usage-contract.mjs +39 -3
- package/server/services/plan-usage-service.mjs +82 -2
- package/server/services/preview-proxy-server.mjs +64 -36
- package/server/services/process-list-service.mjs +122 -0
- package/server/services/provider-reasoning-effort.mjs +3 -0
- package/server/services/relay-console-log.mjs +199 -0
- package/server/services/remote-relay-caller-context.mjs +78 -19
- package/server/services/remote-relay-client.mjs +9 -0
- package/server/services/remote-relay-dispatcher.mjs +451 -39
- package/server/services/remote-relay-inbound.mjs +83 -1
- package/server/services/remote-relay-loopback.mjs +245 -0
- package/server/services/remote-relay-registry-service.mjs +52 -0
- package/server/services/session-worker-launch-service.mjs +100 -15
- package/server/services/session-worker-process-service.mjs +410 -138
- package/server/services/session-worker-registry-service.mjs +1 -1
- package/server/services/session-worker-stop-service.mjs +24 -13
- package/server/services/session-worker-supervisor-service.mjs +89 -2
- package/server/services/usage-limit-pause-service.mjs +443 -0
- package/server/services/worker-launch-note-service.mjs +151 -0
- package/shared/ask-user-bridge.mjs +14 -39
- package/shared/claude-attribution.mjs +117 -0
- package/shared/claude-cloud/api-client.mjs +809 -0
- package/shared/claude-cloud/base-url.mjs +47 -0
- package/shared/claude-cloud/credentials.mjs +221 -0
- package/shared/claude-cloud/repo-url.mjs +111 -0
- package/shared/claude-usage-limit.mjs +257 -0
- package/shared/control-poller.mjs +69 -20
- package/shared/failure-note-text.mjs +38 -0
- package/shared/provider-routing.mjs +4 -2
- package/shared/question-wait.mjs +108 -0
- package/shared/remote-relay-contract.mjs +167 -22
- package/shared/remote-relay-tool-core.mjs +11 -8
- package/shared/steer-settle-failure.mjs +17 -0
- package/shared/steer-settle-markers.mjs +8 -0
- package/shared/worker-runtime/process-tree.mjs +483 -0
- package/shared/worker-runtime/relay-errors.mjs +31 -0
- package/shared/worker-runtime/send-and-wait-errors.mjs +4 -4
- 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
|
-
|
|
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
|
-
|
|
694
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
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
|
+
}
|