@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.
Files changed (146) hide show
  1. package/.github/extensions/web-relay/extension.mjs +0 -3
  2. package/.github/extensions/web-relay/polling/heartbeat.mjs +2 -55
  3. package/.github/extensions/web-relay/polling/polling-loop.mjs +0 -1
  4. package/.github/extensions/web-relay/runtime/api-client.mjs +2 -34
  5. package/.github/extensions/web-relay/runtime/config-loader.mjs +2 -104
  6. package/.github/extensions/web-relay/runtime/send-and-wait-errors.mjs +2 -170
  7. package/.github/extensions/web-relay/runtime/session-io.mjs +1 -71
  8. package/.github/extensions/web-relay/runtime/worker-websocket-link.mjs +2 -325
  9. package/.github/extensions/web-relay/skills/prompt-context.mjs +2 -160
  10. package/CHANGELOG.md +269 -0
  11. package/README.md +407 -424
  12. package/bin/oar.js +142 -11
  13. package/package.json +8 -4
  14. package/server/README.md +59 -22
  15. package/server/claude-worker/claude-sdk-adapter.mjs +7 -2
  16. package/server/claude-worker/claude-session-process.mjs +1013 -45
  17. package/server/claude-worker/claude-session-worker.mjs +54 -9
  18. package/server/claude-worker/claude-turn-publisher.mjs +110 -19
  19. package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
  20. package/server/copilot-sdk-runtime.mjs +12 -3
  21. package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
  22. package/server/copilot-worker/copilot-attachments.mjs +35 -2
  23. package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
  24. package/server/copilot-worker/copilot-model-switch.mjs +425 -0
  25. package/server/copilot-worker/copilot-prompt-context.mjs +20 -8
  26. package/server/copilot-worker/copilot-question-bridge.mjs +129 -16
  27. package/server/copilot-worker/copilot-sdk-adapter.mjs +59 -11
  28. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +136 -12
  29. package/server/copilot-worker/copilot-sdk-session-process.mjs +2753 -527
  30. package/server/copilot-worker/copilot-sdk-session-worker.mjs +94 -25
  31. package/server/cursor-worker/cursor-session-worker.mjs +5 -5
  32. package/server/cursor-worker/cursor-turn-runner.mjs +50 -4
  33. package/server/db-schema.mjs +61 -1
  34. package/server/features.mjs +155 -41
  35. package/server/grok-worker/grok-session-worker.mjs +5 -5
  36. package/server/grok-worker/grok-turn-runner.mjs +45 -4
  37. package/server/migrations/0004-steer-settle-markers.mjs +119 -0
  38. package/server/public/app/annotate-core.mjs +181 -0
  39. package/server/public/app/annotate-editor.js +617 -0
  40. package/server/public/app/api-client.js +86 -5
  41. package/server/public/app/ask-user-view.js +102 -13
  42. package/server/public/app/attachments-view.js +172 -3
  43. package/server/public/app/background-task-timeout-options.mjs +9 -0
  44. package/server/public/app/background-tasks-view.mjs +34 -19
  45. package/server/public/app/bootstrap.js +153 -69
  46. package/server/public/app/composer-control-state.mjs +52 -34
  47. package/server/public/app/context-tier-options.mjs +24 -4
  48. package/server/public/app/conversation-draft-sync.mjs +187 -0
  49. package/server/public/app/conversation-list-filter.mjs +86 -0
  50. package/server/public/app/conversation-preferences.mjs +47 -10
  51. package/server/public/app/conversation-provider-indicator.mjs +8 -0
  52. package/server/public/app/conversation-view.js +1032 -218
  53. package/server/public/app/emoji-view.js +2 -0
  54. package/server/public/app/font-scaling.js +4 -37
  55. package/server/public/app/journal-view.js +190 -24
  56. package/server/public/app/model-selector-options.mjs +185 -1
  57. package/server/public/app/new-conversation-model-choice.mjs +29 -9
  58. package/server/public/app/pwa-install.js +64 -136
  59. package/server/public/app/question-multi-select.mjs +52 -0
  60. package/server/public/app/reasoning-effort-labels.mjs +13 -3
  61. package/server/public/app/relay-prompt-sanitizer.mjs +26 -3
  62. package/server/public/app/router.js +120 -11
  63. package/server/public/app/settings-modal.js +225 -20
  64. package/server/public/app/settings-tabs.js +1 -1
  65. package/server/public/app/socket-handlers.js +47 -4
  66. package/server/public/app/steered-turn-merge.mjs +100 -0
  67. package/server/public/app/store.js +159 -16
  68. package/server/public/app/sync-outbox.mjs +16 -8
  69. package/server/public/app/tool-activity-emoji.mjs +36 -0
  70. package/server/public/app/transcript-viewport-anchor.mjs +210 -0
  71. package/server/public/app/update-ui.js +194 -0
  72. package/server/public/index.html +483 -24
  73. package/server/public/sw.js +7 -0
  74. package/server/relay-tools.md +2 -0
  75. package/server/relay.mjs +23 -21
  76. package/server/repositories/message-repository.mjs +78 -21
  77. package/server/repositories/question-repository.mjs +19 -1
  78. package/server/repositories/session-repository.mjs +7 -31
  79. package/server/routes/ask-user-routes.mjs +10 -0
  80. package/server/routes/messages-routes.mjs +689 -159
  81. package/server/routes/sessions-routes.mjs +574 -80
  82. package/server/server-runtime.mjs +393 -925
  83. package/server/services/claude-auth-service.mjs +7 -1
  84. package/server/services/cli-install-service.mjs +8 -27
  85. package/server/services/cli-process-runner.mjs +30 -0
  86. package/server/services/copilot-model-discovery-service.mjs +202 -0
  87. package/server/services/data-dir-resolution.mjs +47 -0
  88. package/server/services/grok-auth-service.mjs +9 -1
  89. package/server/services/live-turn-picker.mjs +47 -0
  90. package/server/services/model-variant-catalog-service.mjs +1183 -0
  91. package/server/services/oar-cli-helpers.mjs +6 -2
  92. package/server/services/relay-prompt-sanitizer.mjs +30 -3
  93. package/server/services/relay-question-context.mjs +46 -0
  94. package/server/services/sdk-session-import-service.mjs +144 -16
  95. package/server/services/sdk-session-sync-service.mjs +84 -27
  96. package/server/services/session-worker-registry-service.mjs +116 -3
  97. package/server/services/session-worker-supervisor-service.mjs +7 -1
  98. package/server/services/session-worker-websocket-service.mjs +119 -24
  99. package/server/services/status-event-service.mjs +8 -2
  100. package/server/services/steer-resend.mjs +45 -0
  101. package/server/services/update-check-service.mjs +220 -0
  102. package/server/services/update-install-service.mjs +279 -0
  103. package/server/services/windows-autostart-service.mjs +42 -6
  104. package/server/services/windows-boot-autostart-service.mjs +487 -0
  105. package/shared/ask-user-bridge.mjs +37 -5
  106. package/shared/background-task-timeout.mjs +12 -4
  107. package/shared/claude-model-catalog.mjs +49 -0
  108. package/shared/context-window-fallbacks.mjs +1 -0
  109. package/shared/control-poller.mjs +4 -1
  110. package/shared/copilot-model-order.mjs +182 -0
  111. package/shared/media-embed-instructions.mjs +35 -0
  112. package/shared/model-descriptors.mjs +181 -30
  113. package/shared/model-id.mjs +6 -0
  114. package/shared/pwa-app-name.mjs +44 -0
  115. package/shared/question-multi-select.mjs +16 -0
  116. package/shared/relay-stop-failure.mjs +15 -0
  117. package/shared/slash-command-guard.mjs +76 -0
  118. package/shared/steer-note.mjs +66 -0
  119. package/shared/steer-settle-failure.mjs +89 -0
  120. package/shared/steer-settle-markers.mjs +18 -0
  121. package/shared/update-semver.mjs +50 -0
  122. package/shared/worker-crash-guard.mjs +40 -14
  123. package/shared/worker-runtime/api-client.mjs +34 -0
  124. package/shared/worker-runtime/config-loader.mjs +104 -0
  125. package/shared/worker-runtime/heartbeat.mjs +109 -0
  126. package/shared/worker-runtime/prompt-context.mjs +160 -0
  127. package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
  128. package/shared/worker-runtime/send-and-wait-errors.mjs +170 -0
  129. package/shared/worker-runtime/worker-websocket-link.mjs +436 -0
  130. package/server/claude-worker/claude-session-test-harness.mjs +0 -236
  131. package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
  132. package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -329
  133. package/server/copilot-worker/fixtures/abort-turn.json +0 -588
  134. package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
  135. package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
  136. package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
  137. package/server/copilot-worker/fixtures/happy-turn.json +0 -566
  138. package/server/copilot-worker/fixtures/quota-turn.json +0 -647
  139. package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
  140. package/server/copilot-worker/fixtures/resume-turn.json +0 -700
  141. package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
  142. package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
  143. package/server/routes/messages-routes-test-harness.mjs +0 -111
  144. package/server/services/fixtures/claude-auth-stub.sh +0 -62
  145. package/server/services/fixtures/cli-install-stub.sh +0 -75
  146. package/server/services/fixtures/grok-stub.sh +0 -86
