@oar-sh/oar 0.9.13 → 0.9.15

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 (74) hide show
  1. package/.github/extensions/web-relay/extension.mjs +9 -0
  2. package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +15 -1
  3. package/.github/extensions/web-relay/skills/question-bridge.mjs +4 -1
  4. package/CHANGELOG.md +396 -0
  5. package/README.md +12 -12
  6. package/bin/oar.js +677 -103
  7. package/package.json +7 -5
  8. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +10 -4
  9. package/server/claude-worker/claude-session-process.mjs +39 -13
  10. package/server/claude-worker/claude-turn-publisher.mjs +20 -1
  11. package/server/claude-worker/subagent-timeline-recorder.mjs +75 -8
  12. package/server/copilot-worker/copilot-ask-user-tool.mjs +1 -1
  13. package/server/copilot-worker/copilot-prompt-context.mjs +3 -1
  14. package/server/copilot-worker/copilot-question-bridge.mjs +11 -6
  15. package/server/copilot-worker/copilot-sdk-adapter.mjs +11 -0
  16. package/server/copilot-worker/copilot-sdk-session-process.mjs +14 -1
  17. package/server/cursor-worker/cursor-ask-user-tool.mjs +1 -1
  18. package/server/cursor-worker/cursor-sdk-adapter.mjs +22 -7
  19. package/server/cursor-worker/cursor-session-worker.mjs +9 -2
  20. package/server/cursor-worker/cursor-turn-runner.mjs +18 -3
  21. package/server/grok-worker/grok-sdk-adapter.mjs +14 -0
  22. package/server/grok-worker/grok-turn-runner.mjs +13 -3
  23. package/server/public/app/api-client.js +3 -1
  24. package/server/public/app/ask-user-view.js +29 -4
  25. package/server/public/app/conversation-provider-indicator.mjs +18 -8
  26. package/server/public/app/question-answer-rule.mjs +15 -0
  27. package/server/public/app/question-multi-select.mjs +9 -2
  28. package/server/public/app/relay-event-view.mjs +5 -1
  29. package/server/public/app/settings-modal.js +17 -3
  30. package/server/public/index.html +9 -5
  31. package/server/relay-exit-codes.mjs +6 -0
  32. package/server/relay-self-restart.mjs +6 -1
  33. package/server/relay-tools.md +14 -11
  34. package/server/repositories/message-repository.mjs +29 -28
  35. package/server/repositories/subagent-timeline-statements.mjs +2 -0
  36. package/server/routes/ask-user-routes.mjs +41 -7
  37. package/server/routes/messages-routes.mjs +65 -10
  38. package/server/routes/preview-routes.mjs +73 -7
  39. package/server/routes/sessions-routes.mjs +49 -16
  40. package/server/server-runtime.mjs +156 -94
  41. package/server/services/cursor-session-root-service.mjs +70 -6
  42. package/server/services/data-dir-resolution.mjs +208 -19
  43. package/server/services/earlier-database-service.mjs +631 -0
  44. package/server/services/oar-cli-helpers.mjs +18 -5
  45. package/server/services/oar-state-layout.mjs +107 -0
  46. package/server/services/oar-state-migration-service.mjs +387 -96
  47. package/server/services/package-data-rescue-preinstall.mjs +22 -0
  48. package/server/services/package-data-rescue.mjs +137 -0
  49. package/server/services/preview-registry-service.mjs +3 -2
  50. package/server/services/push-dispatch-service.mjs +33 -6
  51. package/server/services/relay-event-service.mjs +183 -5
  52. package/server/services/relay-restart-queue.mjs +10 -3
  53. package/server/services/relay-singleton-guard.mjs +300 -36
  54. package/server/services/remote-relay-caller-context.mjs +7 -2
  55. package/server/services/remote-relay-dispatcher.mjs +30 -9
  56. package/server/services/session-activity-tracker.mjs +52 -15
  57. package/server/services/session-worker-launch-service.mjs +31 -1
  58. package/server/services/stale-turn-recovery.mjs +99 -0
  59. package/server/services/subagent-timeline-service.mjs +4 -1
  60. package/server/services/windows-autostart-service.mjs +415 -45
  61. package/server/services/windows-boot-autostart-service.mjs +962 -185
  62. package/shared/preview-instructions.mjs +17 -12
  63. package/shared/preview-tool-core.mjs +32 -14
  64. package/shared/question-answer-rule.mjs +63 -0
  65. package/shared/relay-events.mjs +54 -4
  66. package/shared/remote-relay-contract.mjs +20 -10
  67. package/shared/subagent-timeline.mjs +4 -0
  68. package/shared/windows-system-tools.mjs +25 -0
  69. package/shared/worker-runtime/config-loader.mjs +54 -8
  70. package/shared/worker-runtime/prompt-context.mjs +3 -1
  71. package/shared/worker-runtime/working-directory.mjs +65 -0
  72. package/.github/extensions/web-relay/README.md +0 -190
  73. package/server/README.md +0 -2187
  74. package/server/uploads/image_input.md +0 -540
@@ -8,6 +8,7 @@ import {
8
8
  isTurnStalledError,
9
9
  resolveTurnStallWindows,
10
10
  } from '../../shared/worker-runtime/turn-liveness.mjs';
11
+ import { WORKING_DIRECTORY_MISSING_CODE, isWorkingDirectoryMissingError } from '../../shared/worker-runtime/working-directory.mjs';
11
12
 
