@oar-sh/oar 0.9.3 → 0.9.4

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 (68) 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 +67 -0
  4. package/README.md +62 -13
  5. package/package.json +5 -2
  6. package/server/README.md +43 -3
  7. package/server/claude-worker/claude-remote-relay-tool.mjs +99 -0
  8. package/server/claude-worker/claude-sdk-adapter.mjs +26 -4
  9. package/server/claude-worker/claude-session-process.mjs +92 -25
  10. package/server/claude-worker/sdk-message-normalizer.mjs +7 -0
  11. package/server/copilot-worker/copilot-remote-relay-tool.mjs +59 -0
  12. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +12 -2
  13. package/server/copilot-worker/copilot-sdk-session-process.mjs +83 -9
  14. package/server/cursor-worker/cursor-remote-relay-tool.mjs +65 -0
  15. package/server/cursor-worker/cursor-turn-runner.mjs +45 -2
  16. package/server/cursor-worker/sdk-message-normalizer.mjs +25 -0
  17. package/server/db-schema.mjs +8 -0
  18. package/server/grok-worker/acp-client.mjs +92 -6
  19. package/server/grok-worker/grok-turn-runner.mjs +59 -8
  20. package/server/grok-worker/sdk-message-normalizer.mjs +13 -0
  21. package/server/mcp/oar-mcp-server.mjs +473 -0
  22. package/server/migrations/0005-remote-relays.mjs +90 -0
  23. package/server/public/app/action-confirmations.js +141 -13
  24. package/server/public/app/api-client.js +103 -0
  25. package/server/public/app/bootstrap.js +21 -6
  26. package/server/public/app/conversation-deep-link.mjs +42 -0
  27. package/server/public/app/conversation-view.js +98 -7
  28. package/server/public/app/host-suspend-ui.js +272 -0
  29. package/server/public/app/journal-view.js +4 -1
  30. package/server/public/app/mention-autocomplete.mjs +298 -0
  31. package/server/public/app/push-settings.js +1 -0
  32. package/server/public/app/pwa-install.js +8 -3
  33. package/server/public/app/relay-prompt-sanitizer.mjs +48 -5
  34. package/server/public/app/remote-relay-origin-view.mjs +76 -0
  35. package/server/public/app/remote-relay-shared.mjs +85 -0
  36. package/server/public/app/remote-relays-settings.js +618 -0
  37. package/server/public/app/remote-relays-store.mjs +126 -0
  38. package/server/public/app/settings-modal.js +4 -2
  39. package/server/public/app/settings-tabs.js +1 -1
  40. package/server/public/app/socket-handlers.js +18 -1
  41. package/server/public/app/thinking-follow.js +260 -0
  42. package/server/public/app/tool-activity-emoji.mjs +4 -0
  43. package/server/public/index.html +257 -8
  44. package/server/relay-tools.md +5 -1
  45. package/server/repositories/message-repository.mjs +7 -0
  46. package/server/repositories/question-repository.mjs +3 -0
  47. package/server/repositories/remote-relay-repository.mjs +75 -0
  48. package/server/repositories/session-repository.mjs +6 -1
  49. package/server/routes/ask-user-routes.mjs +42 -2
  50. package/server/routes/messages-routes.mjs +90 -16
  51. package/server/routes/remote-relay-routes.mjs +133 -0
  52. package/server/routes/sessions-routes.mjs +80 -29
  53. package/server/server-runtime.mjs +238 -0
  54. package/server/services/github-ci-activity-service.mjs +199 -0
  55. package/server/services/host-suspend-command.mjs +38 -0
  56. package/server/services/host-suspend-service.mjs +254 -0
  57. package/server/services/push-dispatch-service.mjs +37 -4
  58. package/server/services/remote-relay-caller-context.mjs +341 -0
  59. package/server/services/remote-relay-client.mjs +332 -0
  60. package/server/services/remote-relay-dispatcher.mjs +1500 -0
  61. package/server/services/remote-relay-inbound.mjs +187 -0
  62. package/server/services/remote-relay-pairing.mjs +189 -0
  63. package/server/services/remote-relay-registry-service.mjs +581 -0
  64. package/server/services/session-worker-launch-service.mjs +131 -12
  65. package/shared/remote-relay-contract.mjs +539 -0
  66. package/shared/remote-relay-mentions.mjs +129 -0
  67. package/shared/remote-relay-tool-core.mjs +314 -0
  68. package/shared/worker-runtime/api-client.mjs +112 -22
