@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.
Files changed (38) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/README.md +46 -18
  3. package/bin/oar.js +320 -18
  4. package/package.json +1 -1
  5. package/server/claude-worker/claude-sdk-adapter.mjs +0 -11
  6. package/server/claude-worker/claude-session-process.mjs +196 -28
  7. package/server/claude-worker/sdk-message-normalizer.mjs +28 -0
  8. package/server/public/app/activity-replay-state.mjs +68 -5
  9. package/server/public/app/api-client.js +6 -2
  10. package/server/public/app/bootstrap.js +124 -57
  11. package/server/public/app/context-usage-view.mjs +127 -8
  12. package/server/public/app/conversation-view.js +62 -7
  13. package/server/public/app/pwa-install.js +5 -0
  14. package/server/public/app/scrollbar-hover.mjs +78 -0
  15. package/server/public/app/settings-modal.js +4 -1
  16. package/server/public/app/socket-handlers.js +2 -1
  17. package/server/public/app/store.js +26 -0
  18. package/server/public/app/transcript-separators.mjs +48 -11
  19. package/server/public/app-icon-192.png +0 -0
  20. package/server/public/app-icon-512.png +0 -0
  21. package/server/public/app-icon-light-192.png +0 -0
  22. package/server/public/app-icon-light-512.png +0 -0
  23. package/server/public/app-icon-light-maskable-512.png +0 -0
  24. package/server/public/app-icon-light.svg +4 -0
  25. package/server/public/app-icon-maskable-512.png +0 -0
  26. package/server/public/app-icon.svg +3 -3
  27. package/server/public/favicon.ico +0 -0
  28. package/server/public/index.html +183 -13
  29. package/server/public/manifest.webmanifest +9 -3
  30. package/server/public/sw.js +6 -1
  31. package/server/routes/messages-routes.mjs +3 -1
  32. package/server/routes/sessions-routes.mjs +15 -2
  33. package/server/server-runtime.mjs +10 -8
  34. package/server/services/claude-context-usage.mjs +51 -3
  35. package/server/services/oar-cli-helpers.mjs +67 -1
  36. package/server/services/pwa-manifest-theme.mjs +30 -0
  37. package/server/services/session-worker-registry-service.mjs +18 -1
  38. package/server/services/update-install-service.mjs +9 -3
@@ -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="#161b22">
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
- if (!__IS_SHARED_ROUTE) {
167
- document.write(`<link rel="manifest" href="${__APP_BASE}/manifest.webmanifest?${__PWA_BUST}">`);
168
- }
169
- document.write(`<link rel="icon" type="image/png" sizes="192x192" href="${__APP_BASE}/app-icon-192.png?${__PWA_BUST}">`);
170
- document.write(`<link rel="icon" type="image/png" sizes="512x512" href="${__APP_BASE}/app-icon-512.png?${__PWA_BUST}">`);
171
- document.write(`<link rel="icon" type="image/svg+xml" href="${__APP_BASE}/app-icon.svg?${__PWA_BUST}">`);
172
- document.write(`<link rel="apple-touch-icon" href="${__APP_BASE}/app-icon-192.png?${__PWA_BUST}">`);
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
- display: flex; align-items: center; justify-content: center;
389
- height: var(--app-height); padding: 24px;
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
- <h1>🛶 OAR</h1>
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": "#161b22",
11
- "theme_color": "#161b22",
10
+ "background_color": "#0d1117",
11
+ "theme_color": "#0d1117",
12
12
  "icons": [
13
13
  {
14
- "src": "app-icon.svg?v=25",
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
  }
@@ -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|app-icon\.svg|favicon\.ico)$/.test(relativePath);
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: parseAutoCompactWindow(conversationRow?.auto_compact_window),
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: '#161b22',
5602
- theme_color: '#161b22',
5602
+ background_color: '#0d1117',
5603
+ theme_color: '#0d1117',
5603
5604
  icons: [
5604
- { src: 'app-icon.svg?v=25', sizes: 'any', type: 'image/svg+xml', purpose: 'any' },
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: parsed?.model || row?.provider_model || row?.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
+ }