@oar-sh/oar 0.9.17 → 0.9.19

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 (104) hide show
  1. package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +13 -1
  2. package/.github/extensions/web-relay/skills/tool-activity.mjs +5 -4
  3. package/CHANGELOG.md +234 -0
  4. package/README.md +13 -12
  5. package/package.json +1 -1
  6. package/server/claude-api-worker/claude-api-backend.mjs +345 -0
  7. package/server/claude-api-worker/claude-api-session-worker.mjs +29 -0
  8. package/server/claude-worker/claude-cli-pinned-env.mjs +150 -0
  9. package/server/claude-worker/claude-remote-relay-tool.mjs +5 -5
  10. package/server/claude-worker/claude-sdk-adapter.mjs +35 -9
  11. package/server/claude-worker/claude-session-process.mjs +166 -21
  12. package/server/claude-worker/claude-session-worker-main.mjs +3 -0
  13. package/server/claude-worker/claude-subscription-key-check.mjs +64 -0
  14. package/server/claude-worker/claude-turn-publisher.mjs +117 -5
  15. package/server/claude-worker/claude-usage-totals.mjs +53 -0
  16. package/server/claude-worker/sdk-message-normalizer.mjs +1 -1
  17. package/server/copilot-worker/copilot-remote-relay-tool.mjs +2 -2
  18. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +1 -1
  19. package/server/copilot-worker/copilot-sdk-session-process.mjs +9 -9
  20. package/server/cursor-worker/cursor-remote-relay-tool.mjs +3 -3
  21. package/server/cursor-worker/cursor-turn-runner.mjs +4 -4
  22. package/server/cursor-worker/sdk-message-normalizer.mjs +2 -2
  23. package/server/db-schema.mjs +33 -1
  24. package/server/deepseek-worker/deepseek-claude-backend.mjs +14 -130
  25. package/server/grok-worker/acp-client.mjs +1 -1
  26. package/server/grok-worker/grok-turn-runner.mjs +4 -4
  27. package/server/grok-worker/sdk-message-normalizer.mjs +1 -1
  28. package/server/mcp/oar-mcp-server.mjs +11 -5
  29. package/server/migrations/0031-queue-provider.mjs +143 -0
  30. package/server/migrations/0032-runtime-session-billing-switch.mjs +94 -0
  31. package/server/migrations/0033-claude-api-usage-checkpoints.mjs +138 -0
  32. package/server/migrations/0034-claude-api-usage-totals.mjs +112 -0
  33. package/server/public/app/activity-replay-state.mjs +4 -3
  34. package/server/public/app/agent-sessions-settings-ui.js +1 -1
  35. package/server/public/app/agent-sessions-settings.mjs +1 -1
  36. package/server/public/app/api-client.js +52 -0
  37. package/server/public/app/bootstrap.js +153 -27
  38. package/server/public/app/claude-api-settings-ui.js +362 -0
  39. package/server/public/app/claude-billing-toggle-ui.js +224 -0
  40. package/server/public/app/claude-billing-toggle.mjs +112 -0
  41. package/server/public/app/context-tier-options.mjs +6 -2
  42. package/server/public/app/context-usage-view.mjs +1 -1
  43. package/server/public/app/conversation-provider-indicator.mjs +11 -0
  44. package/server/public/app/conversation-view.js +29 -3
  45. package/server/public/app/inbox-view.mjs +1 -1
  46. package/server/public/app/journal-view.js +127 -10
  47. package/server/public/app/model-selector-options.mjs +17 -1
  48. package/server/public/app/new-conversation-model-choice.mjs +21 -0
  49. package/server/public/app/provider-readiness.mjs +5 -2
  50. package/server/public/app/relay-error-ctas.mjs +50 -0
  51. package/server/public/app/relay-schedules-ui.js +1 -1
  52. package/server/public/app/settings-modal.js +11 -0
  53. package/server/public/app/settings-tabs.js +1 -1
  54. package/server/public/app/socket-handlers.js +18 -1
  55. package/server/public/app/tool-activity-emoji.mjs +4 -3
  56. package/server/public/app/usage-limit-ui.js +136 -5
  57. package/server/public/index.html +189 -3
  58. package/server/relay-heap-guard.mjs +338 -0
  59. package/server/relay-self-restart.mjs +33 -7
  60. package/server/relay-tools.md +6 -6
  61. package/server/repositories/conversation-group-repository.mjs +1 -1
  62. package/server/repositories/message-repository.mjs +7 -0
  63. package/server/repositories/session-repository.mjs +8 -1
  64. package/server/routes/ask-user-routes.mjs +1 -1
  65. package/server/routes/conversation-group-routes.mjs +1 -1
  66. package/server/routes/messages-routes.mjs +678 -31
  67. package/server/routes/relay-ask-routes.mjs +3 -3
  68. package/server/routes/relay-event-routes.mjs +2 -2
  69. package/server/routes/relay-schedule-routes.mjs +1 -1
  70. package/server/routes/remote-relay-routes.mjs +3 -3
  71. package/server/routes/sessions-routes.mjs +365 -53
  72. package/server/server-runtime.mjs +373 -26
  73. package/server/services/about-service.mjs +1 -0
  74. package/server/services/background-work-view.mjs +2 -1
  75. package/server/services/claude-api-settings-service.mjs +575 -0
  76. package/server/services/claude-api-usage-service.mjs +361 -0
  77. package/server/services/claude-billing-swap-service.mjs +274 -0
  78. package/server/services/claude-billing-switch-service.mjs +343 -0
  79. package/server/services/plan-usage-claude-api.mjs +445 -0
  80. package/server/services/plan-usage-service.mjs +5 -0
  81. package/server/services/provider-model-selection.mjs +3 -2
  82. package/server/services/provider-reasoning-effort.mjs +9 -0
  83. package/server/services/relay-ask-service.mjs +1 -1
  84. package/server/services/relay-event-service.mjs +3 -2
  85. package/server/services/relay-schedule-service.mjs +1 -1
  86. package/server/services/remote-relay-caller-context.mjs +7 -0
  87. package/server/services/remote-relay-dispatcher.mjs +111 -17
  88. package/server/services/remote-relay-registry-service.mjs +1 -1
  89. package/server/services/session-worker-launch-service.mjs +61 -2
  90. package/server/services/session-worker-registry-service.mjs +28 -1
  91. package/server/services/session-worker-websocket-service.mjs +17 -0
  92. package/server/services/usage-limit-pause-service.mjs +55 -4
  93. package/server/services/worker-secret-redaction.mjs +2 -0
  94. package/shared/anthropic-api.mjs +202 -0
  95. package/shared/provider-routing.mjs +5 -3
  96. package/shared/relay-asks.mjs +11 -5
  97. package/shared/relay-events.mjs +2 -2
  98. package/shared/relay-schedules.mjs +2 -2
  99. package/shared/remote-relay-contract.mjs +54 -12
  100. package/shared/remote-relay-tool-core.mjs +23 -6
  101. package/shared/steer-settle-failure.mjs +1 -0
  102. package/shared/worker-runtime/api-client.mjs +2 -2
  103. package/shared/worker-runtime/heartbeat.mjs +5 -0
  104. package/shared/worker-runtime/worker-websocket-link.mjs +6 -0