@@ -1,16 +1,21 @@
1
1
  import { BASE, IS_SHARED_VIEW, showTransientRelayNotice } from './store.js';
2
+ import { loadPwaAppNameSetting, updatePwaAppNameSetting } from './api-client.js';
2
3
 
3
4
  const THEME_COLOR_BASE = '#0d1117';
4
5
  const THEME_COLOR_IMMERSIVE = '#161b22';
6
+ // Legacy per-browser storage of the app name; kept only so adoptLegacyPwaAppName
7
+ // can move an existing value to the server once. The name itself now lives in
8
+ // app_settings and is served by /manifest.webmanifest.
5
9
  const PWA_APP_NAME_STORAGE_KEY = 'copilot_pwa_app_name';
6
- const PWA_APP_NAME_DEFAULT = 'OAR';
7
- const PWA_APP_NAME_MAX_LENGTH = 60;
8
10
  const INSTALLED_DISPLAY_MODE_QUERIES = ['(display-mode: standalone)', '(display-mode: fullscreen)'];
9
11
 
10
12
  let deferredInstallPrompt = null;
11
13
  let pendingInstalledFullscreenGesture = false;
12
- let manifestTemplateCache = null;
13
- let customManifestUrl = null;
14
+ let pwaAppName = '';
15
+ // Monotonic sequence so overlapping loads/saves resolve latest-wins: a stale
16
+ // background GET must never repaint over a just-saved name, and a second edit
17
+ // must never be dropped in favor of an older in-flight save.
18
+ let pwaAppNameRequestSeq = 0;
14
19
 
