@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.
- package/CHANGELOG.md +285 -0
- package/README.md +222 -40
- package/bin/oar.js +320 -18
- package/package.json +1 -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 -13
- package/server/claude-worker/claude-session-process.mjs +225 -24
- package/server/claude-worker/claude-session-worker.mjs +6 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +28 -0
- package/server/db-schema.mjs +17 -0
- package/server/migrations/0006-workspace-root-settings.mjs +67 -0
- package/server/migrations/0007-claude-cloud.mjs +82 -0
- package/server/public/app/activity-replay-state.mjs +68 -5
- 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 +149 -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 +468 -88
- 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 +11 -0
- package/server/public/app/context-menu.mjs +140 -0
- package/server/public/app/context-tier-options.mjs +4 -0
- package/server/public/app/context-usage-view.mjs +127 -8
- 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 +145 -11
- package/server/public/app/cwd-picker.js +12 -0
- package/server/public/app/journal-view.js +698 -28
- 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/pwa-install.js +5 -0
- package/server/public/app/relay-error-ctas.mjs +16 -2
- package/server/public/app/remote-relay-origin-view.mjs +120 -4
- package/server/public/app/router.js +49 -12
- package/server/public/app/scrollbar-hover.mjs +78 -0
- package/server/public/app/settings-modal.js +37 -3
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +53 -1
- package/server/public/app/store.js +26 -0
- package/server/public/app/transcript-separators.mjs +48 -11
- package/server/public/app-icon-192.png +0 -0
- package/server/public/app-icon-512.png +0 -0
- package/server/public/app-icon-light-192.png +0 -0
- package/server/public/app-icon-light-512.png +0 -0
- package/server/public/app-icon-light-maskable-512.png +0 -0
- package/server/public/app-icon-light.svg +4 -0
- package/server/public/app-icon-maskable-512.png +0 -0
- package/server/public/app-icon.svg +3 -3
- package/server/public/favicon.ico +0 -0
- package/server/public/index.html +564 -20
- package/server/public/manifest.webmanifest +9 -3
- package/server/public/sw.js +6 -1
- package/server/relay-tools.md +13 -1
- package/server/repositories/remote-relay-repository.mjs +42 -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 +123 -16
- package/server/routes/remote-relay-routes.mjs +15 -1
- package/server/routes/sessions-routes.mjs +517 -74
- package/server/server-runtime.mjs +268 -33
- 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/claude-context-usage.mjs +51 -3
- package/server/services/git-remote-service.mjs +115 -0
- package/server/services/oar-cli-helpers.mjs +67 -1
- 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/provider-reasoning-effort.mjs +3 -0
- package/server/services/pwa-manifest-theme.mjs +30 -0
- package/server/services/remote-relay-caller-context.mjs +49 -10
- package/server/services/remote-relay-client.mjs +9 -0
- package/server/services/remote-relay-dispatcher.mjs +383 -35
- package/server/services/remote-relay-inbound.mjs +49 -2
- 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 +348 -129
- package/server/services/session-worker-registry-service.mjs +18 -1
- package/server/services/session-worker-stop-service.mjs +24 -13
- package/server/services/session-worker-supervisor-service.mjs +56 -1
- package/server/services/update-install-service.mjs +9 -3
- package/server/services/usage-limit-pause-service.mjs +3 -1
- package/server/services/worker-launch-note-service.mjs +151 -0
- 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/provider-routing.mjs +4 -2
- package/shared/remote-relay-contract.mjs +153 -22
- package/shared/remote-relay-tool-core.mjs +11 -8
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
48
|
-
|
|
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
|
+
}
|