@@ -297,6 +297,8 @@
297
297
  --provider-claude-cloud: #7dd3fc;
298
298
  /* DeepSeek's blue, toward indigo so it reads apart from Copilot's. */
299
299
  --provider-deepseek: #8b9dff;
300
+ /* Claude on an API key: gold, beside the subscription's terracotta. */
301
+ --provider-claude-api: #e3b341;
300
302
  /* The usage charts' series, in a fixed order (blue, orange, green,
301
303
  amber; grey for "other"), stepped for the dark surface and checked
302
304
  for colour-vision deficiency and contrast against it. */
@@ -370,6 +372,7 @@
370
372
  --provider-grok: #a21caf;
371
373
  --provider-claude-cloud: #0369a1;
372
374
  --provider-deepseek: #3b5bdb;
375
+ --provider-claude-api: #8a6100;
373
376
  /* The same series order, stepped for the white surface. */
374
377
  --plan-usage-series-1: #2a78d6;
375
378
  --plan-usage-series-2: #eb6834;
@@ -1006,6 +1009,7 @@
1006
1009
  .conv-provider-indicator[data-provider="grok"] { color: var(--provider-grok); }
1007
1010
  .conv-provider-indicator[data-provider="deepseek"] { color: var(--provider-deepseek); }
1008
1011
  .conv-provider-indicator[data-provider="claude-cloud"] { color: var(--provider-claude-cloud); }
1012
+ .conv-provider-indicator[data-provider="claude-api"] { color: var(--provider-claude-api); }
1009
1013
  .conv-item.worker-ui-offline .conv-title,
1010
1014
  #chat-title[data-ui-state="offline"] { color: var(--conversation-title-offline); }
1011
1015
  /* Ready and idle: the darker green. Ready and working (a turn or
@@ -1492,6 +1496,44 @@
1492
1496
  #settings-provider-panel-deepseek select {
1493
1497
  max-width: 100%;
1494
1498
  }
1499
+ /* Its links in the accent colour, as the usage card's: the browser's own
1500
+ blue is unreadable on the dark panel. */
1501
+ #settings-provider-panel-claude-api .settings-help a { color: var(--accent); text-decoration: none; }
1502
+ #settings-provider-panel-claude-api .settings-help a:hover { text-decoration: underline; }
1503
+ /* The Claude API tab's key and cap: the same text-input look. */
1504
+ #settings-provider-panel-claude-api input[type="password"],
1505
+ #settings-provider-panel-claude-api input[type="number"] {
1506
+ background: var(--bg);
1507
+ color: var(--text);
1508
+ border: 1px solid var(--border);
1509
+ border-radius: 6px;
1510
+ padding: 6px 12px;
1511
+ outline: none;
1512
+ font-size: var(--fs-meta-md);
1513
+ width: min(360px, 100%);
1514
+ }
1515
+ #settings-provider-panel-claude-api select {
1516
+ max-width: 100%;
1517
+ }
1518
+ /* A cap the tab refuses: said under the field, which is marked too. */
1519
+ /* Two classes: .settings-help, later in this sheet, would grey it out. */
1520
+ .settings-help.claude-api-field-error { color: var(--danger); }
1521
+ .settings-help.claude-api-field-error[hidden] { display: none; }
1522
+ /* The native spin buttons of a number field follow the theme: light ones
1523
+ on a dark field read as a glitch. */
1524
+ :root:not([data-theme="light"]) input[type="number"] { color-scheme: dark; }
1525
+ #settings-provider-panel-claude-api input[aria-invalid="true"] { border-color: var(--danger); }
1526
+ /* Three buttons: they wrap on a phone instead of pushing the panel wide. */
1527
+ .claude-api-actions {
1528
+ display: flex;
1529
+ flex-wrap: wrap;
1530
+ gap: 8px;
1531
+ margin-top: 8px;
1532
+ }
1533
+ .claude-api-shared-note .summary-btn {
1534
+ margin-left: 6px;
1535
+ padding: 2px 10px;
1536
+ }
1495
1537
  /* "No longer used" under the starting balance: the warning look of the
1496
1538
  composer's model banner, apart from the help text above it. */