12
13
  /**
13
14
  * The only module in the repo that imports `@cursor/sdk` (and its
@@ -422,6 +423,19 @@ export function classifyCursorError(error) {
422
423
  const message = String(error?.message || error);
423
424
  const isRetryable = error?.isRetryable === true;
424
425
  const name = String(error?.name || '');
426
+ // The conversation's folder is gone; the message names it.
427
+ if (isWorkingDirectoryMissingError(error)) {
428
+ return {
429
+ isAuth: false,
430
+ isBusy: false,
431
+ isRetryable: false,
432
+ code: WORKING_DIRECTORY_MISSING_CODE,
433
+ stableCode: `cursor.${WORKING_DIRECTORY_MISSING_CODE}`,
434
+ message,
435
+ text: message,
436
+ guidance: error.guidance,
437
+ };
438
+ }
425
439
  if (isTurnStalledError(error)) {
426
440
  return {
427
441
  isAuth: false,
@@ -611,12 +625,13 @@ const modelParameterDefsCache = new Map();
611
625
 
612
626
  /**
613
627
  * Map the composer's reasoning effort onto the model params Cursor expects on
614
- * `agent.send`. Effort lives in a per-model parameter named 'effort' or
615
- * 'reasoning' ('extra-high' is Cursor's spelling of 'xhigh'); models that also
616
- * expose a boolean 'thinking' parameter only honor the higher tiers with
617
- * thinking enabled, so an explicit effort turns thinking on and 'none' turns it
618
- * off. Best-effort like readModelContextWindow: null on any failure or when the
619
- * model has no matching parameter, which sends the model's default variant.
628
+ * `agent.send`. Effort lives in a per-model parameter named 'effort',
629
+ * 'reasoning' or 'reasoning_effort', consulted in that order ('extra-high' is
630
+ * Cursor's spelling of 'xhigh'); models that also expose a boolean 'thinking'
631
+ * parameter only honor the higher tiers with thinking enabled, so an explicit
632
+ * effort turns thinking on and 'none' turns it off. Best-effort like
633
+ * readModelContextWindow: null on any failure or when the model has no
634
+ * matching parameter, which sends the model's default variant.
620
635
  */