@@ -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. */
@@ -5106,6 +5249,7 @@
5106
5249
  <div id="chat-actions-menu-backdrop" onclick="window.closeChatActionsMenu && window.closeChatActionsMenu()"></div>
5107
5250
  <div id="font-scale-indicator" aria-live="polite" aria-atomic="true" hidden>100%</div>
5108
5251
  <div id="relay-toast" role="status" aria-live="polite"></div>
5252
+ <div id="pending-action-banner" role="status" aria-live="polite" hidden></div>
5109
5253
 
5110
5254
  <!-- Main App -->
5111
5255
  <div id="app">
@@ -5186,6 +5330,7 @@
5186
5330
  <div id="image-edit-target" aria-live="polite"></div>
5187
5331
  <div id="emoji-popup" aria-hidden="true"></div>
5188
5332
  <div id="slash-autocomplete-popup" aria-hidden="true" role="listbox"></div>
5333
+ <div id="mention-autocomplete-popup" aria-hidden="true" role="listbox" aria-label="Remote relays"></div>
5189
5334
  <details id="background-tasks-panel" hidden>
5190
5335
  <summary id="background-tasks-summary"></summary>
5191
5336
  <div id="background-panel-body">
@@ -5196,7 +5341,7 @@
5196
5341
  <div class="input-row">
5197
5342
  <input id="image-input" type="file" multiple hidden onchange="handleAttachmentInput(this.files); this.value='';">
5198
5343
  <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>
5344
+ <textarea id="msg-input" placeholder="Message Copilot…" rows="1" aria-autocomplete="list" onkeydown="handleKey(event)" oninput="autoResize(this); updateComposerSlashMenu(this)"></textarea>
5200
5345
  <select id="mode-select" title="Mode">
5201
5346
  <option value="agent" selected>Agent</option>
5202
5347
  <option value="ask">Ask</option>
@@ -5333,6 +5478,16 @@
5333
5478
  tabindex="-1"
5334
5479
  data-settings-tab="providers"
5335
5480
  >Providers</button>
5481
+ <button
5482
+ id="settings-tab-relays"
5483
+ class="settings-tab"
5484
+ type="button"
5485
+ role="tab"
5486
+ aria-selected="false"
5487
+ aria-controls="settings-panel-relays"
5488
+ tabindex="-1"
5489
+ data-settings-tab="relays"
5490
+ >Relays</button>
5336
5491
  <button
5337
5492
  id="settings-tab-previews"
5338
5493
  class="settings-tab"
@@ -5448,16 +5603,16 @@
5448
5603
  </div>
5449
5604
  </div>
5450
5605
  <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>
5606
+ <label for="pwa-app-name-input">Relay name</label>
5452
5607
  <input
5453
5608
  id="pwa-app-name-input"
5454
5609
  type="text"
5455
5610
  maxlength="60"
5456
5611
  placeholder="OAR"
5457
- aria-label="Install app name"
5612
+ aria-label="Relay name"
5458
5613
  onchange="updatePwaAppName(this.value)"
5459
5614
  >
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>
5615
+ <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
5616
  </div>
5462
5617
  <div class="settings-row" style="margin-top: 12px; margin-bottom: 0;">
5463
5618
  <label for="update-auto-check-toggle">Check for updates automatically</label>
@@ -5949,6 +6104,96 @@
5949
6104
  </div>
5950
6105
  </div>
5951
6106
  </div>
