@oar-sh/oar 0.9.3 → 0.9.5

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 (76) hide show
  1. package/.github/extensions/web-relay/extension.mjs +6 -1
  2. package/.github/extensions/web-relay/skills/tool-activity.mjs +7 -0
  3. package/CHANGELOG.md +98 -0
  4. package/README.md +76 -14
  5. package/package.json +5 -2
  6. package/server/README.md +43 -3
  7. package/server/claude-worker/claude-remote-relay-tool.mjs +100 -0
  8. package/server/claude-worker/claude-sdk-adapter.mjs +26 -4
  9. package/server/claude-worker/claude-session-process.mjs +385 -43
  10. package/server/claude-worker/claude-session-worker.mjs +5 -0
  11. package/server/claude-worker/sdk-message-normalizer.mjs +7 -0
  12. package/server/copilot-worker/copilot-remote-relay-tool.mjs +59 -0
  13. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +12 -2
  14. package/server/copilot-worker/copilot-sdk-session-process.mjs +83 -9
  15. package/server/copilot-worker/copilot-sdk-session-worker.mjs +5 -0
  16. package/server/cursor-worker/cursor-remote-relay-tool.mjs +65 -0
  17. package/server/cursor-worker/cursor-session-worker.mjs +5 -0
  18. package/server/cursor-worker/cursor-turn-runner.mjs +45 -2
  19. package/server/cursor-worker/sdk-message-normalizer.mjs +25 -0
  20. package/server/db-schema.mjs +8 -0
  21. package/server/grok-worker/acp-client.mjs +92 -6
  22. package/server/grok-worker/grok-session-worker.mjs +5 -0
  23. package/server/grok-worker/grok-turn-runner.mjs +59 -8
  24. package/server/grok-worker/sdk-message-normalizer.mjs +13 -0
  25. package/server/mcp/oar-mcp-server.mjs +473 -0
  26. package/server/migrations/0005-remote-relays.mjs +90 -0
  27. package/server/public/app/action-confirmations.js +141 -13
  28. package/server/public/app/api-client.js +103 -0
  29. package/server/public/app/bootstrap.js +21 -6
  30. package/server/public/app/conversation-deep-link.mjs +42 -0
  31. package/server/public/app/conversation-view.js +100 -7
  32. package/server/public/app/host-suspend-ui.js +272 -0
  33. package/server/public/app/journal-view.js +4 -1
  34. package/server/public/app/mention-autocomplete.mjs +298 -0
  35. package/server/public/app/push-settings.js +1 -0
  36. package/server/public/app/pwa-install.js +8 -3
  37. package/server/public/app/relay-prompt-sanitizer.mjs +48 -5
  38. package/server/public/app/remote-relay-origin-view.mjs +76 -0
  39. package/server/public/app/remote-relay-shared.mjs +85 -0
  40. package/server/public/app/remote-relays-settings.js +618 -0
  41. package/server/public/app/remote-relays-store.mjs +126 -0
  42. package/server/public/app/settings-modal.js +4 -2
  43. package/server/public/app/settings-tabs.js +1 -1
  44. package/server/public/app/socket-handlers.js +18 -1
  45. package/server/public/app/steered-turn-merge.mjs +10 -1
  46. package/server/public/app/thinking-follow.js +260 -0
  47. package/server/public/app/tool-activity-emoji.mjs +4 -0
  48. package/server/public/index.html +264 -11
  49. package/server/relay-tools.md +5 -1
  50. package/server/repositories/message-repository.mjs +7 -0
  51. package/server/repositories/question-repository.mjs +3 -0
  52. package/server/repositories/remote-relay-repository.mjs +75 -0
  53. package/server/repositories/session-repository.mjs +6 -1
  54. package/server/routes/ask-user-routes.mjs +42 -2
  55. package/server/routes/messages-routes.mjs +98 -19
  56. package/server/routes/remote-relay-routes.mjs +133 -0
  57. package/server/routes/sessions-routes.mjs +84 -31
  58. package/server/server-runtime.mjs +238 -0
  59. package/server/services/github-ci-activity-service.mjs +199 -0
  60. package/server/services/host-suspend-command.mjs +38 -0
  61. package/server/services/host-suspend-service.mjs +254 -0
  62. package/server/services/push-dispatch-service.mjs +37 -4
  63. package/server/services/remote-relay-caller-context.mjs +346 -0
  64. package/server/services/remote-relay-client.mjs +332 -0
  65. package/server/services/remote-relay-dispatcher.mjs +1743 -0
  66. package/server/services/remote-relay-inbound.mjs +187 -0
  67. package/server/services/remote-relay-pairing.mjs +189 -0
  68. package/server/services/remote-relay-registry-service.mjs +581 -0
  69. package/server/services/session-worker-launch-service.mjs +148 -14
  70. package/shared/remote-relay-contract.mjs +556 -0
  71. package/shared/remote-relay-mentions.mjs +129 -0
  72. package/shared/remote-relay-tool-core.mjs +314 -0
  73. package/shared/steer-settle-failure.mjs +6 -0
  74. package/shared/steer-settle-markers.mjs +16 -0
  75. package/shared/worker-runtime/api-client.mjs +112 -22
  76. package/shared/worker-runtime/worker-log-file.mjs +97 -0
@@ -1507,6 +1507,39 @@
1507
1507
  padding: 4px 0;
1508
1508
  }
1509
1509
  .thinking-bubble-header:empty { display: none; margin-bottom: 0; }
