@oar-sh/oar 0.9.6 → 0.9.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/CHANGELOG.md +285 -0
  2. package/README.md +222 -40
  3. package/bin/oar.js +320 -18
  4. package/package.json +1 -1
  5. package/server/README.md +174 -7
  6. package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +340 -0
  7. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +1325 -0
  8. package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +155 -0
  9. package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
  10. package/server/claude-worker/claude-sdk-adapter.mjs +18 -13
  11. package/server/claude-worker/claude-session-process.mjs +225 -24
  12. package/server/claude-worker/claude-session-worker.mjs +6 -0
  13. package/server/claude-worker/sdk-message-normalizer.mjs +28 -0
  14. package/server/db-schema.mjs +17 -0
  15. package/server/migrations/0006-workspace-root-settings.mjs +67 -0
  16. package/server/migrations/0007-claude-cloud.mjs +82 -0
  17. package/server/public/app/activity-replay-state.mjs +68 -5
  18. package/server/public/app/agent-sessions-settings-ui.js +197 -0
  19. package/server/public/app/agent-sessions-settings.mjs +134 -0
  20. package/server/public/app/api-client.js +149 -6
  21. package/server/public/app/attachments-view.js +20 -1
  22. package/server/public/app/attribution-control.mjs +46 -0
  23. package/server/public/app/bootstrap.js +468 -88
  24. package/server/public/app/claude-cloud-conversation-ui.js +110 -0
  25. package/server/public/app/claude-cloud-settings-ui.js +374 -0
  26. package/server/public/app/claude-cloud-ui.mjs +611 -0
  27. package/server/public/app/composer-control-state.mjs +11 -0
  28. package/server/public/app/context-menu.mjs +140 -0
  29. package/server/public/app/context-tier-options.mjs +4 -0
  30. package/server/public/app/context-usage-view.mjs +127 -8
  31. package/server/public/app/conversation-origin-ui.js +27 -0
  32. package/server/public/app/conversation-provider-indicator.mjs +22 -4
  33. package/server/public/app/conversation-view.js +145 -11
  34. package/server/public/app/cwd-picker.js +12 -0
  35. package/server/public/app/journal-view.js +698 -28
  36. package/server/public/app/math-dollars.mjs +89 -0
  37. package/server/public/app/model-selector-options.mjs +1 -1
  38. package/server/public/app/plan-usage-view.mjs +36 -8
  39. package/server/public/app/pwa-install.js +5 -0
  40. package/server/public/app/relay-error-ctas.mjs +16 -2
  41. package/server/public/app/remote-relay-origin-view.mjs +120 -4
  42. package/server/public/app/router.js +49 -12
  43. package/server/public/app/scrollbar-hover.mjs +78 -0
  44. package/server/public/app/settings-modal.js +37 -3
  45. package/server/public/app/settings-tabs.js +1 -1
  46. package/server/public/app/socket-handlers.js +53 -1
  47. package/server/public/app/store.js +26 -0
  48. package/server/public/app/transcript-separators.mjs +48 -11
  49. package/server/public/app-icon-192.png +0 -0
  50. package/server/public/app-icon-512.png +0 -0
  51. package/server/public/app-icon-light-192.png +0 -0
  52. package/server/public/app-icon-light-512.png +0 -0
  53. package/server/public/app-icon-light-maskable-512.png +0 -0
  54. package/server/public/app-icon-light.svg +4 -0
  55. package/server/public/app-icon-maskable-512.png +0 -0
  56. package/server/public/app-icon.svg +3 -3
  57. package/server/public/favicon.ico +0 -0
  58. package/server/public/index.html +564 -20
  59. package/server/public/manifest.webmanifest +9 -3
  60. package/server/public/sw.js +6 -1
  61. package/server/relay-tools.md +13 -1
  62. package/server/repositories/remote-relay-repository.mjs +42 -1
  63. package/server/repositories/session-repository.mjs +47 -1
  64. package/server/routes/claude-cloud-routes.mjs +140 -0
  65. package/server/routes/git-routes.mjs +37 -0
  66. package/server/routes/messages-routes.mjs +123 -16
  67. package/server/routes/remote-relay-routes.mjs +15 -1
  68. package/server/routes/sessions-routes.mjs +517 -74
  69. package/server/server-runtime.mjs +268 -33
  70. package/server/services/claude-account-usage-service.mjs +176 -0
  71. package/server/services/claude-cloud-repo-service.mjs +408 -0
  72. package/server/services/claude-cloud-session-service.mjs +326 -0
  73. package/server/services/claude-cloud-settings-service.mjs +269 -0
  74. package/server/services/claude-context-usage.mjs +51 -3
  75. package/server/services/git-remote-service.mjs +115 -0
  76. package/server/services/oar-cli-helpers.mjs +67 -1
  77. package/server/services/plan-usage-claude-cloud.mjs +211 -0
  78. package/server/services/plan-usage-claude.mjs +202 -40
  79. package/server/services/plan-usage-contract.mjs +39 -3
  80. package/server/services/plan-usage-service.mjs +82 -2
  81. package/server/services/provider-reasoning-effort.mjs +3 -0
  82. package/server/services/pwa-manifest-theme.mjs +30 -0
  83. package/server/services/remote-relay-caller-context.mjs +49 -10
  84. package/server/services/remote-relay-client.mjs +9 -0
  85. package/server/services/remote-relay-dispatcher.mjs +383 -35
  86. package/server/services/remote-relay-inbound.mjs +49 -2
  87. package/server/services/remote-relay-loopback.mjs +245 -0
  88. package/server/services/remote-relay-registry-service.mjs +52 -0
  89. package/server/services/session-worker-launch-service.mjs +100 -15
  90. package/server/services/session-worker-process-service.mjs +348 -129
  91. package/server/services/session-worker-registry-service.mjs +18 -1
  92. package/server/services/session-worker-stop-service.mjs +24 -13
  93. package/server/services/session-worker-supervisor-service.mjs +56 -1
  94. package/server/services/update-install-service.mjs +9 -3
  95. package/server/services/usage-limit-pause-service.mjs +3 -1
  96. package/server/services/worker-launch-note-service.mjs +151 -0
  97. package/shared/claude-attribution.mjs +117 -0
  98. package/shared/claude-cloud/api-client.mjs +809 -0
  99. package/shared/claude-cloud/base-url.mjs +47 -0
  100. package/shared/claude-cloud/credentials.mjs +221 -0
  101. package/shared/claude-cloud/repo-url.mjs +111 -0
  102. package/shared/provider-routing.mjs +4 -2
  103. package/shared/remote-relay-contract.mjs +153 -22
  104. package/shared/remote-relay-tool-core.mjs +11 -8
  105. package/shared/steer-settle-failure.mjs +1 -0