621
636
  export async function resolveCursorReasoningParams({
622
637
  apiKey,
@@ -651,7 +666,7 @@ export async function resolveCursorReasoningParams({
651
666
  const paramsById = new Map(
652
667
  parameters.map((param) => [String(param?.id || '').trim().toLowerCase(), param]),
653
668
  );
654
- const effortParam = paramsById.get('effort') || paramsById.get('reasoning');
669
+ const effortParam = paramsById.get('effort') || paramsById.get('reasoning') || paramsById.get('reasoning_effort');
655
670
  const hasThinkingParam = paramsById.has('thinking');
656
671
  const effortValues = new Set(
657
672
  (Array.isArray(effortParam?.values) ? effortParam.values : [])
@@ -17,6 +17,7 @@ import { installWorkerLogFile } from '../../shared/worker-runtime/worker-log-fil
17
17
  import { createControlPoller } from '../../shared/control-poller.mjs';
18
18
  import { installWorkerCrashGuard } from '../../shared/worker-crash-guard.mjs';
19
19
  import { createCursorTurnRunner } from './cursor-turn-runner.mjs';
20
+ import { resolveCursorSessionStoreDir } from '../services/cursor-session-root-service.mjs';
20
21
 
21
22
  // Windows console launch: the launcher names the worker log, this copies
22
23
  // stdout/stderr into it. A no-op wherever the launcher redirects instead.
@@ -57,8 +58,14 @@ async function main() {
57
58
  // renewal hint, which is more visible to the user than a dead worker.
58
59
  console.warn('cursor-session-worker: CURSOR_API_KEY is not set; turns will fail until it is provided');
59
60
  }
60
- const storeDir = String(process.env.CURSOR_AGENT_STORE_DIR || '').trim()
61
- || path.resolve(__dirname, '..', 'data', 'cursor-agents');
61
+ // `$CURSOR_AGENT_STORE_DIR`, else `cursor-agents` in the relay's data dir:
62
+ // the same folder the relay's Session root looks in.
63
+ const storeDir = resolveCursorSessionStoreDir({
64
+ env: process.env,
65
+ serverDir: path.resolve(__dirname, '..'),
66
+ configPath,
67
+ sdkSessionId,
68
+ });
62
69
 
63
70
  const api = createApiClient({
64
71
  serverUrl,
@@ -23,6 +23,7 @@ import { renderMediaEmbedInstructionBlock } from '../../shared/media-embed-instr
23
23
  import { EMPTY_TURN_COMPLETION_NOTE } from '../../shared/empty-turn-completion.mjs';
24
24
  import { resolveFallbackContextLimitTokens } from '../../shared/context-window-fallbacks.mjs';
25
25
  import { countPlanLikeLines } from '../../shared/plan-lines.mjs';
26
+ import { explainLaunchFailure, missingWorkingDirectoryError } from '../../shared/worker-runtime/working-directory.mjs';
26
27
 
27
28
  // Stale-auth recovery budget per turn: expiry is routine on long-lived
28
29
  // handles, and one retry proved too small in practice (an orchestrator turn
@@ -122,6 +123,9 @@ export function createCursorTurnRunner({
122
123
  resolveModelParamsImpl = resolveCursorReasoningParams,
123
124
  createNormalizerImpl = createSdkMessageNormalizer,
124
125
  buildUserMessageImpl = buildCursorUserMessage,
126
+ // The error an agent started in `cwd` fails with up front, or null
127
+ // (shared/worker-runtime/working-directory.mjs).
128
+ checkWorkingDirectoryImpl = missingWorkingDirectoryError,
125
129
  // Whether agent handles carry the relay's `remote_relay` tool
126
130
  // (shared/remote-relay-tool-core.mjs). Default: ask the relay.
127
131
  remoteRelayToolGate = null,
@@ -174,7 +178,9 @@ export function createCursorTurnRunner({
174
178
  // plain refusal the model can relay instead of a failed turn.
175
179
  const previewTool = createPreviewTool({
176
180
  api,
177
- getConversationId: () => activeMessage?.conversationId || '',
181
+ // Between turns, the worker's own session (as remote_relay below): list
182
+ // and close are scoped to a conversation and refused without one.
183
+ getConversationId: () => activeMessage?.conversationId || sdkSessionId || '',
178
184
  dbg,
179
185
  });
180
186
  const customTools = {
@@ -301,6 +307,10 @@ export function createCursorTurnRunner({
301
307
  agentHandle = null;
302
308
  }
303
309
  if (!agentHandle) {
310
+ // The agent works in the conversation's folder: a folder that is gone
311
+ // fails the turn with its name (and is no reason to drop the agent).
312
+ const missingFolder = checkWorkingDirectoryImpl(cwd);
313
+ if (missingFolder) throw missingFolder;
304
314
  const durableId = String(message.cursorAgentId || '').trim() || cursorAgentId;
305
315
  const agents = buildCursorSubagentAgents(rosterModels);
306
316
  await remoteRelayGate.ready();
@@ -309,11 +319,15 @@ export function createCursorTurnRunner({
309
319
  try {
310
320
  agentHandle = await createAgentHandleImpl({ ...options, agentId: durableId });
311
321
  } catch (error) {
312
- if (!durableId) throw error;
322
+ if (!durableId) throw explainLaunchFailure(error, cwd, { check: checkWorkingDirectoryImpl });
313
323
  // The durable agent may have been pruned server-side; fall back to a
314
324
  // fresh agent rather than wedging the conversation.
315
325
  dbg('cursor agent resume failed, creating fresh agent', error?.message || String(error));
316
- agentHandle = await createAgentHandleImpl({ ...options, agentId: '' });
326
+ try {
327
+ agentHandle = await createAgentHandleImpl({ ...options, agentId: '' });
328
+ } catch (freshError) {
329
+ throw explainLaunchFailure(freshError, cwd, { check: checkWorkingDirectoryImpl });
330
+ }
317
331
  }
318
332
  }
319
333
  // Only recorded once a handle exists: a create that threw leaves the
@@ -787,6 +801,7 @@ export function createCursorTurnRunner({
787
801
  // A stall is told in the user's terms, and with its own advice: the
788
802
  // run was cancelled, so sending the message again is all it takes.
789
803
  message: classified.text || classified.message,
804
+ ...(classified.guidance ? { guidance: classified.guidance } : {}),
790
805
  ...(classified.isStalled ? { guidance: 'Send the message again to retry.' } : {}),
791
806
  failedAt: new Date().toISOString(),
792
807
  queueMessageId: String(message.id || '') || null,
@@ -6,12 +6,26 @@ import { AcpClient, extractGrokModelsFromInitialize, pickAllowOnceOptionId } fro
6
6
  import { createAcpHostServices } from './acp-host-services.mjs';
7
7
  import { createSdkMessageNormalizer } from './sdk-message-normalizer.mjs';
8
8
  import { describeTurnStall, isTurnStalledError } from '../../shared/worker-runtime/turn-liveness.mjs';
9
+ import { WORKING_DIRECTORY_MISSING_CODE, isWorkingDirectoryMissingError } from '../../shared/worker-runtime/working-directory.mjs';
9
10
 
10
11
  export { extractGrokModelsFromInitialize };
11
12
 
12
13
  export function classifyGrokError(error) {
13
14
  const message = String(error?.message || error || '').trim();
14
15
  const lower = message.toLowerCase();
16
+ // First, and above all before the ENOENT branch: a spawn in a folder that
17
+ // is gone fails with ENOENT too, and the runner puts it down to the folder,
18
+ // whose path may hold any of the words matched below.
19
+ if (isWorkingDirectoryMissingError(error)) {
20
+ return {
21
+ code: `grok.${WORKING_DIRECTORY_MISSING_CODE}`,
22
+ message,
23
+ text: message,
24
+ guidance: error.guidance,
25
+ isAuth: false,
26
+ isBusy: false,
27
+ };
28
+ }
15
29
  // No bare 'auth' token: it would match unrelated words ("author…") in
16
30
  // arbitrary agent error text and misclassify them as login failures.
17
31
  if (
@@ -12,6 +12,7 @@ import { fetchRemoteRelayInflight } from '../../shared/remote-relay-tool-core.mj
12
12
  import { renderMediaEmbedInstructionBlock } from '../../shared/media-embed-instructions.mjs';
13
13
  import { countPlanLikeLines } from '../../shared/plan-lines.mjs';
14
14
  import { EMPTY_TURN_COMPLETION_NOTE } from '../../shared/empty-turn-completion.mjs';
15
+ import { explainLaunchFailure, missingWorkingDirectoryError } from '../../shared/worker-runtime/working-directory.mjs';
15
16
 
16
17
  const PLAN_BOARD_ACTIONS = [
17
18
  { id: 'autopilot', label: 'Implement in autopilot', mode: 'autopilot' },
@@ -64,6 +65,7 @@ function buildGrokTerminalError(classified, message) {
64
65
  code: classified.code,
65
66
  stableCode: classified.code,
66
67
  message: classified.text || classified.message,
68
+ ...(classified.guidance ? { guidance: classified.guidance } : {}),
67
69
  // A stalled turn was cancelled and its agent is restarted for the next
68
70
  // message, so sending the message again is all it takes.
69
71
  ...(classified.isStalled ? { guidance: 'Send the message again to retry.' } : {}),
@@ -115,6 +117,9 @@ export function createGrokTurnRunner({
115
117
  createAgentHandleImpl = createGrokAgentHandle,
116
118
  startGrokTurnImpl = startGrokTurn,
117
119
  classifyErrorImpl = classifyGrokError,
120
+ // The error a Grok CLI started in `cwd` fails with up front, or null
121
+ // (shared/worker-runtime/working-directory.mjs).
122
+ checkWorkingDirectoryImpl = missingWorkingDirectoryError,
118
123
  getPreviewInstructions = createPreviewInstructionsProvider({ api }),
119
124
  env = process.env,
120
125
  nodePath = process.execPath,
@@ -200,6 +205,11 @@ export function createGrokTurnRunner({
200
205
 
201
206
  async function ensureAgentHandle(message, model) {
202
207
  if (agentHandle) return agentHandle;
208
+ // The CLI is spawned in the conversation's folder; a folder that is gone
209
+ // fails with ENOENT, which would otherwise read as a CLI that is not
210
+ // installed (classifyGrokError).
211
+ const missingFolder = checkWorkingDirectoryImpl(cwd);
212
+ if (missingFolder) throw missingFolder;
203
213
  const resumeId = String(message.grokNativeSessionId || grokNativeSessionId || '').trim();
204
214
  agentHandle = await createAgentHandleImpl({
205
215
  command,
@@ -215,7 +225,7 @@ export function createGrokTurnRunner({
215
225
  ? { AcpClientImpl: withAcpMcpServers(AcpClient, acpMcpServers), mcpServers: acpMcpServers }
216
226
  : {}),
217
227
  dbg,
218
- });
228
+ }).catch((error) => { throw explainLaunchFailure(error, cwd, { check: checkWorkingDirectoryImpl }); });
219
229
  if (agentHandle.sessionId) {
220
230
  await persistNativeSessionId(message, agentHandle.sessionId);
221
231
  }
@@ -427,7 +437,7 @@ export function createGrokTurnRunner({
427
437
  const previewBlock = previewInstructionsSent || acpMcpServers.length
428
438
  ? ''
429
439
  : await Promise.resolve()
430
- .then(() => getPreviewInstructions?.())
440
+ .then(() => getPreviewInstructions?.({ conversationId: message.conversationId || sdkSessionId || '' }))
431
441
  .catch(() => '');
432
442
  const mediaBlock = mediaInstructionsSent ? '' : renderMediaEmbedInstructionBlock();
433
443
  const promptText = [previewBlock, mediaBlock, modeNudge, userText].filter(Boolean).join('\n\n');
@@ -594,7 +604,7 @@ export function createGrokTurnRunner({
594
604
  : classified.isStalled
595
605
  ? classified.text
596
606
  || `System note: the Grok turn stalled and was stopped (${classified.message}). Send a new message to retry.`
597
- : `System note: the Grok turn failed (${classified.message}).`;
607
+ : classified.text || `System note: the Grok turn failed (${classified.message}).`;
598
608
  await publishResponse(message, {
599
609
  text: noteText,
600
610
  model: message.providerModel || defaultModel || null,
@@ -1560,13 +1560,15 @@ export async function loadRelayQuestion(questionId) {
1560
1560
  return apiFetch(`/api/relay-question/${encodeURIComponent(id)}`);
1561
1561
  }
1562
1562
 
1563
- export async function answerRelayQuestion(questionId, answer, sdkSessionId = null) {
1563
+ // `onErrorResponse` reads the relay's refusal (why, and a card's choices).
1564
+ export async function answerRelayQuestion(questionId, answer, sdkSessionId = null, { onErrorResponse } = {}) {
1564
1565
  const id = String(questionId || '').trim();
1565
1566
  if (!id) return null;
1566
1567
  const sessionId = String(sdkSessionId || '').trim();
1567
1568
  return apiFetch(`/api/relay-question/${encodeURIComponent(id)}/answer`, {
1568
1569
  method: 'POST',
1569
1570
  body: JSON.stringify({ answer, sdk_session_id: sessionId || undefined }),
1571
+ onErrorResponse,
1570
1572
  });
1571
1573
  }
1572
1574
 
@@ -14,6 +14,7 @@ import { schemaFieldsFromQuestion } from './question-schema-view.mjs';
14
14
  import { composeMultiSelectAnswer, isMultiSelectQuestion, offersMultiSelectToggle } from './question-multi-select.mjs';
15
15
  import { isChatInteractionHeld } from './selection-guard.mjs';
16
16
  import { questionCardHeadLabel } from './conversation-provider-indicator.mjs';
17
+ import { questionAcceptsFreeText } from './question-answer-rule.mjs';
17
18
  import { beginOwnSend, followsTranscriptEnd, refreshJumpButton, scrollAfterOwnSend } from './transcript-jump-bottom-view.mjs';
18
19
 
19
20
  let relayQuestionRenderHash = '';
@@ -265,6 +266,10 @@ export function renderRelayQuestions() {
265
266
  const modeTag = question.mode ? ` <span class="msg-mode">${escHtml(question.mode)}</span>` : '';
266
267
  const contextText = String(question?.context?.rationale || '').trim();
267
268
  const contextHtml = contextText ? `<div class="relay-question-context">${renderLinkedPlainText(contextText)}</div>` : '';
269
+ // The card's own title ("Agent sessions", "Remote relay", a Claude
270
+ // question's header), above the question.
271
+ const headerText = String(question?.context?.header || '').trim();
272
+ const headerHtml = headerText ? `<div class="relay-question-header">${escHtml(headerText)}</div>` : '';
268
273
  const fields = schemaFieldsFromQuestion(question);
269
274
  const structuredQuestion = fields.length > 0;
270
275
 
@@ -296,14 +301,21 @@ export function renderRelayQuestions() {
296
301
  choices.map((choice) => `<button class="relay-question-choice" disabled>${escHtml(choice)}</button>`).join('')
297
302
  }</div>`;
298
303
  }
299
- const showReplyControls = question.status === 'pending';
304
+ // A card that takes no typed text (approval and permission cards) is
305
+ // answered by its choices alone: no reply box, and a multi-select one
306
+ // keeps only its "Reply with selection".
307
+ const freeText = questionAcceptsFreeText(question);
308
+ const showReplyControls = question.status === 'pending' && (freeText || multiSelect);
300
309
  const draftText = String(relayQuestionDrafts.get(question.id) || '');
301
310
  const replyPlaceholder = multiSelect
302
311
  ? 'Add another answer or a note (optional)… (Ctrl/Cmd+Enter to send)'
303
312
  : 'Type a reply… (Ctrl/Cmd+Enter to send)';
313
+ const replyInputHtml = freeText
314
+ ? `<textarea id="relay-question-input-${question.id}" placeholder="${replyPlaceholder}" onkeydown="handleRelayQuestionKey(event, '${question.id}')" oninput="onRelayQuestionDraftInput('${question.id}', this.value)">${escHtml(draftText)}</textarea>`
315
+ : '';
304
316
  const replyHtml = showReplyControls
305
317
  ? `<div class="relay-question-reply">
306
- <textarea id="relay-question-input-${question.id}" placeholder="${replyPlaceholder}" onkeydown="handleRelayQuestionKey(event, '${question.id}')" oninput="onRelayQuestionDraftInput('${question.id}', this.value)">${escHtml(draftText)}</textarea>
318
+ ${replyInputHtml}
307
319
  <button class="relay-question-submit" onclick="${multiSelect ? 'submitRelayQuestionMultiSelect' : 'submitRelayQuestionAnswer'}('${question.id}')">${multiSelect ? 'Reply with selection' : 'Reply'}</button>
308
320
  </div>`
309
321
  : '';
@@ -320,6 +332,7 @@ export function renderRelayQuestions() {
320
332
  wrapper.innerHTML = `
321
333
  <div class="relay-question-card${question.status === 'timed_out' ? ' relay-question-timed-out' : ''}">
322
334
  <div class="relay-question-head">${questionCardHeadLabel(conversations[question.conversationId])}${modeTag} · ${fmtDate(question.createdAt)}</div>
335
+ ${headerHtml}
323
336
  <div class="relay-question-body">${renderLinkedPlainText(question.prompt || '')}</div>
324
337
  ${contextHtml}
325
338
  ${answeredHtml}
@@ -580,6 +593,15 @@ async function enqueueRelayAnswerForSync(questionId, body) {
580
593
  return queued;
581
594
  }
582
595
 
596
+ // The relay's reason for refusing an answer (a card that takes only its
597
+ // choices names them), or '' when it gave none.
598
+ function relayAnswerRefusalText(refusal) {
599
+ const reason = String(refusal?.error || '').trim();
600
+ if (!reason) return '';
601
+ const choices = Array.isArray(refusal?.choices) ? refusal.choices.map((choice) => String(choice)).filter(Boolean) : [];
602
+ return choices.length ? `${reason}. Choices: ${choices.join(', ')}.` : reason;
603
+ }
604
+
583
605
  export async function submitRelayQuestionAnswer(questionId, presetAnswer = null) {
584
606
  const input = document.getElementById(`relay-question-input-${questionId}`);
585
607
  // Ctrl/Cmd+Enter in a multi-select card's text field sends the ticked
@@ -600,15 +622,18 @@ export async function submitRelayQuestionAnswer(questionId, presetAnswer = null)
600
622
  const question = relayQuestions.get(questionId) || null;
601
623
  const sdkSessionId = String(question?.sdkSessionId || '').trim();
602
624
  const singleFieldStructuredAnswer = resolveSingleFieldStructuredAnswer(question, answer);
625
+ let refusal = null;
603
626
  const r = singleFieldStructuredAnswer
604
627
  ? await answerRelayQuestionStructured(questionId, singleFieldStructuredAnswer, sdkSessionId || null)
605
- : await answerRelayQuestion(questionId, answer, sdkSessionId || null);
628
+ : await answerRelayQuestion(questionId, answer, sdkSessionId || null, {
629
+ onErrorResponse: async (response) => { refusal = await response.json().catch(() => null); },
630
+ });
606
631
  if (!r?.question) {
607
632
  const queued = await enqueueRelayAnswerForSync(questionId, singleFieldStructuredAnswer
608
633
  ? { structuredAnswer: singleFieldStructuredAnswer, sdk_session_id: sdkSessionId || undefined }
609
634
  : { answer, sdk_session_id: sdkSessionId || undefined });
610
635
  if (queued) return;
611
- throw new Error('Failed to submit relay question answer');
636
+ throw new Error(relayAnswerRefusalText(refusal) || 'Failed to submit relay question answer');
612
637
  }
613
638
  relayQuestionDrafts.delete(questionId);
614
639
  relayQuestionStructuredDrafts.delete(questionId);
@@ -33,15 +33,25 @@ export function isConversationUsingClaudeCloudProvider(conversation = null) {
33
33
  // The marker that tells a cloud chat from a local Claude one at a glance.
34
34
  const CLAUDE_CLOUD_MARKER = '☁';
35
35
 
36
+ const PROVIDER_NAMES = Object.freeze({
37
+ openai: 'OpenAI',
38
+ claude: 'Claude',
39
+ 'claude-cloud': 'Claude Cloud',
40
+ cursor: 'Cursor',
41
+ grok: 'Grok',
42
+ github: 'Copilot',
43
+ 'github-copilot': 'Copilot',
44
+ });
45
+
46
+ // A provider type's name as the list pill and the question card say it; ''
47
+ // when unknown. The relay's question push uses it too
48
+ // (services/push-dispatch-service.mjs).
49
+ export function providerDisplayName(providerType = '') {
50
+ return PROVIDER_NAMES[String(providerType || '').trim().toLowerCase()] || '';
51
+ }
52
+
36
53
  function conversationProviderName(conversation = null) {
37
- const providerType = resolveConversationProviderType(conversation);
38
- if (providerType === 'openai') return 'OpenAI';
39
- if (providerType === 'claude') return 'Claude';
40
- if (providerType === 'claude-cloud') return 'Claude Cloud';
41
- if (providerType === 'cursor') return 'Cursor';
42
- if (providerType === 'grok') return 'Grok';
43
- if (providerType === 'github' || providerType === 'github-copilot') return 'Copilot';
44
- return '';
54
+ return providerDisplayName(resolveConversationProviderType(conversation));
45
55
  }
46
56
 
47
57
  // An unresolved provider stays unlabelled rather than defaulting to Copilot, so
@@ -0,0 +1,15 @@
1
+ // Browser mirror of questionAcceptsFreeText in shared/question-answer-rule.mjs
2
+ // (the relay's answer route applies the same rule). The browser cannot import
3
+ // shared/ (only server/public is served), so this is a hand-kept copy;
4
+ // question-answer-rule.test.mjs asserts it behaves exactly like the original.
5
+
6
+ /**
7
+ * Whether a card takes typed text. A card with choices takes it only when it
8
+ * says so (`allowFreeform: true`); a missing flag counts as false there, as
9
+ * the create route and the relay's question rows read it. Without choices a
10
+ * card always does: it could not be answered otherwise.
11
+ */
12
+ export function questionAcceptsFreeText(question) {
13
+ const choices = Array.isArray(question?.choices) ? question.choices : [];
14
+ return question?.allowFreeform === true || choices.length === 0;
15
+ }
@@ -14,14 +14,21 @@
14
14
  // answer is the selected labels joined with ", " — plus whatever the user
15
15
  // typed as an extra answer — which is Claude's own multi-select answer format.
16
16
 
17
+ import { questionAcceptsFreeText } from './question-answer-rule.mjs';
18
+
17
19
  function choiceCount(question) {
18
20
  return Array.isArray(question?.choices) ? question.choices.length : 0;
19
21
  }
20
22
 
21
- /** Whether the card offers the "Select several" switch. */
23
+ /**
24
+ * Whether the card offers the "Select several" switch. Never on a card that
25
+ * takes no typed text: the relay takes several choices there only when the
26
+ * provider flagged the card multi-select.
27
+ */
22
28
  export function offersMultiSelectToggle(question) {
23
29
  if (!question || typeof question !== 'object') return false;
24
- return choiceCount(question) >= 2 && question?.context?.allowMultiSelect === true;
30
+ return choiceCount(question) >= 2 && question?.context?.allowMultiSelect === true
31
+ && questionAcceptsFreeText(question);
25
32
  }
26
33
 
27
34
  /**
@@ -24,6 +24,7 @@ const EVENT_TYPES = new Set([
24
24
  'heartbeat',
25
25
  'link_lost',
26
26
  'link_restored',
27
+ 'relay_restarted',
27
28
  ]);
28
29
  const URGENT_TYPES = new Set(['question_asked', 'error']);
29
30
  const DIGEST_PATTERN = /^Digest of \d+ events?/;
@@ -43,6 +44,7 @@ const TYPE_LABELS = Object.freeze({
43
44
  heartbeat: 'heartbeat',
44
45
  link_lost: 'link lost',
45
46
  link_restored: 'link back',
47
+ relay_restarted: 'relay restarted',
46
48
  });
47
49
 
48
50
  function defaultEscape(value) {
@@ -181,6 +183,8 @@ export function watchersOf(subscriptions, conversationId, { includeAll = true }
181
183
  export function watchingChipText(summary) {
182
184
  if (!summary?.subscriptions?.length) return '';
183
185
  if (summary.all) return summary.count ? `watching all sessions (+${summary.count} listed)` : 'watching all sessions';
186
+ // Only relay_restarted: no session is watched.
187
+ if (!summary.count) return 'waiting for a relay restart';
184
188
  return `watching ${summary.count} session${summary.count === 1 ? '' : 's'}`;
185
189
  }
186
190
 
@@ -198,7 +202,7 @@ function describeSubscription(sub, conversations) {
198
202
  const listed = (Array.isArray(sub.sessions) ? sub.sessions : []).filter((session) => session?.id).map(titleOf).join(', ');
199
203
  const what = sub.scope === 'all'
200
204
  ? (paired ? `All sessions of ${relay}` : 'All sessions')
201
- : (paired ? `${relay} · ${listed}` : listed);
205
+ : (paired ? `${relay} · ${listed}` : (listed || 'This relay'));
202
206
  const extras = [
203
207
  sub.once ? 'once' : '',
204
208
  sub.heartbeatMinutes ? `heartbeat ${sub.heartbeatMinutes} min` : '',
@@ -1502,6 +1502,11 @@ const WINDOWS_BOOT_CUSTOMISATION_LABELS = Object.freeze({
1502
1502
  'boot-delay': 'boot delay',
1503
1503
  launcher: 'launcher edited or from an older OAR',
1504
1504
  'launcher-missing': 'launcher missing',
1505
+ user: 'account',
1506
+ 'run-level': 'run level',
1507
+ settings: 'settings',
1508
+ other: 'other parts',
1509
+ unreadable: 'unreadable',
1505
1510
  });
1506
1511
 
1507
1512
  function windowsAutostartStatusText() {
@@ -1511,21 +1516,30 @@ function windowsAutostartStatusText() {
1511
1516
  }
1512
1517
  if (boot?.lastError) return boot.lastError;
1513
1518
  if (boot?.readError) return `The boot task could not be read: ${boot.readError}`;
1519
+ const [startupEntry] = windowsAutostartState?.signinHandMade || [];
1520
+ if (startupEntry) {
1521
+ const starts = startupEntry.otherInstall ? ` (it starts the OAR install in ${startupEntry.otherInstall})` : '';
1522
+ return `A Startup entry OAR did not make is there: ${startupEntry.path}${starts}. OAR leaves it as it is; to replace it, keeping a copy: oar autostart signin --replace.`;
1523
+ }
1514
1524
  if (windowsAutostartState?.mode === 'boot' && windowsAutostartState?.signinEnabled) {
1515
1525
  return 'A sign-in Startup entry also exists and can double-start the relay. Re-select "At system startup" to clean it up.';
1516
1526
  }
1527
+ // OAR never deletes or writes over an entry it did not make (as `oar autostart`).
1528
+ const replaceHint = 'OAR leaves it as it is; to replace it, keeping a copy: oar autostart boot --replace';
1517
1529
  if (windowsAutostartState?.mode === 'boot' && boot?.customised) {
1518
1530
  const labels = (boot.customisations || []).map((name) => WINDOWS_BOOT_CUSTOMISATION_LABELS[name] || name);
1519
- return `The boot task or its launcher differs from what OAR generates (${labels.join(', ')}). Re-selecting "At system startup" regenerates both and keeps a changed launcher as a .bak copy.`;
1531
+ return boot.handMade?.length
1532
+ ? `The boot task or its launcher was changed by hand (${labels.join(', ')}). ${replaceHint}.`
1533
+ : `The boot task or its launcher differs from what OAR generates (${labels.join(', ')}). Re-selecting "At system startup" regenerates it.`;
1520
1534
  }
1521
1535
  if (windowsAutostartState?.mode === 'boot' && boot?.launcherBackupPath) {
1522
1536
  return `The previous launcher was replaced and kept as ${boot.launcherBackupPath}.`;
1523
1537
  }
1524
1538
  if (boot?.taskStatus === 'foreign') {
1525
- return `A task named "${boot.taskName}" exists but was not created by OAR. Selecting "At system startup" replaces it.`;
1539
+ return `A task named "${boot.taskName}" exists but was not created by OAR. ${replaceHint}.`;
1526
1540
  }
1527
1541
  if (boot?.legacyTaskPresent && windowsAutostartState?.mode !== 'boot') {
1528
- return 'A pre-rebrand boot task exists. Selecting "At system startup" migrates it (one admin confirmation).';
1542
+ return `A pre-rebrand boot task exists. ${replaceHint}.`;
1529
1543
  }
1530
1544
  return '';
1531
1545
  }
@@ -5905,6 +5905,10 @@
5905
5905
  color: var(--muted);
5906
5906
  font-size: var(--fs-meta-xs);
5907
5907
  }
5908
+ .relay-question-header {
5909
+ font-weight: 600;
5910
+ color: var(--text);
5911
+ }
5908
5912
  .relay-question-body {
5909
5913
  line-height: 1.5;
5910
5914
  color: var(--text);
@@ -7190,7 +7194,7 @@
7190
7194
  checked
7191
7195
  >
7192
7196
  </div>
7193
- <div class="settings-help" style="margin-top: 6px;">Hide the menu action when host suspend is unsupported or not needed on this device.</div>
7197
+ <div class="settings-help" style="margin-top: 6px;">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>
7194
7198
  <div class="settings-row" style="margin-top: 12px; margin-bottom: 0;">
7195
7199
  <label for="scroll-on-send-toggle">Jump to the end when I send a message</label>
7196
7200
  <input
@@ -7232,7 +7236,7 @@
7232
7236
  onchange="updateTurnCeilingSetting(this.value)"
7233
7237
  >
7234
7238
  </div>
7235
- <div class="settings-help" style="margin-top: 6px;">A hard cap on how long one turn may run before the relay requeues it. Turns are already kept alive by worker heartbeats, and a turn waiting on your answer to a question is never cut off — so this only catches a worker that has hung. Set to 0 for no limit.</div>
7239
+ <div class="settings-help" style="margin-top: 6px;">A hard cap on how long one turn may run before the relay requeues it, counted from the start of the turn or from your last answer to one of its questions. Turns are already kept alive by worker heartbeats, and the relay never cuts off a turn waiting on your answer to a question — so this only catches a worker that has hung. Grok also enforces the limit itself, counted from the start of the prompt and without pausing for questions (remote relay approvals). Set to 0 for no limit.</div>
7236
7240
  <div class="settings-row settings-row-column" style="margin-top: 12px; margin-bottom: 0;">
7237
7241
  <label for="background-task-timeout-slider">Background task timeout <span id="background-task-timeout-value">4 h</span></label>
7238
7242
  <input
@@ -7489,7 +7493,7 @@
7489
7493
  <div style="display: flex; gap: 8px; margin-top: 8px;">
7490
7494
  <button id="copilot-save-btn" type="button" class="summary-btn" onclick="saveCopilotSettings()">Save Copilot settings</button>
7491
7495
  </div>
7492
- <div class="settings-help">Chooses how GitHub Copilot conversations run. <strong>SDK</strong> is the default: a headless worker per conversation with mid-turn steering and background task cards — no CLI extension to install, but no tmux inspector for those sessions. <strong>Extension</strong> runs the Copilot CLI in a terminal session, which you can attach to with the tmux inspector. A relay that cannot run the SDK engine uses Extension. Applies to newly started conversations; sessions already running keep their engine until their worker restarts.</div>
7496
+ <div class="settings-help">Chooses how GitHub Copilot conversations run. <strong>SDK</strong> is the default: a headless worker per conversation with mid-turn steering and background task cards — no CLI extension to install, but no tmux inspector for those sessions. <strong>Extension</strong> runs the Copilot CLI in a terminal session, which you can attach to with the tmux inspector. A relay where no engine was saved and that cannot run the SDK engine uses Extension. Applies whenever a conversation's worker starts; sessions already running keep their engine until their worker restarts.</div>
7493
7497
  </div>
7494
7498
  </div>
7495
7499
  <div
@@ -7883,7 +7887,7 @@
7883
7887
  <button id="cursor-dashboard-token-save-btn" type="button" class="summary-btn" onclick="saveCursorDashboardToken()">Save token</button>
7884
7888
  <button id="cursor-dashboard-token-remove-btn" type="button" class="summary-btn" onclick="removeCursorDashboardToken()">Remove</button>
7885
7889
  </div>
7886
- <div class="settings-help">Unlocks the live "Included in plan" percentage bars on Check Usage. Detected automatically from this machine's Cursor IDE login when one exists; paste the <code>WorkosCursorSessionToken</code> cookie from a logged-in cursor.com tab (DevTools → Application → Cookies) only for headless relay hosts or to use a different account. Headless hosts can also set <code>CURSOR_SESSION_TOKEN</code> in the relay environment. Tokens stay on the relay.</div>
7890
+ <div class="settings-help">Unlocks the live percentage bars on Check Usage: "Plan usage (total)", "Auto + Composer" and "API". Detected automatically from this machine's Cursor IDE login when one exists; paste the <code>WorkosCursorSessionToken</code> cookie from a logged-in cursor.com tab (DevTools → Application → Cookies) only for headless relay hosts or to use a different account. Headless hosts can also set <code>CURSOR_SESSION_TOKEN</code> in the relay environment. Tokens stay on the relay.</div>
7887
7891
  </div>
7888
7892
  </div>
7889
7893
  </div>
@@ -7929,7 +7933,7 @@
7929
7933
  checked
7930
7934
  >
7931
7935
  </div>
7932
- <div class="settings-help" style="margin-top: 6px;">When off, agents on paired relays can still read sessions here, but cannot send prompts, start sessions, answer questions or stop turns. The relay token stays the real boundary: anyone holding it has full access.</div>
7936
+ <div class="settings-help" style="margin-top: 6px;">When off, agents on paired relays can still list, read and wait on sessions here, but cannot send prompts, start sessions, answer questions, stop turns, cancel queued turns, archive or unarchive sessions, or watch sessions here for events. The relay token stays the real boundary: anyone holding it has full access.</div>
7933
7937
  <!-- Agent sessions (agent-sessions-settings-ui.js). Hidden until the relay
7934
7938
  answers with settings; a relay without the route never shows it. -->
7935
7939
  <div id="agent-sessions-section" hidden>
@@ -2,3 +2,9 @@
2
2
 
3
3
  // Intentional relay restart requested via authenticated localhost API.
4
4
  export const RELAY_RESTART_EXIT_CODE = 75;
5
+ // The runtime stopped on purpose and starting it again at once cannot help:
6
+ // its database needs a decision (adoptEarlierDatabase refused), or its data
7
+ // folder cannot be made (ensureDataDirs). The supervisors do not retry it, so
8
+ // its one line is said once; the code goes on to whatever started the relay.
9
+ // 78 is sysexits' EX_CONFIG.
10
+ export const RELAY_STOP_EXIT_CODE = 78;
@@ -1,6 +1,6 @@
1
1
  import { spawn } from 'child_process';
2
2
  import path from 'path';
3
- import { RELAY_RESTART_EXIT_CODE } from './relay-exit-codes.mjs';
3
+ import { RELAY_RESTART_EXIT_CODE, RELAY_STOP_EXIT_CODE } from './relay-exit-codes.mjs';
4
4
 
5
5
  // Role selection travels on argv, never in the environment. The runtime's env is
6
6
  // inherited by tmux worker sessions and by the Copilot CLI it launches, and the
@@ -151,6 +151,11 @@ export async function runDirectRelaySupervisor({
151
151
  return await exitImpl(0);
152
152
  }
153
153
 
154
+ // Stopped on purpose, with its reason already said: not a crash.
155
+ if (exitCode === RELAY_STOP_EXIT_CODE) {
156
+ return await exitImpl(RELAY_STOP_EXIT_CODE);
157
+ }
158
+
154
159
  const attempt = crashCount + 1;
155
160
  logger?.error?.(`[relay] runtime crashed with exit code ${exitCode ?? 'null'} (crash ${attempt}/${maxCrashRestarts}).`);
156
161
  if (attempt >= maxCrashRestarts) {