@oar-sh/oar 0.9.25 → 0.9.26

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 (33) hide show
  1. package/CHANGELOG.md +142 -0
  2. package/README.md +9 -9
  3. package/package.json +3 -1
  4. package/server/claude-worker/claude-sdk-adapter.mjs +8 -4
  5. package/server/claude-worker/claude-session-process.mjs +4 -0
  6. package/server/claude-worker/claude-turn-publisher.mjs +25 -11
  7. package/server/copilot-worker/copilot-prompt-context.mjs +2 -1
  8. package/server/cursor-worker/cursor-turn-runner.mjs +3 -1
  9. package/server/grok-worker/grok-turn-runner.mjs +3 -1
  10. package/server/public/app/context-usage-view.mjs +2 -2
  11. package/server/public/app/live-activity-cap.mjs +3 -3
  12. package/server/public/app/markdown-math.mjs +471 -0
  13. package/server/public/app/pwa-install.js +25 -4
  14. package/server/public/app/relay-prompt-sanitizer.mjs +3 -0
  15. package/server/public/app/relay-restart-ui.js +2 -2
  16. package/server/public/app/remote-relays-settings.js +33 -6
  17. package/server/public/app/router.js +49 -237
  18. package/server/public/app/settings-tabs.js +10 -1
  19. package/server/public/app/store.js +2 -0
  20. package/server/public/app/stream-view-preference.mjs +1 -1
  21. package/server/public/app/thoughts-preference.mjs +1 -1
  22. package/server/public/app/tmux-inspector-view.js +29 -16
  23. package/server/public/index.html +134 -116
  24. package/server/routes/relay-board-routes.mjs +46 -1
  25. package/server/routes/remote-relay-routes.mjs +1 -1
  26. package/server/server-runtime.mjs +2 -0
  27. package/server/services/relay-prompt-sanitizer.mjs +7 -0
  28. package/server/services/relay-restart-queue.mjs +1 -1
  29. package/server/services/remote-relay-dispatcher.mjs +23 -1
  30. package/server/services/remote-relay-registry-service.mjs +7 -1
  31. package/server/services/session-worker-stop-service.mjs +50 -18
  32. package/shared/math-instructions.mjs +30 -0
  33. package/server/public/app/math-dollars.mjs +0 -89
@@ -211,18 +211,17 @@
211
211
  <title>OAR — Open Agent Relay</title>
212
212
  <script>document.write('<scr'+'ipt src="'+__APP_BASE+'/socket.io/socket.io.js"><\/scr'+'ipt>');</script>
213
213
  <script src="https://cdn.jsdelivr.net/npm/marked@15.0.12/marked.min.js"></script>
214
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.css">
215
- <script src="https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.js"></script>
216
- <script src="https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/contrib/mhchem.min.js"></script>
217
- <script src="https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/contrib/auto-render.min.js"></script>
214
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.19.1/dist/katex.min.css" integrity="sha384-WwGtaC6ywCHxA5cstpj/3+xIZvTez8dq2IcBQaXRkenHZ7FVVq1ZROtURwfbtTlG" crossorigin="anonymous">
215
+ <script src="https://cdn.jsdelivr.net/npm/katex@0.19.1/dist/katex.min.js" integrity="sha384-a1WQdjRj162i159OYq041X6I4K8NenkrI09LlPmiHg0YOb7CR+wJOraXwK9abpnE" crossorigin="anonymous"></script>
216
+ <script src="https://cdn.jsdelivr.net/npm/katex@0.19.1/dist/contrib/mhchem.min.js" integrity="sha384-fB8BH//9nBzROkMUsu/Dr35jWHIbnKesUo9rW0hfEgw8mZGnkAyBAjKX9F98OVuo" crossorigin="anonymous"></script>
218
217
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
219
218
  <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
220
219
  <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/nginx.min.js"></script>
221
220
  <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/powershell.min.js"></script>
222
221
  <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/x86asm.min.js"></script>
223
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.5.0/css/xterm.min.css">
224
- <script src="https://cdn.jsdelivr.net/npm/xterm@5.5.0/lib/xterm.min.js"></script>
225
- <script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.10.0/lib/xterm-addon-fit.min.js"></script>
222
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.css" integrity="sha384-8Xk9wy/gzEDUKrXtrmCFa2bBuK3BpjpDuL/p0SeKQX19Khl/M+lHOgD/CyYf7efP" crossorigin="anonymous">
223
+ <script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.js" integrity="sha384-M169f14mRZOXm3hD/v2Ti0ThIT/RnAQagXA9nlE15yHAtrW19gdePJh/HaTzUOe/" crossorigin="anonymous"></script>
224
+ <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.js" integrity="sha384-iF+jqbuti4XlB64clWgFWYEscb+UnSRv3VgVikGYZu+otNFnSHr7y7NcKfBnGizn" crossorigin="anonymous"></script>
226
225
  <script>
