@oar-sh/oar 0.9.7 → 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 +113 -0
- package/README.md +46 -18
- package/bin/oar.js +320 -18
- package/package.json +1 -1
- package/server/claude-worker/claude-sdk-adapter.mjs +0 -11
- package/server/claude-worker/claude-session-process.mjs +196 -28
- package/server/claude-worker/sdk-message-normalizer.mjs +28 -0
- package/server/public/app/activity-replay-state.mjs +68 -5
- package/server/public/app/api-client.js +6 -2
- package/server/public/app/bootstrap.js +124 -57
- package/server/public/app/context-usage-view.mjs +127 -8
- package/server/public/app/conversation-view.js +62 -7
- package/server/public/app/pwa-install.js +5 -0
- package/server/public/app/scrollbar-hover.mjs +78 -0
- package/server/public/app/settings-modal.js +4 -1
- package/server/public/app/socket-handlers.js +2 -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 +183 -13
- package/server/public/manifest.webmanifest +9 -3
- package/server/public/sw.js +6 -1
- package/server/routes/messages-routes.mjs +3 -1
- package/server/routes/sessions-routes.mjs +15 -2
- package/server/server-runtime.mjs +10 -8
- package/server/services/claude-context-usage.mjs +51 -3
- package/server/services/oar-cli-helpers.mjs +67 -1
- package/server/services/pwa-manifest-theme.mjs +30 -0
- package/server/services/session-worker-registry-service.mjs +18 -1
- package/server/services/update-install-service.mjs +9 -3
package/server/public/index.html
CHANGED
|
@@ -3,12 +3,23 @@
|
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="UTF-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content">
|
|
6
|
-
<meta name="theme-color" content="#
|
|
6
|
+
<meta name="theme-color" content="#0d1117">
|
|
7
7
|
<meta name="application-name" content="OAR">
|
|
8
8
|
<meta name="apple-mobile-web-app-title" content="OAR">
|
|
9
9
|
<meta name="mobile-web-app-capable" content="yes">
|
|
10
10
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
11
11
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
|
12
|
+
<script>
|
|
13
|
+
// The stored Theme setting, applied before the first paint so the start
|
|
14
|
+
// screen (and its logo) already match it.
|
|
15
|
+
const __STORED_THEME = (() => {
|
|
16
|
+
try { return window.localStorage.getItem('copilot_theme') === 'light' ? 'light' : 'dark'; } catch { return 'dark'; }
|
|
17
|
+
})();
|
|
18
|
+
if (__STORED_THEME === 'light') {
|
|
19
|
+
document.documentElement.setAttribute('data-theme', 'light');
|
|
20
|
+
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', '#ffffff');
|
|
21
|
+
}
|
|
22
|
+
</script>
|
|
12
23
|
<script>
|
|
13
24
|
window.__COPILOT_APP_CONFIG = { basePath: '' };
|
|
14
25
|
const __APP_CONFIG = window.__COPILOT_APP_CONFIG;
|
|
@@ -163,13 +174,35 @@
|
|
|
163
174
|
if (errorEl) errorEl.textContent = 'Could not reach the relay';
|
|
164
175
|
}
|
|
165
176
|
};
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
177
|
+
// Manifest and tab icons for one theme: the dark icon set, or its
|
|
178
|
+
// `app-icon-light*` twin with a white launch background.
|
|
179
|
+
const __themeAssetLinks = (theme) => {
|
|
180
|
+
const light = theme === 'light';
|
|
181
|
+
const icon = light ? 'app-icon-light' : 'app-icon';
|
|
182
|
+
return [
|
|
183
|
+
...(__IS_SHARED_ROUTE ? [] : [
|
|
184
|
+
{ rel: 'manifest', href: `${__APP_BASE}/manifest.webmanifest?${__PWA_BUST}${light ? '&theme=light' : ''}` },
|
|
185
|
+
]),
|
|
186
|
+
{ rel: 'icon', type: 'image/png', sizes: '192x192', href: `${__APP_BASE}/${icon}-192.png?${__PWA_BUST}` },
|
|
187
|
+
{ rel: 'icon', type: 'image/png', sizes: '512x512', href: `${__APP_BASE}/${icon}-512.png?${__PWA_BUST}` },
|
|
188
|
+
{ rel: 'icon', type: 'image/svg+xml', href: `${__APP_BASE}/${icon}.svg?${__PWA_BUST}` },
|
|
189
|
+
{ rel: 'apple-touch-icon', href: `${__APP_BASE}/${icon}-192.png?${__PWA_BUST}` },
|
|
190
|
+
];
|
|
191
|
+
};
|
|
192
|
+
const __themeAssetLinkHtml = (theme) => __themeAssetLinks(theme)
|
|
193
|
+
.map((link) => `<link data-theme-asset="1" ${Object.entries(link).map(([key, value]) => `${key}="${value}"`).join(' ')}>`)
|
|
194
|
+
.join('');
|
|
195
|
+
document.write(__themeAssetLinkHtml(__STORED_THEME));
|
|
196
|
+
document.write(`<style>
|
|
197
|
+
.brand-logo { background-image: url("${__APP_BASE}/app-icon.svg?${__PWA_BUST}"); }
|
|
198
|
+
:root[data-theme="light"] .brand-logo { background-image: url("${__APP_BASE}/app-icon-light.svg?${__PWA_BUST}"); }
|
|
199
|
+
</style>`);
|
|
200
|
+
// A Theme change swaps them in place (settings-modal's updateTheme).
|
|
201
|
+
window.__applyThemeAssets = (theme) => {
|
|
202
|
+
const next = theme === 'light' ? 'light' : 'dark';
|
|
203
|
+
for (const node of Array.from(document.head.querySelectorAll('link[data-theme-asset]'))) node.remove();
|
|
204
|
+
document.head.insertAdjacentHTML('beforeend', __themeAssetLinkHtml(next));
|
|
205
|
+
};
|
|
173
206
|
</script>
|
|
174
207
|
<title>OAR — Open Agent Relay</title>
|
|
175
208
|
<script>document.write('<scr'+'ipt src="'+__APP_BASE+'/socket.io/socket.io.js"><\/scr'+'ipt>');</script>
|
|
@@ -384,12 +417,44 @@
|
|
|
384
417
|
display: none; align-items: center; justify-content: center;
|
|
385
418
|
height: var(--app-height); padding: 24px;
|
|
386
419
|
}
|
|
420
|
+
/* The start screen lies over the page until the app (or the sign-in box)
|
|
421
|
+
is ready. In the installed app it takes over from the system's launch
|
|
422
|
+
screen: the logo starts where and as large as the launch screen showed
|
|
423
|
+
it, then settles into this layout while the label fades in. */
|
|
387
424
|
#startup-loading {
|
|
388
|
-
|
|
389
|
-
|
|
425
|
+
position: fixed; inset: 0; z-index: 10000;
|
|
426
|
+
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 20px;
|
|
427
|
+
padding: 24px 24px 124px;
|
|
428
|
+
background: var(--bg);
|
|
390
429
|
color: var(--muted);
|
|
391
430
|
font-size: var(--fs-core-subtitle);
|
|
431
|
+
transition: opacity 150ms ease;
|
|
432
|
+
}
|
|
433
|
+
#startup-loading.is-leaving { opacity: 0; }
|
|
434
|
+
#startup-loading .brand-logo { width: 148px; height: 148px; }
|
|
435
|
+
#startup-loading.is-animated .brand-logo {
|
|
436
|
+
animation: startup-logo-settle 500ms cubic-bezier(0.22, 1, 0.36, 1) 200ms both;
|
|
437
|
+
}
|
|
438
|
+
#startup-loading.is-animated .startup-label {
|
|
439
|
+
animation: startup-label-in 400ms ease 450ms both;
|
|
440
|
+
}
|
|
441
|
+
#startup-loading .startup-label { transition: opacity 150ms ease; }
|
|
442
|
+
@keyframes startup-logo-settle {
|
|
443
|
+
from { transform: translate(var(--startup-dx, 0px), var(--startup-dy, 0px)) scale(var(--startup-scale, 1)); }
|
|
444
|
+
to { transform: none; }
|
|
392
445
|
}
|
|
446
|
+
@keyframes startup-label-in {
|
|
447
|
+
from { opacity: 0; }
|
|
448
|
+
to { opacity: 1; }
|
|
449
|
+
}
|
|
450
|
+
/* The logo for the active theme. A background, not an <img>: the browser
|
|
451
|
+
fetches only the one the matching rule names (the rules with the URLs are
|
|
452
|
+
written by the head script, which knows the relay's base path). */
|
|
453
|
+
.brand-logo {
|
|
454
|
+
width: 128px; height: 128px; flex: none;
|
|
455
|
+
background-position: center; background-size: contain; background-repeat: no-repeat;
|
|
456
|
+
}
|
|
457
|
+
.auth-box .brand-logo { width: 96px; height: 96px; margin: 0 auto 16px; }
|
|
393
458
|
.auth-box {
|
|
394
459
|
background: var(--bg2); border: 1px solid var(--border); border-radius: 12px;
|
|
395
460
|
padding: 40px 32px; width: 100%; max-width: 400px; text-align: center;
|
|
@@ -1352,6 +1417,9 @@
|
|
|
1352
1417
|
padding: 2px 10px;
|
|
1353
1418
|
background: var(--bg2);
|
|
1354
1419
|
}
|
|
1420
|
+
/* A compaction that is still running: the label trails the bouncing dots. */
|
|
1421
|
+
.transcript-separator.is-pending .dots { margin-left: 6px; }
|
|
1422
|
+
.transcript-separator.is-pending .dots span { width: 4px; height: 4px; }
|
|
1355
1423
|
|
|
1356
1424
|
.empty-state {
|
|
1357
1425
|
flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center;
|
|
@@ -1744,6 +1812,12 @@
|
|
|
1744
1812
|
word-break: break-word;
|
|
1745
1813
|
}
|
|
1746
1814
|
.thinking-stream[hidden] { display: none; }
|
|
1815
|
+
.thinking-compacting-note {
|
|
1816
|
+
color: var(--muted);
|
|
1817
|
+
font-size: var(--fs-meta-sm);
|
|
1818
|
+
font-style: italic;
|
|
1819
|
+
}
|
|
1820
|
+
.thinking-compacting-note[hidden] { display: none; }
|
|
1747
1821
|
.thinking-stream > :first-child { margin-top: 0; }
|
|
1748
1822
|
.thinking-stream > :last-child { margin-bottom: 0; }
|
|
1749
1823
|
.thinking-activity {
|
|
@@ -2097,6 +2171,9 @@
|
|
|
2097
2171
|
font-size: 0.76rem;
|
|
2098
2172
|
color: var(--muted);
|
|
2099
2173
|
}
|
|
2174
|
+
.ctx-autocompact-notes { display: flex; flex-direction: column; gap: 4px; }
|
|
2175
|
+
.ctx-autocompact-notes:empty { display: none; }
|
|
2176
|
+
.ctx-autocompact-warning { color: var(--warn); }
|
|
2100
2177
|
/* Claude-only thinking control (on/off + text visibility), under the
|
|
2101
2178
|
auto-compact slider. */
|
|
2102
2179
|
/* The folder's commit attribution (attribution-control.mjs), styled like
|
|
@@ -5585,6 +5662,18 @@
|
|
|
5585
5662
|
::-webkit-scrollbar { width: 6px; }
|
|
5586
5663
|
::-webkit-scrollbar-track { background: transparent; }
|
|
5587
5664
|
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
|
5665
|
+
/* With a mouse the track is twice as wide and the thumb is drawn thin in
|
|
5666
|
+
its middle; the thumb fills the track while the pointer is on the
|
|
5667
|
+
scrollbar and for a moment after (scrollbar-hover.mjs). The track keeps
|
|
5668
|
+
its width, so the page does not move when the thumb grows. */
|
|
5669
|
+
@media (hover: hover) and (pointer: fine) {
|
|
5670
|
+
::-webkit-scrollbar { width: 12px; }
|
|
5671
|
+
::-webkit-scrollbar-thumb {
|
|
5672
|
+
border: 3px solid transparent; border-top-width: 0; border-bottom-width: 0;
|
|
5673
|
+
background-clip: padding-box; border-radius: 6px;
|
|
5674
|
+
}
|
|
5675
|
+
.scrollbar-wide::-webkit-scrollbar-thumb { border-width: 0; background: var(--muted); }
|
|
5676
|
+
}
|
|
5588
5677
|
</style>
|
|
5589
5678
|
</head>
|
|
5590
5679
|
<body>
|
|
@@ -5592,7 +5681,8 @@
|
|
|
5592
5681
|
<!-- Auth Gate -->
|
|
5593
5682
|
<div id="auth-gate">
|
|
5594
5683
|
<div class="auth-box">
|
|
5595
|
-
<
|
|
5684
|
+
<div class="brand-logo" aria-hidden="true"></div>
|
|
5685
|
+
<h1>OAR</h1>
|
|
5596
5686
|
<p>Enter your access token to connect</p>
|
|
5597
5687
|
<input id="token-input" type="password" placeholder="Access token" autofocus onkeydown="if(event.key==='Enter')window.__copilotAuthConnect(event)">
|
|
5598
5688
|
<button onclick="window.__copilotAuthConnect(event)">Connect</button>
|
|
@@ -5600,7 +5690,87 @@
|
|
|
5600
5690
|
</div>
|
|
5601
5691
|
</div>
|
|
5602
5692
|
|
|
5603
|
-
<div id="startup-loading" role="status" aria-live="polite">Connecting to OAR…</div>
|
|
5693
|
+
<div id="startup-loading" role="status" aria-live="polite"><div class="brand-logo" aria-hidden="true"></div><div class="startup-label">Connecting to OAR…</div></div>
|
|
5694
|
+
<script>
|
|
5695
|
+
// The start screen's hand-over from the system launch screen, and its two
|
|
5696
|
+
// ways out: into the app (finish) or into the sign-in box (toAuth). Only the
|
|
5697
|
+
// installed app animates and makes its exits wait for the animation; a
|
|
5698
|
+
// browser tab has no launch screen to continue from and leaves at once.
|
|
5699
|
+
window.__startupSplash = (() => {
|
|
5700
|
+
const root = document.getElementById('startup-loading');
|
|
5701
|
+
const logo = root.querySelector('.brand-logo');
|
|
5702
|
+
const label = root.querySelector('.startup-label');
|
|
5703
|
+
const media = (query) => { try { return window.matchMedia(query).matches; } catch { return false; } };
|
|
5704
|
+
const installed = media('(display-mode: standalone)') || media('(display-mode: fullscreen)')
|
|
5705
|
+
|| window.navigator.standalone === true;
|
|
5706
|
+
const animated = installed && !media('(prefers-reduced-motion: reduce)');
|
|
5707
|
+
const wait = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); });
|
|
5708
|
+
let done = Promise.resolve();
|
|
5709
|
+
if (animated) {
|
|
5710
|
+
// Android draws its launch icon around the centre of the whole screen,
|
|
5711
|
+
// 275 px across for a maskable icon, which makes the otter 155 px wide.
|
|
5712
|
+
// The tile used here carries the otter at 373/512 of its width.
|
|
5713
|
+
const LAUNCH_TILE_PX = 155.2 / (373 / 512);
|
|
5714
|
+
// The page only knows the sum of the system bars; a three-button
|
|
5715
|
+
// navigation bar (48 px) is assumed for the split.
|
|
5716
|
+
const NAV_BAR_PX = 48;
|
|
5717
|
+
const rect = logo.getBoundingClientRect();
|
|
5718
|
+
const bars = Math.max(0, (window.screen?.height || window.innerHeight) - window.innerHeight);
|
|
5719
|
+
const topBar = Math.max(0, bars - NAV_BAR_PX);
|
|
5720
|
+
let targetY = (window.screen?.height || window.innerHeight) / 2 - topBar;
|
|
5721
|
+
// An implausible reading (split screen, a desktop window) falls back to
|
|
5722
|
+
// the middle of the page.
|
|
5723
|
+
if (!(Math.abs(targetY - window.innerHeight / 2) <= 60)) targetY = window.innerHeight / 2;
|
|
5724
|
+
root.style.setProperty('--startup-dx', `${window.innerWidth / 2 - (rect.left + rect.width / 2)}px`);
|
|
5725
|
+
root.style.setProperty('--startup-dy', `${targetY - (rect.top + rect.height / 2)}px`);
|
|
5726
|
+
root.style.setProperty('--startup-scale', String(LAUNCH_TILE_PX / rect.width));
|
|
5727
|
+
root.classList.add('is-animated');
|
|
5728
|
+
// Rest 200 ms, settle 500 ms; the label's fade ends at 850 ms.
|
|
5729
|
+
done = wait(850);
|
|
5730
|
+
}
|
|
5731
|
+
let left = null;
|
|
5732
|
+
const leave = (run) => {
|
|
5733
|
+
// Removed whatever happens on the way out: a start screen left over
|
|
5734
|
+
// the page would cover the whole app.
|
|
5735
|
+
if (!left) {
|
|
5736
|
+
left = (animated && root.isConnected ? done.then(run) : Promise.resolve())
|
|
5737
|
+
.catch(() => {})
|
|
5738
|
+
.then(() => { root.remove(); });
|
|
5739
|
+
}
|
|
5740
|
+
return left;
|
|
5741
|
+
};
|
|
5742
|
+
return {
|
|
5743
|
+
animated,
|
|
5744
|
+
// Into the app: fade the start screen away over the page below it.
|
|
5745
|
+
finish: () => leave(async () => {
|
|
5746
|
+
root.classList.add('is-leaving');
|
|
5747
|
+
await wait(150);
|
|
5748
|
+
}),
|
|
5749
|
+
// Into the sign-in box: the logo travels on into the box's own logo
|
|
5750
|
+
// place, shrinking to its size, and the box fades in around it.
|
|
5751
|
+
toAuth: () => leave(async () => {
|
|
5752
|
+
const box = document.querySelector('#auth-gate .auth-box');
|
|
5753
|
+
const target = box?.querySelector('.brand-logo')?.getBoundingClientRect();
|
|
5754
|
+
if (!box || !target || !target.width) return;
|
|
5755
|
+
box.style.opacity = '0';
|
|
5756
|
+
root.style.background = 'transparent';
|
|
5757
|
+
label.style.animation = 'none';
|
|
5758
|
+
label.style.opacity = '0';
|
|
5759
|
+
const from = logo.getBoundingClientRect();
|
|
5760
|
+
logo.style.animation = 'none';
|
|
5761
|
+
logo.style.transition = 'transform 450ms cubic-bezier(0.22, 1, 0.36, 1)';
|
|
5762
|
+
logo.style.transform = `translate(${target.left + target.width / 2 - (from.left + from.width / 2)}px, `
|
|
5763
|
+
+ `${target.top + target.height / 2 - (from.top + from.height / 2)}px) scale(${target.width / from.width})`;
|
|
5764
|
+
await wait(450);
|
|
5765
|
+
box.style.transition = 'opacity 300ms ease';
|
|
5766
|
+
box.style.opacity = '1';
|
|
5767
|
+
await wait(300);
|
|
5768
|
+
box.style.transition = '';
|
|
5769
|
+
box.style.opacity = '';
|
|
5770
|
+
}),
|
|
5771
|
+
};
|
|
5772
|
+
})();
|
|
5773
|
+
</script>
|
|
5604
5774
|
|
|
5605
5775
|
<!-- Sidebar overlay (mobile) -->
|
|
5606
5776
|
<div id="sidebar-overlay" onclick="closeSidebar()"></div>
|
|
@@ -6908,8 +7078,8 @@
|
|
|
6908
7078
|
|
|
6909
7079
|
<script>
|
|
6910
7080
|
window.__loadCopilotApp().catch((error) => {
|
|
6911
|
-
document.getElementById('startup-loading')?.remove();
|
|
6912
7081
|
document.getElementById('auth-gate').style.display = 'flex';
|
|
7082
|
+
void window.__startupSplash.toAuth();
|
|
6913
7083
|
const errorEl = document.getElementById('auth-error');
|
|
6914
7084
|
if (errorEl) errorEl.textContent = `App failed to initialize: ${String(error?.message || error || 'unknown error')}`;
|
|
6915
7085
|
});
|
|
@@ -7,11 +7,11 @@
|
|
|
7
7
|
"scope": "./",
|
|
8
8
|
"display_override": ["standalone"],
|
|
9
9
|
"display": "standalone",
|
|
10
|
-
"background_color": "#
|
|
11
|
-
"theme_color": "#
|
|
10
|
+
"background_color": "#0d1117",
|
|
11
|
+
"theme_color": "#0d1117",
|
|
12
12
|
"icons": [
|
|
13
13
|
{
|
|
14
|
-
"src": "app-icon.svg?v=
|
|
14
|
+
"src": "app-icon.svg?v=26",
|
|
15
15
|
"sizes": "any",
|
|
16
16
|
"type": "image/svg+xml",
|
|
17
17
|
"purpose": "any"
|
|
@@ -27,6 +27,12 @@
|
|
|
27
27
|
"sizes": "512x512",
|
|
28
28
|
"type": "image/png",
|
|
29
29
|
"purpose": "any"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"src": "app-icon-maskable-512.png",
|
|
33
|
+
"sizes": "512x512",
|
|
34
|
+
"type": "image/png",
|
|
35
|
+
"purpose": "maskable"
|
|
30
36
|
}
|
|
31
37
|
]
|
|
32
38
|
}
|
package/server/public/sw.js
CHANGED
|
@@ -13,6 +13,11 @@ const STATIC_ASSETS = [
|
|
|
13
13
|
'app-icon-512.png',
|
|
14
14
|
'favicon.ico',
|
|
15
15
|
'app-icon.svg',
|
|
16
|
+
'app-icon-light-192.png',
|
|
17
|
+
'app-icon-light-512.png',
|
|
18
|
+
'app-icon-light.svg',
|
|
19
|
+
'app-icon-maskable-512.png',
|
|
20
|
+
'app-icon-light-maskable-512.png',
|
|
16
21
|
];
|
|
17
22
|
|
|
18
23
|
function sameOrigin(url) {
|
|
@@ -26,7 +31,7 @@ function isApiRequest(url) {
|
|
|
26
31
|
function isPwaMetadataRequest(url) {
|
|
27
32
|
if (!url.pathname.startsWith(REGISTRATION_SCOPE_PATH)) return false;
|
|
28
33
|
const relativePath = url.pathname.slice(REGISTRATION_SCOPE_PATH.length);
|
|
29
|
-
return /^(?:manifest\.webmanifest|app-icon(?:-\d+)?\.png|
|
|
34
|
+
return /^(?:manifest\.webmanifest|app-icon(?:-light)?(?:-maskable)?(?:-\d+)?\.(?:png|svg)|favicon\.ico)$/.test(relativePath);
|
|
30
35
|
}
|
|
31
36
|
|
|
32
37
|
function isApplicationModuleRequest(url) {
|
|
@@ -5147,7 +5147,9 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
5147
5147
|
// in either must reach the status payload (composer gate, bubble
|
|
5148
5148
|
// Cancel), so it counts as a change like the hold fields do.
|
|
5149
5149
|
|| (stored.supported === true) !== (steering.supported === true)
|
|
5150
|
-
|| storedCancellableIds.join('\n') !== steering.cancellableIds.join('\n')
|
|
5150
|
+
|| storedCancellableIds.join('\n') !== steering.cancellableIds.join('\n')
|
|
5151
|
+
// The running CLI's compaction window (context-usage modal).
|
|
5152
|
+
|| JSON.stringify(stored.autoCompactWindow ?? null) !== JSON.stringify(steering.autoCompactWindow ?? null);
|
|
5151
5153
|
if (workerForSteering && changed) {
|
|
5152
5154
|
sessionWorkerRegistry?.upsertWorker?.({ ...workerForSteering, steering });
|
|
5153
5155
|
// Pushed rather than left for the clients' status poll: the
|
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
withClaudeUltracodeTier,
|
|
22
22
|
} from '../services/provider-reasoning-effort.mjs';
|
|
23
23
|
import { mapUsageSnapshotRow, fetchUsageSummaryPromise } from '../services/usage-snapshot-helpers.mjs';
|
|
24
|
-
import { readStoredClaudeContextUsage } from '../services/claude-context-usage.mjs';
|
|
24
|
+
import { readStoredClaudeContextUsage, resolveAutoCompactWindowStatus } from '../services/claude-context-usage.mjs';
|
|
25
25
|
import { buildContextUsageView } from '../services/context-usage-view.mjs';
|
|
26
26
|
import { cleanupGeneratedImagesForConversation as cleanupGeneratedImagesForConversationDefault } from '../services/generated-image-cleanup-service.mjs';
|
|
27
27
|
import { stopSessionWorkerProcesses } from '../services/session-worker-stop-service.mjs';
|
|
@@ -3212,6 +3212,7 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
3212
3212
|
return {
|
|
3213
3213
|
snapshot: stored.snapshot,
|
|
3214
3214
|
contextUsage: stored.contextUsage,
|
|
3215
|
+
modelContextLimit: stored.modelContextLimit,
|
|
3215
3216
|
eventsPath: null,
|
|
3216
3217
|
error: stored.snapshot
|
|
3217
3218
|
? null
|
|
@@ -3231,6 +3232,15 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
3231
3232
|
const conversationRow = resolveConversationByIdOrSdkSessionId(
|
|
3232
3233
|
String(runtimeSession?.conversation_id || '').trim() || lookupId,
|
|
3233
3234
|
);
|
|
3235
|
+
const autoCompactWindow = parseAutoCompactWindow(conversationRow?.auto_compact_window);
|
|
3236
|
+
// Only the local Claude worker runs a CLI whose window the slider sets;
|
|
3237
|
+
// it reports that CLI on its heartbeat's steering snapshot.
|
|
3238
|
+
const windowStatus = resolveAutoCompactWindowStatus({
|
|
3239
|
+
storedWindow: autoCompactWindow,
|
|
3240
|
+
steering: providerType === 'claude' && copilotSessionId
|
|
3241
|
+
? sessionWorkerRegistry?.getWorker?.(copilotSessionId)?.steering || null
|
|
3242
|
+
: null,
|
|
3243
|
+
});
|
|
3234
3244
|
|
|
3235
3245
|
return {
|
|
3236
3246
|
conversationId: lookupId,
|
|
@@ -3238,7 +3248,10 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
3238
3248
|
// for the modal's refresh handle), so the id a preferences PATCH needs is
|
|
3239
3249
|
// reported separately.
|
|
3240
3250
|
resolvedConversationId: conversationRow?.id || null,
|
|
3241
|
-
autoCompactWindow
|
|
3251
|
+
autoCompactWindow,
|
|
3252
|
+
activeAutoCompactWindow: windowStatus.activeAutoCompactWindow,
|
|
3253
|
+
autoCompactWindowDeferred: windowStatus.autoCompactWindowDeferred,
|
|
3254
|
+
modelContextLimit: parsed.modelContextLimit ?? null,
|
|
3242
3255
|
thinkingEnabled: parseThinkingEnabled(conversationRow?.thinking_enabled),
|
|
3243
3256
|
thinkingDisplay: parseThinkingDisplay(conversationRow?.thinking_display),
|
|
3244
3257
|
// What this folder's Claude commits say (the 🧠 modal's override row).
|
|
@@ -176,6 +176,7 @@ import {
|
|
|
176
176
|
isSafeProviderModelId,
|
|
177
177
|
} from '../shared/model-id.mjs';
|
|
178
178
|
import { createModelVariantCatalogService } from './services/model-variant-catalog-service.mjs';
|
|
179
|
+
import { applyPwaManifestTheme, normalizePwaTheme } from './services/pwa-manifest-theme.mjs';
|
|
179
180
|
|
|
180
181
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
181
182
|
const REPO_ROOT = path.resolve(__dirname, '..');
|
|
@@ -2992,6 +2993,7 @@ const updateCheckService = updateCheckKilled ? null : createUpdateCheckService({
|
|
|
2992
2993
|
const updateInstallService = createUpdateInstallService({
|
|
2993
2994
|
runningVersion: RUNNING_VERSION,
|
|
2994
2995
|
installMethod: INSTALL_METHOD,
|
|
2996
|
+
packageRoot: PACKAGE_ROOT,
|
|
2995
2997
|
readSetting: readAppSettingValue,
|
|
2996
2998
|
writeSetting: (key, value) => stmts.upsertAppSetting.run(key, value, new Date().toISOString()),
|
|
2997
2999
|
deleteSetting: (key) => stmts.deleteAppSetting.run(key),
|
|
@@ -5525,8 +5527,7 @@ function shouldIncludeInPwaVersion(relativePath) {
|
|
|
5525
5527
|
const normalized = String(relativePath).replace(/\\/g, '/').replace(/^\/+/, '');
|
|
5526
5528
|
if (!normalized || normalized.startsWith('.')) return false;
|
|
5527
5529
|
if (normalized === 'index.html' || normalized === 'sw.js' || normalized === 'manifest.webmanifest') return true;
|
|
5528
|
-
if (/^app-icon(?:-\d+)?\.png$/i.test(normalized)) return true;
|
|
5529
|
-
if (/^app-icon\.svg$/i.test(normalized)) return true;
|
|
5530
|
+
if (/^app-icon(?:-light)?(?:-maskable)?(?:-\d+)?\.(?:png|svg)$/i.test(normalized)) return true;
|
|
5530
5531
|
if (/^favicon\.ico$/i.test(normalized)) return true;
|
|
5531
5532
|
if (normalized.startsWith('app/')) {
|
|
5532
5533
|
const ext = path.extname(normalized).toLowerCase();
|
|
@@ -5598,12 +5599,13 @@ function loadPwaManifestTemplate() {
|
|
|
5598
5599
|
description: 'OAR — Open Agent Relay: drive your local coding agents from any browser.',
|
|
5599
5600
|
display_override: ['standalone'],
|
|
5600
5601
|
display: 'standalone',
|
|
5601
|
-
background_color: '#
|
|
5602
|
-
theme_color: '#
|
|
5602
|
+
background_color: '#0d1117',
|
|
5603
|
+
theme_color: '#0d1117',
|
|
5603
5604
|
icons: [
|
|
5604
|
-
{ src: 'app-icon.svg?v=
|
|
5605
|
+
{ src: 'app-icon.svg?v=26', sizes: 'any', type: 'image/svg+xml', purpose: 'any' },
|
|
5605
5606
|
{ src: 'app-icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
|
|
5606
5607
|
{ src: 'app-icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
|
|
5608
|
+
{ src: 'app-icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
|
5607
5609
|
],
|
|
5608
5610
|
};
|
|
5609
5611
|
try {
|
|
@@ -5614,8 +5616,8 @@ function loadPwaManifestTemplate() {
|
|
|
5614
5616
|
return fallback;
|
|
5615
5617
|
}
|
|
5616
5618
|
|
|
5617
|
-
function buildScopedPwaManifest({ shared = false } = {}) {
|
|
5618
|
-
const manifest = loadPwaManifestTemplate();
|
|
5619
|
+
function buildScopedPwaManifest({ shared = false, theme = 'dark' } = {}) {
|
|
5620
|
+
const manifest = applyPwaManifestTheme(loadPwaManifestTemplate(), theme);
|
|
5619
5621
|
// The custom name must be baked into every served manifest: Android's WebAPK
|
|
5620
5622
|
// update check re-fetches this route outside any page session, so a name the
|
|
5621
5623
|
// route doesn't know reverts on the phone with an accept-or-uninstall prompt.
|
|
@@ -6358,7 +6360,7 @@ app.get('/socket.io/socket.io.js', (req, res, next) => {
|
|
|
6358
6360
|
app.get('/manifest.webmanifest', (req, res, next) => {
|
|
6359
6361
|
try {
|
|
6360
6362
|
const shared = String(req.query?.shared || '').trim() === '1';
|
|
6361
|
-
const manifest = buildScopedPwaManifest({ shared });
|
|
6363
|
+
const manifest = buildScopedPwaManifest({ shared, theme: normalizePwaTheme(req.query?.theme) });
|
|
6362
6364
|
res.setHeader('Cache-Control', 'no-store');
|
|
6363
6365
|
res.type('application/manifest+json').send(JSON.stringify(manifest, null, 2));
|
|
6364
6366
|
} catch (error) {
|
|
@@ -209,26 +209,40 @@ export function buildClaudeContextSnapshot({
|
|
|
209
209
|
};
|
|
210
210
|
}
|
|
211
211
|
|
|
212
|
+
/**
|
|
213
|
+
* The model's own context limit in tokens, from the turn result's
|
|
214
|
+
* `modelUsage[model].contextWindow`. Unlike the usage response's maxTokens
|
|
215
|
+
* (which follows the active auto-compact window), this is what the model can
|
|
216
|
+
* hold. Null when the snapshot does not carry it.
|
|
217
|
+
*/
|
|
218
|
+
export function resolveModelContextLimit({ contextUsage = null, modelUsage = null, model = '' } = {}) {
|
|
219
|
+
const usage = normalizeClaudeContextUsage(contextUsage);
|
|
220
|
+
const entry = resolveModelUsageEntry(modelUsage, model || usage?.model);
|
|
221
|
+
const limit = toNullableInt(entry?.contextWindow);
|
|
222
|
+
return limit !== null && limit > 0 ? limit : null;
|
|
223
|
+
}
|
|
224
|
+
|
|
212
225
|
/**
|
|
213
226
|
* Parse the JSON blob persisted on `runtime_sessions` and rebuild both the
|
|
214
227
|
* snapshot and the normalized usage the view layer renders.
|
|
215
228
|
*/
|
|
216
229
|
export function readStoredClaudeContextUsage(row) {
|
|
217
230
|
const rawJson = normalizeText(row?.context_usage_json);
|
|
218
|
-
if (!rawJson) return { snapshot: null, contextUsage: null };
|
|
231
|
+
if (!rawJson) return { snapshot: null, contextUsage: null, modelContextLimit: null };
|
|
219
232
|
|
|
220
233
|
let parsed = null;
|
|
221
234
|
try {
|
|
222
235
|
parsed = JSON.parse(rawJson);
|
|
223
236
|
} catch {
|
|
224
|
-
return { snapshot: null, contextUsage: null };
|
|
237
|
+
return { snapshot: null, contextUsage: null, modelContextLimit: null };
|
|
225
238
|
}
|
|
226
239
|
|
|
227
240
|
const capturedAt = normalizeText(row?.context_usage_captured_at) || null;
|
|
241
|
+
const model = parsed?.model || row?.provider_model || row?.model || '';
|
|
228
242
|
const snapshot = buildClaudeContextSnapshot({
|
|
229
243
|
contextUsage: parsed?.contextUsage,
|
|
230
244
|
modelUsage: parsed?.modelUsage,
|
|
231
|
-
model
|
|
245
|
+
model,
|
|
232
246
|
runtimeSessionId: row?.id || null,
|
|
233
247
|
sdkSessionId: row?.sdk_session_id || null,
|
|
234
248
|
capturedAt,
|
|
@@ -236,5 +250,39 @@ export function readStoredClaudeContextUsage(row) {
|
|
|
236
250
|
return {
|
|
237
251
|
snapshot,
|
|
238
252
|
contextUsage: normalizeClaudeContextUsage(parsed?.contextUsage),
|
|
253
|
+
modelContextLimit: resolveModelContextLimit({
|
|
254
|
+
contextUsage: parsed?.contextUsage,
|
|
255
|
+
modelUsage: parsed?.modelUsage,
|
|
256
|
+
model,
|
|
257
|
+
}),
|
|
239
258
|
};
|
|
240
259
|
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Where the conversation's stored compaction window stands against the
|
|
263
|
+
* running Claude CLI, for the context-usage modal. `steering` is the worker's
|
|
264
|
+
* heartbeat snapshot; its `autoCompactWindow` ({active, backgroundWork}) is
|
|
265
|
+
* present only while a CLI runs, so an older worker or a stopped one yields
|
|
266
|
+
* nulls.
|
|
267
|
+
*
|
|
268
|
+
* - `activeAutoCompactWindow`: the window the running CLI was spawned with.
|
|
269
|
+
* - `autoCompactWindowDeferred`: why a stored window that differs from it is
|
|
270
|
+
* not applied yet — 'turn' (a turn is running), 'background' (idle, but
|
|
271
|
+
* background agents/shells hold the process) or 'next-message' (the next
|
|
272
|
+
* message respawns the CLI). Null when the two agree or no CLI runs.
|
|
273
|
+
*/
|
|
274
|
+
export function resolveAutoCompactWindowStatus({ storedWindow = null, steering = null } = {}) {
|
|
275
|
+
const running = steering?.autoCompactWindow;
|
|
276
|
+
if (!running || typeof running !== 'object') {
|
|
277
|
+
return { activeAutoCompactWindow: null, autoCompactWindowDeferred: null };
|
|
278
|
+
}
|
|
279
|
+
const active = toNullableInt(running.active);
|
|
280
|
+
const stored = toNullableInt(storedWindow);
|
|
281
|
+
let deferred = null;
|
|
282
|
+
if (stored !== active) {
|
|
283
|
+
if (steering.turnActive === true) deferred = 'turn';
|
|
284
|
+
else if (running.backgroundWork === true) deferred = 'background';
|
|
285
|
+
else deferred = 'next-message';
|
|
286
|
+
}
|
|
287
|
+
return { activeAutoCompactWindow: active, autoCompactWindowDeferred: deferred };
|
|
288
|
+
}
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { randomBytes } from 'node:crypto';
|
|
7
|
+
import fs from 'node:fs';
|
|
7
8
|
import os from 'node:os';
|
|
8
9
|
import path from 'node:path';
|
|
9
10
|
|
|
@@ -45,7 +46,7 @@ export function relayUrl({ config, lanAddress = null } = {}) {
|
|
|
45
46
|
* the service runs server.js directly with the same env the launcher would set,
|
|
46
47
|
* so a `gh copilot` session is not tied to the unit's lifetime.
|
|
47
48
|
*/
|
|
48
|
-
export function buildSystemdUnit({ nodeBin, packageRoot, configPath, dataDir, logDir }) {
|
|
49
|
+
export function buildSystemdUnit({ nodeBin, packageRoot, configPath, dataDir, logDir, pathEnv = '' }) {
|
|
49
50
|
// systemd units are Linux-only, so the path inside the unit is always
|
|
50
51
|
// posix-joined — host-platform path.join would write backslashes when this
|
|
51
52
|
// template is exercised on Windows (tests; the CLI never writes it there).
|
|
@@ -59,6 +60,10 @@ export function buildSystemdUnit({ nodeBin, packageRoot, configPath, dataDir, lo
|
|
|
59
60
|
`Environment=COPILOT_WEB_RELAY_CONFIG=${configPath}`,
|
|
60
61
|
`Environment=COPILOT_WEB_RELAY_DATA_DIR=${dataDir}`,
|
|
61
62
|
`Environment=COPILOT_WEB_RELAY_LOG_DIR=${logDir}`,
|
|
63
|
+
// A user service starts with systemd's bare PATH: without this line the
|
|
64
|
+
// relay's sessions find neither the provider CLIs in ~/.local/bin nor, on
|
|
65
|
+
// an install with a private Node, `node` and `npm` themselves.
|
|
66
|
+
...(pathEnv ? [`Environment="PATH=${escapeSystemdValue(pathEnv)}"`] : []),
|
|
62
67
|
'Restart=on-failure',
|
|
63
68
|
'RestartSec=5',
|
|
64
69
|
'',
|
|
@@ -68,6 +73,67 @@ export function buildSystemdUnit({ nodeBin, packageRoot, configPath, dataDir, lo
|
|
|
68
73
|
].join('\n');
|
|
69
74
|
}
|
|
70
75
|
|
|
76
|
+
// Inside a quoted systemd assignment a backslash and a quote need escaping,
|
|
77
|
+
// and `%` opens a specifier.
|
|
78
|
+
function escapeSystemdValue(value) {
|
|
79
|
+
return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/%/g, '%%');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The PATH a relay service gets: the PATH `oar setup` ran with (the user's
|
|
84
|
+
* shell, so their tools), with two directories added when they are not on it:
|
|
85
|
+
* `~/.local/bin`, where the provider CLIs install themselves and which a
|
|
86
|
+
* shell only picks up once it exists, and the directory of the Node that runs
|
|
87
|
+
* the relay.
|
|
88
|
+
*/
|
|
89
|
+
export function buildServicePath({ nodeBin, envPath = '', homeDir = '' } = {}) {
|
|
90
|
+
const entries = String(envPath || '').split(':').filter(Boolean);
|
|
91
|
+
const localBin = homeDir ? path.posix.join(String(homeDir), '.local', 'bin') : '';
|
|
92
|
+
if (localBin && !entries.includes(localBin)) entries.unshift(localBin);
|
|
93
|
+
const nodeDir = nodeBin ? path.posix.dirname(String(nodeBin)) : '';
|
|
94
|
+
if (nodeDir && !entries.includes(nodeDir)) entries.push(nodeDir);
|
|
95
|
+
return entries.join(':');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The dependencies whose install scripts OAR needs (they fetch or build a
|
|
100
|
+
* native binary). npm 12 runs no install script it was not told to allow, and
|
|
101
|
+
* a global install has no package.json of its own to say so; npm 10 and 11
|
|
102
|
+
* accept the flag and run the scripts as before. `npm-install-scripts.test.mjs`
|
|
103
|
+
* holds this list to the lockfile.
|
|
104
|
+
*/
|
|
105
|
+
export const NPM_INSTALL_SCRIPT_PACKAGES = Object.freeze(['better-sqlite3', 'cloudflared', 'koffi']);
|
|
106
|
+
export const NPM_ALLOW_SCRIPTS_ARG = `--allow-scripts=${NPM_INSTALL_SCRIPT_PACKAGES.join(',')}`;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* How to run `npm install -g` so the update lands in the install that is
|
|
110
|
+
* running. Away from Windows: the npm that belongs to this Node (a relay
|
|
111
|
+
* service or a private Node has no `npm` on PATH, and a version manager may
|
|
112
|
+
* put another Node's there), this Node first on the child's PATH because npm
|
|
113
|
+
* is a `node` script, and the prefix this package sits in, so an install made
|
|
114
|
+
* with `--prefix` is updated in place.
|
|
115
|
+
*/
|
|
116
|
+
export function resolveNpmInvocation({
|
|
117
|
+
execPath = process.execPath,
|
|
118
|
+
packageRoot = '',
|
|
119
|
+
platform = process.platform,
|
|
120
|
+
env = process.env,
|
|
121
|
+
existsImpl = fs.existsSync,
|
|
122
|
+
} = {}) {
|
|
123
|
+
if (platform === 'win32') return { command: 'npm.cmd', prefixArgs: [], env: { ...env } };
|
|
124
|
+
const nodeDir = path.posix.dirname(String(execPath || ''));
|
|
125
|
+
const beside = path.posix.join(nodeDir, 'npm');
|
|
126
|
+
const command = nodeDir && existsImpl(beside) ? beside : 'npm';
|
|
127
|
+
const envPath = String(env?.PATH || '');
|
|
128
|
+
const childPath = envPath.split(':')[0] === nodeDir ? envPath : [nodeDir, envPath].filter(Boolean).join(':');
|
|
129
|
+
const suffix = '/lib/node_modules/@oar-sh/oar';
|
|
130
|
+
const root = String(packageRoot || '').replace(/\/+$/, '');
|
|
131
|
+
const prefixArgs = root.endsWith(suffix) && root.length > suffix.length
|
|
132
|
+
? ['--prefix', root.slice(0, -suffix.length)]
|
|
133
|
+
: [];
|
|
134
|
+
return { command, prefixArgs, env: { ...env, PATH: childPath } };
|
|
135
|
+
}
|
|
136
|
+
|
|
71
137
|
/**
|
|
72
138
|
* Provider CLIs `oar doctor` probes, with the args that answer fast. Cursor is
|
|
73
139
|
* deliberately absent: the relay drives it through the bundled @cursor/sdk npm
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// The installed app's icon and launch screen follow the Theme setting: the
|
|
2
|
+
// page links the manifest with `?theme=light` in Day mode, and the manifest
|
|
3
|
+
// then names the light icon set and a white background.
|
|
4
|
+
|
|
5
|
+
export const PWA_LIGHT_BACKGROUND = '#ffffff';
|
|
6
|
+
|
|
7
|
+
export function normalizePwaTheme(value) {
|
|
8
|
+
return String(value || '').trim().toLowerCase() === 'light' ? 'light' : 'dark';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function lightIconSrc(src) {
|
|
12
|
+
return String(src || '').replace(/(^|\/)app-icon(?=[-.])/, '$1app-icon-light');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The manifest for one theme. Dark is the template as it stands; light swaps
|
|
17
|
+
* every `app-icon*` source for its `app-icon-light*` twin and whitens the
|
|
18
|
+
* launch background and the toolbar colour.
|
|
19
|
+
*/
|
|
20
|
+
export function applyPwaManifestTheme(manifest, theme) {
|
|
21
|
+
if (normalizePwaTheme(theme) !== 'light') return manifest;
|
|
22
|
+
return {
|
|
23
|
+
...manifest,
|
|
24
|
+
background_color: PWA_LIGHT_BACKGROUND,
|
|
25
|
+
theme_color: PWA_LIGHT_BACKGROUND,
|
|
26
|
+
icons: Array.isArray(manifest?.icons)
|
|
27
|
+
? manifest.icons.map((icon) => ({ ...icon, src: lightIconSrc(icon?.src) }))
|
|
28
|
+
: manifest?.icons,
|
|
29
|
+
};
|
|
30
|
+
}
|