1510
+ /* Follow buttons (thinking-follow.js): 💭 thoughts, 📃 answer, 🛠️ tools.
1511
+ Chips on the right of the header, Stop stays left; the armed one gets
1512
+ the accent ring. */
1513
+ .thinking-follow-group {
1514
+ display: inline-flex;
1515
+ align-items: center;
1516
+ gap: 4px;
1517
+ flex-shrink: 0;
1518
+ margin-left: auto; /* right edge even when there is no Stop button */
1519
+ }
1520
+ .thinking-follow-btn {
1521
+ display: inline-flex;
1522
+ align-items: center;
1523
+ justify-content: center;
1524
+ min-width: 30px;
1525
+ height: 26px;
1526
+ padding: 0 6px;
1527
+ font-size: var(--fs-meta-md);
1528
+ line-height: 1;
1529
+ color: var(--muted);
1530
+ background: transparent;
1531
+ border: 1px solid transparent;
1532
+ border-radius: 6px;
1533
+ cursor: pointer;
1534
+ transition: background 0.15s, border-color 0.15s, opacity 0.15s;
1535
+ }
1536
+ .thinking-follow-btn:hover { background: rgba(127, 127, 127, 0.12); }
1537
+ .thinking-follow-btn.active,
1538
+ .thinking-follow-btn[aria-pressed="true"] {
1539
+ color: var(--accent);
1540
+ border-color: var(--accent);
1541
+ background: color-mix(in srgb, var(--accent) 16%, transparent);
1542
+ }
1510
1543
  .bubble-action-btn {
1511
1544
  display: inline-flex;
1512
1545
  align-items: center;
@@ -2343,6 +2376,48 @@
2343
2376
  cursor: pointer;
2344
2377
  }
2345
2378
  .relay-toast-action:hover { background: rgba(210, 153, 34, 0.2); }
2379
+ /* Persistent app-wide banner for a queued host suspend / relay restart
2380
+ (host-suspend-ui.js). Same look as the toast, one row per pending action,
2381
+ sits just below the toast so both stay readable; takes pointer input for
2382
+ its Cancel buttons. */
2383
+ #pending-action-banner {
2384
+ display: none;
2385
+ position: fixed;
2386
+ top: calc(var(--header-safe-top, 0px) + 10px);
2387
+ left: 50%;
2388
+ transform: translateX(-50%);
2389
+ z-index: 12590;
2390
+ max-width: min(640px, calc(100vw - 24px));
2391
+ background: var(--bg2);
2392
+ background: color-mix(in srgb, var(--warn) 14%, var(--bg2));
2393
+ border: 1px solid var(--warn);
2394
+ color: var(--warn);
2395
+ border-radius: 10px;
2396
+ padding: 6px 12px;
2397
+ font-size: var(--fs-meta-sm);
2398
+ box-shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
2399
+ pointer-events: auto;
2400
+ }
2401
+ #pending-action-banner.visible { display: flex; flex-direction: column; gap: 4px; }
2402
+ #relay-toast.visible ~ #app #pending-action-banner,
2403
+ #relay-toast.visible + #pending-action-banner { top: calc(var(--header-safe-top, 0px) + 56px); }
2404
+ .pending-action-row {
2405
+ display: flex;
2406
+ align-items: center;
2407
+ justify-content: center;
2408
+ gap: 8px;
2409
+ flex-wrap: wrap;
2410
+ text-align: center;
2411
+ }
2412
+ .pending-action-text { overflow-wrap: anywhere; }
2413
+ .pending-action-cancel { margin-left: 0; }
2414
+ .suspend-host-blockers {
2415
+ margin: 6px 0 10px;
2416
+ padding-left: 20px;
2417
+ color: var(--text);
2418
+ font-size: var(--fs-meta-md);
2419
+ }
2420
+ .suspend-host-blockers li { margin: 2px 0; overflow-wrap: anywhere; }
2346
2421
  #model-metadata-blocker {
2347
2422
  display: none;
2348
2423
  align-items: center;
@@ -2441,8 +2516,11 @@
2441
2516
  gap: 6px;
2442
2517
  }
2443
2518
  /* Slash-command autocompletion: same composer-anchored overlay as the emoji
2444
- popup, but list-shaped. Row highlight follows the arrow keys only. */
2445
- #slash-autocomplete-popup {
2519
+ popup, but list-shaped. Row highlight follows the arrow keys only. The
2520
+ @relay mention popup (mention-autocomplete.mjs) is its twin; the two are
2521
+ never open at once. */
2522
+ #slash-autocomplete-popup,
2523
+ #mention-autocomplete-popup {
2446
2524
  display: none;
2447
2525
  position: absolute;
2448
2526
  left: 16px;
@@ -2457,7 +2535,9 @@
2457
2535
  box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45);
2458
2536
  z-index: 30;
2459
2537
  }
2460
- #slash-autocomplete-popup.visible { display: block; }
2538
+ #slash-autocomplete-popup.visible,
2539
+ #mention-autocomplete-popup.visible { display: block; }
2540
+ .mention-item { align-items: center; }
2461
2541
  .slash-item {
2462
2542
  display: flex; align-items: baseline; gap: 10px;
2463
2543
  padding: 6px 8px; border-radius: 8px; cursor: pointer;
@@ -2469,7 +2549,8 @@
2469
2549
  .slash-item-hint { cursor: default; }
2470
2550
  .slash-item-hint:hover { background: transparent; }
2471
2551
  .slash-item-hint .slash-item-name { color: var(--muted); }
2472
- body.shared-reader-mode #slash-autocomplete-popup { display: none; }
2552
+ body.shared-reader-mode #slash-autocomplete-popup,
2553
+ body.shared-reader-mode #mention-autocomplete-popup { display: none; }
2473
2554
  #emoji-popup.visible {
2474
2555
  display: grid;
2475
2556
  }