227
226
  if (window.hljs && typeof window.hljs.registerAliases === 'function') {
228
227
  window.hljs.registerAliases(['pwsh', 'ps1'], { languageName: 'powershell' });
@@ -1978,6 +1977,8 @@
1978
1977
  }
1979
1978
  .settings-section:first-child .settings-section-title { margin-top: 0; }
1980
1979
  .settings-section-title + .settings-row { margin-top: 0; }
1980
+ /* Settings → Appearance: the one line saying where its settings live. */
1981
+ #appearance-browser-note { margin-bottom: calc(8px * var(--ui-scale)); }
1981
1982
  /* A row inside a column block: the block's full width; the lead row (the
1982
1983
  provider's on/off toggle) keeps a small gap to the fields below. */
1983
1984
  .settings-row-full { width: 100%; }
@@ -2429,6 +2430,9 @@
2429
2430
  The anchor class comes from JS rather than :has() so positioning works
2430
2431
  everywhere the transcript renders. */
2431
2432
  pre.has-code-copy { position: relative; }
2433
+ /* The button gets a strip of its own above the first line, so a long line
2434
+ never runs under it. */
2435
+ pre.has-code-copy > code { padding-top: calc(0.72rem + 12px * var(--ui-scale) + 4px); }
2432
2436
  .code-copy-btn {
2433
2437
  position: absolute;
2434
2438
  top: calc(4px * var(--ui-scale));
@@ -2470,8 +2474,8 @@
2470
2474
  vertical-align: bottom;
2471
2475
  -webkit-overflow-scrolling: touch;
2472
2476
  }