15
20
  function matchesDisplayMode(query) {
16
21
  try {
@@ -298,152 +303,75 @@ export function initFullscreenButton() {
298
303
  updateFullscreenButton();
299
304
  }
300
305
 
301
- function normalizePwaAppName(rawValue, { allowEmpty = true } = {}) {
302
- const normalized = String(rawValue || '').replace(/\s+/g, ' ').trim();
303
- if (!normalized) {
304
- return allowEmpty
305
- ? { value: '', error: null }
306
- : { value: '', error: 'App name cannot be empty.' };
307
- }
308
- if (normalized.length > PWA_APP_NAME_MAX_LENGTH) {
309
- return { value: '', error: `App name must be ${PWA_APP_NAME_MAX_LENGTH} characters or fewer.` };
310
- }
311
- return { value: normalized, error: null };
312
- }
313
-
314
- function derivePwaShortName(name) {
315
- const text = String(name || '').trim();
316
- if (!text) return 'Copilot';
317
- const firstWord = text.split(/\s+/)[0] || text;
318
- if (firstWord.length <= 12) return firstWord;
319
- return text.slice(0, 12).trim() || 'Copilot';
320
- }
321
-
322
- function resolveManifestUrlValue(rawValue, baseHref) {
323
- const value = String(rawValue || '').trim();
324
- if (!value || value.startsWith('data:') || value.startsWith('blob:')) return value;
325
- try {
326
- return new URL(value, baseHref).href;
327
- } catch {
328
- return value;
329
- }
330
- }
331
-
332
- function normalizeManifestForBlob(manifest, defaultHref) {
333
- const baseHref = new URL(String(defaultHref || '').trim(), window.location.href).href;
334
- const next = { ...(manifest || {}) };
335
- next.id = resolveManifestUrlValue(next.id, baseHref);
336
- next.start_url = resolveManifestUrlValue(next.start_url, baseHref);
337
- next.scope = resolveManifestUrlValue(next.scope, baseHref);
338
- if (Array.isArray(next.icons)) {
339
- next.icons = next.icons.map((icon) => {
340
- if (!icon || typeof icon !== 'object') return icon;
341
- const source = resolveManifestUrlValue(icon.src, baseHref);
342
- return { ...icon, src: source };
343
- });
344
- }
345
- return next;
346
- }
347
-
348
- function readStoredPwaAppName() {
349
- const { value } = normalizePwaAppName(localStorage.getItem(PWA_APP_NAME_STORAGE_KEY), { allowEmpty: true });
350
- return value;
351
- }
306
+ // The app name is stored on the relay and baked into /manifest.webmanifest by
307
+ // the server, so every manifest fetch — including Android's out-of-page WebAPK
308
+ // update checks — sees the same name. The client only edits the setting.
352
309
 
353
- export function syncPwaAppNameInput() {
310
+ function paintPwaAppNameInput() {
354
311
  const input = document.getElementById('pwa-app-name-input');
355
312
  if (!input) return;
356
- input.value = readStoredPwaAppName();
313
+ // Don't clobber typing when a background refresh lands mid-edit.
314
+ if (document.activeElement === input) return;
315
+ input.value = pwaAppName;
357
316
  }
358
317
 
359
- async function loadManifestTemplate(defaultHref) {
360
- if (manifestTemplateCache) return { ...manifestTemplateCache };
361
- const fallback = {
362
- name: PWA_APP_NAME_DEFAULT,
363
- short_name: derivePwaShortName(PWA_APP_NAME_DEFAULT),
364
- description: 'OAR — Open Agent Relay: drive your local coding agents from any browser.',
365
- id: './__copilot_remote_pwa__',
366
- start_url: './',
367
- scope: './',
368
- display_override: ['standalone'],
369
- display: 'standalone',
370
- background_color: '#161b22',
371
- theme_color: '#161b22',
372
- icons: [
373
- { src: 'app-icon.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'any' },
374
- { src: 'app-icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
375
- { src: 'app-icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
376
- ],
377
- };
378
- try {
379
- const response = await fetch(defaultHref, { cache: 'no-store' });
380
- const manifest = response.ok ? await response.json() : null;
381
- if (manifest && typeof manifest === 'object' && !Array.isArray(manifest)) {
382
- manifestTemplateCache = manifest;
383
- return { ...manifestTemplateCache };
384
- }
385
- } catch (error) {
386
- console.warn('Failed to load manifest template; using fallback.', error);
387
- }
388
- manifestTemplateCache = fallback;
389
- return { ...manifestTemplateCache };
318
+ export function syncPwaAppNameInput() {
319
+ paintPwaAppNameInput();
320
+ const seq = ++pwaAppNameRequestSeq;
321
+ void loadPwaAppNameSetting().then((result) => {
322
+ if (seq !== pwaAppNameRequestSeq) return; // superseded by a newer load/save
323
+ if (!result || typeof result.appName !== 'string') return;
324
+ pwaAppName = result.appName;
325
+ paintPwaAppNameInput();
326
+ });
390
327
  }
391
328
 
392
- export async function applyPwaManifestFromSettings() {
393
- const manifestLink = document.querySelector('link[rel="manifest"]');
394
- if (!manifestLink) return;
395
-
396
- const defaultHref = String(manifestLink.dataset.defaultHref || manifestLink.getAttribute('href') || '').trim();
397
- if (!defaultHref) return;
398
- if (!manifestLink.dataset.defaultHref) manifestLink.dataset.defaultHref = defaultHref;
399
-
400
- const customName = readStoredPwaAppName();
401
- if (!customName) {
402
- if (customManifestUrl) {
403
- URL.revokeObjectURL(customManifestUrl);
404
- customManifestUrl = null;
405
- }
406
- manifestLink.setAttribute('href', defaultHref);
329
+ export async function updatePwaAppName(rawValue) {
330
+ const seq = ++pwaAppNameRequestSeq;
331
+ const result = await updatePwaAppNameSetting(rawValue);
332
+ if (!result || typeof result.appName !== 'string') {
333
+ alert('Failed to update the install app name.');
334
+ if (seq === pwaAppNameRequestSeq) paintPwaAppNameInput();
407
335
  return;
408
336
  }
409
-
410
- const baseManifest = await loadManifestTemplate(defaultHref);
411
- const nextManifest = {
412
- ...baseManifest,
413
- name: customName,
414
- short_name: derivePwaShortName(customName),
415
- };
416
- const normalizedManifest = normalizeManifestForBlob(nextManifest, defaultHref);
417
- const manifestBlob = new Blob([JSON.stringify(normalizedManifest, null, 2)], { type: 'application/manifest+json' });
418
- const objectUrl = URL.createObjectURL(manifestBlob);
419
- if (customManifestUrl) URL.revokeObjectURL(customManifestUrl);
420
- customManifestUrl = objectUrl;
421
- manifestLink.setAttribute('href', objectUrl);
337
+ if (seq !== pwaAppNameRequestSeq) return; // a newer edit owns the UI now
338
+ pwaAppName = result.appName;
339
+ showTransientRelayNotice(result.appName
340
+ ? `Install app name updated to "${result.appName}".`
341
+ : 'Install app name reset to default.');
342
+ paintPwaAppNameInput();
422
343
  }
423
344
 
424
- export function updatePwaAppName(rawValue) {
425
- const normalized = normalizePwaAppName(rawValue, { allowEmpty: true });
426
- if (normalized.error) {
427
- alert(normalized.error);
428
- syncPwaAppNameInput();
345
+ /**
346
+ * One-time move of a legacy per-browser name into the relay's settings. The
347
+ * key is removed whether the value is adopted or the server already has a
348
+ * name — either way it is dead weight; only a network/auth failure keeps it
349
+ * so the next boot can retry.
350
+ */
351
+ export async function adoptLegacyPwaAppName() {
352
+ if (IS_SHARED_VIEW) return;
353
+ let stored = '';
354
+ try {
355
+ stored = String(localStorage.getItem(PWA_APP_NAME_STORAGE_KEY) || '').trim();
356
+ } catch {
429
357
  return;
430
358
  }
431
- if (normalized.value) {
432
- localStorage.setItem(PWA_APP_NAME_STORAGE_KEY, normalized.value);
433
- } else {
434
- localStorage.removeItem(PWA_APP_NAME_STORAGE_KEY);
359
+ if (!stored) return;
360
+ // A legacy over-long value would 400 forever; best-effort truncate instead.
361
+ if (stored.length > 60) stored = stored.slice(0, 60).trim();
362
+ const seq = ++pwaAppNameRequestSeq;
363
+ const current = await loadPwaAppNameSetting();
364
+ if (!current || typeof current.appName !== 'string') return; // offline/unauthed: retry next boot
365
+ let adoptedName = current.appName;
366
+ if (!current.appName) {
367
+ const adopted = await updatePwaAppNameSetting(stored);
368
+ if (!adopted || typeof adopted.appName !== 'string') return; // POST failed: keep the key
369
+ adoptedName = adopted.appName;
435
370
  }
436
- applyPwaManifestFromSettings()
437
- .then(() => {
438
- syncPwaAppNameInput();
439
- showTransientRelayNotice(normalized.value
440
- ? `Install app name updated to "${normalized.value}".`
441
- : 'Install app name reset to default.');
442
- })
443
- .catch((error) => {
444
- alert(error?.message || 'Failed to apply install app name');
445
- syncPwaAppNameInput();
446
- });
371
+ try { localStorage.removeItem(PWA_APP_NAME_STORAGE_KEY); } catch {}
372
+ if (seq !== pwaAppNameRequestSeq) return; // the user already edited; their state wins
373
+ pwaAppName = adoptedName;
374
+ paintPwaAppNameInput();
447
375
  }
448
376
 
449
377
  export function registerPwaShell() {
@@ -0,0 +1,52 @@
1
+ // Multi-select question cards: the pure rules the card renderer and its
2
+ // submit path share, kept DOM-free so they are unit-testable.
3
+ //
4
+ // Two card hints ride the question's `context` (kept by the relay's
5
+ // services/relay-question-context.mjs allow-list):
6
+ // - `multiSelect: true` — several answers are allowed. Claude states this on
7
+ // every AskUserQuestion question; Copilot models declare it through the
8
+ // relay's own `ask_user` (`multi_select`), and the worker falls back to the
9
+ // wording ("select all that apply").
10
+ // - `allowMultiSelect: true` — the provider cannot always tell (Copilot), so
11
+ // the card offers a "Select several" switch. Claude cards never carry it:
12
+ // they follow Claude's flag strictly (maintainer decision, 2026-09-25).
13
+ // A multi-select card offers checkmarks and one "Reply with selection"; the
14
+ // answer is the selected labels joined with ", " — plus whatever the user
15
+ // typed as an extra answer — which is Claude's own multi-select answer format.
16
+
17
+ function choiceCount(question) {
18
+ return Array.isArray(question?.choices) ? question.choices.length : 0;
19
+ }
20
+
21
+ /** Whether the card offers the "Select several" switch. */
22
+ export function offersMultiSelectToggle(question) {
23
+ if (!question || typeof question !== 'object') return false;
24
+ return choiceCount(question) >= 2 && question?.context?.allowMultiSelect === true;
25
+ }
26
+
27
+ /**
28
+ * Whether the card renders checkmarks. `override` is the user's switch
29
+ * position for this card (true/false), or undefined while untouched — then the
30
+ * provider's flag decides. The switch only counts on cards that offer it.
31
+ */
32
+ export function isMultiSelectQuestion(question, override = undefined) {
33
+ if (!question || typeof question !== 'object') return false;
34
+ if (choiceCount(question) < 2) return false;
35
+ if (typeof override === 'boolean' && offersMultiSelectToggle(question)) return override;
36
+ return question?.context?.multiSelect === true;
37
+ }
38
+
39
+ /**
40
+ * The answer string for a multi-select card: the selected labels in offer
41
+ * order, then the free-text answer (if any) as one more item. Empty when
42
+ * nothing was selected or typed.
43
+ */
44
+ export function composeMultiSelectAnswer(selectedLabels, freeText = '') {
45
+ const labels = (Array.isArray(selectedLabels) ? selectedLabels : [])
46
+ .map((label) => String(label ?? '').trim())
47
+ .filter(Boolean)
48
+ .filter((label, index, all) => all.indexOf(label) === index);
49
+ const extra = String(freeText ?? '').trim();
50
+ if (extra && !labels.includes(extra)) labels.push(extra);
51
+ return labels.join(', ');
52
+ }
@@ -27,16 +27,26 @@ export const ULTRACODE_EFFORT_LABEL = 'Ultracode';
27
27
  export const ULTRACODE_EFFORT_OPTION_TITLE =
28
28
  'xhigh effort plus multi-agent workflow orchestration — expect much higher token use';
29
29
 
30
+ // Gemini and some OpenAI models offer a rung under "low". It reads like the
31
+ // other plain tiers (lowercase, no rename): an "Off"-style label would promise
32
+ // a switch the runtime does not have, and Title Case would single it out from
33
+ // its neighbours. The tooltip explains the rung instead.
34
+ export const MINIMAL_EFFORT = 'minimal';
35
+ export const MINIMAL_EFFORT_LABEL = 'minimal';
36
+ export const MINIMAL_EFFORT_OPTION_TITLE = 'Lowest reasoning tier this model offers — faster and cheaper than low';
37
+
30
38
  export function reasoningEffortOptionLabel(effort, { reasoningOffUnsupported = false } = {}) {
31
39
  const value = String(effort || '').trim().toLowerCase();
32
40
  if (!value) return '';
33
41
  if (value === 'none' && reasoningOffUnsupported) return PROVIDER_DEFAULT_EFFORT_LABEL;
34
42
  if (value === CLAUDE_ULTRACODE_EFFORT) return ULTRACODE_EFFORT_LABEL;
43
+ if (value === MINIMAL_EFFORT) return MINIMAL_EFFORT_LABEL;
35
44
  return value;
36
45
  }
37
46
 
38
47
  export function reasoningEffortOptionTitle(effort) {
39
- return String(effort || '').trim().toLowerCase() === CLAUDE_ULTRACODE_EFFORT
40
- ? ULTRACODE_EFFORT_OPTION_TITLE
41
- : '';
48
+ const value = String(effort || '').trim().toLowerCase();
49
+ if (value === CLAUDE_ULTRACODE_EFFORT) return ULTRACODE_EFFORT_OPTION_TITLE;
50
+ if (value === MINIMAL_EFFORT) return MINIMAL_EFFORT_OPTION_TITLE;
51
+ return '';
42
52
  }
@@ -102,13 +102,36 @@ function stripAttachmentPromptArtifacts(text) {
102
102
  .trim();
103
103
  }
104
104
 
105
+ // The once-per-worker media-embed guidance (shared/media-embed-instructions.mjs)
106
+ // can precede the mode marker on the Cursor/Grok prompt paths. This module is
107
+ // served to the browser and cannot import shared/, so the block is matched
108
+ // structurally — the heading plus its single paragraph — rather than verbatim.
109
+ const MEDIA_EMBED_BLOCK_PATTERN = /##\s*Embedding media in replies\s+[\s\S]*?(?=\n\s*\n|$)/gi;
110
+
111
+ // A steered message's hidden note (shared/steer-note.mjs) leads the prompt the
112
+ // runtime stored, right after any mode marker. Matched by its stable opening
113
+ // words, because the browser copy of this module cannot import shared/.
114
+ const STEER_NOTE_PATTERN = /^\s*\[Sent while you were still working on my previous message\.[^\]\n]*\]\s*/;
115
+
116
+ // The Claude worker's slash-command guard (shared/slash-command-guard.mjs): a
117
+ // zero-width space in front of a "/x" it delivers as text. trim() keeps it, so
118
+ // it is matched on its own, and only where it guards a "/".
119
+ const SLASH_COMMAND_GUARD_PATTERN = /^\u200B(?=\s*\/)/;
120
+
121
+ function stripLeadingRelayNotes(text) {
122
+ const stripped = String(text || '').replace(STEER_NOTE_PATTERN, '').replace(SLASH_COMMAND_GUARD_PATTERN, '');
123
+ return stripped.trim() ? stripped.trim() : String(text || '').trim();
124
+ }
125
+
105
126
  export function stripRelayPromptContext(text, relayMode = '') {
106
- const value = stripAttachmentPromptArtifacts(text);
127
+ const value = stripAttachmentPromptArtifacts(text)
128
+ .replace(MEDIA_EMBED_BLOCK_PATTERN, '')
129
+ .trim();
107
130
  if (!value) return '';
108
131
  const patterns = buildPromptPrefixPatterns(relayMode);
109
132
  for (const pattern of patterns) {
110
133
  const stripped = value.replace(pattern, '').trim();
111
- if (stripped && stripped !== value) return stripped;
134
+ if (stripped && stripped !== value) return stripLeadingRelayNotes(stripped);
112
135
  }
113
- return value;
136
+ return stripLeadingRelayNotes(value);
114
137
  }
@@ -121,6 +121,45 @@ export function driveFileHrefFromPath(rawPath) {
121
121
  return `${BASE}/api/drives/file?path=${encodeURIComponent(normalized)}`;
122
122
  }
123
123
 
124
+ /**
125
+ * Recognize this app's own file-serving hrefs (the shapes
126
+ * driveFileHrefFromPath / workspaceFileHrefFromPath emit, which embedded
127
+ * message media points at after rewriting) and give back the original file
128
+ * path, so a click on an inline image can reopen it in the file viewer.
129
+ */
130
+ export function parseAppFileHref(rawSrc) {
131
+ const value = String(rawSrc || '').trim();
132
+ if (!value) return null;
133
+ let parsed = null;
134
+ try {
135
+ parsed = new URL(value, window.location.origin);
136
+ } catch {
137
+ return null;
138
+ }
139
+ if (parsed.origin !== window.location.origin) return null;
140
+ const base = String(BASE || '');
141
+ const pathname = parsed.pathname || '';
142
+ const appPath = base && pathname.startsWith(base) ? pathname.slice(base.length) : pathname;
143
+ if (appPath === '/api/drives/file') {
144
+ const path = String(parsed.searchParams.get('path') || '').trim();
145
+ return path ? { kind: 'drive', path } : null;
146
+ }
147
+ if (appPath.startsWith('/api/files/')) {
148
+ const path = appPath.slice('/api/files/'.length)
149
+ .split('/')
150
+ .map((segment) => {
151
+ try {
152
+ return decodeURIComponent(segment);
153
+ } catch {
154
+ return segment;
155
+ }
156
+ })
157
+ .join('/');
158
+ return path ? { kind: 'workspace', path } : null;
159
+ }
160
+ return null;
161
+ }
162
+
124
163
  export function drivePreviewApiPath(rawPath) {
125
164
  const normalized = normalizeDriveBrowserPath(rawPath);
126
165
  if (!normalized) return '';
@@ -196,10 +235,10 @@ export function isSafePreviewUrl(url, allowDataImage = false) {
196
235
  }
197
236
 
198
237
  const PREVIEW_ALLOWED_TAGS = new Set([
199
- 'a', 'article', 'b', 'blockquote', 'br', 'code', 'del', 'details', 'div', 'em',
238
+ 'a', 'article', 'audio', 'b', 'blockquote', 'br', 'code', 'del', 'details', 'div', 'em',
200
239
  'figcaption', 'figure', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'img',
201
240
  'kbd', 'li', 'mark', 'ol', 'p', 'pre', 's', 'small', 'span', 'strong', 'sub',
202
- 'summary', 'sup', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'tr', 'u', 'ul',
241
+ 'summary', 'sup', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'tr', 'u', 'ul', 'video',
203
242
  ]);
204
243
  const PREVIEW_GLOBAL_ATTRIBUTES = new Set(['dir', 'lang', 'title']);
205
244
  const PREVIEW_ALIGNMENT_VALUES = new Set(['left', 'center', 'right', 'justify']);
@@ -210,15 +249,24 @@ const PREVIEW_SPAN_VALUE = /^[1-9]\d{0,2}$/;
210
249
  function isAllowedPreviewAttribute(tagName, name, value) {
211
250
  if (PREVIEW_GLOBAL_ATTRIBUTES.has(name)) return true;
212
251
  if (name === 'href') return tagName === 'a' && isSafePreviewUrl(value, false);
213
- if (name === 'src') return tagName === 'img' && isSafePreviewUrl(value, true);
252
+ if (name === 'src') {
253
+ if (tagName === 'img') return isSafePreviewUrl(value, true);
254
+ return (tagName === 'video' || tagName === 'audio') && isSafePreviewUrl(value, false);
255
+ }
214
256
  if (name === 'alt') return tagName === 'img';
257
+ if (name === 'controls' || name === 'muted' || name === 'loop') {
258
+ return (tagName === 'video' || tagName === 'audio') && value === '';
259
+ }
260
+ if (name === 'playsinline') return tagName === 'video' && value === '';
261
+ if (name === 'preload') return (tagName === 'video' || tagName === 'audio')
262
+ && ['none', 'metadata', 'auto'].includes(value.toLowerCase());
215
263
  if (name === 'target') return tagName === 'a' && value === '_blank';
216
264
  if (name === 'rel') return tagName === 'a';
217
265
  if (name === 'align') return ['div', 'p', 'table', 'td', 'th', 'tr'].includes(tagName)
218
266
  && PREVIEW_ALIGNMENT_VALUES.has(value.toLowerCase());
219
267
  if (name === 'valign') return ['td', 'th', 'tr'].includes(tagName)
220
268
  && PREVIEW_VERTICAL_ALIGNMENT_VALUES.has(value.toLowerCase());
221
- if (name === 'width' || name === 'height') return ['img', 'table', 'td', 'th'].includes(tagName)
269
+ if (name === 'width' || name === 'height') return ['img', 'table', 'td', 'th', 'video'].includes(tagName)
222
270
  && PREVIEW_DIMENSION_VALUE.test(value);
223
271
  if (name === 'rowspan' || name === 'colspan') return ['td', 'th'].includes(tagName)
224
272
  && PREVIEW_SPAN_VALUE.test(value);
@@ -230,27 +278,88 @@ function isAllowedPreviewAttribute(tagName, name, value) {
230
278
  return false;
231
279
  }
232
280
 
281
+ const EMBED_VIDEO_EXTENSIONS = new Set(['mp4', 'webm', 'm4v', 'mov']);
282
+ const EMBED_AUDIO_EXTENSIONS = new Set(['mp3', 'm4a', 'ogg', 'opus', 'wav', 'flac']);
283
+
284
+ // 'video' | 'audio' | '' for an already-safe embed src; drive/workspace hrefs
285
+ // carry the real file path in their `path` query parameter.
286
+ function mediaKindForEmbedSource(src) {
287
+ const value = String(src || '').trim();
288
+ if (!value || value.toLowerCase().startsWith('data:')) return '';
289
+ let candidate = value;
290
+ try {
291
+ const parsed = new URL(value, window.location.origin);
292
+ candidate = parsed.searchParams.get('path') || parsed.pathname;
293
+ } catch {}
294
+ const match = /\.([A-Za-z0-9]{1,5})$/.exec(String(candidate || '').trim());
295
+ const extension = match ? match[1].toLowerCase() : '';
296
+ if (EMBED_VIDEO_EXTENSIONS.has(extension)) return 'video';
297
+ if (EMBED_AUDIO_EXTENSIONS.has(extension)) return 'audio';
298
+ return '';
299
+ }
300
+
301
+ function convertImageToMediaElement(img, mediaTag, src) {
302
+ const media = document.createElement(mediaTag);
303
+ media.setAttribute('src', src);
304
+ media.setAttribute('controls', '');
305
+ media.setAttribute('preload', 'metadata');
306
+ const alt = String(img.getAttribute('alt') || '').trim();
307
+ if (alt) media.setAttribute('title', alt);
308
+ if (mediaTag === 'video') {
309
+ for (const dimension of ['width', 'height']) {
310
+ const value = String(img.getAttribute(dimension) || '').trim();
311
+ if (value) media.setAttribute(dimension, value);
312
+ }
313
+ }
314
+ img.replaceWith(media);
315
+ return media;
316
+ }
317
+
233
318
  export function sanitizePreviewHtml(html) {
234
319
  const template = document.createElement('template');
235
320
  template.innerHTML = String(html || '');
236
321
  const nodes = Array.from(template.content.querySelectorAll('*'));
237
322
  for (const el of nodes) {
238
- const tagName = String(el.tagName || '').toLowerCase();
323
+ let element = el;
324
+ let tagName = String(element.tagName || '').toLowerCase();
325
+ if (tagName === 'img' || tagName === 'video' || tagName === 'audio') {
326
+ // Bare local absolute paths (C:\...\shot.png, /home/user/shot.png) are
327
+ // resolved to the drive/workspace file route HERE, before the generic
328
+ // attribute filter would strip them — this is what lets agents embed
329
+ // media by plain path instead of a hand-encoded /api/drives/file URL.
330
+ const rawSrc = String(element.getAttribute('src') || '').trim();
331
+ // Only genuine local-path shapes may be resolved; anything else that
332
+ // fails the safe-URL check (javascript:, vbscript:, …) must simply die,
333
+ // not be laundered into a workspace href by the resolver.
334
+ const looksLocalAbsolute = /^[A-Za-z]:[\\/]/.test(rawSrc) || rawSrc.startsWith('\\');
335
+ const safeSrc = rawSrc && isSafePreviewUrl(rawSrc, tagName === 'img')
336
+ ? rawSrc
337
+ : (looksLocalAbsolute ? resolveLocalAssetUrl(rawSrc) : '');
338
+ if (safeSrc && safeSrc !== rawSrc) element.setAttribute('src', safeSrc);
339
+ else if (!safeSrc && rawSrc) element.removeAttribute('src');
340
+ if (tagName === 'img') {
341
+ const mediaTag = mediaKindForEmbedSource(safeSrc);
342
+ if (mediaTag) {
343
+ element = convertImageToMediaElement(element, mediaTag, safeSrc);
344
+ tagName = mediaTag;
345
+ }
346
+ }
347
+ }
239
348
  if (!PREVIEW_ALLOWED_TAGS.has(tagName)) {
240
- el.replaceWith(document.createTextNode(el.textContent || ''));
349
+ element.replaceWith(document.createTextNode(element.textContent || ''));
241
350
  continue;
242
351
  }
243
- const attrs = Array.from(el.attributes || []);
352
+ const attrs = Array.from(element.attributes || []);
244
353
  for (const attr of attrs) {
245
354
  const name = String(attr.name || '').toLowerCase();
246
355
  const value = String(attr.value || '').trim();
247
356
  if (!isAllowedPreviewAttribute(tagName, name, value)) {
248
- el.removeAttribute(attr.name);
357
+ element.removeAttribute(attr.name);
249
358
  }
250
359
  }
251
360
  if (tagName === 'a') {
252
- el.setAttribute('target', '_blank');
253
- el.setAttribute('rel', 'noopener noreferrer');
361
+ element.setAttribute('target', '_blank');
362
+ element.setAttribute('rel', 'noopener noreferrer');
254
363
  }
255
364
  }
256
365
  return template.innerHTML;
@@ -616,7 +725,7 @@ export function rewriteLocalAssetUrlsInNode(root, options = {}) {
616
725
  if (!(root instanceof Element)) return;
617
726
  const preferDrive = options?.preferDrive === true;
618
727
  const rewriteAnchors = options?.rewriteAnchors !== false;
619
- const images = Array.from(root.querySelectorAll('img[src]'));
728
+ const images = Array.from(root.querySelectorAll('img[src], video[src], audio[src]'));
620
729
  for (const img of images) {
621
730
  const rawSrc = img.getAttribute('src') || '';
622
731
  const nextSrc = resolveLocalAssetUrl(rawSrc, { preferDrive });