6107
+ <div
6108
+ id="settings-panel-relays"
6109
+ class="settings-panel"
6110
+ role="tabpanel"
6111
+ tabindex="0"
6112
+ aria-labelledby="settings-tab-relays"
6113
+ data-settings-panel="relays"
6114
+ hidden
6115
+ >
6116
+ <div id="remote-relays-self-section" class="settings-row settings-row-column" style="margin-top: 12px; margin-bottom: 0;">
6117
+ <label>This relay</label>
6118
+ <div class="remote-relays-self-name-row">
6119
+ <span id="remote-relays-self-name" class="remote-relays-self-name">—</span>
6120
+ <span class="settings-help">Change it under General → Relay name.</span>
6121
+ </div>
6122
+ </div>
6123
+ <div class="settings-row settings-row-column" style="margin-top: 12px; margin-bottom: 0;">
6124
+ <label for="remote-relays-public-url-input">Public address</label>
6125
+ <input
6126
+ id="remote-relays-public-url-input"
6127
+ type="url"
6128
+ inputmode="url"
6129
+ autocomplete="off"
6130
+ autocapitalize="off"
6131
+ spellcheck="false"
6132
+ placeholder="https://…"
6133
+ aria-label="Public address of this relay"
6134
+ onchange="saveRemoteRelayPublicUrl(this.value)"
6135
+ >
6136
+ <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>
6137
+ <div id="remote-relays-self-status" class="settings-help settings-provider-status" role="status" aria-live="polite" hidden></div>
6138
+ </div>
6139
+ <div class="settings-row" style="margin-top: 12px; margin-bottom: 0;">
6140
+ <label for="remote-relays-inbound-toggle">Accept prompts from other relays' agents</label>
6141
+ <input
6142
+ id="remote-relays-inbound-toggle"
6143
+ type="checkbox"
6144
+ onchange="toggleRemoteRelayInbound(this.checked)"
6145
+ aria-label="Accept prompts from other relays' agents"
6146
+ checked
6147
+ >
6148
+ </div>
6149
+ <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>
6150
+ <div id="remote-relays-list-section" class="settings-row settings-row-column" style="margin-top: 16px; margin-bottom: 0;">
6151
+ <label>Remote relays</label>
6152
+ <div id="remote-relays-list"></div>
6153
+ <div id="remote-relays-empty" class="settings-help">Loading remote relays…</div>
6154
+ <div id="remote-relays-status" class="settings-help settings-provider-status" role="status" aria-live="polite" hidden></div>
6155
+ <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>
6156
+ </div>
6157
+ <div id="remote-relays-add-section" class="settings-row settings-row-column" style="margin-top: 16px; margin-bottom: 0;">
6158
+ <label for="remote-relays-add-url-input">Add a relay</label>
6159
+ <input
6160
+ id="remote-relays-add-url-input"
6161
+ type="url"
6162
+ inputmode="url"
6163
+ autocomplete="off"
6164
+ autocapitalize="off"
6165
+ spellcheck="false"
6166
+ placeholder="Paste the other relay's web address"
6167
+ aria-label="Paste the other relay's web address"
6168
+ onkeydown="if (event.key === 'Enter') { event.preventDefault(); addRemoteRelayFromForm(); }"
6169
+ >
6170
+ <div id="remote-relays-add-token-row" class="remote-relays-add-token-row" hidden>
6171
+ <label for="remote-relays-add-token-input">Token of the other relay</label>
6172
+ <input
6173
+ id="remote-relays-add-token-input"
6174
+ type="password"
6175
+ autocomplete="off"
6176
+ autocapitalize="off"
6177
+ spellcheck="false"
6178
+ placeholder="Only needed when it differs from this relay's token"
6179
+ aria-label="Token of the other relay"
6180
+ onkeydown="if (event.key === 'Enter') { event.preventDefault(); addRemoteRelayFromForm(); }"
6181
+ >
6182
+ </div>
6183
+ <button id="remote-relays-use-token-btn" class="remote-relays-link-btn" type="button" onclick="showRemoteRelayTokenField()">Use a different token</button>
6184
+ <label class="remote-relays-pair-back">
6185
+ <input id="remote-relays-pair-back-toggle" type="checkbox" checked>
6186
+ Also add this relay there
6187
+ </label>
6188
+ <div id="remote-relays-pair-back-hint" class="settings-help">
6189
+ The other relay gets this relay's public address and, if it uses a different token, this
6190
+ relay's token too: its agents can then work here. Removing a relay here does not revoke
6191
+ that; rotate this relay's token to lock it out.
6192
+ </div>
6193
+ <button id="remote-relays-add-btn" class="chat-title-action-btn" type="button" onclick="addRemoteRelayFromForm()">Add</button>
6194
+ <div id="remote-relays-add-status" class="settings-help settings-provider-status" role="status" aria-live="polite" hidden></div>
6195
+ </div>
6196
+ </div>
5952
6197
  <div
5953
6198
  id="settings-panel-previews"
5954
6199
  class="settings-panel"
@@ -6018,6 +6263,10 @@
6018
6263
  <label for="push-event-cli-offline-toggle">CLI went offline</label>
6019
6264
  <input id="push-event-cli-offline-toggle" type="checkbox" onchange="updatePushPreferencesFromControls()" disabled>
6020
6265
  </div>
6266
+ <div class="settings-row" style="width: 100%; margin-top: 6px; margin-bottom: 0;">
6267
+ <label for="push-event-host-suspend-toggle">Host suspend fired or was dropped</label>
6268
+ <input id="push-event-host-suspend-toggle" type="checkbox" onchange="updatePushPreferencesFromControls()" checked disabled>
6269
+ </div>
6021
6270
  <label style="margin-top: 10px;">Devices</label>
6022
6271
  <div id="push-device-list" style="width: 100%;"></div>
6023
6272
  <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);