2473
- .katex-error {
2474
- color: var(--danger);
2477
+ /* A formula KaTeX could not set is shown as its source, line breaks kept. */
2478
+ .math-source {
2475
2479
  white-space: pre-wrap;
2476
2480
  }
2477
2481
  .msg-bubble code:not(pre code) {
@@ -5066,8 +5070,7 @@
5066
5070
  background: var(--bg); color: var(--text); border: 1px solid var(--border);
5067
5071
  border-radius: calc(6px * var(--ui-scale)); padding: calc(2px * var(--ui-scale)) calc(4px * var(--ui-scale)); font-size: var(--fs-meta-sm);
5068
5072
  }
5069
- .remote-relays-self-name-row { display: flex; align-items: baseline; gap: calc(5px * var(--ui-scale)); flex-wrap: wrap; }
5070
- .remote-relays-self-name { font-family: var(--mono, monospace); color: var(--text); }
5073
+ #settings-panel-relays #pwa-app-name-input,
5071
5074
  #remote-relays-public-url-input,
5072
5075
  #remote-relays-add-url-input,
5073
5076
  #remote-relays-add-token-input {
@@ -8844,6 +8847,16 @@
8844
8847
  tabindex="0"
8845
8848
  data-settings-tab="general"
8846
8849
  >General</button>
8850
+ <button
8851
+ id="settings-tab-appearance"
8852
+ class="settings-tab"
8853
+ type="button"
8854
+ role="tab"
8855
+ aria-selected="false"
8856
+ aria-controls="settings-panel-appearance"
8857
+ tabindex="-1"
8858
+ data-settings-tab="appearance"
8859
+ >Appearance</button>
8847
8860
  <button
8848
8861
  id="settings-tab-providers"
8849
8862
  class="settings-tab"
@@ -8914,68 +8927,6 @@
8914
8927
  aria-labelledby="settings-tab-general"
8915
8928
  data-settings-panel="general"
8916
8929
  >
8917
- <section class="settings-section" id="settings-section-appearance" aria-labelledby="settings-section-appearance-title">
8918
- <h3 class="settings-section-title" id="settings-section-appearance-title">Appearance</h3>
8919
- <div class="settings-row">
8920
- <label for="theme-select">Theme</label>
8921
- <select id="theme-select" onchange="updateTheme(this.value)">
8922
- <option value="dark">Dark</option>
8923
- <option value="light">Light</option>
8924
- </select>
8925
- </div>
8926
- <div class="settings-row" style="margin-bottom: 0;">
8927
- <label for="font-scale-select">Text scaling</label>
8928
- <select id="font-scale-select" onchange="updateFontScaleFromSelect(this.value)" aria-label="Text scaling"></select>
8929
- </div>
8930
- <div class="settings-row settings-gap">
8931
- <label for="thoughts-expanded-toggle">Expand thoughts while an agent works</label>
8932
- <input
8933
- id="thoughts-expanded-toggle"
8934
- type="checkbox"
8935
- onchange="updateThoughtsExpandedSetting(this.checked)"
8936
- aria-label="Expand thoughts while an agent works"
8937
- checked
8938
- >
8939
- </div>
8940
- <div class="settings-help settings-gap-xs">Open thoughts in the live turn, in sub-agent bubbles and in the sub-agent viewer. Saved in this browser.</div>
8941
- <div class="settings-row settings-row-column settings-gap">
8942
- <label for="live-activity-cap-slider">Tool lines shown while an agent works <span id="live-activity-cap-value">50 lines</span></label>
8943
- <input
8944
- id="live-activity-cap-slider"
8945
- type="range"
8946
- min="10"
8947
- max="510"
8948
- step="10"
8949
- value="50"
8950
- aria-label="Tool lines shown while an agent works"
8951
- aria-valuetext="50 lines"
8952
- oninput="previewLiveActivityCap(this.value)"
8953
- onchange="updateLiveActivityCap(this.value)"
8954
- >
8955
- </div>
8956
- <div class="settings-help settings-gap-xs">Per agent: the main agent and each sub-agent keep this many of their newest tool lines in the live bubble. The relay keeps up to 500 per agent. Saved in this browser.</div>
8957
- <div class="settings-row settings-gap">
8958
- <label for="stream-view-toggle">Stream view: thoughts, tool calls and the reply in order</label>
8959
- <input
8960
- id="stream-view-toggle"
8961
- type="checkbox"
8962
- onchange="updateStreamViewSetting(this.checked)"
8963
- aria-label="Stream view: thoughts, tool calls and the reply in order"
8964
- >
8965
- </div>
8966
- <div class="settings-help settings-gap-xs">Turns recorded before this version keep the grouped layout. Saved in this browser.</div>
8967
- <div class="settings-row settings-gap">
8968
- <label for="show-suspend-host-toggle">Show Suspend host action</label>
8969
- <input
8970
- id="show-suspend-host-toggle"
8971
- type="checkbox"
8972
- onchange="updateShowSuspendHostSetting(this.checked)"
8973
- aria-label="Show Suspend host action"
8974
- checked
8975
- >
8976
- </div>
8977
- <div class="settings-help settings-gap-xs">Shows or hides the menu action in this browser only. Suspend works only when the relay runs on Windows; a queued suspend still shows its banner here either way.</div>
8978
- </section>
8979
8930
  <section class="settings-section" id="settings-section-reading" aria-labelledby="settings-section-reading-title">
8980
8931
  <h3 class="settings-section-title" id="settings-section-reading-title">Reading</h3>
8981
8932
  <div class="settings-row settings-gap">
@@ -9090,44 +9041,8 @@
9090
9041
  <div id="model-refresh-providers" class="settings-gap" role="group" aria-label="Providers checked for new models"></div>
9091
9042
  <div class="settings-help settings-gap-xs">The relay asks each switched-on provider for its model list this often. A provider whose list was refreshed in the meantime (Refresh in Select Models, a settings save, a sign-in) waits a full interval again, and a failed check is tried again after an hour at the earliest. Whether a new model starts on is set per provider in Select Models. A setting of this relay.</div>
9092
9043
  </section>
9093
- <section class="settings-section" id="settings-section-relay" aria-labelledby="settings-section-relay-title">
9094
- <h3 class="settings-section-title" id="settings-section-relay-title">This relay</h3>
9095
- <div class="settings-row settings-row-column settings-gap">
9096
- <label for="pwa-app-name-input">Relay name</label>
9097
- <input
9098
- id="pwa-app-name-input"
9099
- type="text"
9100
- maxlength="60"
9101
- placeholder="OAR"
9102
- aria-label="Relay name"
9103
- onchange="updatePwaAppName(this.value)"
9104
- >
9105
- <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. A setting of this relay.</div>
9106
- </div>
9107
- <div class="settings-row settings-gap">
9108
- <label for="restart-resume-toggle">After a relay restart, tell interrupted sessions to carry on</label>
9109
- <input
9110
- id="restart-resume-toggle"
9111
- type="checkbox"
9112
- onchange="updateRestartResumeSettingFromToggle(this.checked)"
9113
- aria-label="After a relay restart, tell interrupted sessions to carry on"
9114
- >
9115
- </div>
9116
- <div class="settings-help settings-gap-xs">Most turns a restart interrupts run again by themselves. A turn that cannot (the agent had already taken the message, or had started on its own after a background task) just stops. With this on, such a session gets one relay note once the relay is back, saying when it restarted and asking the agent to check what it was doing and carry on. Your message is never sent again; idle, archived and usage-limit-paused sessions get nothing. Off by default; a setting of this relay.</div>
9117
- <div class="settings-row settings-gap">
9118
- <label for="relay-restart-max-wait-select">Ask after a queued relay restart has waited</label>
9119
- <select id="relay-restart-max-wait-select" onchange="updateRelayRestartMaxWaitSetting(this.value)" aria-label="Ask after a queued relay restart has waited">
9120
- <option value="5">5 min</option>
9121
- <option value="10">10 min</option>
9122
- <option value="15">15 min</option>
9123
- <option value="30" selected>30 min</option>
9124
- <option value="60">1 h</option>
9125
- <option value="120">2 h</option>
9126
- <option value="240">4 h</option>
9127
- <option value="0">No limit</option>
9128
- </select>
9129
- </div>
9130
- <div class="settings-help settings-gap-xs">A queued relay restart waits for the running turns, and new messages wait for the restart. After this long the banner asks whether to restart now, keep waiting or cancel; it never restarts while the turns run, and still restarts as soon as they end. No limit waits until the turns end. A setting of this relay.</div>
9044
+ <section class="settings-section" id="settings-section-computer" aria-labelledby="settings-section-computer-title">
9045
+ <h3 class="settings-section-title" id="settings-section-computer-title">This computer</h3>
9131
9046
  <div class="settings-row settings-row-column settings-gap">
9132
9047
  <label for="default-session-workspace-root-input">Default CWD for new sessions</label>
9133
9048
  <input
@@ -9204,6 +9119,80 @@
9204
9119
  </div>
9205
9120
  </section>
9206
9121
  </div>
9122
+ <div
9123
+ id="settings-panel-appearance"
9124
+ class="settings-panel"
9125
+ role="tabpanel"
9126
+ tabindex="0"
9127
+ aria-labelledby="settings-tab-appearance"
9128
+ data-settings-panel="appearance"
9129
+ hidden
9130
+ >
9131
+ <div id="appearance-browser-note" class="settings-help">These settings are kept in this browser; other devices keep their own.</div>
9132
+ <div class="settings-row">
9133
+ <label for="theme-select">Theme</label>
9134
+ <select id="theme-select" onchange="updateTheme(this.value)" aria-describedby="appearance-browser-note">
9135
+ <option value="dark">Dark</option>
9136
+ <option value="light">Light</option>
9137
+ </select>
9138
+ </div>
9139
+ <div class="settings-row" style="margin-bottom: 0;">
9140
+ <label for="font-scale-select">Text scaling</label>
9141
+ <select id="font-scale-select" onchange="updateFontScaleFromSelect(this.value)" aria-label="Text scaling" aria-describedby="appearance-browser-note"></select>
9142
+ </div>
9143
+ <div class="settings-row settings-gap">
9144
+ <label for="thoughts-expanded-toggle">Expand thoughts while an agent works</label>
9145
+ <input
9146
+ id="thoughts-expanded-toggle"
9147
+ aria-describedby="appearance-browser-note"
9148
+ type="checkbox"
9149
+ onchange="updateThoughtsExpandedSetting(this.checked)"
9150
+ aria-label="Expand thoughts while an agent works"
9151
+ checked
9152
+ >
9153
+ </div>
9154
+ <div class="settings-help settings-gap-xs">Open thoughts in the live turn, in sub-agent bubbles and in the sub-agent viewer.</div>
9155
+ <div class="settings-row settings-row-column settings-gap">
9156
+ <label for="live-activity-cap-slider">Tool lines shown while an agent works <span id="live-activity-cap-value">50 lines</span></label>
9157
+ <input
9158
+ id="live-activity-cap-slider"
9159
+ aria-describedby="appearance-browser-note"
9160
+ type="range"
9161
+ min="10"
9162
+ max="510"
9163
+ step="10"
9164
+ value="50"
9165
+ aria-label="Tool lines shown while an agent works"
9166
+ aria-valuetext="50 lines"
9167
+ oninput="previewLiveActivityCap(this.value)"
9168
+ onchange="updateLiveActivityCap(this.value)"
9169
+ >
9170
+ </div>
9171
+ <div class="settings-help settings-gap-xs">Per agent: the main agent and each sub-agent keep this many of their newest tool lines in the live bubble. The relay keeps up to 500 per agent.</div>
9172
+ <div class="settings-row settings-gap">
9173
+ <label for="stream-view-toggle">Stream view: thoughts, tool calls and the reply in order</label>
9174
+ <input
9175
+ id="stream-view-toggle"
9176
+ aria-describedby="appearance-browser-note"
9177
+ type="checkbox"
9178
+ onchange="updateStreamViewSetting(this.checked)"
9179
+ aria-label="Stream view: thoughts, tool calls and the reply in order"
9180
+ >
9181
+ </div>
9182
+ <div class="settings-help settings-gap-xs">Turns recorded before this version keep the grouped layout.</div>
9183
+ <div class="settings-row settings-gap">
9184
+ <label for="show-suspend-host-toggle">Show Suspend host action</label>
9185
+ <input
9186
+ id="show-suspend-host-toggle"
9187
+ aria-describedby="appearance-browser-note"
9188
+ type="checkbox"
9189
+ onchange="updateShowSuspendHostSetting(this.checked)"
9190
+ aria-label="Show Suspend host action"
9191
+ checked
9192
+ >
9193
+ </div>
9194
+ <div class="settings-help settings-gap-xs">Shows or hides the menu action in this browser only. Suspend works only when the relay runs on Windows; a queued suspend still shows its banner here either way.</div>
9195
+ </div>
9207
9196
  <div
9208
9197
  id="settings-panel-providers"
9209
9198
  class="settings-panel"
@@ -9936,11 +9925,16 @@
9936
9925
  hidden
9937
9926
  >
9938
9927
  <div id="remote-relays-self-section" class="settings-row settings-row-column settings-gap">
9939
- <label>This relay</label>
9940
- <div class="remote-relays-self-name-row">
9941
- <span id="remote-relays-self-name" class="remote-relays-self-name">—</span>
9942
- <span class="settings-help">Change it under General → Relay name.</span>
9943
- </div>
9928
+ <label for="pwa-app-name-input">Relay name</label>
9929
+ <input
9930
+ id="pwa-app-name-input"
9931
+ type="text"
9932
+ maxlength="60"
9933
+ placeholder="OAR"
9934
+ aria-label="Relay name"
9935
+ onchange="updatePwaAppName(this.value)"
9936
+ >
9937
+ <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. A setting of this relay.</div>
9944
9938
  </div>
9945
9939
  <div class="settings-row settings-row-column settings-gap">
9946
9940
  <label for="remote-relays-public-url-input">Public address</label>
@@ -9958,6 +9952,30 @@
9958
9952
  <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>
9959
9953
  <div id="remote-relays-self-status" class="settings-help settings-provider-status" role="status" aria-live="polite" hidden></div>
9960
9954
  </div>
9955
+ <div class="settings-row settings-gap">
9956
+ <label for="restart-resume-toggle">After a relay restart, tell interrupted sessions to carry on</label>
9957
+ <input
9958
+ id="restart-resume-toggle"
9959
+ type="checkbox"
9960
+ onchange="updateRestartResumeSettingFromToggle(this.checked)"
9961
+ aria-label="After a relay restart, tell interrupted sessions to carry on"
9962
+ >
9963
+ </div>
9964
+ <div class="settings-help settings-gap-xs">Most turns a restart interrupts run again by themselves. A turn that cannot (the agent had already taken the message, or had started on its own after a background task) just stops. With this on, such a session gets one relay note once the relay is back, saying when it restarted and asking the agent to check what it was doing and carry on. Your message is never sent again; idle, archived and usage-limit-paused sessions get nothing. Off by default; a setting of this relay.</div>
9965
+ <div class="settings-row settings-gap">
9966
+ <label for="relay-restart-max-wait-select">Ask after a queued relay restart has waited</label>
9967
+ <select id="relay-restart-max-wait-select" onchange="updateRelayRestartMaxWaitSetting(this.value)" aria-label="Ask after a queued relay restart has waited">
9968
+ <option value="5">5 min</option>
9969
+ <option value="10">10 min</option>
9970
+ <option value="15">15 min</option>
9971
+ <option value="30" selected>30 min</option>
9972
+ <option value="60">1 h</option>
9973
+ <option value="120">2 h</option>
9974
+ <option value="240">4 h</option>
9975
+ <option value="0">No limit</option>
9976
+ </select>
9977
+ </div>
9978
+ <div class="settings-help settings-gap-xs">A queued relay restart waits for the running turns, and new messages wait for the restart. After this long the banner asks whether to restart now, keep waiting or cancel; it never restarts while the turns run, and still restarts as soon as they end. No limit waits until the turns end. A setting of this relay.</div>
9961
9979
  <div class="settings-row settings-gap">
9962
9980
  <label for="remote-relays-inbound-toggle">Accept prompts from other relays' agents</label>
9963
9981
  <input
@@ -21,6 +21,51 @@ function normalizeBoardText(value, fallback = '') {
21
21
  return text.slice(0, 12_000);
22
22
  }
23
23
 
24
+ // A plan is shown whole on its card: the old shared limit of 12,000 characters
25
+ // cut a longer one in mid-sentence and left no mark. Past this size the card
26
+ // says how much is missing.
27
+ const BOARD_BODY_MAX_CHARS = 200_000;
28
+
29
+ /**
30
+ * The code fence still open at the end of `text`, as the characters that
31
+ * close it, or ''. By the Markdown rules: a fence is three or more backticks
32
+ * or tildes behind at most three spaces (a backtick fence's info string holds
33
+ * no backtick); it is closed by a line of the same character, at least as
34
+ * long, with nothing but blanks behind it. Lines inside it open nothing.
35
+ */
36
+ function openCodeFence(text) {
37
+ let open = null;
38
+ // A plan file read on Windows ends its lines with CRLF.
39
+ for (const line of text.split(/\r?\n/)) {
40
+ const fence = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line);
41
+ if (!fence) continue;
42
+ const [, run, rest] = fence;
43
+ if (!open) {
44
+ if (run[0] === '`' && rest.includes('`')) continue;
45
+ open = { char: run[0], length: run.length };
46
+ } else if (run[0] === open.char && run.length >= open.length && !rest.trim()) {
47
+ open = null;
48
+ }
49
+ }
50
+ return open ? open.char.repeat(open.length) : '';
51
+ }
52
+
53
+ function normalizeBoardBody(value, context) {
54
+ const text = String(value || '').trim();
55
+ if (text.length <= BOARD_BODY_MAX_CHARS) return text;
56
+ const lineBreak = text.lastIndexOf('\n', BOARD_BODY_MAX_CHARS);
57
+ let kept = text.slice(0, lineBreak > BOARD_BODY_MAX_CHARS / 2 ? lineBreak : BOARD_BODY_MAX_CHARS);
58
+ // Not half a character.
59
+ if (/[\uD800-\uDBFF]$/.test(kept)) kept = kept.slice(0, -1);
60
+ kept = kept.trimEnd();
61
+ // A cut inside a code block would put the note into the code: close the block first.
62
+ const fence = openCodeFence(kept);
63
+ const openFence = fence ? `\n${fence}` : '';
64
+ const planFilePath = String(context?.planFilePath || '').trim();
65
+ const where = planFilePath ? `; the whole plan is in \`${planFilePath}\`` : '';
66
+ return `${kept}${openFence}\n\n… cut here: ${text.length - kept.length} more characters${where}.`;
67
+ }
68
+
24
69
  function normalizeBoardType(value) {
25
70
  const text = String(value || '').trim().toLowerCase();
26
71
  if (!text) return 'generic';
@@ -197,7 +242,7 @@ export function registerRelayBoardRoutes(app, deps) {
197
242
  normalizedBoardType,
198
243
  relayMode,
199
244
  normalizeBoardText(title, 'Plan ready for review'),
200
- normalizeBoardText(body, ''),
245
+ normalizeBoardBody(body, boardContext),
201
246
  normalizedActions.length ? JSON.stringify(normalizedActions) : null,
202
247
  normalizedRecommended,
203
248
  boardContext ? JSON.stringify(boardContext) : null,
@@ -44,7 +44,7 @@ export function registerRemoteRelayRoutes(app, deps) {
44
44
  app.get('/api/remote-relays', auth, (_req, res) => {
45
45
  res.json({
46
46
  relays: registry.listPublic(),
47
- self: { ...registry.selfIdentity(), ...registry.getSelfSettings() },
47
+ self: registry.selfPayload(),
48
48
  });
49
49
  });
50
50
 
@@ -2401,6 +2401,8 @@ function setPwaAppName(rawValue) {
2401
2401
  } else {
2402
2402
  stmts.deleteAppSetting.run(PWA_APP_NAME_SETTING_KEY);
2403
2403
  }
2404
+ // Settings open on another device show the new name (Settings › Relays).
2405
+ try { remoteRelayRegistry.notifySelfChanged(); } catch {}
2404
2406
  return { ok: true, appName: normalized.value };
2405
2407
  }
2406
2408
 
@@ -4,6 +4,7 @@ import fs from 'fs';
4
4
  import path from 'path';
5
5
  import { fileURLToPath } from 'url';
6
6
 
7
+ import { renderMathInstructionBlock } from '../../shared/math-instructions.mjs';
7
8
  import { renderMediaEmbedInstructionBlock } from '../../shared/media-embed-instructions.mjs';
8
9
 
9
10
  const DEFAULT_RELAY_TOOL_GUIDANCE = [
@@ -148,6 +149,11 @@ const MEDIA_EMBED_BLOCK_PATTERN = new RegExp(
148
149
  whitespaceFlexiblePattern(renderMediaEmbedInstructionBlock()),
149
150
  'gi',
150
151
  );
152
+ // The formula guidance (shared/math-instructions.mjs) rides right behind it.
153
+ const MATH_BLOCK_PATTERN = new RegExp(
154
+ whitespaceFlexiblePattern(renderMathInstructionBlock()),
155
+ 'gi',
156
+ );
151
157
 
152
158
  // A steered message's hidden note (shared/steer-note.mjs) leads the prompt the
153
159
  // runtime stored, right after any mode marker. Matched by its stable opening
@@ -167,6 +173,7 @@ function stripLeadingRelayNotes(text) {
167
173
  export function stripRelayPromptContext(text, relayMode = '', attachments = []) {
168
174
  const value = stripAttachmentPromptArtifacts(text)
169
175
  .replace(MEDIA_EMBED_BLOCK_PATTERN, '')
176
+ .replace(MATH_BLOCK_PATTERN, '')
170
177
  .trim();
171
178
  if (!value) return '';
172
179
  const patterns = buildPromptPrefixPatterns(relayMode);
@@ -8,7 +8,7 @@
8
8
  * The request waits until no turn is processing (relay-shutdown-gate.mjs);
9
9
  * while it waits, no pending row is handed to a worker. A turn that runs for
10
10
  * hours would hold every session's new messages for hours, so the wait has a
11
- * maximum (Settings › General). Past it the relay does not force anything:
11
+ * maximum (Settings › Relays). Past it the relay does not force anything:
12
12
  * the request stays queued and turns into a question — restart now, keep
13
13
  * waiting another period, or cancel — that the banner shows and a push
14
14
  * notification announces once. Only the user, or an API caller that says
@@ -4127,6 +4127,24 @@ export function createRemoteRelayDispatcher({
4127
4127
  return null;
4128
4128
  }
4129
4129
 
4130
+ /**
4131
+ * Is `session` on `relay` the session whose agent created this conversation
4132
+ * (its origin, which an agent of this relay never sets for a paired one)?
4133
+ */
4134
+ function sendsToCreator(conversationId, relay, session) {
4135
+ if (relay.local === true || !toText(relay.relayId) || !toText(session)) return false;
4136
+ let origin = null;
4137
+ try {
4138
+ origin = repository.getConversationOrigin?.(conversationId) || null;
4139
+ } catch {
4140
+ return false;
4141
+ }
4142
+ return !!origin && origin.local !== true
4143
+ && toText(origin.relayId) === toText(relay.relayId)
4144
+ && !!toText(origin.conversationId)
4145
+ && toText(origin.conversationId) === toText(session);
4146
+ }
4147
+
4130
4148
  async function runRelayAction({ conversationId, action, args, req, signal, waitNote }) {
4131
4149
  const target = await resolveTarget(args.relay);
4132
4150
  if (!target.relay) return target.failure;
@@ -4189,7 +4207,11 @@ export function createRemoteRelayDispatcher({
4189
4207
  const origins = Array.isArray(caller.originRelayIds) ? caller.originRelayIds.map(toText) : [];
4190
4208
  const answering = !!toText(relay.relayId) && origins.length > 0
4191
4209
  && origins.every((id) => id === toText(relay.relayId));
4192
- if (write && access !== 'user' && !answering && hops > REMOTE_RELAY_LIMITS.hopLimit) {
4210
+ // A session another relay's agent created may always send to the session
4211
+ // that created it, whatever else its turn acts on (a prompt of a session
4212
+ // on this relay, say): its reports go back the way its work came.
4213
+ if (write && access !== 'user' && !answering && hops > REMOTE_RELAY_LIMITS.hopLimit
4214
+ && !(action === 'send' && sendsToCreator(conversationId, relay, args.session))) {
4193
4215
  return finish(failure(403, REMOTE_RELAY_ERROR_CODES.hopLimit,
4194
4216
  `This turn acts on a prompt from another relay's agent (hop ${hops - 1}); passing it on to relay "${relay.name}" would pass the limit of ${REMOTE_RELAY_LIMITS.hopLimit} hops. The user can allow it by mentioning @${relay.name} in this conversation.`,
4195
4217
  { relay: relay.name }));
@@ -232,8 +232,10 @@ export function createRemoteRelayRegistry({
232
232
  };
233
233
  }
234
234
 
235
+ // For this relay's own Settings only, never for other relays: `defaultName`
236
+ // is the name it goes by while the relay name is empty.
235
237
  function selfPayload() {
236
- return { ...selfIdentity(), ...getSelfSettings() };
238
+ return { ...selfIdentity(), ...getSelfSettings(), defaultName: toText(hostname) || 'OAR' };
237
239
  }
238
240
 
239
241
  function emitChange() {
@@ -622,8 +624,12 @@ export function createRemoteRelayRegistry({
622
624
  instanceId,
623
625
  selfName,
624
626
  selfIdentity,
627
+ selfPayload,
625
628
  getSelfSettings,
626
629
  setSelfSettings,
630
+ // The relay name lives outside the registry: its setter calls this so
631
+ // every open Settings sees the new name.
632
+ notifySelfChanged: emitChange,
627
633
  getAgentSessionsSettings,
628
634
  setAgentSessionsSettings,
629
635
  list,
@@ -25,13 +25,24 @@ function toPidList(rows, extraPid) {
25
25
  // read: the caller must not pretend it stopped what it could not see. The
26
26
  // registry's pid counts only while that process is alive: a worker that is
27
27
  // gone is stopped already (on Windows its stop call alone could fail).
28
+ //
29
+ // On POSIX the list is every process with its command line, so a live worker
30
+ // of the session is on it as long as its argv carries the session id within
31
+ // ps's width (procps-ng cuts `args` at 128 KiB even with -ww), and the
32
+ // registry's pid counts only when it is: a pid that is alive but not listed
33
+ // is no worker of this session. It is
34
+ // the old worker's pid handed to another process (another user's, which no
35
+ // signal reaches, or one of the relay user's own, which the stop would
36
+ // kill), or the worker ended and its parent has not reaped it yet (a zombie
37
+ // answers signal 0 and has no command line left).
28
38
  async function enumeratePids({ sdkSessionId, worker, processInspector, platform, isPidAliveImpl }) {
29
39
  const rows = platform === 'win32'
30
40
  ? await processInspector?.findWindowsProcessTreeForSessionAsync?.(sdkSessionId, { fresh: true })
31
41
  : await processInspector?.findProcessesForSessionAsync?.(sdkSessionId, { fresh: true });
32
42
  const listed = toPidList(rows || []);
33
43
  const workerPid = Number(worker?.pid);
34
- const workerAlive = Number.isInteger(workerPid) && workerPid > 0 && (listed.includes(workerPid) || isPidAliveImpl(workerPid));
44
+ const workerAlive = Number.isInteger(workerPid) && workerPid > 0
45
+ && (listed.includes(workerPid) || (platform === 'win32' && isPidAliveImpl(workerPid)));
35
46
  return workerAlive ? toPidList(rows || [], workerPid) : listed;
36
47
  }
37
48
 
@@ -98,6 +109,36 @@ export async function stopSessionWorkerProcesses({
98
109
 
99
110
  const survivors = (list) => list.filter((pid) => isPidAliveImpl(pid));
100
111
 
112
+ // POSIX: a signal no process takes (ESRCH) is ignored; one it is not ours
113
+ // to send (EPERM: another user's process) is remembered and the others
114
+ // still get theirs; the re-check below decides about that pid.
115
+ const refused = new Map();
116
+ const signal = (pid, name) => {
117
+ try {
118
+ killImpl(pid, name);
119
+ } catch (error) {
120
+ const code = String(error?.code || '');
121
+ if (code === 'EPERM') refused.set(pid, error);
122
+ else if (code !== 'ESRCH') throw error;
123
+ }
124
+ };
125
+
126
+ // POSIX: a pid that outlived the stop counts only while a fresh list still
127
+ // shows it as the session's. The list matches command lines, so a match
128
+ // can be a process that was ending anyway (a shell that carried the
129
+ // session's arguments, another user's one included), and a process that
130
+ // ended but is not reaped yet (a zombie) still answers signal 0 with no
131
+ // command line left. A list that cannot be read changes nothing.
132
+ const stillListed = async (list) => {
133
+ if (platform === 'win32' || !list.length) return list;
134
+ try {
135
+ const fresh = await enumeratePids({ sdkSessionId: sid, worker, processInspector, platform, isPidAliveImpl });
136
+ return list.filter((pid) => fresh.includes(pid) && isPidAliveImpl(pid));
137
+ } catch {
138
+ return list;
139
+ }
140
+ };
141
+
101
142
  const waitFor = async (list, timeoutMs) => {
102
143
  const deadline = nowMs() + Math.max(0, Number(timeoutMs) || 0);
103
144
  let remaining = survivors(list);
@@ -114,13 +155,7 @@ export async function stopSessionWorkerProcesses({
114
155
  // Stop-Process -Force, so there is no gentler first pass to attempt.
115
156
  await stopWindowsPids(processInspector, pids);
116
157
  } else {
117
- for (const pid of pids) {
118
- try {
119
- killImpl(pid, 'SIGTERM');
120
- } catch (error) {
121
- if (String(error?.code || '') !== 'ESRCH') throw error;
122
- }
123
- }
158
+ for (const pid of pids) signal(pid, 'SIGTERM');
124
159
  // The tmux session can outlive its child after SIGTERM.
125
160
  killTmuxSessionImpl?.(sid);
126
161
  }
@@ -140,13 +175,7 @@ export async function stopSessionWorkerProcesses({
140
175
  escalationTargets = [...new Set([...remainingPids, ...rediscovered.filter((pid) => isPidAliveImpl(pid))])];
141
176
  await stopWindowsPids(processInspector, escalationTargets);
142
177
  } else {
143
- for (const pid of escalationTargets) {
144
- try {
145
- killImpl(pid, 'SIGKILL');
146
- } catch (error) {
147
- if (String(error?.code || '') !== 'ESRCH') throw error;
148
- }
149
- }
178
+ for (const pid of escalationTargets) signal(pid, 'SIGKILL');
150
179
  }
151
180
  } catch (error) {
152
181
  return done({
@@ -158,15 +187,18 @@ export async function stopSessionWorkerProcesses({
158
187
  });
159
188
  }
160
189
 
161
- remainingPids = await waitFor(escalationTargets, escalationTimeoutMs);
190
+ remainingPids = await stillListed(await waitFor(escalationTargets, escalationTimeoutMs));
162
191
  if (remainingPids.length) {
192
+ // A refused signal is the reason only when every survivor refused one;
193
+ // a survivor that took its signals and lives on is a timeout.
194
+ const refusal = remainingPids.every((pid) => refused.has(pid)) ? refused.get(remainingPids[0]) : null;
163
195
  return done({
164
196
  ok: false,
165
197
  pids,
166
198
  remainingPids,
167
199
  escalated: true,
168
- timedOut: true,
169
- error: `worker-stop-timeout:${remainingPids.join(',')}`,
200
+ timedOut: !refusal,
201
+ error: refusal ? (refusal.message || 'Failed to stop the CLI') : `worker-stop-timeout:${remainingPids.join(',')}`,
170
202
  });
171
203
  }
172
204
  return done({ pids, escalated: true });