@oar-sh/oar 0.9.5 → 0.9.7

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 (119) hide show
  1. package/.github/extensions/web-relay/polling/polling-loop.mjs +12 -32
  2. package/.github/extensions/web-relay/skills/question-bridge.mjs +14 -39
  3. package/CHANGELOG.md +340 -0
  4. package/README.md +196 -26
  5. package/package.json +2 -1
  6. package/server/README.md +174 -7
  7. package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +340 -0
  8. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +1325 -0
  9. package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +155 -0
  10. package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
  11. package/server/claude-worker/claude-sdk-adapter.mjs +18 -2
  12. package/server/claude-worker/claude-session-process.mjs +151 -1
  13. package/server/claude-worker/claude-session-worker.mjs +6 -0
  14. package/server/claude-worker/claude-turn-failure.mjs +92 -0
  15. package/server/claude-worker/claude-turn-publisher.mjs +52 -3
  16. package/server/claude-worker/sdk-message-normalizer.mjs +13 -1
  17. package/server/copilot-worker/copilot-model-switch.mjs +29 -3
  18. package/server/copilot-worker/copilot-question-bridge.mjs +2 -4
  19. package/server/copilot-worker/copilot-runtime-tree.mjs +91 -0
  20. package/server/copilot-worker/copilot-sdk-adapter.mjs +48 -8
  21. package/server/copilot-worker/copilot-sdk-session-process.mjs +897 -166
  22. package/server/cursor-worker/cursor-sdk-adapter.mjs +80 -13
  23. package/server/cursor-worker/cursor-turn-runner.mjs +5 -2
  24. package/server/db-schema.mjs +24 -0
  25. package/server/grok-worker/acp-client.mjs +68 -12
  26. package/server/grok-worker/grok-sdk-adapter.mjs +4 -0
  27. package/server/grok-worker/grok-turn-runner.mjs +6 -2
  28. package/server/migrations/0006-workspace-root-settings.mjs +67 -0
  29. package/server/migrations/0007-claude-cloud.mjs +82 -0
  30. package/server/public/app/agent-sessions-settings-ui.js +197 -0
  31. package/server/public/app/agent-sessions-settings.mjs +134 -0
  32. package/server/public/app/api-client.js +160 -6
  33. package/server/public/app/attachments-view.js +20 -1
  34. package/server/public/app/attribution-control.mjs +46 -0
  35. package/server/public/app/bootstrap.js +347 -31
  36. package/server/public/app/claude-cloud-conversation-ui.js +110 -0
  37. package/server/public/app/claude-cloud-settings-ui.js +374 -0
  38. package/server/public/app/claude-cloud-ui.mjs +611 -0
  39. package/server/public/app/composer-control-state.mjs +15 -3
  40. package/server/public/app/context-menu.mjs +140 -0
  41. package/server/public/app/context-tier-options.mjs +4 -0
  42. package/server/public/app/conversation-origin-ui.js +27 -0
  43. package/server/public/app/conversation-provider-indicator.mjs +22 -4
  44. package/server/public/app/conversation-view.js +178 -8
  45. package/server/public/app/cwd-picker.js +12 -0
  46. package/server/public/app/journal-view.js +704 -28
  47. package/server/public/app/markdown-strikethrough.mjs +31 -0
  48. package/server/public/app/math-dollars.mjs +89 -0
  49. package/server/public/app/model-selector-options.mjs +1 -1
  50. package/server/public/app/plan-usage-view.mjs +36 -8
  51. package/server/public/app/relay-board-inline.mjs +70 -0
  52. package/server/public/app/relay-board-view.js +157 -61
  53. package/server/public/app/relay-error-ctas.mjs +28 -5
  54. package/server/public/app/remote-relay-origin-view.mjs +120 -4
  55. package/server/public/app/router.js +49 -12
  56. package/server/public/app/settings-modal.js +33 -2
  57. package/server/public/app/settings-tabs.js +1 -1
  58. package/server/public/app/socket-handlers.js +60 -0
  59. package/server/public/app/store.js +4 -1
  60. package/server/public/app/subagent-fold.mjs +61 -0
  61. package/server/public/app/usage-limit-ui.js +225 -0
  62. package/server/public/index.html +471 -8
  63. package/server/relay-tools.md +13 -1
  64. package/server/repositories/message-repository.mjs +10 -1
  65. package/server/repositories/question-repository.mjs +1 -0
  66. package/server/repositories/remote-relay-repository.mjs +72 -1
  67. package/server/repositories/session-repository.mjs +47 -1
  68. package/server/routes/claude-cloud-routes.mjs +140 -0
  69. package/server/routes/git-routes.mjs +37 -0
  70. package/server/routes/messages-routes.mjs +202 -26
  71. package/server/routes/relay-board-routes.mjs +56 -2
  72. package/server/routes/remote-relay-routes.mjs +15 -1
  73. package/server/routes/sessions-routes.mjs +538 -72
  74. package/server/server-runtime.mjs +291 -27
  75. package/server/services/claude-account-usage-service.mjs +176 -0
  76. package/server/services/claude-cloud-repo-service.mjs +408 -0
  77. package/server/services/claude-cloud-session-service.mjs +326 -0
  78. package/server/services/claude-cloud-settings-service.mjs +269 -0
  79. package/server/services/git-remote-service.mjs +115 -0
  80. package/server/services/plan-usage-claude-cloud.mjs +211 -0
  81. package/server/services/plan-usage-claude.mjs +202 -40
  82. package/server/services/plan-usage-contract.mjs +39 -3
  83. package/server/services/plan-usage-service.mjs +82 -2
  84. package/server/services/preview-proxy-server.mjs +64 -36
  85. package/server/services/process-list-service.mjs +122 -0
  86. package/server/services/provider-reasoning-effort.mjs +3 -0
  87. package/server/services/relay-console-log.mjs +199 -0
  88. package/server/services/remote-relay-caller-context.mjs +78 -19
  89. package/server/services/remote-relay-client.mjs +9 -0
  90. package/server/services/remote-relay-dispatcher.mjs +451 -39
  91. package/server/services/remote-relay-inbound.mjs +83 -1
  92. package/server/services/remote-relay-loopback.mjs +245 -0
  93. package/server/services/remote-relay-registry-service.mjs +52 -0
  94. package/server/services/session-worker-launch-service.mjs +100 -15
  95. package/server/services/session-worker-process-service.mjs +410 -138
  96. package/server/services/session-worker-registry-service.mjs +1 -1
  97. package/server/services/session-worker-stop-service.mjs +24 -13
  98. package/server/services/session-worker-supervisor-service.mjs +89 -2
  99. package/server/services/usage-limit-pause-service.mjs +443 -0
  100. package/server/services/worker-launch-note-service.mjs +151 -0
  101. package/shared/ask-user-bridge.mjs +14 -39
  102. package/shared/claude-attribution.mjs +117 -0
  103. package/shared/claude-cloud/api-client.mjs +809 -0
  104. package/shared/claude-cloud/base-url.mjs +47 -0
  105. package/shared/claude-cloud/credentials.mjs +221 -0
  106. package/shared/claude-cloud/repo-url.mjs +111 -0
  107. package/shared/claude-usage-limit.mjs +257 -0
  108. package/shared/control-poller.mjs +69 -20
  109. package/shared/failure-note-text.mjs +38 -0
  110. package/shared/provider-routing.mjs +4 -2
  111. package/shared/question-wait.mjs +108 -0
  112. package/shared/remote-relay-contract.mjs +167 -22
  113. package/shared/remote-relay-tool-core.mjs +11 -8
  114. package/shared/steer-settle-failure.mjs +17 -0
  115. package/shared/steer-settle-markers.mjs +8 -0
  116. package/shared/worker-runtime/process-tree.mjs +483 -0
  117. package/shared/worker-runtime/relay-errors.mjs +31 -0
  118. package/shared/worker-runtime/send-and-wait-errors.mjs +4 -4
  119. package/shared/worker-runtime/turn-liveness.mjs +249 -0
