@oar-sh/oar 0.9.1 → 0.9.3
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/extension.mjs +0 -3
- package/.github/extensions/web-relay/polling/heartbeat.mjs +2 -55
- package/.github/extensions/web-relay/polling/polling-loop.mjs +0 -1
- package/.github/extensions/web-relay/runtime/api-client.mjs +2 -34
- package/.github/extensions/web-relay/runtime/config-loader.mjs +2 -104
- package/.github/extensions/web-relay/runtime/send-and-wait-errors.mjs +2 -170
- package/.github/extensions/web-relay/runtime/session-io.mjs +1 -71
- package/.github/extensions/web-relay/runtime/worker-websocket-link.mjs +2 -325
- package/.github/extensions/web-relay/skills/prompt-context.mjs +2 -160
- package/CHANGELOG.md +269 -0
- package/README.md +407 -424
- package/bin/oar.js +142 -11
- package/package.json +8 -4
- package/server/README.md +59 -22
- package/server/claude-worker/claude-sdk-adapter.mjs +7 -2
- package/server/claude-worker/claude-session-process.mjs +1013 -45
- package/server/claude-worker/claude-session-worker.mjs +54 -9
- package/server/claude-worker/claude-turn-publisher.mjs +110 -19
- package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
- package/server/copilot-sdk-runtime.mjs +12 -3
- package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
- package/server/copilot-worker/copilot-attachments.mjs +35 -2
- package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
- package/server/copilot-worker/copilot-model-switch.mjs +425 -0
- package/server/copilot-worker/copilot-prompt-context.mjs +20 -8
- package/server/copilot-worker/copilot-question-bridge.mjs +129 -16
- package/server/copilot-worker/copilot-sdk-adapter.mjs +59 -11
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +136 -12
- package/server/copilot-worker/copilot-sdk-session-process.mjs +2753 -527
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +94 -25
- package/server/cursor-worker/cursor-session-worker.mjs +5 -5
- package/server/cursor-worker/cursor-turn-runner.mjs +50 -4
- package/server/db-schema.mjs +61 -1
- package/server/features.mjs +155 -41
- package/server/grok-worker/grok-session-worker.mjs +5 -5
- package/server/grok-worker/grok-turn-runner.mjs +45 -4
- package/server/migrations/0004-steer-settle-markers.mjs +119 -0
- package/server/public/app/annotate-core.mjs +181 -0
- package/server/public/app/annotate-editor.js +617 -0
- package/server/public/app/api-client.js +86 -5
- package/server/public/app/ask-user-view.js +102 -13
- package/server/public/app/attachments-view.js +172 -3
- package/server/public/app/background-task-timeout-options.mjs +9 -0
- package/server/public/app/background-tasks-view.mjs +34 -19
- package/server/public/app/bootstrap.js +153 -69
- package/server/public/app/composer-control-state.mjs +52 -34
- package/server/public/app/context-tier-options.mjs +24 -4
- package/server/public/app/conversation-draft-sync.mjs +187 -0
- package/server/public/app/conversation-list-filter.mjs +86 -0
- package/server/public/app/conversation-preferences.mjs +47 -10
- package/server/public/app/conversation-provider-indicator.mjs +8 -0
- package/server/public/app/conversation-view.js +1032 -218
- package/server/public/app/emoji-view.js +2 -0
- package/server/public/app/font-scaling.js +4 -37
- package/server/public/app/journal-view.js +190 -24
- package/server/public/app/model-selector-options.mjs +185 -1
- package/server/public/app/new-conversation-model-choice.mjs +29 -9
- package/server/public/app/pwa-install.js +64 -136
- package/server/public/app/question-multi-select.mjs +52 -0
- package/server/public/app/reasoning-effort-labels.mjs +13 -3
- package/server/public/app/relay-prompt-sanitizer.mjs +26 -3
- package/server/public/app/router.js +120 -11
- package/server/public/app/settings-modal.js +225 -20
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +47 -4
- package/server/public/app/steered-turn-merge.mjs +100 -0
- package/server/public/app/store.js +159 -16
- package/server/public/app/sync-outbox.mjs +16 -8
- package/server/public/app/tool-activity-emoji.mjs +36 -0
- package/server/public/app/transcript-viewport-anchor.mjs +210 -0
- package/server/public/app/update-ui.js +194 -0
- package/server/public/index.html +483 -24
- package/server/public/sw.js +7 -0
- package/server/relay-tools.md +2 -0
- package/server/relay.mjs +23 -21
- package/server/repositories/message-repository.mjs +78 -21
- package/server/repositories/question-repository.mjs +19 -1
- package/server/repositories/session-repository.mjs +7 -31
- package/server/routes/ask-user-routes.mjs +10 -0
- package/server/routes/messages-routes.mjs +689 -159
- package/server/routes/sessions-routes.mjs +574 -80
- package/server/server-runtime.mjs +393 -925
- package/server/services/claude-auth-service.mjs +7 -1
- package/server/services/cli-install-service.mjs +8 -27
- package/server/services/cli-process-runner.mjs +30 -0
- package/server/services/copilot-model-discovery-service.mjs +202 -0
- package/server/services/data-dir-resolution.mjs +47 -0
- package/server/services/grok-auth-service.mjs +9 -1
- package/server/services/live-turn-picker.mjs +47 -0
- package/server/services/model-variant-catalog-service.mjs +1183 -0
- package/server/services/oar-cli-helpers.mjs +6 -2
- package/server/services/relay-prompt-sanitizer.mjs +30 -3
- package/server/services/relay-question-context.mjs +46 -0
- package/server/services/sdk-session-import-service.mjs +144 -16
- package/server/services/sdk-session-sync-service.mjs +84 -27
- package/server/services/session-worker-registry-service.mjs +116 -3
- package/server/services/session-worker-supervisor-service.mjs +7 -1
- package/server/services/session-worker-websocket-service.mjs +119 -24
- package/server/services/status-event-service.mjs +8 -2
- package/server/services/steer-resend.mjs +45 -0
- package/server/services/update-check-service.mjs +220 -0
- package/server/services/update-install-service.mjs +279 -0
- package/server/services/windows-autostart-service.mjs +42 -6
- package/server/services/windows-boot-autostart-service.mjs +487 -0
- package/shared/ask-user-bridge.mjs +37 -5
- package/shared/background-task-timeout.mjs +12 -4
- package/shared/claude-model-catalog.mjs +49 -0
- package/shared/context-window-fallbacks.mjs +1 -0
- package/shared/control-poller.mjs +4 -1
- package/shared/copilot-model-order.mjs +182 -0
- package/shared/media-embed-instructions.mjs +35 -0
- package/shared/model-descriptors.mjs +181 -30
- package/shared/model-id.mjs +6 -0
- package/shared/pwa-app-name.mjs +44 -0
- package/shared/question-multi-select.mjs +16 -0
- package/shared/relay-stop-failure.mjs +15 -0
- package/shared/slash-command-guard.mjs +76 -0
- package/shared/steer-note.mjs +66 -0
- package/shared/steer-settle-failure.mjs +89 -0
- package/shared/steer-settle-markers.mjs +18 -0
- package/shared/update-semver.mjs +50 -0
- package/shared/worker-crash-guard.mjs +40 -14
- package/shared/worker-runtime/api-client.mjs +34 -0
- package/shared/worker-runtime/config-loader.mjs +104 -0
- package/shared/worker-runtime/heartbeat.mjs +109 -0
- package/shared/worker-runtime/prompt-context.mjs +160 -0
- package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
- package/shared/worker-runtime/send-and-wait-errors.mjs +170 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +436 -0
- package/server/claude-worker/claude-session-test-harness.mjs +0 -236
- package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
- package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -329
- package/server/copilot-worker/fixtures/abort-turn.json +0 -588
- package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
- package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
- package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
- package/server/copilot-worker/fixtures/happy-turn.json +0 -566
- package/server/copilot-worker/fixtures/quota-turn.json +0 -647
- package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
- package/server/copilot-worker/fixtures/resume-turn.json +0 -700
- package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
- package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
- package/server/routes/messages-routes-test-harness.mjs +0 -111
- package/server/services/fixtures/claude-auth-stub.sh +0 -62
- package/server/services/fixtures/cli-install-stub.sh +0 -75
- package/server/services/fixtures/grok-stub.sh +0 -86
|
@@ -29,7 +29,11 @@ import {
|
|
|
29
29
|
updateTurnCeilingSetting as requestTurnCeilingSetting,
|
|
30
30
|
loadBackgroundTaskTimeoutSetting,
|
|
31
31
|
updateBackgroundTaskTimeoutSetting as requestBackgroundTaskTimeoutSetting,
|
|
32
|
+
loadFeatureFlagSettings,
|
|
33
|
+
updateFeatureFlagSetting as requestFeatureFlagSetting,
|
|
34
|
+
requestRelayRestart,
|
|
32
35
|
} from './api-client.js';
|
|
36
|
+
import { DEFAULT_BACKGROUND_TASK_TIMEOUT_MINUTES } from './background-task-timeout-options.mjs';
|
|
33
37
|
import { syncFontScaleSelect } from './font-scaling.js';
|
|
34
38
|
import { syncPwaAppNameInput } from './pwa-install.js';
|
|
35
39
|
import { normalizeKnownCwdPath } from './cwd-picker.js';
|
|
@@ -38,6 +42,7 @@ import { selectSettingsTab } from './settings-tabs.js';
|
|
|
38
42
|
import { refreshClaudeAuthSection } from './claude-auth-ui.js';
|
|
39
43
|
import { refreshGrokAuthSection } from './grok-auth-ui.js';
|
|
40
44
|
import { refreshCliInstallSections } from './cli-install-ui.js';
|
|
45
|
+
import { refreshUpdateSection } from './update-ui.js';
|
|
41
46
|
import {
|
|
42
47
|
getPreviews,
|
|
43
48
|
renderPreviewRowsInto,
|
|
@@ -69,7 +74,8 @@ function ensureOpenAISettingsInputTracking() {
|
|
|
69
74
|
}
|
|
70
75
|
}
|
|
71
76
|
let windowsAutostartUpdateInFlight = false;
|
|
72
|
-
let
|
|
77
|
+
let windowsAutostartState = null;
|
|
78
|
+
let windowsAutostartPollTimer = null;
|
|
73
79
|
|
|
74
80
|
// Mirrors shared/turn-ceiling.mjs, which the browser cannot import — only
|
|
75
81
|
// server/public is served. The authoritative bounds come from the API response;
|
|
@@ -147,9 +153,10 @@ export async function updateTurnCeilingSetting(value) {
|
|
|
147
153
|
}
|
|
148
154
|
}
|
|
149
155
|
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
|
|
156
|
+
// Starts at the default the relay enforces, so the slider tells the truth while
|
|
157
|
+
// the settings request is in flight or after it failed. Bounds come from the
|
|
158
|
+
// API response.
|
|
159
|
+
let backgroundTaskTimeoutMinutes = DEFAULT_BACKGROUND_TASK_TIMEOUT_MINUTES;
|
|
153
160
|
let backgroundTaskTimeoutUpdateInFlight = false;
|
|
154
161
|
|
|
155
162
|
function syncBackgroundTaskTimeoutSlider() {
|
|
@@ -348,8 +355,8 @@ export function applyCopilotSettingsState(settings = {}, { error = '' } = {}) {
|
|
|
348
355
|
status.dataset.state = 'error';
|
|
349
356
|
} else {
|
|
350
357
|
status.textContent = copilotSettingsState.engine === 'sdk'
|
|
351
|
-
? 'New Copilot conversations run on the
|
|
352
|
-
: 'New Copilot conversations run on the Copilot CLI with the web-relay extension
|
|
358
|
+
? 'New Copilot conversations run on the headless SDK worker. No tmux inspector for these sessions.'
|
|
359
|
+
: 'New Copilot conversations run on the Copilot CLI with the web-relay extension.';
|
|
353
360
|
status.dataset.state = copilotSettingsState.engine === 'sdk' ? 'active' : 'unconfigured';
|
|
354
361
|
}
|
|
355
362
|
}
|
|
@@ -391,7 +398,7 @@ export async function saveCopilotSettings() {
|
|
|
391
398
|
settled = result;
|
|
392
399
|
showTransientRelayNotice(
|
|
393
400
|
String(result.engine) === 'sdk'
|
|
394
|
-
? 'Copilot engine set to SDK
|
|
401
|
+
? 'Copilot engine set to SDK. New conversations use the headless worker; running sessions keep their engine until they restart.'
|
|
395
402
|
: 'Copilot engine set to Extension. New conversations use the Copilot CLI.',
|
|
396
403
|
6000,
|
|
397
404
|
);
|
|
@@ -1250,15 +1257,79 @@ export async function removeOpenAISettings() {
|
|
|
1250
1257
|
}
|
|
1251
1258
|
}
|
|
1252
1259
|
|
|
1260
|
+
function windowsAutostartStatusText() {
|
|
1261
|
+
const boot = windowsAutostartState?.boot;
|
|
1262
|
+
if (boot?.pendingElevation) {
|
|
1263
|
+
return 'Confirm the admin prompt on the PC… (it only appears on the physical desktop)';
|
|
1264
|
+
}
|
|
1265
|
+
if (boot?.lastError) return boot.lastError;
|
|
1266
|
+
if (windowsAutostartState?.mode === 'boot' && windowsAutostartState?.signinEnabled) {
|
|
1267
|
+
return 'A sign-in Startup entry also exists and can double-start the relay. Re-select "At system startup" to clean it up.';
|
|
1268
|
+
}
|
|
1269
|
+
if (boot?.taskStatus === 'foreign') {
|
|
1270
|
+
return `A task named "${boot.taskName}" exists but was not created by OAR. Selecting "At system startup" replaces it.`;
|
|
1271
|
+
}
|
|
1272
|
+
if (boot?.legacyTaskPresent && windowsAutostartState?.mode !== 'boot') {
|
|
1273
|
+
return 'A pre-rebrand boot task exists. Selecting "At system startup" migrates it (one admin confirmation).';
|
|
1274
|
+
}
|
|
1275
|
+
return '';
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1253
1278
|
function syncWindowsAutostartSetting() {
|
|
1254
1279
|
const container = document.getElementById('windows-autostart-setting');
|
|
1255
|
-
const checkbox = document.getElementById('windows-autostart-toggle');
|
|
1256
1280
|
const supported = serverPlatform === 'win32';
|
|
1257
1281
|
if (container) container.hidden = !supported;
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1282
|
+
const mode = windowsAutostartState?.mode || 'off';
|
|
1283
|
+
const pending = !!windowsAutostartState?.boot?.pendingElevation;
|
|
1284
|
+
for (const radio of document.querySelectorAll('input[name="windows-autostart-mode"]')) {
|
|
1285
|
+
radio.checked = radio.value === mode;
|
|
1286
|
+
radio.disabled = !supported || windowsAutostartUpdateInFlight || pending;
|
|
1261
1287
|
}
|
|
1288
|
+
const status = document.getElementById('windows-autostart-status');
|
|
1289
|
+
if (status) {
|
|
1290
|
+
const text = windowsAutostartStatusText();
|
|
1291
|
+
status.hidden = !text;
|
|
1292
|
+
status.textContent = text;
|
|
1293
|
+
}
|
|
1294
|
+
const manual = document.getElementById('windows-autostart-manual');
|
|
1295
|
+
const manualCommand = document.getElementById('windows-autostart-manual-command');
|
|
1296
|
+
const showManual = !!windowsAutostartState?.boot?.lastError && !!windowsAutostartState?.boot?.manualCommand;
|
|
1297
|
+
if (manual) manual.hidden = !showManual;
|
|
1298
|
+
if (manualCommand && showManual) manualCommand.textContent = windowsAutostartState.boot.manualCommand;
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
function stopWindowsAutostartPoll() {
|
|
1302
|
+
if (windowsAutostartPollTimer) {
|
|
1303
|
+
clearTimeout(windowsAutostartPollTimer);
|
|
1304
|
+
windowsAutostartPollTimer = null;
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
/**
|
|
1309
|
+
* The UAC prompt renders on the PC's own desktop, so a remote browser can
|
|
1310
|
+
* only wait: poll until the elevation resolves either way, bounded to the
|
|
1311
|
+
* service's own 120s timeout.
|
|
1312
|
+
*/
|
|
1313
|
+
function pollWindowsAutostartWhilePending(deadline) {
|
|
1314
|
+
stopWindowsAutostartPoll();
|
|
1315
|
+
if (!windowsAutostartState?.boot?.pendingElevation || Date.now() > deadline) return;
|
|
1316
|
+
windowsAutostartPollTimer = setTimeout(async () => {
|
|
1317
|
+
try {
|
|
1318
|
+
windowsAutostartState = await loadWindowsAutostartSetting();
|
|
1319
|
+
} catch {
|
|
1320
|
+
// transient; the next tick retries
|
|
1321
|
+
}
|
|
1322
|
+
syncWindowsAutostartSetting();
|
|
1323
|
+
if (windowsAutostartState?.boot?.pendingElevation) {
|
|
1324
|
+
pollWindowsAutostartWhilePending(deadline);
|
|
1325
|
+
} else if (!windowsAutostartState?.boot?.lastError) {
|
|
1326
|
+
showTransientRelayNotice(
|
|
1327
|
+
windowsAutostartState?.mode === 'boot'
|
|
1328
|
+
? 'System-startup autostart enabled.'
|
|
1329
|
+
: 'Boot task removed.',
|
|
1330
|
+
);
|
|
1331
|
+
}
|
|
1332
|
+
}, 2_000);
|
|
1262
1333
|
}
|
|
1263
1334
|
|
|
1264
1335
|
export async function refreshWindowsAutostartSetting() {
|
|
@@ -1272,16 +1343,19 @@ export async function refreshWindowsAutostartSetting() {
|
|
|
1272
1343
|
alert('Failed to read the Windows autostart setting.');
|
|
1273
1344
|
return;
|
|
1274
1345
|
}
|
|
1275
|
-
|
|
1346
|
+
windowsAutostartState = result;
|
|
1276
1347
|
} catch (error) {
|
|
1277
1348
|
alert(error?.message || 'Failed to read the Windows autostart setting.');
|
|
1278
1349
|
} finally {
|
|
1279
1350
|
windowsAutostartUpdateInFlight = false;
|
|
1280
1351
|
syncWindowsAutostartSetting();
|
|
1352
|
+
if (windowsAutostartState?.boot?.pendingElevation) {
|
|
1353
|
+
pollWindowsAutostartWhilePending(Date.now() + 120_000);
|
|
1354
|
+
}
|
|
1281
1355
|
}
|
|
1282
1356
|
}
|
|
1283
1357
|
|
|
1284
|
-
export async function
|
|
1358
|
+
export async function updateWindowsAutostartMode(mode) {
|
|
1285
1359
|
if (serverPlatform !== 'win32' || windowsAutostartUpdateInFlight) {
|
|
1286
1360
|
syncWindowsAutostartSetting();
|
|
1287
1361
|
return;
|
|
@@ -1289,17 +1363,21 @@ export async function updateWindowsAutostartSettingFromToggle(enabled) {
|
|
|
1289
1363
|
windowsAutostartUpdateInFlight = true;
|
|
1290
1364
|
syncWindowsAutostartSetting();
|
|
1291
1365
|
try {
|
|
1292
|
-
const result = await updateWindowsAutostartSetting(
|
|
1366
|
+
const result = await updateWindowsAutostartSetting(String(mode));
|
|
1293
1367
|
if (!result?.supported) {
|
|
1294
1368
|
alert('Failed to update the Windows autostart setting.');
|
|
1295
1369
|
return;
|
|
1296
1370
|
}
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1371
|
+
windowsAutostartState = result;
|
|
1372
|
+
if (result?.boot?.pendingElevation) {
|
|
1373
|
+
pollWindowsAutostartWhilePending(Date.now() + 120_000);
|
|
1374
|
+
} else if (!result?.boot?.lastError) {
|
|
1375
|
+
showTransientRelayNotice(
|
|
1376
|
+
result.mode === 'signin' ? 'Sign-in autostart enabled.'
|
|
1377
|
+
: result.mode === 'off' ? 'Windows autostart disabled.'
|
|
1378
|
+
: 'Windows autostart updated.',
|
|
1379
|
+
);
|
|
1380
|
+
}
|
|
1303
1381
|
} catch (error) {
|
|
1304
1382
|
alert(error?.message || 'Failed to update the Windows autostart setting.');
|
|
1305
1383
|
} finally {
|
|
@@ -1308,6 +1386,131 @@ export async function updateWindowsAutostartSettingFromToggle(enabled) {
|
|
|
1308
1386
|
}
|
|
1309
1387
|
}
|
|
1310
1388
|
|
|
1389
|
+
// ─── Features tab ─────────────────────────────────────────────────────────────
|
|
1390
|
+
// Flags are resolved once at relay boot; the rows here render entirely from the
|
|
1391
|
+
// server's registry payload (labels and descriptions live in server/features.mjs),
|
|
1392
|
+
// and the restart notice appears whenever a stored value differs from the
|
|
1393
|
+
// running snapshot. Rows are dynamic, so listeners are attached during render
|
|
1394
|
+
// instead of via inline onchange globals.
|
|
1395
|
+
|
|
1396
|
+
let featureFlagsState = null;
|
|
1397
|
+
let featureFlagsUpdateInFlight = false;
|
|
1398
|
+
let featuresRestartButtonBound = false;
|
|
1399
|
+
|
|
1400
|
+
function featureToggleId(name) {
|
|
1401
|
+
return `feature-toggle-${String(name || '').toLowerCase()}`;
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
function syncFeatureFlagsSection() {
|
|
1405
|
+
const list = document.getElementById('features-flag-list');
|
|
1406
|
+
if (!list) return;
|
|
1407
|
+
list.textContent = '';
|
|
1408
|
+
const flags = Array.isArray(featureFlagsState?.flags) ? featureFlagsState.flags : [];
|
|
1409
|
+
for (const flag of flags) {
|
|
1410
|
+
const row = document.createElement('div');
|
|
1411
|
+
row.className = 'settings-row';
|
|
1412
|
+
row.style.marginTop = '12px';
|
|
1413
|
+
row.style.marginBottom = '0';
|
|
1414
|
+
|
|
1415
|
+
const label = document.createElement('label');
|
|
1416
|
+
label.htmlFor = featureToggleId(flag.name);
|
|
1417
|
+
label.textContent = flag.label || flag.name;
|
|
1418
|
+
row.appendChild(label);
|
|
1419
|
+
|
|
1420
|
+
const checkbox = document.createElement('input');
|
|
1421
|
+
checkbox.id = featureToggleId(flag.name);
|
|
1422
|
+
checkbox.type = 'checkbox';
|
|
1423
|
+
checkbox.checked = flag.effectiveNext === true;
|
|
1424
|
+
checkbox.disabled = featureFlagsUpdateInFlight || flag.envOverride !== null;
|
|
1425
|
+
checkbox.setAttribute('aria-label', flag.label || flag.name);
|
|
1426
|
+
checkbox.addEventListener('change', () => {
|
|
1427
|
+
void updateFeatureFlagFromToggle(flag.name, checkbox.checked);
|
|
1428
|
+
});
|
|
1429
|
+
row.appendChild(checkbox);
|
|
1430
|
+
list.appendChild(row);
|
|
1431
|
+
|
|
1432
|
+
const help = document.createElement('div');
|
|
1433
|
+
help.className = 'settings-help';
|
|
1434
|
+
help.style.marginTop = '6px';
|
|
1435
|
+
let text = String(flag.description || '');
|
|
1436
|
+
if (flag.envOverride !== null) {
|
|
1437
|
+
text += ` Pinned ${flag.envOverride ? 'on' : 'off'} by COPILOT_REMOTE_${flag.name} in the relay's environment; the toggle is locked while that variable is set.`;
|
|
1438
|
+
}
|
|
1439
|
+
if (flag.restartRequired) {
|
|
1440
|
+
text += ' Restart pending: the saved value takes effect after the relay restarts.';
|
|
1441
|
+
}
|
|
1442
|
+
help.textContent = text;
|
|
1443
|
+
list.appendChild(help);
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
const notice = document.getElementById('features-restart-notice');
|
|
1447
|
+
if (notice) notice.hidden = featureFlagsState?.restartRequired !== true;
|
|
1448
|
+
const restartBtn = document.getElementById('features-restart-btn');
|
|
1449
|
+
if (restartBtn) {
|
|
1450
|
+
restartBtn.disabled = featureFlagsUpdateInFlight;
|
|
1451
|
+
if (!featuresRestartButtonBound) {
|
|
1452
|
+
featuresRestartButtonBound = true;
|
|
1453
|
+
restartBtn.addEventListener('click', () => { void restartRelayForFeatureFlags(); });
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
function setFeaturesSettingsStatus(text) {
|
|
1459
|
+
const status = document.getElementById('features-settings-status');
|
|
1460
|
+
if (!status) return;
|
|
1461
|
+
status.hidden = !text;
|
|
1462
|
+
status.textContent = text || '';
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
export async function refreshFeatureFlagsSection() {
|
|
1466
|
+
syncFeatureFlagsSection();
|
|
1467
|
+
const result = await loadFeatureFlagSettings();
|
|
1468
|
+
if (!result || !Array.isArray(result.flags)) {
|
|
1469
|
+
setFeaturesSettingsStatus('Failed to load feature flags from the relay.');
|
|
1470
|
+
return;
|
|
1471
|
+
}
|
|
1472
|
+
featureFlagsState = result;
|
|
1473
|
+
setFeaturesSettingsStatus('');
|
|
1474
|
+
syncFeatureFlagsSection();
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
async function updateFeatureFlagFromToggle(name, enabled) {
|
|
1478
|
+
if (featureFlagsUpdateInFlight) {
|
|
1479
|
+
syncFeatureFlagsSection();
|
|
1480
|
+
return;
|
|
1481
|
+
}
|
|
1482
|
+
featureFlagsUpdateInFlight = true;
|
|
1483
|
+
syncFeatureFlagsSection();
|
|
1484
|
+
try {
|
|
1485
|
+
const result = await requestFeatureFlagSetting(name, enabled);
|
|
1486
|
+
if (!result || !Array.isArray(result.flags)) {
|
|
1487
|
+
alert('Failed to update the feature flag.');
|
|
1488
|
+
return;
|
|
1489
|
+
}
|
|
1490
|
+
featureFlagsState = result;
|
|
1491
|
+
} finally {
|
|
1492
|
+
featureFlagsUpdateInFlight = false;
|
|
1493
|
+
syncFeatureFlagsSection();
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
async function restartRelayForFeatureFlags() {
|
|
1498
|
+
if (featureFlagsUpdateInFlight) return;
|
|
1499
|
+
featureFlagsUpdateInFlight = true;
|
|
1500
|
+
syncFeatureFlagsSection();
|
|
1501
|
+
try {
|
|
1502
|
+
const result = await requestRelayRestart({ reason: 'feature-flags-changed' });
|
|
1503
|
+
if (!result) {
|
|
1504
|
+
alert('Failed to request a relay restart. Restarts can only be requested from the relay host itself.');
|
|
1505
|
+
return;
|
|
1506
|
+
}
|
|
1507
|
+
showTransientRelayNotice('Relay restart requested. It will restart once the queue is idle.');
|
|
1508
|
+
} finally {
|
|
1509
|
+
featureFlagsUpdateInFlight = false;
|
|
1510
|
+
syncFeatureFlagsSection();
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1311
1514
|
function readShowSuspendHostSetting() {
|
|
1312
1515
|
const stored = String(readLocalStorage(SHOW_SUSPEND_HOST_STORAGE_KEY) || '').trim().toLowerCase();
|
|
1313
1516
|
if (!stored) return true;
|
|
@@ -1473,6 +1676,8 @@ export function openSettingsModal(tab, providerTab) {
|
|
|
1473
1676
|
void refreshBackgroundTaskTimeoutSetting();
|
|
1474
1677
|
void refreshPushSettingsSection();
|
|
1475
1678
|
void refreshPreviewsSection();
|
|
1679
|
+
void refreshFeatureFlagsSection();
|
|
1680
|
+
void refreshUpdateSection();
|
|
1476
1681
|
modal?.classList.add('visible');
|
|
1477
1682
|
modal?.setAttribute('aria-hidden', 'false');
|
|
1478
1683
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
const SETTINGS_TAB_STORAGE_KEY = 'copilot_settings_tab';
|
|
2
2
|
const SETTINGS_PROVIDER_TAB_STORAGE_KEY = 'copilot_settings_provider_tab';
|
|
3
3
|
|
|
4
|
-
const SETTINGS_TABS = ['general', 'providers', 'previews', 'notifications'];
|
|
4
|
+
const SETTINGS_TABS = ['general', 'providers', 'previews', 'notifications', 'features'];
|
|
5
5
|
// Copilot leads: it is the default provider, and `SETTINGS_PROVIDER_TABS[0]`
|
|
6
6
|
// is also the sub-tab a first-time visitor lands on.
|
|
7
7
|
const SETTINGS_PROVIDER_TABS = ['copilot', 'openai', 'claude', 'grok', 'cursor'];
|
|
@@ -28,6 +28,8 @@ import {
|
|
|
28
28
|
addSubagentThought,
|
|
29
29
|
clearSubagentCancelInFlight,
|
|
30
30
|
setConversationWatcherCount,
|
|
31
|
+
setUpdateState,
|
|
32
|
+
applySessionWorkerSteering,
|
|
31
33
|
} from './store.js';
|
|
32
34
|
import { scheduleContextUsageRefresh } from './api-client.js';
|
|
33
35
|
import { publishStatusEvent, recordStatusEvent } from './status-store.mjs';
|
|
@@ -43,6 +45,7 @@ import { setPreviews } from './preview-cards.mjs';
|
|
|
43
45
|
import { applyClaudeAuthState } from './claude-auth-ui.js';
|
|
44
46
|
import { applyGrokAuthState } from './grok-auth-ui.js';
|
|
45
47
|
import { applyCliInstallState } from './cli-install-ui.js';
|
|
48
|
+
import { renderUpdateSection } from './update-ui.js';
|
|
46
49
|
import {
|
|
47
50
|
showThinking,
|
|
48
51
|
removeThinking,
|
|
@@ -53,6 +56,7 @@ import {
|
|
|
53
56
|
appendThinkingActivity,
|
|
54
57
|
appendThinkingThought,
|
|
55
58
|
applyRelayStreamEvent,
|
|
59
|
+
clearRelayStreamState,
|
|
56
60
|
clearRelayStreamStateForMessage,
|
|
57
61
|
applyConversationTurnStatus,
|
|
58
62
|
renderMessages,
|
|
@@ -63,6 +67,8 @@ import {
|
|
|
63
67
|
removeUserBubbleCancelButton,
|
|
64
68
|
updateSubagentBubbleFromStatus,
|
|
65
69
|
markSubagentStopUnsupported,
|
|
70
|
+
syncComposerButtonState,
|
|
71
|
+
syncCancellableSteerButtons,
|
|
66
72
|
} from './conversation-view.js';
|
|
67
73
|
import { loadRepoBrowserTree, refreshRepoBrowserIfWorkspaceOpen } from './attachments-view.js';
|
|
68
74
|
import { clearMessageSearchRuntimeState } from './message-search-view.js';
|
|
@@ -376,6 +382,12 @@ export async function connectSocket(overrideDeps) {
|
|
|
376
382
|
socket.on('cli_install_state', (payload) => {
|
|
377
383
|
applyCliInstallState(payload || null);
|
|
378
384
|
});
|
|
385
|
+
// Self-update transitions AND npm log chunks; same whole-buffer contract as
|
|
386
|
+
// cli_install_state, so a client connecting mid-update renders the full log.
|
|
387
|
+
socket.on('update_state', (payload) => {
|
|
388
|
+
setUpdateState(payload || null);
|
|
389
|
+
renderUpdateSection();
|
|
390
|
+
});
|
|
379
391
|
socket.on('grok_settings_updated', (payload) => {
|
|
380
392
|
deps?.applyGrokSettingsState?.(payload || {});
|
|
381
393
|
if (Number(payload?.reconciliation?.updatedUnstartedConversations || 0) > 0) {
|
|
@@ -437,8 +449,12 @@ export async function connectSocket(overrideDeps) {
|
|
|
437
449
|
socket.on('assistant_message', ({ conversationId, message, messageId, sourceMessageId }) => {
|
|
438
450
|
const isCurrentConversation = conversationId === currentConvId;
|
|
439
451
|
const autoScroll = isCurrentConversation ? isMessagesAtBottom() : false;
|
|
440
|
-
|
|
441
|
-
|
|
452
|
+
// A background conversation finishing its turn must not wipe the viewed
|
|
453
|
+
// conversation's live bubble.
|
|
454
|
+
if (isCurrentConversation) {
|
|
455
|
+
collapseThinkingThoughts();
|
|
456
|
+
removeThinking();
|
|
457
|
+
}
|
|
442
458
|
if ((!message?.activities || !message.activities.length) && sourceMessageId) {
|
|
443
459
|
const cached = relayActivities.get(sourceMessageId) || [];
|
|
444
460
|
if (cached.length) message.activities = cached.slice(0, 48);
|
|
@@ -614,11 +630,27 @@ export async function connectSocket(overrideDeps) {
|
|
|
614
630
|
runtimeSessionId: String(runtimeSessionId || conversations[id].runtimeSessionId || '').trim() || null,
|
|
615
631
|
};
|
|
616
632
|
}
|
|
633
|
+
// The composer reads the worker's steering snapshot under the session id
|
|
634
|
+
// just bound; fetch it now rather than on the next status poll.
|
|
635
|
+
refreshSessionWorkerStatus().catch(() => {});
|
|
617
636
|
await refreshConversations();
|
|
618
637
|
if (currentConvId === id) {
|
|
619
638
|
await openConversation(id);
|
|
620
639
|
}
|
|
621
640
|
});
|
|
641
|
+
// A worker's steering snapshot changed (its heartbeat): the composer's
|
|
642
|
+
// Steer/Queue label and the pushed rows' Cancel follow it at once instead of
|
|
643
|
+
// on the next status poll.
|
|
644
|
+
socket.on('session_worker_steering', ({ sdkSessionId, steering } = {}) => {
|
|
645
|
+
const changed = applySessionWorkerSteering(sdkSessionId, steering);
|
|
646
|
+
if (changed === null) {
|
|
647
|
+
refreshSessionWorkerStatus().catch(() => {});
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
if (!changed) return;
|
|
651
|
+
syncComposerButtonState();
|
|
652
|
+
syncCancellableSteerButtons();
|
|
653
|
+
});
|
|
622
654
|
socket.on('message_status', ({ messageId, conversationId, status }) => {
|
|
623
655
|
const normalizedStatus = String(status || '').trim().toLowerCase();
|
|
624
656
|
const clearsProcessingStatus = ['done', 'failed', 'dropped', 'pending', 'parked', 'cancelled'].includes(normalizedStatus);
|
|
@@ -650,9 +682,20 @@ export async function connectSocket(overrideDeps) {
|
|
|
650
682
|
if (messageId) renderRestoredSubagentBubbles(messageId);
|
|
651
683
|
if (messageId) removeUserBubbleCancelButton(messageId);
|
|
652
684
|
}
|
|
653
|
-
if (
|
|
685
|
+
if (normalizedStatus === 'processing') {
|
|
686
|
+
// A worker owns the message now: dropping the pending entry re-arms the
|
|
687
|
+
// duplicate-send guard and hands poll liveness over to localTurnStatus.
|
|
654
688
|
clearPendingUserMessage(messageId);
|
|
655
|
-
|
|
689
|
+
}
|
|
690
|
+
if (clearsProcessingStatus) {
|
|
691
|
+
// 'pending'/'parked' mean the message is (still) queued: its pending
|
|
692
|
+
// entry keeps the live poll armed while it waits, and only a genuinely
|
|
693
|
+
// terminal status may blacklist the id from relay_stream frames — the
|
|
694
|
+
// server acks every enqueue with 'pending', which would otherwise mute
|
|
695
|
+
// live streaming for the whole turn.
|
|
696
|
+
if (isTerminalStatus) clearPendingUserMessage(messageId);
|
|
697
|
+
if (messageId && isTerminalStatus) clearRelayStreamStateForMessage(messageId);
|
|
698
|
+
else if (messageId) clearRelayStreamState(messageId);
|
|
656
699
|
if (messageId) clearBubbleCancelState(messageId);
|
|
657
700
|
if (messageId) removeUserBubbleCancelButton(messageId);
|
|
658
701
|
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Mid-turn steering: a message sent while a Claude turn was running is pushed
|
|
2
|
+
// into the live turn. Three settle shapes reach the transcript:
|
|
3
|
+
// - handoff: the interrupted reply settles with kind='absorbed', and the rest
|
|
4
|
+
// of the turn (with the final reply) lands on the steered message's own row.
|
|
5
|
+
// Those rows render as ONE flow — absorbed reply, steered user bubble,
|
|
6
|
+
// continuing reply.
|
|
7
|
+
// - fold: the CLI answered the steer inside the running turn; its row settles
|
|
8
|
+
// with a kind='folded' stub, a compact marker under its own user message.
|
|
9
|
+
// It never merges forward — the next message is an ordinary turn.
|
|
10
|
+
// - stopped: the steer was pushed into a turn the user then stopped, so it
|
|
11
|
+
// went unanswered (kind='stopped', a marker with Resend).
|
|
12
|
+
// The steered user message of a fold or stop is still classed as steered.
|
|
13
|
+
//
|
|
14
|
+
// One idempotent DOM pass, same contract as syncTranscriptSeparators: every
|
|
15
|
+
// insertion path (full render, live append, prepended history page) re-runs it,
|
|
16
|
+
// so classes heal when the pair arrives across two loads and un-apply when a
|
|
17
|
+
// re-render drops the absorbed marker. Kept DOM-only and import-light so it is
|
|
18
|
+
// jsdom-testable (conversation-view.js touches window at module scope).
|
|
19
|
+
import { SEPARATOR_CLASS } from './transcript-separators.mjs';
|
|
20
|
+
|
|
21
|
+
export const ABSORBED_MSG_CLASS = 'msg-absorbed';
|
|
22
|
+
export const STEERED_MSG_CLASS = 'msg-steered';
|
|
23
|
+
export const STEERED_CONTINUATION_CLASS = 'msg-steered-continuation';
|
|
24
|
+
export const FOLDED_MSG_CLASS = 'msg-folded';
|
|
25
|
+
export const STEER_STOPPED_MSG_CLASS = 'msg-steer-stopped';
|
|
26
|
+
|
|
27
|
+
/** The assistant row kinds that are settle markers of a steer, not replies. */
|
|
28
|
+
export const STEER_MARKER_CLASS_BY_KIND = Object.freeze({
|
|
29
|
+
folded: FOLDED_MSG_CLASS,
|
|
30
|
+
stopped: STEER_STOPPED_MSG_CLASS,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
function isSteerMarker(node) {
|
|
34
|
+
return !!node?.classList
|
|
35
|
+
&& node.classList.contains('assistant')
|
|
36
|
+
&& (node.classList.contains(FOLDED_MSG_CLASS) || node.classList.contains(STEER_STOPPED_MSG_CLASS));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The `.msg` row above, refusing to cross a separator row: a steering pair is
|
|
41
|
+
* seconds apart, so a day or compaction break between two rows proves they are
|
|
42
|
+
* NOT that pair, and pulling them together across it would look broken.
|
|
43
|
+
*/
|
|
44
|
+
function previousMessageNode(node) {
|
|
45
|
+
for (let prev = node?.previousElementSibling; prev; prev = prev.previousElementSibling) {
|
|
46
|
+
if (prev.classList?.contains(SEPARATOR_CLASS)) return null;
|
|
47
|
+
if (prev.classList?.contains('msg')) return prev;
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The `.msg` row below, refusing to cross a separator row. */
|
|
53
|
+
function nextMessageNode(node) {
|
|
54
|
+
for (let next = node?.nextElementSibling; next; next = next.nextElementSibling) {
|
|
55
|
+
if (next.classList?.contains(SEPARATOR_CLASS)) return null;
|
|
56
|
+
if (next.classList?.contains('msg')) return next;
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function setClass(node, className, on) {
|
|
62
|
+
if (!node?.classList) return 0;
|
|
63
|
+
if (node.classList.contains(className) === on) return 0;
|
|
64
|
+
node.classList.toggle(className, on);
|
|
65
|
+
return 1;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Sync the merge classes over the container's rows. Returns the number of
|
|
70
|
+
* class changes (0 = the pass was a no-op), which the tests assert on.
|
|
71
|
+
*/
|
|
72
|
+
export function syncSteeredTurnMerge(container) {
|
|
73
|
+
if (!container || !container.children) return 0;
|
|
74
|
+
let changes = 0;
|
|
75
|
+
for (const node of container.children) {
|
|
76
|
+
if (!node.classList?.contains('msg')) continue;
|
|
77
|
+
if (node.classList.contains('user')) {
|
|
78
|
+
// A user row is steered iff the row above it is an absorbed reply, or
|
|
79
|
+
// its own settle marker (fold/stop) sits right below it.
|
|
80
|
+
const previous = previousMessageNode(node);
|
|
81
|
+
const steered = (!!previous
|
|
82
|
+
&& previous.classList.contains('assistant')
|
|
83
|
+
&& previous.classList.contains(ABSORBED_MSG_CLASS))
|
|
84
|
+
|| isSteerMarker(nextMessageNode(node));
|
|
85
|
+
changes += setClass(node, STEERED_MSG_CLASS, steered);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (node.classList.contains('assistant')) {
|
|
89
|
+
// The reply continuing a steered message keeps the group together. A
|
|
90
|
+
// settle marker is not a reply: it keeps its own compact styling.
|
|
91
|
+
const previous = previousMessageNode(node);
|
|
92
|
+
const continues = !isSteerMarker(node)
|
|
93
|
+
&& !!previous
|
|
94
|
+
&& previous.classList.contains('user')
|
|
95
|
+
&& previous.classList.contains(STEERED_MSG_CLASS);
|
|
96
|
+
changes += setClass(node, STEERED_CONTINUATION_CLASS, continues);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return changes;
|
|
100
|
+
}
|