@@ -2619,6 +2700,68 @@
2619
2700
  .preview-close:hover:not(:disabled) { border-color: #f85149; color: #f85149; }
2620
2701
  .preview-close:disabled { opacity: 0.5; cursor: default; }
2621
2702
  #settings-previews-empty { color: var(--muted); font-size: var(--fs-meta-sm); }
2703
+ /* Remote relays (Settings → Relays, the @mention popup): a health dot —
2704
+ online green, unreachable/refused red, never checked grey. */
2705
+ .remote-relay-dot {
2706
+ width: 9px; height: 9px; border-radius: 50%; display: inline-block; flex-shrink: 0;
2707
+ background: var(--relay-tone-offline);
2708
+ }
2709
+ .remote-relay-dot[data-status="online"] { background: var(--relay-tone-online); }
2710
+ .remote-relay-dot[data-status="offline"],
2711
+ .remote-relay-dot[data-status="unauthorized"],
2712
+ .remote-relay-dot[data-status="error"] { background: #f85149; }
2713
+ #remote-relays-list { width: 100%; }
2714
+ .remote-relay-tag {
2715
+ border: 1px solid var(--border); border-radius: 999px; padding: 0 7px;
2716
+ font-size: var(--fs-meta-sm); color: var(--muted); background: var(--bg3); white-space: nowrap;
2717
+ }
2718
+ .remote-relay-error { color: #f85149; overflow-wrap: anywhere; }
2719
+ .remote-relay-http-warning { color: var(--warn); opacity: 1; }
2720
+ label.remote-relay-permission { display: flex; align-items: center; gap: 6px; font-size: var(--fs-meta-sm); color: var(--muted); font-weight: normal; }
2721
+ .remote-relay-permission select {
2722
+ background: var(--bg); color: var(--text); border: 1px solid var(--border);
2723
+ border-radius: 6px; padding: 2px 6px; font-size: var(--fs-meta-sm);
2724
+ }
2725
+ .remote-relays-self-name-row { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
2726
+ .remote-relays-self-name { font-family: var(--mono, monospace); color: var(--text); }
2727
+ #remote-relays-public-url-input,
2728
+ #remote-relays-add-url-input,
2729
+ #remote-relays-add-token-input {
2730
+ background: var(--bg); color: var(--text); border: 1px solid var(--border);
2731
+ border-radius: 6px; padding: 6px 12px; outline: none; font-size: var(--fs-meta-md);
2732
+ width: min(420px, 100%); box-sizing: border-box;
2733
+ }
2734
+ .remote-relays-add-token-row { display: flex; flex-direction: column; gap: 6px; width: 100%; }
2735
+ .remote-relays-add-token-row[hidden] { display: none; }
2736
+ .remote-relays-add-token-row label { font-weight: normal; color: var(--muted); font-size: var(--fs-meta-sm); }
2737
+ .remote-relays-link-btn {
2738
+ background: none; border: none; padding: 0; cursor: pointer;
2739
+ color: var(--accent); font-size: var(--fs-meta-sm); text-decoration: underline;
2740
+ }
2741
+ .remote-relays-link-btn[hidden] { display: none; }
2742
+ .settings-row label.remote-relays-pair-back { display: flex; align-items: center; gap: 8px; font-weight: normal; }
2743
+ #remote-relays-add-status[data-state="working"] { color: var(--muted); }
2744
+ #remote-relays-add-status[data-state="added"],
2745
+ #remote-relays-add-status[data-state="paired"] { color: #3fb950; }
2746
+ #remote-relays-add-status[data-state="pair-failed"],
2747
+ #remote-relays-add-status[data-state="needs-token"] { color: var(--warn); }
2748
+ #remote-relays-add-status[data-state="self"],
2749
+ #remote-relays-add-status[data-state="error"] { color: #f85149; }
2750
+ /* Provenance of a prompt another relay's agent sent (remote-relay-origin-view.mjs). */
2751
+ .msg-origin { margin-bottom: 6px; max-width: 100%; }
2752
+ .msg-origin-badge {
2753
+ display: inline-block; max-width: 100%; box-sizing: border-box;
2754
+ padding: 1px 8px; border-radius: 999px; font-size: var(--fs-meta-sm); line-height: 1.5;
2755
+ border: 1px solid color-mix(in srgb, currentColor 40%, transparent);
2756
+ background: color-mix(in srgb, currentColor 12%, transparent);
2757
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
2758
+ }
2759
+ .msg.user .msg-bubble a.msg-origin-badge { text-decoration: none; }
2760
+ .msg.user .msg-bubble a.msg-origin-badge:hover { text-decoration: underline; }
2761
+ .conv-origin-marker {
2762
+ flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
2763
+ color: var(--muted); font-size: var(--fs-ui-chip);
2764
+ }
2622
2765
  /* Transcript card: a preview published during this turn, pinned into the
2623
2766
  response bubble. Compact single row; closed previews keep the label but
2624
2767
  lose the dead link. */
@@ -2669,10 +2812,13 @@
2669
2812
  kind='absorbed' and the steered message carries the continuing reply. A
2670
2813
  steer cut off by Stop settles as kind='stopped' with a Resend button.
2671
2814
  Markers are notes, not answers, so they render muted and compact.
2672
- steered-turn-merge.mjs adds the adjacency classes. */
2815
+ steered-turn-merge.mjs adds the adjacency classes.
2816
+ kind='answered-elsewhere' is no steer's marker but looks like one: it
2817
+ closes a message whose answer is on a background-continuation row. */
2673
2818
  .msg.assistant.msg-absorbed .msg-bubble,
2674
2819
  .msg.assistant.msg-folded .msg-bubble,
2675
- .msg.assistant.msg-steer-stopped .msg-bubble {
2820
+ .msg.assistant.msg-steer-stopped .msg-bubble,
2821
+ .msg.assistant.msg-answered-elsewhere .msg-bubble {
2676
2822
  font-size: var(--fs-meta-md); color: var(--muted); font-style: italic;
2677
2823
  padding: 6px 12px; opacity: 0.85;
2678
2824
  }
@@ -2681,7 +2827,8 @@
2681
2827
  .msg.assistant.msg-folded,
2682
2828
  .msg.assistant.msg-steer-stopped { margin-top: -8px; }
2683
2829
  .msg.assistant.msg-folded .msg-label,
2684
- .msg.assistant.msg-steer-stopped .msg-label { display: none; }
2830
+ .msg.assistant.msg-steer-stopped .msg-label,
2831
+ .msg.assistant.msg-answered-elsewhere .msg-label { display: none; }
2685
2832
  .msg.assistant.msg-steer-stopped .msg-bubble-actions { font-style: normal; }
2686
2833
  .msg.user.msg-steered .msg-bubble { border-style: dashed; }
2687
2834
  .msg.user.msg-steered .msg-label::after { content: ' · steered mid-turn'; }
@@ -5106,6 +5253,7 @@
5106
5253
  <div id="chat-actions-menu-backdrop" onclick="window.closeChatActionsMenu && window.closeChatActionsMenu()"></div>
5107
5254
  <div id="font-scale-indicator" aria-live="polite" aria-atomic="true" hidden>100%</div>
5108
5255
  <div id="relay-toast" role="status" aria-live="polite"></div>
5256
+ <div id="pending-action-banner" role="status" aria-live="polite" hidden></div>
5109
5257
 
5110
5258
  <!-- Main App -->
5111
5259
  <div id="app">
@@ -5186,6 +5334,7 @@
5186
5334
  <div id="image-edit-target" aria-live="polite"></div>
5187
5335
  <div id="emoji-popup" aria-hidden="true"></div>
5188
5336
  <div id="slash-autocomplete-popup" aria-hidden="true" role="listbox"></div>
5337
+ <div id="mention-autocomplete-popup" aria-hidden="true" role="listbox" aria-label="Remote relays"></div>
5189
5338
  <details id="background-tasks-panel" hidden>
5190
5339
  <summary id="background-tasks-summary"></summary>
5191
5340
  <div id="background-panel-body">
@@ -5196,7 +5345,7 @@
5196
5345
  <div class="input-row">
5197
5346
  <input id="image-input" type="file" multiple hidden onchange="handleAttachmentInput(this.files); this.value='';">
5198
5347
  <button id="emoji-btn" onclick="toggleEmojiPicker()" title="Emoji picker (Tip: Win + .)">🙂</button>
5199
- <textarea id="msg-input" placeholder="Message Copilot…" rows="1" onkeydown="handleKey(event)" oninput="autoResize(this); updateComposerSlashMenu(this)"></textarea>
5348
+ <textarea id="msg-input" placeholder="Message Copilot…" rows="1" aria-autocomplete="list" onkeydown="handleKey(event)" oninput="autoResize(this); updateComposerSlashMenu(this)"></textarea>
5200
5349
  <select id="mode-select" title="Mode">
5201
5350
  <option value="agent" selected>Agent</option>
5202
5351
  <option value="ask">Ask</option>
@@ -5333,6 +5482,16 @@
5333
5482
  tabindex="-1"
5334
5483
  data-settings-tab="providers"
5335
5484
  >Providers</button>
5485
+ <button
5486
+ id="settings-tab-relays"
5487
+ class="settings-tab"
5488
+ type="button"
5489
+ role="tab"
5490
+ aria-selected="false"
5491
+ aria-controls="settings-panel-relays"
5492
+ tabindex="-1"
5493
+ data-settings-tab="relays"
5494
+ >Relays</button>
5336
5495
  <button
5337
5496
  id="settings-tab-previews"
5338
5497
  class="settings-tab"
@@ -5448,16 +5607,16 @@
5448
5607
  </div>
5449
5608
  </div>
5450
5609
  <div class="settings-row settings-row-column" style="margin-top: 12px; margin-bottom: 0;">
5451
- <label for="pwa-app-name-input">Install app name</label>
5610
+ <label for="pwa-app-name-input">Relay name</label>
5452
5611
  <input
5453
5612
  id="pwa-app-name-input"
5454
5613
  type="text"
5455
5614
  maxlength="60"
5456
5615
  placeholder="OAR"
5457
- aria-label="Install app name"
5616
+ aria-label="Relay name"
5458
5617
  onchange="updatePwaAppName(this.value)"
5459
5618
  >
5460
- <div class="settings-help">Used for the installed PWA app label on every device connecting to this relay. On Android, a rename reaches an already-installed app after a short delay, with one confirmation prompt showing the new name.</div>
5619
+ <div class="settings-help">Shown to other relays and used as the installed app's name. On Android, a rename reaches an already-installed app after a short delay, with one confirmation prompt showing the new name.</div>
5461
5620
  </div>
5462
5621
  <div class="settings-row" style="margin-top: 12px; margin-bottom: 0;">
5463
5622
  <label for="update-auto-check-toggle">Check for updates automatically</label>
@@ -5949,6 +6108,96 @@
5949
6108
  </div>
5950
6109
  </div>
5951
6110
  </div>
6111
+ <div
6112
+ id="settings-panel-relays"
6113
+ class="settings-panel"
6114
+ role="tabpanel"
6115
+ tabindex="0"
6116
+ aria-labelledby="settings-tab-relays"
6117
+ data-settings-panel="relays"
6118
+ hidden
6119
+ >
6120
+ <div id="remote-relays-self-section" class="settings-row settings-row-column" style="margin-top: 12px; margin-bottom: 0;">
6121
+ <label>This relay</label>
6122
+ <div class="remote-relays-self-name-row">
6123
+ <span id="remote-relays-self-name" class="remote-relays-self-name">—</span>
6124
+ <span class="settings-help">Change it under General → Relay name.</span>
6125
+ </div>
6126
+ </div>
6127
+ <div class="settings-row settings-row-column" style="margin-top: 12px; margin-bottom: 0;">
6128
+ <label for="remote-relays-public-url-input">Public address</label>
6129
+ <input
6130
+ id="remote-relays-public-url-input"
6131
+ type="url"
6132
+ inputmode="url"
6133
+ autocomplete="off"
6134
+ autocapitalize="off"
6135
+ spellcheck="false"
6136
+ placeholder="https://…"
6137
+ aria-label="Public address of this relay"
6138
+ onchange="saveRemoteRelayPublicUrl(this.value)"
6139
+ >
6140
+ <div class="settings-help">How other relays reach this one — the address you open this relay at from another device. Needed for "Also add this relay there".</div>
6141
+ <div id="remote-relays-self-status" class="settings-help settings-provider-status" role="status" aria-live="polite" hidden></div>
6142
+ </div>
6143
+ <div class="settings-row" style="margin-top: 12px; margin-bottom: 0;">
6144
+ <label for="remote-relays-inbound-toggle">Accept prompts from other relays' agents</label>
6145
+ <input
6146
+ id="remote-relays-inbound-toggle"
6147
+ type="checkbox"
6148
+ onchange="toggleRemoteRelayInbound(this.checked)"
6149
+ aria-label="Accept prompts from other relays' agents"
6150
+ checked
6151
+ >
6152
+ </div>
6153
+ <div class="settings-help" style="margin-top: 6px;">When off, agents on paired relays can still read sessions here, but cannot send prompts, start sessions, answer questions or stop turns. The relay token stays the real boundary: anyone holding it has full access.</div>
6154
+ <div id="remote-relays-list-section" class="settings-row settings-row-column" style="margin-top: 16px; margin-bottom: 0;">
6155
+ <label>Remote relays</label>
6156
+ <div id="remote-relays-list"></div>
6157
+ <div id="remote-relays-empty" class="settings-help">Loading remote relays…</div>
6158
+ <div id="remote-relays-status" class="settings-help settings-provider-status" role="status" aria-live="polite" hidden></div>
6159
+ <div class="settings-help">Agents in any session here can work on these relays once you mention one in the conversation (for example <code>@name</code>). "Agents may" limits what they can do there.</div>
6160
+ </div>
6161
+ <div id="remote-relays-add-section" class="settings-row settings-row-column" style="margin-top: 16px; margin-bottom: 0;">
6162
+ <label for="remote-relays-add-url-input">Add a relay</label>
6163
+ <input
6164
+ id="remote-relays-add-url-input"
6165
+ type="url"
6166
+ inputmode="url"
6167
+ autocomplete="off"
6168
+ autocapitalize="off"
6169
+ spellcheck="false"
6170
+ placeholder="Paste the other relay's web address"
6171
+ aria-label="Paste the other relay's web address"
6172
+ onkeydown="if (event.key === 'Enter') { event.preventDefault(); addRemoteRelayFromForm(); }"
6173
+ >
6174
+ <div id="remote-relays-add-token-row" class="remote-relays-add-token-row" hidden>
6175
+ <label for="remote-relays-add-token-input">Token of the other relay</label>
6176
+ <input
6177
+ id="remote-relays-add-token-input"
6178
+ type="password"
6179
+ autocomplete="off"
6180
+ autocapitalize="off"
6181
+ spellcheck="false"
6182
+ placeholder="Only needed when it differs from this relay's token"
6183
+ aria-label="Token of the other relay"
6184
+ onkeydown="if (event.key === 'Enter') { event.preventDefault(); addRemoteRelayFromForm(); }"
6185
+ >
6186
+ </div>
6187
+ <button id="remote-relays-use-token-btn" class="remote-relays-link-btn" type="button" onclick="showRemoteRelayTokenField()">Use a different token</button>
6188
+ <label class="remote-relays-pair-back">
6189
+ <input id="remote-relays-pair-back-toggle" type="checkbox" checked>
6190
+ Also add this relay there
6191
+ </label>
6192
+ <div id="remote-relays-pair-back-hint" class="settings-help">
6193
+ The other relay gets this relay's public address and, if it uses a different token, this
6194
+ relay's token too: its agents can then work here. Removing a relay here does not revoke
6195
+ that; rotate this relay's token to lock it out.
6196
+ </div>
6197
+ <button id="remote-relays-add-btn" class="chat-title-action-btn" type="button" onclick="addRemoteRelayFromForm()">Add</button>
6198
+ <div id="remote-relays-add-status" class="settings-help settings-provider-status" role="status" aria-live="polite" hidden></div>
6199
+ </div>
6200
+ </div>
5952
6201
  <div
5953
6202
  id="settings-panel-previews"
5954
6203
  class="settings-panel"
@@ -6018,6 +6267,10 @@
6018
6267
  <label for="push-event-cli-offline-toggle">CLI went offline</label>
6019
6268
  <input id="push-event-cli-offline-toggle" type="checkbox" onchange="updatePushPreferencesFromControls()" disabled>
6020
6269
  </div>
6270
+ <div class="settings-row" style="width: 100%; margin-top: 6px; margin-bottom: 0;">
6271
+ <label for="push-event-host-suspend-toggle">Host suspend fired or was dropped</label>
6272
+ <input id="push-event-host-suspend-toggle" type="checkbox" onchange="updatePushPreferencesFromControls()" checked disabled>
6273
+ </div>
6021
6274
  <label style="margin-top: 10px;">Devices</label>
6022
6275
  <div id="push-device-list" style="width: 100%;"></div>
6023
6276
  <div class="settings-help">Notifications are only sent while no device has the app in the foreground. Content defaults to generic text; message previews are opt-in per device.</div>
@@ -26,4 +26,8 @@ Use `restart: true` in the request body when the user wants a real relay restart
26
26
 
27
27
  Publish a local web server or a static directory on a public preview URL so the user can open it from any device, or list/close existing previews. Use this whenever the user wants to see, try, or share something with a web UI — never ask them to open localhost or forward a port. For a dev server: call this FIRST with {action:"create", port} to get the basePath, then start the server configured to serve under that basePath (Vite --base, Next basePath, Express mount prefix); the proxy forwards X-Forwarded-Prefix but does not rewrite bodies, so root-absolute asset paths will not work. For plain files or a build output: pass {action:"create", dir} instead and the relay serves the directory itself — no dev server needed. Always tell the user the returned URL and that the link is public: anyone who has it can reach the app without logging in. Previews never expire on their own; close them with {action:"close"} when the user is done.
28
28
 
29
- There is no `preview` tool on this provider — use the authenticated localhost API (same auth as `POST /api/relay/shutdown`): `POST /api/previews` with `{ "conversationId": "<conv>", "port": 5173, "label": "web app" }` (or `"dir": "./dist"` instead of the port) publishes, `GET /api/previews` lists, `DELETE /api/previews/:token` closes the link without touching the dev server behind it. A 503 with `details` means the preview lane is disabled or misconfigured — surface those details, they are the operator's fix. See `docs/preview-servers.md`.
29
+ If you have a `preview` tool, use it. Otherwise use the authenticated localhost API (same auth as `POST /api/relay/shutdown`): `POST /api/previews` with `{ "conversationId": "<conv>", "port": 5173, "label": "web app" }` (or `"dir": "./dist"` instead of the port) publishes, `GET /api/previews` lists, `DELETE /api/previews/:token` closes the link without touching the dev server behind it. A 503 with `details` means the preview lane is disabled or misconfigured — surface those details, they are the operator's fix. See `docs/preview-servers.md`.
30
+
31
+ ## Remote relays
32
+
33
+ If you have a `remote_relay` tool, this relay is paired with other OAR relays and the tool works on them: list and read their sessions, prompt a session, start a new one and wait for its reply. Use it only when the user asks for work on another relay or mentions one, and start with `{"action":"list_relays"}`. A relay stays locked until the user mentions it in this conversation (`@name` or its plain name); a locked relay refuses everything except `list_relays`, so ask the user to mention it rather than retrying. Write actions may first wait for the user's approval. If a result lists `pendingQuestions`, the remote agent is waiting for an answer: ask the user (unless they told you to decide), then pass it on with `answer_question`. Always tell the user which relay and session you worked on.
@@ -284,6 +284,13 @@ export function createMessageRepository(db) {
284
284
  LIMIT ?
285
285
  `),
286
286
  countStatus: db.prepare(`SELECT status, COUNT(*) as cnt FROM queue WHERE status IN ('pending','processing','parked') GROUP BY status`),
287
+ // Deferred host suspend: which conversations still hold live turns.
288
+ listActiveQueueCountsByConversation: db.prepare(`
289
+ SELECT q.conversation_id, COUNT(*) AS cnt, MAX(c.title) AS title
290
+ FROM queue q LEFT JOIN conversations c ON c.id = q.conversation_id
291
+ WHERE q.status IN ('pending','processing','parked')
292
+ GROUP BY q.conversation_id
293
+ `),
287
294
  countRuntimeSessions: db.prepare(`SELECT COUNT(*) AS cnt FROM runtime_sessions WHERE status = 'active'`),
288
295
  setProcessing: db.prepare(`UPDATE queue SET status = 'processing', processing_at = ?, attempt_id = ? WHERE id = ?`),
289
296
  setProcessingWithWorkerLease: db.prepare(`
@@ -112,6 +112,9 @@ export function createQuestionRepository(db) {
112
112
  FROM subagent_runs
113
113
  WHERE queue_message_id = ? AND status = 'running'
114
114
  `),
115
+ countRunningSubagentRuns: db.prepare(`
116
+ SELECT COUNT(*) AS cnt FROM subagent_runs WHERE status = 'running'
117
+ `),
115
118
  closeRunningSubagentRunsByQueueMessage: db.prepare(`
116
119
  UPDATE subagent_runs
117
120
  SET status = ?, updated_at = ?, completed_at = COALESCE(completed_at, ?)
@@ -0,0 +1,75 @@
1
+ 'use strict';
2
+
3
+ import { normalizeRemoteRelayOrigin } from '../../shared/remote-relay-contract.mjs';
4
+
5
+ // Storage for remote relays that is not app settings: which remote relays a
6
+ // conversation has unlocked (the user mentioned them), and the provenance of
7
+ // messages and conversations another relay's agent created. Tables and
8
+ // columns come from migrations/0005-remote-relays.mjs.
9
+
10
+ function parseOrigin(json) {
11
+ if (!json) return null;
12
+ try {
13
+ return normalizeRemoteRelayOrigin(JSON.parse(json));
14
+ } catch {
15
+ return null;
16
+ }
17
+ }
18
+
19
+ export function createRemoteRelayRepository(db) {
20
+ const hasUnlock = db.prepare(`SELECT 1 FROM conversation_remote_relay_unlocks WHERE conversation_id = ? AND remote_relay_id = ?`);
21
+ const listUnlocks = db.prepare(`SELECT remote_relay_id, message_id, created_at FROM conversation_remote_relay_unlocks WHERE conversation_id = ? ORDER BY created_at ASC`);
22
+ const insertUnlock = db.prepare(`INSERT OR IGNORE INTO conversation_remote_relay_unlocks (conversation_id, remote_relay_id, message_id, created_at) VALUES (?, ?, ?, ?)`);
23
+ const deleteUnlocksForRelay = db.prepare(`DELETE FROM conversation_remote_relay_unlocks WHERE remote_relay_id = ?`);
24
+ const deleteUnlocksForConversation = db.prepare(`DELETE FROM conversation_remote_relay_unlocks WHERE conversation_id = ?`);
25
+ const setMessageOrigin = db.prepare(`UPDATE messages SET origin_json = ? WHERE id = ?`);
26
+ const getMessageOrigin = db.prepare(`SELECT origin_json FROM messages WHERE id = ?`);
27
+ const setConversationOrigin = db.prepare(`UPDATE conversations SET origin_json = ? WHERE id = ?`);
28
+ const getConversationOrigin = db.prepare(`SELECT origin_json FROM conversations WHERE id = ?`);
29
+
30
+ return {
31
+ hasUnlock(conversationId, remoteRelayId) {
32
+ return !!hasUnlock.get(String(conversationId || ''), String(remoteRelayId || ''));
33
+ },
34
+ /** `[{ remoteRelayId, messageId, createdAt }]`, oldest first. */
35
+ listUnlocks(conversationId) {
36
+ return listUnlocks.all(String(conversationId || '')).map((row) => ({
37
+ remoteRelayId: row.remote_relay_id,
38
+ messageId: row.message_id || null,
39
+ createdAt: row.created_at,
40
+ }));
41
+ },
42
+ /** True when this call created the unlock (false when it already existed). */
43
+ recordUnlock(conversationId, remoteRelayId, messageId = null, nowIso = new Date().toISOString()) {
44
+ const conversation = String(conversationId || '').trim();
45
+ const relay = String(remoteRelayId || '').trim();
46
+ if (!conversation || !relay) return false;
47
+ return insertUnlock.run(conversation, relay, messageId ? String(messageId) : null, nowIso).changes > 0;
48
+ },
49
+ forgetRelay(remoteRelayId) {
50
+ return deleteUnlocksForRelay.run(String(remoteRelayId || '')).changes;
51
+ },
52
+ forgetConversation(conversationId) {
53
+ return deleteUnlocksForConversation.run(String(conversationId || '')).changes;
54
+ },
55
+ setMessageOrigin(messageId, origin) {
56
+ const normalized = normalizeRemoteRelayOrigin(origin);
57
+ return setMessageOrigin.run(normalized ? JSON.stringify(normalized) : null, String(messageId || '')).changes > 0;
58
+ },
59
+ getMessageOrigin(messageId) {
60
+ return parseOrigin(getMessageOrigin.get(String(messageId || ''))?.origin_json);
61
+ },
62
+ setConversationOrigin(conversationId, origin) {
63
+ const normalized = normalizeRemoteRelayOrigin(origin);
64
+ return setConversationOrigin.run(normalized ? JSON.stringify(normalized) : null, String(conversationId || '')).changes > 0;
65
+ },
66
+ getConversationOrigin(conversationId) {
67
+ return parseOrigin(getConversationOrigin.get(String(conversationId || ''))?.origin_json);
68
+ },
69
+ };
70
+ }
71
+
72
+ /** Parses an `origin_json` column value read by other queries. */
73
+ export function parseRemoteRelayOriginJson(json) {
74
+ return parseOrigin(json);
75
+ }
@@ -9,6 +9,11 @@ export function createSessionRepository(db) {
9
9
  const runtimeProviderSelect = runtimeSessionsSupportProviders
10
10
  ? 'rs.model AS runtime_model, rs.provider_type AS runtime_provider_type, rs.provider_model AS runtime_provider_model'
11
11
  : 'rs.model AS runtime_model, NULL AS runtime_provider_type, NULL AS runtime_provider_model';
12
+ // Provenance of conversations another relay's agent created (migration
13
+ // 0005); NULL on a schema that predates it.
14
+ const conversationsHaveOrigin = db.prepare(`PRAGMA table_info(conversations)`).all()
15
+ .some((column) => String(column?.name || '').trim() === 'origin_json');
16
+ const conversationOriginSelect = conversationsHaveOrigin ? 'c.origin_json' : 'NULL AS origin_json';
12
17
  return {
13
18
  // conversations
14
19
  getConv: db.prepare(`SELECT * FROM conversations WHERE id = ? AND status != 'deleted'`),
@@ -16,7 +21,7 @@ export function createSessionRepository(db) {
16
21
  getConvBySdkSessionId: db.prepare(`SELECT * FROM conversations WHERE sdk_session_id = ? AND status != 'deleted' ORDER BY updated_at DESC LIMIT 1`),
17
22
  listConvIdsMissingRuntimeSession: db.prepare(`SELECT c.id AS id FROM conversations c LEFT JOIN runtime_sessions rs ON rs.conversation_id = c.id WHERE rs.id IS NULL AND c.status != 'deleted'`),
18
23
  runtimeSessionsSupportProviders,
19
- listConvs: db.prepare(`SELECT c.id, c.title, c.title_source, c.archived, c.compacted_into, c.compacted_from, c.sdk_session_id, c.preferred_relay_mode, c.preferred_model, c.preferred_reasoning_effort, c.configured_workspace_root_path, c.runtime_workspace_root_path, c.draft_text, c.draft_updated_at, c.draft_updated_by_client_id, c.draft_attachments, c.created_at, c.updated_at, rs.id AS runtime_session_id, rs.strategy AS runtime_strategy, rs.status AS runtime_status, rs.last_used_at AS runtime_last_used_at, ${runtimeProviderSelect}, COUNT(m.id) as message_count FROM conversations c LEFT JOIN messages m ON m.conversation_id = c.id LEFT JOIN runtime_sessions rs ON rs.conversation_id = c.id WHERE c.status != 'deleted' AND (? = 1 OR c.archived = 0) GROUP BY c.id ORDER BY CASE WHEN c.sdk_session_id IS NULL OR c.sdk_session_id = '' THEN 1 ELSE 0 END ASC, c.updated_at DESC`),
24
+ listConvs: db.prepare(`SELECT c.id, c.title, c.title_source, c.archived, c.compacted_into, c.compacted_from, c.sdk_session_id, c.preferred_relay_mode, c.preferred_model, c.preferred_reasoning_effort, c.configured_workspace_root_path, c.runtime_workspace_root_path, c.draft_text, c.draft_updated_at, c.draft_updated_by_client_id, c.draft_attachments, ${conversationOriginSelect}, c.created_at, c.updated_at, rs.id AS runtime_session_id, rs.strategy AS runtime_strategy, rs.status AS runtime_status, rs.last_used_at AS runtime_last_used_at, ${runtimeProviderSelect}, COUNT(m.id) as message_count FROM conversations c LEFT JOIN messages m ON m.conversation_id = c.id LEFT JOIN runtime_sessions rs ON rs.conversation_id = c.id WHERE c.status != 'deleted' AND (? = 1 OR c.archived = 0) GROUP BY c.id ORDER BY CASE WHEN c.sdk_session_id IS NULL OR c.sdk_session_id = '' THEN 1 ELSE 0 END ASC, c.updated_at DESC`),
20
25
  insertConv: db.prepare(`INSERT OR IGNORE INTO conversations (id, title, created_at, updated_at) VALUES (?, ?, ?, ?)`),
21
26
  updateConvTime: db.prepare(`UPDATE conversations SET updated_at = ? WHERE id = ?`),
22
27
  updateConvTitle: db.prepare(`UPDATE conversations SET title = ?, title_source = 'manual' WHERE id = ?`),
@@ -1,6 +1,8 @@
1
1
  'use strict';
2
2
 
3
3
  import { createAskUserRoutingService } from '../services/ask-user-routing-service.mjs';
4
+ import { admitRemoteRelayRequest, readRemoteRelayRequest } from '../services/remote-relay-inbound.mjs';
5
+ import { isRemoteRelayApprovalQuestion } from '../../shared/remote-relay-contract.mjs';
4
6
  import {
5
7
  extractRequestedSchema,
6
8
  normalizeSchema,
@@ -33,6 +35,21 @@ function firstKnownValue(source, keyPaths = []) {
33
35
  return null;
34
36
  }
35
37
 
38
+ /**
39
+ * A stored question row that is this relay's Allow / Deny card for one of its
40
+ * own agent's calls to another relay (remote-relay-caller-context.mjs). The
41
+ * context lives in the row's request envelope.
42
+ */
43
+ function isRemoteRelayApprovalRow(row) {
44
+ let envelope = null;
45
+ try {
46
+ envelope = JSON.parse(String(row?.request || ''));
47
+ } catch {
48
+ return false;
49
+ }
50
+ return isRemoteRelayApprovalQuestion(envelope);
51
+ }
52
+
36
53
  function extractContinuationOwnership(rawRequest) {
37
54
  const parsed = rawRequest && typeof rawRequest === 'object' ? rawRequest : null;
38
55
  if (!parsed) {
@@ -99,6 +116,9 @@ export function registerAskUserRoutes(app, deps) {
99
116
  DEFAULT_RELAY_MODE,
100
117
  sessionWorkerRegistry,
101
118
  pushDispatchService,
119
+ // Remote relays: another relay's agent answering a question is refused
120
+ // while the inbound switch is off. Absent in older wiring (no-op).
121
+ remoteRelayInbound = null,
102
122
  } = deps;
103
123
 
104
124
  const continuationRoutingEnabled = runtimeState?.featureFlags?.SESSION_WORKER_CONTINUATION_ROUTING_ENABLED === true;
@@ -108,16 +128,26 @@ export function registerAskUserRoutes(app, deps) {
108
128
  sessionWorkerRegistry,
109
129
  });
110
130
 
131
+ // A request from another relay's agent, by the test the inbound switch
132
+ // applies (X-OAR-Remote-Origin or a body origin). Made even without the
133
+ // remote-relay deps: an approval card is only ever this relay's user's.
134
+ function fromRemoteRelay(req) {
135
+ return readRemoteRelayRequest(req, remoteRelayInbound || {}).remote;
136
+ }
137
+
111
138
  app.get('/api/relay-questions', auth, (req, res) => {
112
139
  const conversationId = req.query.conversationId ? String(req.query.conversationId) : null;
113
140
  const status = String(req.query.status || 'pending').trim() || 'pending';
114
- const rows = stmts.listQuestions.all(status, conversationId, conversationId);
141
+ let rows = stmts.listQuestions.all(status, conversationId, conversationId);
142
+ if (fromRemoteRelay(req)) rows = rows.filter((row) => !isRemoteRelayApprovalRow(row));
115
143
  res.json({ questions: rows.map(formatQuestionRow) });
116
144
  });
117
145
 
118
146
  app.get('/api/relay-question/:id', auth, (req, res) => {
119
147
  const row = stmts.getQuestion.get(req.params.id);
120
- if (!row) return res.status(404).json({ error: 'Not found' });
148
+ if (!row || (isRemoteRelayApprovalRow(row) && fromRemoteRelay(req))) {
149
+ return res.status(404).json({ error: 'Not found' });
150
+ }
121
151
  res.json({ question: formatQuestionRow(row) });
122
152
  });
123
153
 
@@ -220,6 +250,7 @@ export function registerAskUserRoutes(app, deps) {
220
250
  });
221
251
 
222
252
  app.post('/api/relay-question/:id/answer', auth, (req, res) => {
253
+ if (!admitRemoteRelayRequest(req, res, remoteRelayInbound)) return;
223
254
  const id = String(req.params.id || '').trim();
224
255
  const { answer, structuredAnswer, sdk_session_id, continuation_id, continuation_question_id } = req.body;
225
256
  const sdkSessionId = normalizeId(sdk_session_id);
@@ -228,6 +259,12 @@ export function registerAskUserRoutes(app, deps) {
228
259
 
229
260
  const row = stmts.getQuestion.get(id);
230
261
  if (!row) return res.status(404).json({ error: 'Not found' });
262
+ // Allow / Deny on this relay's own call to another relay is its user's
263
+ // decision; the relay that call went to must never make it.
264
+ if (isRemoteRelayApprovalRow(row) && fromRemoteRelay(req)) {
265
+ console.warn(`[${ts()}] QUESTION reject id=${id.slice(0, 8)} reason=remote-relay-approval-from-remote`);
266
+ return res.status(403).json({ error: 'Only this relay\'s user can answer a remote relay approval' });
267
+ }
231
268
  if (row.status !== 'pending') return res.status(409).json({ error: `Question already ${row.status}` });
232
269
 
233
270
  const schema = parseQuestionRequest(row.request_schema);
@@ -287,6 +324,9 @@ export function registerAskUserRoutes(app, deps) {
287
324
  app.post('/api/relay-question/:id/timeout', auth, (req, res) => {
288
325
  const row = stmts.getQuestion.get(req.params.id);
289
326
  if (!row) return res.status(404).json({ error: 'Not found' });
327
+ if (isRemoteRelayApprovalRow(row) && fromRemoteRelay(req)) {
328
+ return res.status(403).json({ error: 'Only this relay\'s user can answer a remote relay approval' });
329
+ }
290
330
  if (row.status !== 'pending') return res.json({ ok: true, question: formatQuestionRow(row) });
291
331
 
292
332
  const result = stmts.timeoutQuestion.run(row.id);