@@ -22,6 +22,7 @@ import {
22
22
  STEER_SETTLE_FAILED_CODE,
23
23
  STEER_SETTLE_FAILED_STABLE_CODE,
24
24
  } from '../../shared/steer-settle-failure.mjs';
25
+ import { matchFailureNote } from '../../shared/failure-note-text.mjs';
25
26
  import { buildRelayStopFailure } from '../../shared/relay-stop-failure.mjs';
26
27
  import { stopSessionWorkerProcesses } from '../services/session-worker-stop-service.mjs';
27
28
  import {
@@ -31,6 +32,7 @@ import {
31
32
  usageSnapshotFromSummary,
32
33
  } from '../services/usage-snapshot-helpers.mjs';
33
34
  import { claudePlanUsageFromResult, normalizeClaudePlanUsage } from '../services/plan-usage-claude.mjs';
35
+ import { isUsageLimitTerminalError } from '../services/usage-limit-pause-service.mjs';
34
36
  import { normalizeCopilotWorkerUsage } from '../services/plan-usage-copilot.mjs';
35
37
  import { normalizeGrokTurnUsage } from '../services/plan-usage-grok.mjs';
36
38
  import { openAIReasoningEffortsForModel } from '../../shared/openai-reasoning.mjs';
@@ -41,9 +43,13 @@ import { ANSWERED_ELSEWHERE_KIND, STEER_FOLDED_TEXT } from '../../shared/steer-s
41
43
  import { isLiveResend } from '../services/steer-resend.mjs';
42
44
  import { resolveProviderModelSelection } from '../services/provider-model-selection.mjs';
43
45
  import { stripRemotePromptHeader } from '../../shared/remote-relay-contract.mjs';
46
+ import { stripModelTierSuffix } from '../../shared/claude-cloud/repo-url.mjs';
47
+ import { buildClaudeCloudDelivery, isClaudeCloudProviderType } from '../services/claude-cloud-session-service.mjs';
44
48
  import {
45
49
  admitRemoteRelayRequest,
46
50
  findMentionedRemoteRelays,
51
+ findOriginRemoteRelay,
52
+ formatRemoteRelayOriginHint,
47
53
  formatRemoteRelayMentionHint,
48
54
  recordRemoteRelayUnlocks,
49
55
  withRemoteRelayMentionHint,
@@ -110,6 +116,8 @@ function normalizeRequestedProviderType(value = '') {
110
116
  // "no provider stated" and the guards below could never fire.
111
117
  if (normalized === 'grok' || normalized === 'xai' || normalized === 'xai-grok') return 'grok';
112
118
  if (normalized === 'claude' || normalized === 'anthropic') return 'claude';
119
+ // Its own provider, never read as `claude`.
120
+ if (normalized === 'claude-cloud') return 'claude-cloud';
113
121
  return '';
114
122
  }
115
123
 
@@ -117,6 +125,7 @@ const BOOTSTRAP_ONLY_PROVIDER_LABELS = Object.freeze({
117
125
  cursor: 'Cursor',
118
126
  grok: 'Grok',
119
127
  claude: 'Claude',
128
+ 'claude-cloud': 'Claude Cloud',
120
129
  });
121
130
 
122
131
  function deriveModelOrigin(model) {
@@ -450,15 +459,19 @@ function extractTerminalId(text, patterns = []) {
450
459
  return null;
451
460
  }
452
461
 
462
+ /**
463
+ * Reads a failure a sender reported as text only, without a `terminalError`
464
+ * record. The text has to BE a failure note, not merely contain one
465
+ * (shared/failure-note-text.mjs): a reply that quoted a note was stored as a
466
+ * failed turn, cut off at the quoted code.
467
+ */
453
468
  export function parseTerminalFailureText(text) {
454
469
  const raw = String(text || '').trim();
455
- if (!raw) return null;
456
- const stableCodeMatch = raw.match(/error code:\s*(relay\.[a-z0-9-]+)/i);
457
- if (!stableCodeMatch) return null;
458
- const stableCode = String(stableCodeMatch[1] || '').trim().toLowerCase();
459
- const code = normalizeTerminalErrorCode(stableCode);
470
+ const note = matchFailureNote(raw);
471
+ if (!note) return null;
472
+ const code = normalizeTerminalErrorCode(note.stableCode);
460
473
  if (!code) return null;
461
- const message = normalizeTerminalErrorText(raw.slice(0, stableCodeMatch.index).trim().replace(/\s+$/, '.'));
474
+ const message = normalizeTerminalErrorText(note.lead.trim().replace(/\s+$/, '.'));
462
475
  const detailMatch = raw.match(/details:\s*(.+)$/i);
463
476
  return {
464
477
  terminal: true,
@@ -478,7 +491,7 @@ export function parseTerminalFailureText(text) {
478
491
  };
479
492
  }
480
493
 
481
- function buildTerminalFailureTextForChat(terminalFailure, fallbackMessage = null) {
494
+ export function buildTerminalFailureTextForChat(terminalFailure, fallbackMessage = null) {
482
495
  const failure = terminalFailure && typeof terminalFailure === 'object' ? terminalFailure : {};
483
496
  const code = normalizeTerminalErrorCode(failure.code || failure.stableCode) || 'unknown-terminal';
484
497
  const stableCode = `relay.${code}`;
@@ -486,7 +499,7 @@ function buildTerminalFailureTextForChat(terminalFailure, fallbackMessage = null
486
499
  || normalizeTerminalErrorText(fallbackMessage)
487
500
  || 'The relay runtime hit a terminal error and could not complete this turn.';
488
501
  const guidance = normalizeTerminalErrorText(failure.guidance)
489
- || 'Retry the message. If this keeps failing, restart the relay and include the error code.';
502
+ || 'Send the message again to retry. If this keeps failing, include the error code when you report it.';
490
503
  const detail = normalizeTerminalErrorText(failure.detail);
491
504
  const ids = [
492
505
  normalizeTerminalErrorText(failure.functionCallId) ? `functionCallId=${failure.functionCallId}` : null,
@@ -581,9 +594,17 @@ async function resolveRelayResponseText({
581
594
  }
582
595
  }
583
596
 
584
- export function resolveTerminalFailurePayload(payload = {}, { fallbackText = null } = {}) {
597
+ /**
598
+ * `inferFromText`: whether a text shaped like a failure note counts as one
599
+ * when no record came with it. A session worker always sends the record
600
+ * (`terminalError`), so for its publishes (an `attemptId` names the attempt)
601
+ * the text is a reply whatever it says — a reply that names a code after
602
+ * fixing a failed test is a reply. Senders without an attempt (the Copilot
603
+ * extension, a note read from another relay) still report as text.
604
+ */
605
+ export function resolveTerminalFailurePayload(payload = {}, { fallbackText = null, inferFromText = true } = {}) {
585
606
  const body = payload && typeof payload === 'object' ? payload : {};
586
- const parsedTextFailure = parseTerminalFailureText(body.text);
607
+ const parsedTextFailure = inferFromText ? parseTerminalFailureText(body.text) : null;
587
608
  const direct = body.terminalError && typeof body.terminalError === 'object'
588
609
  ? body.terminalError
589
610
  : null;
@@ -628,8 +649,13 @@ export function resolveTerminalFailurePayload(payload = {}, { fallbackText = nul
628
649
  const code = explicitCode
629
650
  || normalizeTerminalErrorCode(parsedTextFailure?.code)
630
651
  || 'unknown-terminal';
652
+ // Known from the text alone: nothing but the shape of the text says that
653
+ // this is a failure, so the text is kept as it was sent.
654
+ const fromText = !!parsedTextFailure && !direct && !explicitCode
655
+ && body.terminal !== true && body.isTerminal !== true && body.errorKind !== 'terminal';
631
656
  return {
632
657
  terminal: true,
658
+ ...(fromText ? { fromText: true } : {}),
633
659
  code,
634
660
  stableCode: `relay.${code}`,
635
661
  message: explicitMessage || parsedTextFailure?.message || normalizeTerminalErrorText(fallbackText),
@@ -1108,6 +1134,7 @@ export function buildDequeuedRelayMessage({
1108
1134
  defaultRelayMode,
1109
1135
  defaultModel,
1110
1136
  getCursorProviderSettings = () => null,
1137
+ getClaudeCloudProviderSettings = () => null,
1111
1138
  } = {}) {
1112
1139
  if (!msg) return null;
1113
1140
  const attachments = parseAttachments(msg.attachments).map(hydrateAttachment).filter(Boolean);
@@ -1137,6 +1164,17 @@ export function buildDequeuedRelayMessage({
1137
1164
  .map((value) => String(value || '').trim())
1138
1165
  .filter(Boolean);
1139
1166
  }
1167
+ // A Claude Cloud worker creates its session from the first delivered
1168
+ // message and sends into it afterwards: the repository, the branch and the
1169
+ // binding it reported ride on every message, so a restarted worker needs
1170
+ // nothing but the delivery. Other providers never get the field.
1171
+ const claudeCloud = isClaudeCloudProviderType(providerType)
1172
+ ? buildClaudeCloudDelivery({
1173
+ conversation: stmts.getConvAnyStatus?.get?.(msg.conversation_id) || null,
1174
+ runtimeSession,
1175
+ fallbackEnvironmentId: getClaudeCloudProviderSettings()?.environmentId || '',
1176
+ })
1177
+ : null;
1140
1178
  return {
1141
1179
  id: msg.id,
1142
1180
  conversationId: msg.conversation_id,
@@ -1150,6 +1188,7 @@ export function buildDequeuedRelayMessage({
1150
1188
  cursorAgentId: String(runtimeSession?.cursor_agent_id || '').trim() || null,
1151
1189
  cursorSubagentModels,
1152
1190
  grokNativeSessionId: String(runtimeSession?.grok_native_session_id || '').trim() || null,
1191
+ ...(isClaudeCloudProviderType(providerType) ? { claudeCloud } : {}),
1153
1192
  reasoningEffort: String(msg.reasoning_effort || '').trim() || null,
1154
1193
  contextTier: String(msg.context_tier || '').trim() || 'default',
1155
1194
  quality: normalizeOpenAIImageQuality(msg.reasoning_effort),
@@ -1770,6 +1809,7 @@ export function registerMessagesRoutes(app, deps) {
1770
1809
  getClaudeProviderSettings = () => ({ enabled: false, model: '', models: [] }),
1771
1810
  getCursorProviderSettings = () => ({ enabled: false, model: '', models: [] }),
1772
1811
  getGrokProviderSettings = () => ({ enabled: false, model: '', models: [] }),
1812
+ getClaudeCloudProviderSettings = () => ({ enabled: false, defaultModel: '', environmentId: '', models: [] }),
1773
1813
  rebindUnstartedOpenAIConversationModel = null,
1774
1814
  normalizeRelayMode,
1775
1815
  DEFAULT_RELAY_MODE,
@@ -1804,6 +1844,7 @@ export function registerMessagesRoutes(app, deps) {
1804
1844
  resolveSessionStateRoot,
1805
1845
  fetchUsageSummary = null,
1806
1846
  planUsageService = null,
1847
+ usageLimitPauseService = null,
1807
1848
  getCursorPlanAllowanceSettings = () => ({ cursorModelsUsd: null, otherModelsUsd: null, resetDay: 1 }),
1808
1849
  getGrokPlanAllowanceSettings = () => ({ monthlyUsd: null, resetDay: 1 }),
1809
1850
  opaqueResponseRecoveryWaitMs = OPAQUE_RESPONSE_RECOVERY_WAIT_MS,
@@ -4152,7 +4193,8 @@ export function registerMessagesRoutes(app, deps) {
4152
4193
  const runtimeBoundToExplicitProvider = runtimeProviderType === 'openai'
4153
4194
  || runtimeProviderType === 'claude'
4154
4195
  || runtimeProviderType === 'cursor'
4155
- || runtimeProviderType === 'grok';
4196
+ || runtimeProviderType === 'grok'
4197
+ || runtimeProviderType === 'claude-cloud';
4156
4198
  const configuredOpenAIModel = String(configuredOpenAI?.model || '').trim();
4157
4199
  const availableOpenAIModels = new Set(
4158
4200
  (Array.isArray(configuredOpenAI?.models) ? configuredOpenAI.models : [])
@@ -4276,6 +4318,17 @@ export function registerMessagesRoutes(app, deps) {
4276
4318
  });
4277
4319
  }
4278
4320
  }
4321
+ // Claude Cloud conversations are bootstrap-created only as well: each
4322
+ // carries the repository its cloud session clones. Only an explicit
4323
+ // provider counts here — the cloud's model ids are the Claude provider's
4324
+ // own, so an id alone never means "cloud".
4325
+ const runtimeUsesClaudeCloud = runtimeProviderType === 'claude-cloud';
4326
+ if (!shouldCreateConversation && !runtimeUsesClaudeCloud && requestedProviderType === 'claude-cloud') {
4327
+ return res.status(409).json({
4328
+ error: 'Claude Cloud requires creating a new Claude Cloud conversation',
4329
+ code: 'CLAUDE_CLOUD_REQUIRES_NEW_CONVERSATION',
4330
+ });
4331
+ }
4279
4332
  if (
4280
4333
  useOpenAIProvider
4281
4334
  && requestedOpenAIModel
@@ -4408,6 +4461,32 @@ export function registerMessagesRoutes(app, deps) {
4408
4461
  const grokModel = runtimeUsesGrok
4409
4462
  ? (pinnedGrokModel || String(configuredGrok?.model || '').trim())
4410
4463
  : '';
4464
+ // Claude Cloud conversations resolve models against the cloud provider's
4465
+ // own list (plain ids, no "[1m]" tier). The model is part of the cloud
4466
+ // session's configuration: it can still change before the first message
4467
+ // creates that session, and is locked afterwards.
4468
+ const configuredClaudeCloud = runtimeUsesClaudeCloud ? getClaudeCloudProviderSettings() : null;
4469
+ const pinnedClaudeCloudModel = runtimeUsesClaudeCloud
4470
+ ? stripModelTierSuffix(existingRuntimeSession?.provider_model)
4471
+ : '';
4472
+ const claudeCloudModelSelection = runtimeUsesClaudeCloud
4473
+ ? resolveProviderModelSelection({
4474
+ requestedModel: stripModelTierSuffix(model),
4475
+ configuredModel: pinnedClaudeCloudModel || String(configuredClaudeCloud?.defaultModel || '').trim(),
4476
+ availableModels: Array.isArray(configuredClaudeCloud?.models) ? configuredClaudeCloud.models : [],
4477
+ })
4478
+ : null;
4479
+ if (claudeCloudModelSelection && !claudeCloudModelSelection.ok) {
4480
+ return res.status(400).json({
4481
+ error: `Claude Cloud model "${claudeCloudModelSelection.requestedModel}" is not available`,
4482
+ code: 'CLAUDE_CLOUD_MODEL_UNAVAILABLE',
4483
+ supportedModels: claudeCloudModelSelection.availableModels || [],
4484
+ });
4485
+ }
4486
+ // A cloud session's model can be switched between turns (the worker
4487
+ // sends set_model before the message), so a changed model is taken as
4488
+ // for a local Claude chat.
4489
+ const claudeCloudModel = claudeCloudModelSelection ? claudeCloudModelSelection.model : '';
4411
4490
  const modelResolution = useOpenAIProvider
4412
4491
  ? {
4413
4492
  ok: !!openAIModel,
@@ -4440,7 +4519,15 @@ export function registerMessagesRoutes(app, deps) {
4440
4519
  reasoningEffort: null,
4441
4520
  error: grokModel ? null : 'Grok model is not configured',
4442
4521
  }
4443
- : resolveRequestedModel(model))));
4522
+ : (runtimeUsesClaudeCloud
4523
+ ? {
4524
+ ok: !!claudeCloudModel,
4525
+ model: claudeCloudModel,
4526
+ modelVariantId: claudeCloudModel,
4527
+ reasoningEffort: null,
4528
+ error: claudeCloudModel ? null : 'Claude Cloud model is not configured',
4529
+ }
4530
+ : resolveRequestedModel(model)))));
4444
4531
  if (!modelResolution.ok) return res.status(400).json({ error: modelResolution.error, supportedModels: modelResolution.available || [] });
4445
4532
  const requestedModel = String(modelResolution.model || '').trim();
4446
4533
  const requestedAutoModel = requestedModel.toLowerCase() === AUTO_MODEL_SENTINEL;
@@ -4509,10 +4596,13 @@ export function registerMessagesRoutes(app, deps) {
4509
4596
  ? resolveCursorReasoningEffort()
4510
4597
  : (runtimeUsesGrok
4511
4598
  ? resolveGrokReasoningEffort()
4512
- : resolveRequestedReasoningEffort(
4513
- requestedModel,
4514
- explicitReasoningEffort || modelResolution.reasoningEffort || null,
4515
- ))));
4599
+ // A cloud session takes no effort: whatever was asked, it is none.
4600
+ : (runtimeUsesClaudeCloud
4601
+ ? { ok: true, effort: 'none', supported: ['none'] }
4602
+ : resolveRequestedReasoningEffort(
4603
+ requestedModel,
4604
+ explicitReasoningEffort || modelResolution.reasoningEffort || null,
4605
+ )))));
4516
4606
  if (!reasoningResolution?.ok && !explicitReasoningEffort) {
4517
4607
  const supportedEfforts = Array.isArray(reasoningResolution?.supported)
4518
4608
  ? reasoningResolution.supported.map((value) => String(value || '').trim().toLowerCase()).filter(Boolean)
@@ -4539,6 +4629,9 @@ export function registerMessagesRoutes(app, deps) {
4539
4629
  }
4540
4630
  } else if (!['default', 'long_context'].includes(requestedContextTier)) {
4541
4631
  return res.status(400).json({ error: 'Unsupported context tier' });
4632
+ } else if (runtimeUsesClaudeCloud) {
4633
+ // The cloud has no long-context tier to pick.
4634
+ normalizedContextTier = 'default';
4542
4635
  }
4543
4636
  if (!requestedRelayMode) return res.status(400).json({ error: 'Unsupported relay mode' });
4544
4637
  const conversationWorkspaceState = (!shouldCreateConversation && typeof resolveConversationWorkspaceState === 'function')
@@ -4685,13 +4778,19 @@ export function registerMessagesRoutes(app, deps) {
4685
4778
  trimmedText || '(User sent image attachments only.)',
4686
4779
  ].join('\n')
4687
4780
  : trimmedText;
4688
- // Only what a human typed unlocks a remote relay (decision 19). The hint
4781
+ // Only what a human typed unlocks a remote relay by mention (decision 19);
4782
+ // a prompt from a relay's agent opens that one relay and no other. The hint
4689
4783
  // rides in the queued prompt only, so the stored message and the user's
4690
4784
  // bubble stay exactly what was typed.
4691
4785
  const mentionedRemoteRelays = remoteRequest.remote
4692
4786
  ? []
4693
4787
  : findMentionedRemoteRelays(remoteRelayInbound, trimmedText);
4694
- const queueText = withRemoteRelayMentionHint(promptText, formatRemoteRelayMentionHint(mentionedRemoteRelays));
4788
+ // A prompt from another relay's agent opens that relay for this
4789
+ // conversation (it may be answered): the agent is told where it came from.
4790
+ const remoteRelayHint = remoteRequest.remote
4791
+ ? formatRemoteRelayOriginHint(findOriginRemoteRelay(remoteRelayInbound, remoteRequest.origin), remoteRequest.origin)
4792
+ : formatRemoteRelayMentionHint(mentionedRemoteRelays);
4793
+ const queueText = withRemoteRelayMentionHint(promptText, remoteRelayHint);
4695
4794
  // A Resend never came from the composer, and neither did another relay's
4696
4795
  // agent: whatever the user is typing there stays their draft.
4697
4796
  const keepsComposerDraft = !!resendOriginId || remoteRequest.remote;
@@ -4764,6 +4863,17 @@ export function registerMessagesRoutes(app, deps) {
4764
4863
  ) {
4765
4864
  stmts.updateRuntimeSessionProvider.run('claude', claudeModel, claudeModel, now, runtimeSession.id);
4766
4865
  }
4866
+ // A cloud conversation follows the composer like a Claude one: the
4867
+ // worker switches the sandbox's model before the message.
4868
+ if (
4869
+ runtimeUsesClaudeCloud
4870
+ && claudeCloudModel
4871
+ && runtimeSession?.id
4872
+ && claudeCloudModel !== String(existingRuntimeSession?.provider_model || '').trim()
4873
+ && typeof stmts.updateRuntimeSessionProvider?.run === 'function'
4874
+ ) {
4875
+ stmts.updateRuntimeSessionProvider.run('claude-cloud', claudeCloudModel, claudeCloudModel, now, runtimeSession.id);
4876
+ }
4767
4877
  // Another relay's agent picks the model and mode of its own turn; the
4768
4878
  // composer keeps restoring the user's choice for this conversation.
4769
4879
  conversationPreferences = remoteRequest.remote && !shouldCreateConversation
@@ -5265,7 +5375,10 @@ export function registerMessagesRoutes(app, deps) {
5265
5375
  if (!runtimeSession) {
5266
5376
  return res.status(404).json({ error: 'Runtime session not found for conversation' });
5267
5377
  }
5268
- if (String(runtimeSession.provider_type || 'github').trim().toLowerCase() !== 'claude') {
5378
+ // The Claude Cloud worker reports the same shape (the cloud session's
5379
+ // context and token usage), so both providers are taken here.
5380
+ const contextUsageProvider = String(runtimeSession.provider_type || 'github').trim().toLowerCase();
5381
+ if (contextUsageProvider !== 'claude' && contextUsageProvider !== 'claude-cloud') {
5269
5382
  return res.status(409).json({ error: 'Conversation is not bound to the Claude provider' });
5270
5383
  }
5271
5384
  if (typeof stmts.updateRuntimeSessionContextUsage?.run !== 'function') {
@@ -5325,13 +5438,17 @@ export function registerMessagesRoutes(app, deps) {
5325
5438
  // Claude-bound conversation's worker may write the Claude card. The
5326
5439
  // snapshot itself is global, so a misrouted poster would corrupt it for
5327
5440
  // everyone.
5441
+ // A Claude Cloud worker posts here too; its report is kept under its own
5442
+ // snapshot key, so the Claude card stays what the Claude workers wrote.
5328
5443
  const conversationId = String(req.body?.conversationId || '').trim();
5444
+ let snapshotProvider = 'claude';
5329
5445
  if (conversationId) {
5330
5446
  const runtimeSession = stmts.getRuntimeSessionByConversation?.get?.(conversationId) || null;
5331
5447
  const boundProvider = String(runtimeSession?.provider_type || 'github').trim().toLowerCase();
5332
- if (runtimeSession && boundProvider !== 'claude') {
5448
+ if (runtimeSession && boundProvider !== 'claude' && boundProvider !== 'claude-cloud') {
5333
5449
  return res.status(409).json({ error: 'Conversation is not bound to the Claude provider' });
5334
5450
  }
5451
+ if (runtimeSession && boundProvider === 'claude-cloud') snapshotProvider = 'claude-cloud';
5335
5452
  }
5336
5453
  const usage = normalizeClaudePlanUsage(req.body?.usage)
5337
5454
  || claudePlanUsageFromResult({
@@ -5339,13 +5456,34 @@ export function registerMessagesRoutes(app, deps) {
5339
5456
  totalCostUsd: req.body?.totalCostUsd,
5340
5457
  });
5341
5458
  if (!usage) return res.status(400).json({ error: 'Missing usable usage payload' });
5342
- planUsageService.saveSnapshot('claude', usage, {
5459
+ planUsageService.saveSnapshot(snapshotProvider, usage, {
5343
5460
  source: 'worker',
5344
5461
  error: String(req.body?.error || '').trim() || null,
5345
5462
  });
5346
5463
  res.json({ ok: true });
5347
5464
  });
5348
5465
 
5466
+ // POST /api/claude-usage-limit — the Claude worker forwards what the CLI
5467
+ // reported about the subscription's usage limit (a `rate_limit_event`): the
5468
+ // clients warn before the limit and show the reset once it is reached.
5469
+ app.post('/api/claude-usage-limit', auth, (req, res) => {
5470
+ touchCli();
5471
+ if (!usageLimitPauseService) return res.status(501).json({ error: 'Usage limit handling is unavailable' });
5472
+ // The report is the account's, so only a Claude-bound conversation's
5473
+ // worker may write it (same validation as the plan usage card); a Claude
5474
+ // Cloud chat runs on the same account and reports the same limit.
5475
+ const conversationId = String(req.body?.conversationId || '').trim();
5476
+ if (conversationId) {
5477
+ const runtimeSession = stmts.getRuntimeSessionByConversation?.get?.(conversationId) || null;
5478
+ const boundProvider = String(runtimeSession?.provider_type || 'github').trim().toLowerCase();
5479
+ if (runtimeSession && boundProvider !== 'claude' && boundProvider !== 'claude-cloud') {
5480
+ return res.status(409).json({ error: 'Conversation is not bound to the Claude provider' });
5481
+ }
5482
+ }
5483
+ const state = usageLimitPauseService.recordReport({ report: req.body?.report });
5484
+ res.json({ ok: true, state });
5485
+ });
5486
+
5349
5487
  // POST /api/copilot-plan-usage — the Copilot SDK worker reports the turn's
5350
5488
  // billing/token detail.
5351
5489
  //
@@ -5942,6 +6080,7 @@ export function registerMessagesRoutes(app, deps) {
5942
6080
  defaultRelayMode: DEFAULT_RELAY_MODE,
5943
6081
  defaultModel: DEFAULT_MODEL,
5944
6082
  getCursorProviderSettings,
6083
+ getClaudeCloudProviderSettings,
5945
6084
  });
5946
6085
 
5947
6086
  if (out.attachments.length) {
@@ -6535,8 +6674,11 @@ export function registerMessagesRoutes(app, deps) {
6535
6674
  });
6536
6675
  }
6537
6676
  };
6538
- const trimmedText = String(text || '').trim();
6539
- const terminalFailure = resolveTerminalFailurePayload(req.body, { fallbackText: trimmedText });
6677
+ let trimmedText = String(text || '').trim();
6678
+ let terminalFailure = resolveTerminalFailurePayload(req.body, {
6679
+ fallbackText: trimmedText,
6680
+ inferFromText: !String(req.body?.attemptId || '').trim(),
6681
+ });
6540
6682
  // Final digests of background workflows that settled during this turn —
6541
6683
  // the transcript's "Finished background task" cards. Same structural
6542
6684
  // sanitizer as the live panel rows; junk entries drop silently (this
@@ -6590,6 +6732,22 @@ export function registerMessagesRoutes(app, deps) {
6590
6732
  }
6591
6733
 
6592
6734
  const relayMode = normalizeRelayMode(mode || q?.relay_mode) || DEFAULT_RELAY_MODE;
6735
+ // A Claude turn refused at the usage limit is paused, not failed: its row
6736
+ // ends with a note like any answered one, and a follow-up is queued that
6737
+ // is held until the reset (usage-limit-pause-service).
6738
+ const usageLimitPause = terminalFailure && q && isUsageLimitTerminalError(req.body?.terminalError)
6739
+ ? (usageLimitPauseService?.planPause?.({
6740
+ terminalError: req.body.terminalError,
6741
+ conversationId: targetConversationId,
6742
+ providerType: stmts.getRuntimeSessionByConversation?.get(targetConversationId)?.provider_type,
6743
+ queueRow: q,
6744
+ partialText: req.body?.partialText,
6745
+ }) || null)
6746
+ : null;
6747
+ if (usageLimitPause) {
6748
+ trimmedText = usageLimitPause.noteText;
6749
+ terminalFailure = null;
6750
+ }
6593
6751
  if (terminalFailure) {
6594
6752
  // Terminal failures carry provenance too: the original hijack incident
6595
6753
  // presented exactly as stolen turns dying on 402 through this path.
@@ -6615,14 +6773,22 @@ export function registerMessagesRoutes(app, deps) {
6615
6773
  if (terminalCrossConversation.crossConversation) {
6616
6774
  console.warn(`[${ts()}] CONVERSATION MISMATCH ${messageId?.slice(0, 8)} conv=${targetConversationId?.slice(0, 8)} responderConv=${terminalCrossConversation.responderConversationId?.slice(0, 8)} — terminal failure posted by another conversation's worker`);
6617
6775
  }
6618
- const failureText = buildTerminalFailureTextForChat(terminalFailure, trimmedText);
6776
+ // A failure read from the text alone keeps that text, whole: it is the
6777
+ // note already, and should the reading be wrong, nothing of a reply is
6778
+ // lost.
6779
+ const failureText = terminalFailure.fromText
6780
+ ? trimmedText
6781
+ : buildTerminalFailureTextForChat(terminalFailure, trimmedText);
6782
+ // What the turn had written before it failed stays readable: the note
6783
+ // goes below it instead of replacing it.
6784
+ const partialText = String(req.body.partialText || '').trim();
6619
6785
  const failed = failQueueMessage({
6620
6786
  queueRow: q,
6621
6787
  messageId,
6622
6788
  conversationId: targetConversationId,
6623
6789
  relayMode,
6624
6790
  model: model || q?.model || null,
6625
- responseText: failureText,
6791
+ responseText: partialText ? `${partialText}\n\n${failureText}` : failureText,
6626
6792
  executedProvider: terminalExecutedProvider,
6627
6793
  failureRecord: {
6628
6794
  kind: 'terminal',
@@ -7015,6 +7181,13 @@ export function registerMessagesRoutes(app, deps) {
7015
7181
  },
7016
7182
  });
7017
7183
  io.emit('message_status', { messageId, conversationId: targetConversationId, status: 'done' });
7184
+ if (usageLimitPause) {
7185
+ try {
7186
+ usageLimitPauseService.schedulePause(usageLimitPause, { queueRow: q });
7187
+ } catch (error) {
7188
+ console.warn(`[${ts()}] USAGE LIMIT ${messageId?.slice(0, 8)} conv=${targetConversationId?.slice(0, 8)} the follow-up could not be queued: ${error?.message || error}`);
7189
+ }
7190
+ }
7018
7191
  // An absorbed row is not a finished turn — the reply continues on the
7019
7192
  // steered message's row, whose own completion sends the one notification.
7020
7193
  // A stopped steer has no reply at all, and a message answered on a
@@ -7035,7 +7208,10 @@ export function registerMessagesRoutes(app, deps) {
7035
7208
  // Providers whose workers turn runtime-initiated activity into continuation
7036
7209
  // rows: the Claude worker (background tasks/agents) and the Copilot SDK
7037
7210
  // worker (detached background shells) serving both Copilot-CLI providers.
7038
- const CONTINUATION_PROVIDER_TYPES = new Set(['claude', 'github', 'openai']);
7211
+ // A Claude Cloud session can take a turn of its own too: one the agent
7212
+ // starts when its background work reports back, or one started on
7213
+ // claude.ai; the cloud worker follows it and registers it here.
7214
+ const CONTINUATION_PROVIDER_TYPES = new Set(['claude', 'github', 'openai', 'claude-cloud']);
7039
7215
 
7040
7216
  // POST /api/continuation-turn — a session worker's CLI started a turn on its
7041
7217
  // own (a background task's notification). The synthetic queue row gives that
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
 
3
+ import { persistConversationPreferences } from '../services/conversation-preferences-service.mjs';
4
+
3
5
  function normalizeId(value) {
4
6
  const text = String(value || '').trim();
5
7
  return text || null;
@@ -86,6 +88,7 @@ export function registerRelayBoardRoutes(app, deps) {
86
88
  const {
87
89
  auth,
88
90
  io,
91
+ db,
89
92
  stmts,
90
93
  uuidv4,
91
94
  normalizeRelayMode,
@@ -96,9 +99,52 @@ export function registerRelayBoardRoutes(app, deps) {
96
99
  pushDispatchService,
97
100
  } = deps;
98
101
 
102
+ /**
103
+ * The session goes on in the mode that was chosen on the board: after
104
+ * "Implement in autopilot" the composer says Autopilot, and the next message
105
+ * carries the work on instead of planning again. The model and the effort
106
+ * stay as they are. Advisory: the follow-up is queued whatever happens here.
107
+ */
108
+ function followChosenMode(conversationId, relayMode, updatedAt) {
109
+ try {
110
+ const existing = stmts.getConvAnyStatus?.get?.(conversationId) || null;
111
+ if (!existing || String(existing.status || '').trim() === 'deleted') return;
112
+ if ((normalizeRelayMode(existing.preferred_relay_mode) || '') === relayMode) return;
113
+ const persisted = persistConversationPreferences({
114
+ db,
115
+ stmts,
116
+ conversationId,
117
+ preferredRelayMode: relayMode,
118
+ preferredModel: existing.preferred_model,
119
+ preferredReasoningEffort: existing.preferred_reasoning_effort,
120
+ updatedAt,
121
+ });
122
+ if (!persisted.ok) return;
123
+ // No sender: the page that pressed the button has to follow as well.
124
+ io.emit('conversation_preferences_updated', {
125
+ conversationId,
126
+ preferredRelayMode: persisted.preferredRelayMode,
127
+ preferredModel: persisted.preferredModel,
128
+ preferredReasoningEffort: persisted.preferredReasoningEffort,
129
+ autoCompactWindow: persisted.autoCompactWindow ?? null,
130
+ thinkingEnabled: persisted.thinkingEnabled ?? null,
131
+ thinkingDisplay: persisted.thinkingDisplay ?? null,
132
+ updatedAt: persisted.updatedAt,
133
+ senderClientId: null,
134
+ });
135
+ } catch {}
136
+ }
137
+
99
138
  app.get('/api/relay-boards', auth, (req, res) => {
100
139
  const conversationId = req.query.conversationId ? String(req.query.conversationId) : null;
101
140
  const status = String(req.query.status || 'pending').trim() || 'pending';
141
+ // Every board of one conversation, settled ones included: a reply goes on
142
+ // showing the plan it offered and what was chosen. Per conversation only.
143
+ if (status === 'all') {
144
+ if (!conversationId) return res.status(400).json({ error: 'Missing conversationId' });
145
+ const all = stmts.listBoardsForConversation.all(conversationId);
146
+ return res.json({ boards: all.map(formatRelayBoardRow).filter(Boolean) });
147
+ }
102
148
  const rows = stmts.listBoards.all(status, conversationId, conversationId);
103
149
  res.json({ boards: rows.map(formatRelayBoardRow).filter(Boolean) });
104
150
  });
@@ -222,7 +268,11 @@ export function registerRelayBoardRoutes(app, deps) {
222
268
  String(resolvedVariantModel || '').trim().toLowerCase() === 'auto' ? 'auto' : 'manual',
223
269
  );
224
270
  stmts.updateConvTime.run(now, convId);
225
- stmts.insertQ.run(
271
+ // One value per placeholder of the queue insert: the context tier
272
+ // (none: the follow-up runs on the conversation's own) and, where the
273
+ // queue has the column, the image operation. Two short, the insert
274
+ // threw and the action answered 500 with the board already marked.
275
+ const queueArgs = [
226
276
  queuedMessageId,
227
277
  convId,
228
278
  runtimeSession?.id || null,
@@ -230,6 +280,7 @@ export function registerRelayBoardRoutes(app, deps) {
230
280
  resolvedBaseModel,
231
281
  resolvedVariantModel,
232
282
  resolvedReasoningEffort,
283
+ null,
233
284
  relayMode,
234
285
  followupPrompt,
235
286
  null,
@@ -238,7 +289,9 @@ export function registerRelayBoardRoutes(app, deps) {
238
289
  null,
239
290
  null,
240
291
  null,
241
- );
292
+ ];
293
+ if (stmts.queueHasImageOperationId) queueArgs.push(null);
294
+ stmts.insertQ.run(...queueArgs);
242
295
  io.emit('user_message', {
243
296
  conversationId: convId,
244
297
  messageId: queuedMessageId,
@@ -253,6 +306,7 @@ export function registerRelayBoardRoutes(app, deps) {
253
306
  timestamp: now,
254
307
  },
255
308
  });
309
+ followChosenMode(convId, relayMode, now);
256
310
  }
257
311
  }
258
312
 
@@ -42,12 +42,15 @@ export function registerRemoteRelayRoutes(app, deps) {
42
42
  });
43
43
  });
44
44
 
45
- // Workers ask at start whether to register the remote_relay tool at all.
45
+ // Workers ask at start whether to register the remote_relay tool at all:
46
+ // when a relay is paired (`count`), or when this relay's own sessions are
47
+ // open to its agents (`localEnabled`, the local target).
46
48
  app.get('/api/remote-relays/summary', auth, (_req, res) => {
47
49
  const relays = registry.listPublic();
48
50
  res.json({
49
51
  count: relays.length,
50
52
  online: relays.filter((relay) => relay.lastStatus === 'online').length,
53
+ localEnabled: registry.getAgentSessionsSettings?.().enabled === true,
51
54
  });
52
55
  });
53
56
 
@@ -111,6 +114,17 @@ export function registerRemoteRelayRoutes(app, deps) {
111
114
  sendResult(res, registry.setSelfSettings(req.body || {}));
112
115
  });
113
116
 
117
+ // Agent sessions: whether this relay's agents may start and use sessions on
118
+ // it, and the longest wait of one tool call. POST takes
119
+ // `{ enabled?, maxWaitSeconds? }` and answers the GET shape plus `ok`.
120
+ app.get('/api/settings/agent-sessions', auth, (_req, res) => {
121
+ res.json(registry.getAgentSessionsSettings());
122
+ });
123
+
124
+ app.post('/api/settings/agent-sessions', auth, (req, res) => {
125
+ sendResult(res, registry.setAgentSessionsSettings(req.body || {}));
126
+ });
127
+
114
128
  // PATCH /api/remote-relays/:id — { permission, url, token, tokenMode }.
115
129
  app.patch('/api/remote-relays/:id', auth, (req, res) => {
116
130
  sendResult(res, registry.update(req.params?.id, req.body || {}));