@@ -46,7 +46,9 @@ export function areNetworkRequestsEnabled() {
46
46
  }
47
47
 
48
48
  export async function apiFetch(url, opts = {}) {
49
- if (!networkRequestsEnabled) return null;
49
+ // A keepalive request is a page's last word; it goes out even when the
50
+ // page's regular network work is already suspended.
51
+ if (!networkRequestsEnabled && !opts.keepalive) return null;
50
52
  try {
51
53
  const response = await fetch(`${BASE}${url}`, {
52
54
  signal: requestTimeoutSignal(),
@@ -273,11 +275,13 @@ export async function updateClaudeSettings({
273
275
  enabled = undefined,
274
276
  model = undefined,
275
277
  enabledModels = undefined,
278
+ attributionMode = undefined,
276
279
  } = {}) {
277
280
  const payload = {};
278
281
  if (typeof enabled === 'boolean') payload.enabled = enabled;
279
282
  if (typeof model === 'string' && model.trim()) payload.model = model.trim();
280
283
  if (Array.isArray(enabledModels)) payload.enabledModels = enabledModels;
284
+ if (typeof attributionMode === 'string' && attributionMode.trim()) payload.attributionMode = attributionMode.trim();
281
285
  return settingsRequest('/api/settings/claude', payload, 'Failed to update Claude settings');
282
286
  }
283
287
 
@@ -370,6 +374,65 @@ export async function updateGrokSettings({
370
374
  return settingsRequest('/api/settings/grok', payload, 'Failed to update Grok settings');
371
375
  }
372
376
 
377
+ // Claude Cloud (provider `claude-cloud`). A relay without these routes answers
378
+ // 404, and a folder outside the workspace roots is refused: both must read as
379
+ // "nothing to show" (the New Chat option stays hidden, the fields stay empty),
380
+ // so these reads skip apiFetch's error logging.
381
+ async function quietGet(path) {
382
+ if (!networkRequestsEnabled) return null;
383
+ try {
384
+ const response = await fetch(`${BASE}${path}`, {
385
+ signal: requestTimeoutSignal(),
386
+ headers: { 'Content-Type': 'application/json', ...authHeaders() },
387
+ });
388
+ if (!response.ok) return null;
389
+ noteFetchSuccess();
390
+ return await response.json().catch(() => null);
391
+ } catch (error) {
392
+ noteFetchFailure(path, error);
393
+ return null;
394
+ }
395
+ }
396
+
397
+ export async function loadClaudeCloudSettings() {
398
+ return quietGet('/api/settings/claude-cloud');
399
+ }
400
+
401
+ export async function updateClaudeCloudSettings({
402
+ enabled = undefined,
403
+ defaultModel = undefined,
404
+ environmentId = undefined,
405
+ } = {}) {
406
+ const payload = {};
407
+ if (typeof enabled === 'boolean') payload.enabled = enabled;
408
+ if (typeof defaultModel === 'string' && defaultModel.trim()) payload.defaultModel = defaultModel.trim();
409
+ if (typeof environmentId === 'string' && environmentId.trim()) payload.environmentId = environmentId.trim();
410
+ return settingsRequest('/api/settings/claude-cloud', payload, 'Failed to update Claude Cloud settings');
411
+ }
412
+
413
+ // origin + current branch of a folder on the relay host, for the New Chat
414
+ // Repository and Branch fields. null when the relay could not say.
415
+ export async function loadGitRemote(rootPath) {
416
+ const root = String(rootPath || '').trim();
417
+ if (!root) return null;
418
+ return quietGet(`/api/git/remote?root=${encodeURIComponent(root)}`);
419
+ }
420
+
421
+ // The repositories the Claude GitHub app can reach, plus the ones used in
422
+ // earlier cloud chats here, for the New Chat Repository suggestions. null
423
+ // when the relay could not say (the field is then typed, as before).
424
+ export async function loadClaudeCloudRepos({ refresh = false } = {}) {
425
+ return quietGet(`/api/claude-cloud/repos${refresh ? '?refresh=1' : ''}`);
426
+ }
427
+
428
+ // The branches of a GitHub repository as the relay host's git sees them, for
429
+ // the New Chat Branch suggestions. null when the relay could not say.
430
+ export async function loadClaudeCloudBranches(repo) {
431
+ const text = String(repo || '').trim();
432
+ if (!text) return null;
433
+ return quietGet(`/api/claude-cloud/branches?repo=${encodeURIComponent(text)}`);
434
+ }
435
+
373
436
  export async function updateOpenAISettings({
374
437
  apiKey = '',
375
438
  model = 'gpt-4o',
@@ -542,6 +605,23 @@ export async function updateRemoteRelaySettings(patch = {}) {
542
605
  return remoteRelayRequest('/api/settings/remote-relays', { method: 'POST', body });
543
606
  }
544
607
 
608
+ // ─── Agent sessions (Settings → Relays) ──────────────────────────────────────
609
+ // `{ enabled, maxWaitSeconds, limits, maxActiveSessions }`, with `ok` and
610
+ // `status` as above. A relay without the feature answers 404, which the
611
+ // section reads as "do not show me", so the status has to travel with the
612
+ // answer instead of apiFetch's bare null.
613
+
614
+ export async function loadAgentSessionsSettings() {
615
+ return remoteRelayRequest('/api/settings/agent-sessions');
616
+ }
617
+
618
+ export async function updateAgentSessionsSettings(patch = {}) {
619
+ const body = {};
620
+ if (typeof patch.enabled === 'boolean') body.enabled = patch.enabled;
621
+ if (Number.isFinite(patch.maxWaitSeconds)) body.maxWaitSeconds = patch.maxWaitSeconds;
622
+ return remoteRelayRequest('/api/settings/agent-sessions', { method: 'POST', body });
623
+ }
624
+
545
625
  export async function loadUpdateState() {
546
626
  return apiFetch('/api/update/state');
547
627
  }
@@ -607,8 +687,18 @@ export async function relaunchSessionWorkerWithWorkspaceRoot(conversationId, roo
607
687
  });
608
688
  }
609
689
 
610
- export async function loadUsageSummary() {
611
- return apiFetch('/api/usage');
690
+ export async function loadUsageSummary({ providers = null } = {}) {
691
+ // The conversation the modal was opened from: the Claude Cloud card shows
692
+ // that conversation's own cost next to the totals. `providers` names the
693
+ // cards to read live (the rest come from the relay's last live answer);
694
+ // none means everything live.
695
+ const conversationId = String(currentConvId || '').trim();
696
+ const query = new URLSearchParams();
697
+ if (conversationId) query.set('conversationId', conversationId);
698
+ const ids = (Array.isArray(providers) ? providers : []).map((id) => String(id || '').trim()).filter(Boolean);
699
+ if (ids.length) query.set('providers', ids.join(','));
700
+ const text = query.toString();
701
+ return apiFetch(text ? `/api/usage?${text}` : '/api/usage');
612
702
  }
613
703
 
614
704
  export async function loadCursorAllowanceSettings() {
@@ -690,8 +780,9 @@ export async function loadConversations(options = {}) {
690
780
  if (beforeConversationId) params.set('beforeConversationId', beforeConversationId);
691
781
  const beforeUpdatedAt = String(options.beforeUpdatedAt || '').trim();
692
782
  if (beforeUpdatedAt) params.set('beforeUpdatedAt', beforeUpdatedAt);
693
- const includeArchived = String(options.archived || '').trim().toLowerCase();
694
- if (includeArchived === 'true') params.set('archived', 'true');
783
+ // 'true' lists live and archived chats, 'only' the archived ones.
784
+ const archivedMode = String(options.archived || '').trim().toLowerCase();
785
+ if (archivedMode === 'true' || archivedMode === 'only') params.set('archived', archivedMode);
695
786
  const query = params.toString();
696
787
  return apiFetch(`/api/conversations${query ? `?${query}` : ''}`);
697
788
  }
@@ -743,6 +834,39 @@ export async function searchMessages(options = {}) {
743
834
  // Raw fetch instead of apiFetch: a refused delete (the conversation is still
744
835
  // working, or its session would not stop) must surface the server's reason.
745
836
  // The long timeout covers stopping the session and deleting its CLI session.
837
+ // Archive and unarchive answer `{ ok }`, or `{ ok: false, message }` when the
838
+ // relay refuses (a chat still working on a reply is not archived).
839
+ export async function archiveConversation(id) {
840
+ const convId = String(id || '').trim();
841
+ if (!convId) return null;
842
+ return conversationActionRequest(`/api/conversation/${encodeURIComponent(convId)}/archive`);
843
+ }
844
+
845
+ export async function unarchiveConversation(id) {
846
+ const convId = String(id || '').trim();
847
+ if (!convId) return null;
848
+ return conversationActionRequest(`/api/conversation/${encodeURIComponent(convId)}/unarchive`);
849
+ }
850
+
851
+ async function conversationActionRequest(url) {
852
+ if (!areNetworkRequestsEnabled()) return null;
853
+ try {
854
+ const response = await fetch(`${BASE}${url}`, {
855
+ signal: requestTimeoutSignal(LONG_REQUEST_TIMEOUT_MS),
856
+ method: 'POST',
857
+ headers: { 'Content-Type': 'application/json', ...authHeaders() },
858
+ body: '{}',
859
+ });
860
+ const payload = await response.json().catch(() => null);
861
+ if (!response.ok) return { ...(payload || {}), ok: false, status: response.status };
862
+ noteFetchSuccess();
863
+ return payload || { ok: true };
864
+ } catch (error) {
865
+ noteFetchFailure(url, error);
866
+ return null;
867
+ }
868
+ }
869
+
746
870
  export async function deleteConversation(id) {
747
871
  const convId = String(id || '').trim();
748
872
  if (!convId || !areNetworkRequestsEnabled()) return null;
@@ -785,12 +909,31 @@ export async function updateConversationTitle(id, title) {
785
909
  });
786
910
  }
787
911
 
788
- export async function updateConversationPreferences(id, preferences = {}) {
912
+ // The commit attribution a repo folder's Claude sessions use (null = the
913
+ // provider setting). Keyed by the workspace root path.
914
+ export async function loadWorkspaceRootAttribution(path) {
915
+ const root = String(path || '').trim();
916
+ if (!root) return null;
917
+ return apiFetch(`/api/workspace-root/attribution?path=${encodeURIComponent(root)}`);
918
+ }
919
+
920
+ export async function updateWorkspaceRootAttribution(path, attributionMode) {
921
+ const root = String(path || '').trim();
922
+ if (!root) return null;
923
+ return apiFetch('/api/workspace-root/attribution', {
924
+ method: 'POST',
925
+ body: JSON.stringify({ path: root, attributionMode: attributionMode || null }),
926
+ });
927
+ }
928
+
929
+ // `keepalive` lets the request outlive the page (a save made while it closes).
930
+ export async function updateConversationPreferences(id, preferences = {}, { keepalive = false } = {}) {
789
931
  const convId = String(id || '').trim();
790
932
  if (!convId) return null;
791
933
  return apiFetch(`/api/conversation/${convId}/preferences`, {
792
934
  method: 'PATCH',
793
935
  body: JSON.stringify(preferences || {}),
936
+ ...(keepalive ? { keepalive: true } : {}),
794
937
  });
795
938
  }
796
939
 
@@ -13,6 +13,7 @@ import {
13
13
  showTransientRelayNotice,
14
14
  getConversationCurrentWorkspaceRootPath,
15
15
  IS_SHARED_VIEW,
16
+ conversations,
16
17
  } from './store.js';
17
18
  import { openAnnotateEditor } from './annotate-editor.js';
18
19
  import {
@@ -61,6 +62,11 @@ import {
61
62
  writeRepoBrowserHiddenPreference,
62
63
  } from './repo-browser-preferences.mjs';
63
64
  import { joinLaunchCwdPath } from './launch-cwd-path.mjs';
65
+ import {
66
+ cloudAttachmentRejectionNotice,
67
+ isClaudeCloudConversation,
68
+ partitionCloudAttachments,
69
+ } from './claude-cloud-ui.mjs';
64
70
 
65
71
  function currentConversationId() {
66
72
  return String(currentConvId || '').trim();
@@ -509,7 +515,7 @@ export async function ingestFiles(files, { source = 'picker' } = {}) {
509
515
  // conversations, and the result must never land in the wrong composer.
510
516
  const ownerConversationId = currentConversationId();
511
517
  const now = new Date();
512
- const prepared = [];
518
+ let prepared = [];
513
519
  for (let index = 0; index < inputFiles.length; index += 1) {
514
520
  prepared.push(await prepareIncomingFile(inputFiles[index], { source, now, index }));
515
521
  }
@@ -519,6 +525,19 @@ export async function ingestFiles(files, { source = 'picker' } = {}) {
519
525
  return [];
520
526
  }
521
527
 
528
+ // A Claude Cloud session takes inline images only; anything else would be
529
+ // refused by its worker with the whole turn. Checked after the re-encode
530
+ // above, which is what brings a large photo under the size limit.
531
+ if (isClaudeCloudConversation(conversations[ownerConversationId])) {
532
+ const { accepted, rejected } = partitionCloudAttachments(prepared);
533
+ if (rejected.length) {
534
+ for (const { attachment } of rejected) releaseAttachmentPreviewUrl(attachment);
535
+ showTransientRelayNotice(cloudAttachmentRejectionNotice(rejected), 7000);
536
+ prepared = accepted;
537
+ if (!prepared.length) return [];
538
+ }
539
+ }
540
+
522
541
  const plan = planAttachmentMerge(selectedAttachments, prepared, MAX_UPLOAD_ATTACHMENTS);
523
542
  const rejected = prepared.filter((item) => !plan.acceptedAdditions.includes(item));
524
543
  for (const item of rejected) {
@@ -0,0 +1,46 @@
1
+ // The commit-attribution row of the 🧠 context modal (Claude sessions only):
2
+ // what this repo folder's commits say, and a select to override the provider
3
+ // setting for the folder — the payload comes from /api/context (`attribution`).
4
+
5
+ const MODE_LABELS = Object.freeze({
6
+ oar: 'OAR — Open Agent Relay (model)',
7
+ vanilla: 'Vanilla — Claude Code\'s own',
8
+ off: 'Off — none',
9
+ });
10
+
11
+ function esc(value) {
12
+ return String(value ?? '')
13
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
14
+ }
15
+
16
+ export function attributionEffectText({ effectiveMode, attributionExample } = {}) {
17
+ if (effectiveMode === 'vanilla') return 'Commits keep Claude Code\'s own attribution.';
18
+ if (effectiveMode === 'off') return 'Commits and pull requests carry no attribution line.';
19
+ const trailer = String(attributionExample || '').trim();
20
+ return trailer ? `Commits end with: ${trailer}` : 'Commits end with the Open Agent Relay trailer.';
21
+ }
22
+
23
+ /**
24
+ * @param {object|null} attribution the `attribution` field of an /api/context response
25
+ * @returns {string} the row's HTML, or '' when the payload has no folder to speak of
26
+ */
27
+ export function renderAttributionControlHtml(attribution) {
28
+ if (!attribution || typeof attribution !== 'object') return '';
29
+ const path = String(attribution.path || '').trim();
30
+ if (!path) return '';
31
+ const folderMode = ['oar', 'vanilla', 'off'].includes(attribution.attributionMode) ? attribution.attributionMode : '';
32
+ const providerMode = ['oar', 'vanilla', 'off'].includes(attribution.providerMode) ? attribution.providerMode : 'oar';
33
+ const options = [
34
+ `<option value=""${folderMode === '' ? ' selected' : ''}>Inherit (provider: ${esc(MODE_LABELS[providerMode].split(' — ')[0])})</option>`,
35
+ ...['oar', 'vanilla', 'off'].map((mode) => `<option value="${mode}"${folderMode === mode ? ' selected' : ''}>${esc(MODE_LABELS[mode])}</option>`),
36
+ ].join('');
37
+ return `
38
+ <div class="ctx-attribution" data-workspace-root="${esc(path)}">
39
+ <div class="ctx-attribution-row">
40
+ <span class="ctx-attribution-label">Commit attribution:</span>
41
+ <select id="ctx-attribution-select" class="ctx-attribution-select" aria-label="Commit attribution for this folder" data-workspace-root="${esc(path)}">${options}</select>
42
+ </div>
43
+ <div class="ctx-attribution-note">For every Claude session in <code>${esc(path)}</code>. ${esc(attributionEffectText(attribution))} A change reaches running sessions with their next message.</div>
44
+ </div>
45
+ `;
46
+ }