1497
1539
  #deepseek-starting-balance-status {
@@ -1562,9 +1604,9 @@
1562
1604
  font-size: var(--fs-meta-sm);
1563
1605
  line-height: 1.35;
1564
1606
  }
1565
- .settings-provider-status[data-state="active"] { color: #3fb950; }
1607
+ .settings-provider-status[data-state="active"] { color: var(--success); }
1566
1608
  .settings-provider-status[data-state="saved"] { color: var(--accent); }
1567
- .settings-provider-status[data-state="error"] { color: #f85149; }
1609
+ .settings-provider-status[data-state="error"] { color: var(--danger); }
1568
1610
  /* These rows are laid out as flex and hidden from script via `.hidden = true`.
1569
1611
  Their `display: flex` lives HERE rather than in a style="" attribute on
1570
1612
  purpose: an inline declaration outranks every selector-based rule (short of
@@ -3335,6 +3377,9 @@
3335
3377
  box-shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
3336
3378
  pointer-events: none;
3337
3379
  }
3380
+ /* The light theme's --warn on the toast's tinted ground is about 3.6:1;
3381
+ its text takes a darker amber (about 5.6:1), the border keeps --warn. */
3382
+ :root[data-theme="light"] #relay-toast { color: #7d4e00; }
3338
3383
  #relay-toast.visible { display: block; }
3339
3384
  #relay-toast.has-action { pointer-events: auto; }
3340
3385
  .relay-toast-action {
@@ -3440,6 +3485,48 @@
3440
3485
  #model-lock-note[data-provider="grok"] { color: var(--provider-grok); }
3441
3486
  #model-lock-note[data-provider="deepseek"] { color: var(--provider-deepseek); }
3442
3487
  #model-lock-note[data-provider="claude-cloud"] { color: var(--provider-claude-cloud); }
3488
+ #model-lock-note[data-provider="claude-api"] { color: var(--provider-claude-api); }
3489
+ /* The billing toggle of a Claude chat: a two-way switch in the provider
3490
+ colours, with what a pending switch waits for beside it. It wraps under
3491
+ the switch on a phone instead of widening the composer. */
3492
+ .claude-billing-toggle {
3493
+ display: flex;
3494
+ flex-wrap: wrap;
3495
+ align-items: center;
3496
+ gap: 4px 10px;
3497
+ margin-bottom: 6px;
3498
+ min-width: 0;
3499
+ font-size: var(--fs-meta-sm);
3500
+ }
3501
+ .claude-billing-toggle[hidden] { display: none; }
3502
+ .claude-billing-label { color: var(--muted); font-weight: 600; }
3503
+ .claude-billing-options {
3504
+ display: inline-flex;
3505
+ border: 1px solid var(--border);
3506
+ border-radius: 999px;
3507
+ overflow: hidden;
3508
+ flex: 0 0 auto;
3509
+ }
3510
+ .claude-billing-option {
3511
+ border: 0;
3512
+ background: transparent;
3513
+ color: var(--muted);
3514
+ font: inherit;
3515
+ font-weight: 600;
3516
+ padding: 3px 12px;
3517
+ min-height: 26px;
3518
+ cursor: pointer;
3519
+ }
3520
+ .claude-billing-option + .claude-billing-option { border-left: 1px solid var(--border); }
3521
+ .claude-billing-option[aria-pressed="true"] { cursor: default; color: var(--bg); }
3522
+ .claude-billing-option[aria-pressed="true"][data-billing="claude"] { background: var(--provider-claude); }
3523
+ .claude-billing-option[aria-pressed="true"][data-billing="claude-api"] { background: var(--provider-claude-api); }
3524
+ .claude-billing-option[aria-disabled="true"] { opacity: 0.55; cursor: not-allowed; }
3525
+ .claude-billing-option:not([aria-pressed="true"]):not([aria-disabled="true"]):hover { color: var(--text); }
3526
+ .claude-billing-option:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
3527
+ .claude-billing-status { flex: 1 1 14em; min-width: 0; color: var(--muted); line-height: 1.4; }
3528
+ .claude-billing-status:empty { display: none; }
3529
+ .claude-billing-status[data-kind="warning"] { color: var(--warn); }
3443
3530
  /* Claude Cloud conversations (claude-cloud-conversation-ui.js): where the
3444
3531
  session runs and what it pushed, above the composer like the lock note.
3445
3532
  The items wrap instead of scrolling, so a long branch name can never push
@@ -4345,6 +4432,7 @@
4345
4432
  body.shared-reader-mode #reasoning-effort-select,
4346
4433
  body.shared-reader-mode #send-btn,
4347
4434
  body.shared-reader-mode #model-lock-note,
4435
+ body.shared-reader-mode #claude-billing-toggle,
4348
4436
  body.shared-reader-mode #cloud-session-line,
4349
4437
  body.shared-reader-mode #model-pricing-details {
4350
4438
  display: none !important;
@@ -4831,6 +4919,14 @@
4831
4919
  min-height: 42px;
4832
4920
  margin-top: 8px;
4833
4921
  }
4922
+ /* The selects take the inputs' look too: left to the browser they stay a
4923
+ white box with black text in the dark theme. */
4924
+ #new-conversation-provider-select,
4925
+ #new-conversation-model-select,
4926
+ #new-conversation-reasoning-select,
4927
+ #new-conversation-context-select,
4928
+ #new-conversation-size-select,
4929
+ #new-conversation-cwd-select,
4834
4930
  #new-conversation-cwd-manual,
4835
4931
  #new-conversation-cloud-repo,
4836
4932
  #new-conversation-cloud-branch {
@@ -4842,6 +4938,18 @@
4842
4938
  color: var(--text);
4843
4939
  }
4844
4940
  #new-conversation-cwd-manual[hidden] { display: none; }
4941
+ /* With the inputs' colours a disabled select (the read-only context row,
4942
+ the effort before a model is chosen) would look editable: dimmed as in
4943
+ the composer. */
4944
+ #new-conversation-provider-select:disabled,
4945
+ #new-conversation-model-select:disabled,
4946
+ #new-conversation-reasoning-select:disabled,
4947
+ #new-conversation-context-select:disabled,
4948
+ #new-conversation-size-select:disabled,
4949
+ #new-conversation-cwd-select:disabled {
4950
+ opacity: 0.55;
4951
+ cursor: not-allowed;
4952
+ }
4845
4953
  #new-conversation-cloud-repo[aria-invalid="true"],
4846
4954
  #new-conversation-cloud-branch[aria-invalid="true"] { border-color: var(--danger); }
4847
4955
  /* Warning lines under Repository/Branch: what the cloud clone will not see. */
@@ -5214,6 +5322,13 @@
5214
5322
  line-height: 1.5;
5215
5323
  }
5216
5324
  .new-conversation-setup-note[hidden] { display: none; }
5325
+ .new-conversation-refusal { border-left-color: var(--warn); }
5326
+ .new-conversation-refusal-actions {
5327
+ display: flex;
5328
+ flex-wrap: wrap;
5329
+ gap: 8px;
5330
+ margin-top: 8px;
5331
+ }
5217
5332
  .summary-close {
5218
5333
  width: 32px;
5219
5334
  padding: 0;
@@ -7378,6 +7493,8 @@
7378
7493
  <div id="model-banner"></div>
7379
7494
  <div id="usage-limit-banner" role="status" aria-live="polite" hidden></div>
7380
7495
  <div id="model-lock-note" hidden></div>
7496
+ <!-- Subscription | API key in a Claude chat (claude-billing-toggle-ui.js). -->
7497
+ <div id="claude-billing-toggle" class="claude-billing-toggle" hidden></div>
7381
7498
  <div id="cloud-session-line" aria-label="Cloud session" hidden></div>
7382
7499
  <div id="model-metadata-blocker" aria-live="polite">
7383
7500
  <span id="model-metadata-blocker-text">Model metadata is unavailable. Refresh to choose a model and reasoning effort.</span>
@@ -7847,6 +7964,16 @@
7847
7964
  tabindex="-1"
7848
7965
  data-settings-provider-tab="claude"
7849
7966
  >Claude</button>
7967
+ <button
7968
+ id="settings-provider-tab-claude-api"
7969
+ class="settings-tab settings-subtab"
7970
+ type="button"
7971
+ role="tab"
7972
+ aria-selected="false"
7973
+ aria-controls="settings-provider-panel-claude-api"
7974
+ tabindex="-1"
7975
+ data-settings-provider-tab="claude-api"
7976
+ >Claude API</button>
7850
7977
  <button
7851
7978
  id="settings-provider-tab-claude-cloud"
7852
7979
  class="settings-tab settings-subtab"
@@ -8081,6 +8208,63 @@
8081
8208
  <div class="settings-help">Runs Claude conversations through the Claude Agent SDK using the relay host's logged-in Claude credentials (no API key stored). Disabling also rebinds conversations that have not sent a message yet.</div>
8082
8209
  </div>
8083
8210
  </div>
8211
+ <!-- Claude API (claude-api-settings-ui.js): Claude chats on a Claude Console
8212
+ API key. The key is write-only; the relay never sends it back. -->
8213
+ <div
8214
+ id="settings-provider-panel-claude-api"
8215
+ class="settings-panel"
8216
+ role="tabpanel"
8217
+ tabindex="0"
8218
+ aria-labelledby="settings-provider-tab-claude-api"
8219
+ data-settings-provider-panel="claude-api"
8220
+ hidden
8221
+ >
8222
+ <div class="settings-row settings-row-column" style="margin-top: 12px; margin-bottom: 0;">
8223
+ <label for="claude-api-key-input">Claude API</label>
8224
+ <div class="settings-row" style="width: 100%; margin-bottom: 8px;">
8225
+ <label for="claude-api-enabled-toggle">Enable Claude API for New Chat and the billing switch</label>
8226
+ <input id="claude-api-enabled-toggle" type="checkbox" onchange="toggleClaudeApiProvider(this.checked)" disabled>
8227
+ </div>
8228
+ <input
8229
+ id="claude-api-key-input"
8230
+ type="password"
8231
+ autocomplete="new-password"
8232
+ autocapitalize="off"
8233
+ spellcheck="false"
8234
+ placeholder="Claude Console API key…"
8235
+ aria-label="Claude API key"
8236
+ aria-describedby="claude-api-key-help"
8237
+ >
8238
+ <div id="claude-api-key-help" class="settings-help">An API key from the Claude Console (<a href="https://platform.claude.com/settings/keys" target="_blank" rel="noopener noreferrer">platform.claude.com → API keys</a>). It is stored on the relay host, never sent back to a browser, and used only for Claude API chats and for listing the key's models. Claude login tokens and Admin keys are refused.</div>
8239
+ <label for="claude-api-model-select" style="margin-top: 8px;">Default model</label>
8240
+ <select id="claude-api-model-select" aria-label="Claude API default model"></select>
8241
+ <div id="claude-api-models-status" class="settings-help" hidden></div>
8242
+ <label for="claude-api-monthly-cap-input" style="margin-top: 8px;">Monthly cap in US dollars (optional)</label>
8243
+ <input
8244
+ id="claude-api-monthly-cap-input"
8245
+ type="number"
8246
+ inputmode="decimal"
8247
+ min="0.01"
8248
+ max="1000000"
8249
+ step="0.01"
8250
+ placeholder="No cap"
8251
+ aria-label="Claude API monthly cap in US dollars"
8252
+ aria-describedby="claude-api-monthly-cap-error claude-api-monthly-cap-help"
8253
+ >
8254
+ <div id="claude-api-monthly-cap-error" class="settings-help claude-api-field-error" role="alert" hidden></div>
8255
+ <div id="claude-api-monthly-cap-help" class="settings-help">Once this relay's estimated Claude API spend of the calendar month reaches the cap, new API key turns and switches to the API key are refused; a running turn finishes. Leave it empty for no cap. Spend limits set in the Claude Console apply on top.</div>
8256
+ <div id="claude-api-settings-status" class="settings-help settings-provider-status">Loading Claude API settings…</div>
8257
+ <div class="claude-api-actions">
8258
+ <button id="claude-api-save-btn" type="button" class="summary-btn" onclick="saveClaudeApiSettings()">Save Claude API settings</button>
8259
+ <button id="claude-api-refresh-btn" type="button" class="summary-btn" onclick="refreshClaudeApiModels()" disabled>Refresh models</button>
8260
+ <button id="claude-api-remove-btn" type="button" class="summary-btn" onclick="removeClaudeApiSettings()" disabled>Remove saved key</button>
8261
+ </div>
8262
+ <div class="settings-help">Claude API chats run Claude Code on the relay host like Claude chats, on this key instead of the host's Claude login, and are billed to the Claude Console organisation the key belongs to. A Claude chat can switch between the subscription and the API key from its composer. Credit and spend limits are in the Console: <a href="https://platform.claude.com/settings/billing" target="_blank" rel="noopener noreferrer">billing</a>, <a href="https://platform.claude.com/settings/limits" target="_blank" rel="noopener noreferrer">limits</a>.</div>
8263
+ <div class="settings-help claude-api-shared-note">Commit attribution, the background command limit, the effort levels and the compaction window are shared with Claude chats and set on the Claude tab.
8264
+ <button id="claude-api-open-claude-tab-btn" type="button" class="summary-btn" onclick="openClaudeTabFromClaudeApi()">Claude tab</button>
8265
+ </div>
8266
+ </div>
8267
+ </div>
8084
8268
  <!-- Claude Cloud (claude-cloud-settings-ui.js). The login itself is managed on
8085
8269
  the Claude tab; this panel only says which account pays. -->
8086
8270
  <div
@@ -8373,7 +8557,7 @@
8373
8557
  <option value="CNY">CNY (¥)</option>
8374
8558
  <option value="">The balance's currency (may change)</option>
8375
8559
  </select>
8376
- <div class="settings-help">The usage card shows this relay's estimated DeepSeek spend of the calendar month (UTC) against the budget, in the budget's currency, amber near the budget and red over it. Leave the amount empty for no budget bar.</div>
8560
+ <div class="settings-help">The usage card shows this relay's estimated DeepSeek spend of the calendar month (UTC) against the budget, in the budget's currency. The bar changes colour at 50 % and 75 % of the budget and turns red at 90 %. Leave the amount empty for no budget bar.</div>
8377
8561
  <label for="deepseek-starting-balance-input" style="margin-top: 8px;">Starting balance (optional)</label>
8378
8562
  <input
8379
8563
  id="deepseek-starting-balance-input"
@@ -8758,6 +8942,8 @@
8758
8942
  Settings is the main button; Start stays as "anyway", because the
8759
8943
  Copilot signal can be wrong. -->
8760
8944
  <div id="new-conversation-setup-note" class="new-conversation-setup-note" role="status" hidden></div>
8945
+ <!-- Claude API refused to start the chat (journal-view.js). -->
8946
+ <div id="new-conversation-refusal" class="new-conversation-setup-note new-conversation-refusal" role="alert" hidden></div>
8761
8947
  <div class="summary-modal-actions">
8762
8948
  <button type="button" class="summary-btn" onclick="closeNewConversationModelModal()">Cancel</button>
8763
8949
  <button id="new-conversation-open-settings" type="button" class="summary-btn summary-btn-accent" hidden>Open Settings → Providers</button>
@@ -0,0 +1,338 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import v8 from 'v8';
5
+
6
+ import { resolveDataDir } from './services/data-dir-resolution.mjs';
7
+
8
+ // The config.json key an operator sets to pin the runtime's heap (MB). A key
9
+ // and not an env var: a shell's NODE_OPTIONS is lost on the next relaunch by
10
+ // systemd, an autostart launcher or the CLI extension; config.json is read by
11
+ // every one of them.
12
+ export const HEAP_LIMIT_CONFIG_KEY = 'heapLimitMb';
13
+ export const MIN_HEAP_LIMIT_MB = 512;
14
+ const DEFAULT_FLOOR_MB = 2048;
15
+ const DEFAULT_CAP_MB = 8192;
16
+ const DEFAULT_RAM_SHARE = 0.6;
17
+ // v8's heap_size_limit is old space plus the young generation, while
18
+ // --max-old-space-size sets old space only: compare like with like. Node 24
19
+ // reserves 3 × 64 MB semi-spaces (old space 2048 → heap_size_limit 2240);
20
+ // older Nodes reserve less, which only sets this floor a little low.
21
+ const YOUNG_GENERATION_ALLOWANCE_MB = 192;
22
+ const MB = 1024 * 1024;
23
+
24
+ const HEAP_FLAG = /^--max[-_]old[-_]space[-_]size(?:=(.*))?$/;
25
+ // Node 24.11+: the old space as a percentage of the memory Node sees.
26
+ const HEAP_PERCENTAGE_FLAG = /^--max[-_]old[-_]space[-_]size[-_]percentage(?:=(.*))?$/;
27
+ const SNAPSHOT_FLAG = /^--heapsnapshot[-_]near[-_]heap[-_]limit(?:=|$)/;
28
+ const DIAGNOSTIC_DIR_FLAG = /^--diagnostic[-_]dir(?:=(.*))?$/;
29
+
30
+ /**
31
+ * NODE_OPTIONS as Node splits it (ParseNodeOptionsEnvVar): on spaces, except
32
+ * inside double quotes, which are taken off; inside them a backslash escapes
33
+ * the next character. A single quote is an ordinary character.
34
+ */
35
+ export function splitNodeOptions(value) {
36
+ const args = [];
37
+ let current = '';
38
+ let inString = false;
39
+ const text = String(value || '');
40
+ for (let index = 0; index < text.length; index += 1) {
41
+ let char = text[index];
42
+ if (char === '\\' && inString && index + 1 < text.length) {
43
+ index += 1;
44
+ char = text[index];
45
+ } else if (char === ' ' && !inString) {
46
+ if (current) args.push(current);
47
+ current = '';
48
+ continue;
49
+ } else if (char === '"') {
50
+ inString = !inString;
51
+ continue;
52
+ }
53
+ current += char;
54
+ }
55
+ if (current) args.push(current);
56
+ return args;
57
+ }
58
+
59
+ // The value the last `flag` in an arg list sets (`=V` or V as the next arg),
60
+ // as in Node; undefined when none sets it.
61
+ function lastFlagValue(args, flag) {
62
+ let found;
63
+ for (let index = 0; index < args.length; index += 1) {
64
+ const match = flag.exec(String(args[index]));
65
+ if (match) found = match[1] !== undefined ? match[1] : String(args[index + 1] ?? '');
66
+ }
67
+ return found;
68
+ }
69
+
70
+ function positiveNumber(value) {
71
+ const number = Number.parseFloat(String(value ?? ''));
72
+ return Number.isFinite(number) && number > 0 ? number : null;
73
+ }
74
+
75
+ /**
76
+ * The heap limit the operator set in execArgv or NODE_OPTIONS: `{ mb }` for
77
+ * --max-old-space-size, `{ percentage }` for --max-old-space-size-percentage,
78
+ * with its `source`; null when neither sets one. As in Node, a percentage
79
+ * wins over a size wherever each is set, and execArgv over NODE_OPTIONS.
80
+ */
81
+ export function operatorHeapLimit({ execArgv = [], nodeOptions = '' } = {}) {
82
+ const sources = [['execArgv', Array.from(execArgv || [])], ['NODE_OPTIONS', splitNodeOptions(nodeOptions)]];
83
+ for (const [source, args] of sources) {
84
+ const percentage = positiveNumber(lastFlagValue(args, HEAP_PERCENTAGE_FLAG));
85
+ if (percentage) return { percentage, source };
86
+ }
87
+ for (const [source, args] of sources) {
88
+ const mb = positiveNumber(lastFlagValue(args, HEAP_FLAG));
89
+ if (mb) return { mb: Math.floor(mb), source };
90
+ }
91
+ return null;
92
+ }
93
+
94
+ /**
95
+ * The memory this host gives the relay: a container's or cgroup's limit
96
+ * (process.constrainedMemory) when it is set and under the RAM, else the RAM.
97
+ * Without a limit it reports 0, nothing, or a huge number: ignored.
98
+ */
99
+ export function availableMemory({ totalMem = os.totalmem(), constrainedMem = process.constrainedMemory?.() } = {}) {
100
+ const total = Number(totalMem) || 0;
101
+ const constrained = Number(constrainedMem);
102
+ if (Number.isFinite(constrained) && constrained > 0 && constrained < total) {
103
+ return { bytes: constrained, label: 'memory limit' };
104
+ }
105
+ return { bytes: total, label: 'RAM' };
106
+ }
107
+
108
+ /**
109
+ * 60 % of RAM in whole 64 MB steps, between 2048 and 8192 MB, and never under
110
+ * Node's own default (`nodeDefaultMb`, old space only). 60 % leaves room for
111
+ * the CLI workers and for writing a heap snapshot: a heap that fills RAM ends
112
+ * in the kernel's OOM killer, which leaves neither a snapshot nor a log line.
113
+ */
114
+ export function defaultHeapLimitMb({ totalMem = os.totalmem(), nodeDefaultMb = 0 } = {}) {
115
+ const share = Math.floor((Number(totalMem) || 0) * DEFAULT_RAM_SHARE / MB / 64) * 64;
116
+ const clamped = Math.min(DEFAULT_CAP_MB, Math.max(DEFAULT_FLOOR_MB, share));
117
+ return Math.max(clamped, Math.floor(Number(nodeDefaultMb) || 0));
118
+ }
119
+
120
+ /** Node's default old-space limit, from this process's v8 heap limit. */
121
+ export function nodeDefaultOldSpaceMb(heapSizeLimit = v8.getHeapStatistics().heap_size_limit) {
122
+ return Math.max(0, Math.floor(Number(heapSizeLimit) / MB) - YOUNG_GENERATION_ALLOWANCE_MB);
123
+ }
124
+
125
+ function resolveConfigPath(env, serverDir) {
126
+ const fromEnv = String(env?.COPILOT_WEB_RELAY_CONFIG || '').trim();
127
+ return fromEnv ? path.resolve(fromEnv) : path.join(String(serverDir || ''), 'config.json');
128
+ }
129
+
130
+ /**
131
+ * config.json's heapLimitMb: `{ mb }`, `{ invalid }` for a value that is not a
132
+ * whole number of at least MIN_HEAP_LIMIT_MB, or null when it sets none. An
133
+ * unreadable config sets none: the runtime reports config problems itself.
134
+ */
135
+ export function configuredHeapLimit({ configPath, fsImpl = fs } = {}) {
136
+ let parsed = null;
137
+ try { parsed = JSON.parse(fsImpl.readFileSync(configPath, 'utf8')); } catch { return null; }
138
+ const value = parsed?.[HEAP_LIMIT_CONFIG_KEY];
139
+ if (value === undefined || value === null || value === '') return null;
140
+ const mb = Number(value);
141
+ return Number.isInteger(mb) && mb >= MIN_HEAP_LIMIT_MB ? { mb } : { invalid: value };
142
+ }
143
+
144
+ function freeBytes(dir, fsImpl) {
145
+ try {
146
+ if (typeof fsImpl.statfsSync !== 'function') return null;
147
+ const stats = fsImpl.statfsSync(dir);
148
+ return Number(stats.bavail) * Number(stats.bsize);
149
+ } catch {
150
+ return null;
151
+ }
152
+ }
153
+
154
+ /** A path for the log: `~` for the home folder, so a log line names no user. */
155
+ export function displayPath(target, homeDir = os.homedir()) {
156
+ const resolved = path.resolve(String(target || ''));
157
+ const home = String(homeDir || '').trim() ? path.resolve(String(homeDir)) : '';
158
+ if (!home) return resolved;
159
+ const relative = path.relative(home, resolved);
160
+ if (relative === '') return '~';
161
+ if (relative.startsWith('..') || path.isAbsolute(relative)) return resolved;
162
+ return `~${path.sep}${relative}`;
163
+ }
164
+
165
+ function countSnapshots(dir, fsImpl) {
166
+ try {
167
+ return fsImpl.readdirSync(dir).filter((name) => name.endsWith('.heapsnapshot')).length;
168
+ } catch {
169
+ return 0;
170
+ }
171
+ }
172
+
173
+ /**
174
+ * The Node flags every relay runtime starts with: a known heap limit, and one
175
+ * heap snapshot near it, written to `<data dir>/diagnostics`.
176
+ *
177
+ * The heap limit comes from execArgv or NODE_OPTIONS when the operator set
178
+ * one there (nothing added), else config.json's heapLimitMb, else
179
+ * defaultHeapLimitMb; neither of the last two goes above the memory the host
180
+ * gives the relay (availableMemory). The diagnostics folder is made 0700 (a
181
+ * snapshot holds the auth token and provider keys); a --diagnostic-dir or
182
+ * --heapsnapshot-near-heap-limit the operator set is kept as it is. When the
183
+ * folder cannot be made, or its drive has less than twice the heap limit
184
+ * free (the database lives there too), no snapshot is asked for. Says what
185
+ * it chose on `logger`, with the home folder as `~`.
186
+ */
187
+ export function relayRuntimeNodeFlags({
188
+ env = process.env,
189
+ execArgv = process.execArgv,
190
+ serverDir,
191
+ totalMem = os.totalmem(),
192
+ constrainedMem = process.constrainedMemory?.(),
193
+ heapSizeLimit = v8.getHeapStatistics().heap_size_limit,
194
+ fsImpl = fs,
195
+ platform = process.platform,
196
+ homeDir = os.homedir(),
197
+ logger = console,
198
+ } = {}) {
199
+ const log = (line) => logger?.log?.(line);
200
+ const operatorArgs = [...Array.from(execArgv || []), ...splitNodeOptions(env?.NODE_OPTIONS)];
201
+ const memory = availableMemory({ totalMem, constrainedMem });
202
+ const memoryMb = Math.floor(memory.bytes / MB / 64) * 64;
203
+ const memoryText = `${(memory.bytes / 1024 ** 3).toFixed(1)} GB ${memory.label}`;
204
+ const flags = [];
205
+
206
+ let heapLimitMb;
207
+ let source;
208
+ const operator = operatorHeapLimit({ execArgv, nodeOptions: env?.NODE_OPTIONS });
209
+ if (operator?.mb) {
210
+ heapLimitMb = operator.mb;
211
+ source = `from ${operator.source}`;
212
+ } else if (operator?.percentage) {
213
+ heapLimitMb = Math.floor(memory.bytes * operator.percentage / 100 / MB);
214
+ source = `${operator.percentage}% of ${memoryText}, from ${operator.source}`;
215
+ } else {
216
+ const configured = configuredHeapLimit({ configPath: resolveConfigPath(env, serverDir), fsImpl });
217
+ if (configured?.invalid !== undefined) {
218
+ log(`[relay] config ${HEAP_LIMIT_CONFIG_KEY}=${JSON.stringify(configured.invalid)} ignored: expected a whole number of MB, at least ${MIN_HEAP_LIMIT_MB}`);
219
+ }
220
+ if (configured?.mb) {
221
+ heapLimitMb = configured.mb;
222
+ source = `from config ${HEAP_LIMIT_CONFIG_KEY}`;
223
+ if (memoryMb >= MIN_HEAP_LIMIT_MB && heapLimitMb > memoryMb) {
224
+ log(`[relay] config ${HEAP_LIMIT_CONFIG_KEY}=${heapLimitMb} is more than the ${memoryText}; using ${memoryMb} MB`);
225
+ heapLimitMb = memoryMb;
226
+ }
227
+ } else {
228
+ heapLimitMb = defaultHeapLimitMb({ totalMem: memory.bytes, nodeDefaultMb: nodeDefaultOldSpaceMb(heapSizeLimit) });
229
+ if (memoryMb >= MIN_HEAP_LIMIT_MB) heapLimitMb = Math.min(heapLimitMb, memoryMb);
230
+ source = `default: ${Math.round(DEFAULT_RAM_SHARE * 100)}% of ${memoryText}`;
231
+ }
232
+ flags.push(`--max-old-space-size=${heapLimitMb}`);
233
+ }
234
+ log(`[relay] heap limit ${heapLimitMb} MB (${source})`);
235
+
236
+ // The operator's --diagnostic-dir is theirs: checked, never made or changed.
237
+ const operatorDir = lastFlagValue(operatorArgs, DIAGNOSTIC_DIR_FLAG);
238
+ const diagnosticDir = operatorDir
239
+ ? path.resolve(operatorDir)
240
+ : path.join(resolveDataDir({ env, configPath: resolveConfigPath(env, serverDir), serverDir, fsImpl }), 'diagnostics');
241
+ const shownDir = displayPath(diagnosticDir, homeDir);
242
+ let snapshot = false;
243
+ try {
244
+ if (!operatorDir) {
245
+ fsImpl.mkdirSync(diagnosticDir, { recursive: true, mode: 0o700 });
246
+ // mkdir leaves a folder that was there as it was.
247
+ if (platform !== 'win32') fsImpl.chmodSync?.(diagnosticDir, 0o700);
248
+ }
249
+ const free = freeBytes(diagnosticDir, fsImpl);
250
+ if (operatorArgs.some((arg) => SNAPSHOT_FLAG.test(arg))) {
251
+ snapshot = true;
252
+ } else if (free !== null && free < 2 * heapLimitMb * MB) {
253
+ log(`[relay] no heap snapshot near the limit: ${shownDir} has ${Math.floor(free / MB)} MB free, under twice the heap limit`);
254
+ } else {
255
+ flags.push('--heapsnapshot-near-heap-limit=1');
256
+ snapshot = true;
257
+ }
258
+ if (!operatorDir) flags.push(`--diagnostic-dir=${diagnosticDir}`);
259
+ if (snapshot) {
260
+ log(`[relay] heap snapshots near the limit go to ${shownDir} (${countSnapshots(diagnosticDir, fsImpl)} already there)`);
261
+ }
262
+ } catch (error) {
263
+ log(`[relay] no heap snapshot near the limit: cannot prepare ${shownDir} (${error?.code || error?.message || error})`);
264
+ }
265
+
266
+ return { flags, heapLimitMb, source, diagnosticDir, snapshot };
267
+ }
268
+
269
+ /**
270
+ * relayRuntimeNodeFlags' flags, or none when it fails: a runtime must start
271
+ * even when its heap guard cannot be worked out.
272
+ */
273
+ export function relayRuntimeNodeFlagsOrNone(nodeFlags, options = {}) {
274
+ try {
275
+ return nodeFlags(options);
276
+ } catch (error) {
277
+ options.logger?.log?.(`[relay] starting the runtime without heap flags: ${error?.message || error}`);
278
+ return { flags: [] };
279
+ }
280
+ }
281
+
282
+ function toMb(bytes) {
283
+ return Math.round((Number(bytes) || 0) / MB);
284
+ }
285
+
286
+ /** `[relay] memory rss=<MB> heapUsed=<MB> heapLimit=<MB>` */
287
+ export function formatMemoryLine({ rss, heapUsed, heapLimit }) {
288
+ return `[relay] memory rss=${toMb(rss)} heapUsed=${toMb(heapUsed)} heapLimit=${toMb(heapLimit)}`;
289
+ }
290
+
291
+ /**
292
+ * Says once when heapUsed crosses `threshold` of the heap limit, and again
293
+ * only after it has been back under it. `check` returns whether it said so.
294
+ */
295
+ export function createHeapWatch({ threshold = 0.8, logger = console } = {}) {
296
+ let above = false;
297
+ return {
298
+ check({ heapUsed, heapLimit }) {
299
+ const over = Number(heapLimit) > 0 && Number(heapUsed) >= threshold * Number(heapLimit);
300
+ const crossed = over && !above;
301
+ above = over;
302
+ if (crossed) {
303
+ logger?.log?.(`[relay] memory high: heapUsed=${toMb(heapUsed)} is over ${Math.round(threshold * 100)}% of heapLimit=${toMb(heapLimit)}`);
304
+ }
305
+ return crossed;
306
+ },
307
+ };
308
+ }
309
+
310
+ /**
311
+ * The runtime's memory in the status log: a line now and every 10 minutes,
312
+ * and a silent check every minute that says when heapUsed crosses 80 % of
313
+ * the limit. Both timers are unref'd; returns them for runtimeTimers.
314
+ */
315
+ export function startRelayMemoryLog({
316
+ logger = console,
317
+ memoryUsage = () => process.memoryUsage(),
318
+ heapLimit = () => v8.getHeapStatistics().heap_size_limit,
319
+ logIntervalMs = 10 * 60 * 1000,
320
+ watchIntervalMs = 60 * 1000,
321
+ } = {}) {
322
+ const watch = createHeapWatch({ logger });
323
+ const sample = () => {
324
+ const { rss, heapUsed } = memoryUsage();
325
+ return { rss, heapUsed, heapLimit: heapLimit() };
326
+ };
327
+ const logLine = () => {
328
+ const now = sample();
329
+ logger?.log?.(formatMemoryLine(now));
330
+ watch.check(now);
331
+ };
332
+ logLine();
333
+ const log = setInterval(logLine, logIntervalMs);
334
+ log.unref?.();
335
+ const check = setInterval(() => watch.check(sample()), watchIntervalMs);
336
+ check.unref?.();
337
+ return { log, check };
338
+ }