@oar-sh/oar 0.9.5 → 0.9.6

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 (63) 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 +168 -0
  4. package/README.md +20 -4
  5. package/package.json +2 -1
  6. package/server/claude-worker/claude-session-process.mjs +118 -1
  7. package/server/claude-worker/claude-turn-failure.mjs +92 -0
  8. package/server/claude-worker/claude-turn-publisher.mjs +52 -3
  9. package/server/claude-worker/sdk-message-normalizer.mjs +13 -1
  10. package/server/copilot-worker/copilot-model-switch.mjs +29 -3
  11. package/server/copilot-worker/copilot-question-bridge.mjs +2 -4
  12. package/server/copilot-worker/copilot-runtime-tree.mjs +91 -0
  13. package/server/copilot-worker/copilot-sdk-adapter.mjs +48 -8
  14. package/server/copilot-worker/copilot-sdk-session-process.mjs +897 -166
  15. package/server/cursor-worker/cursor-sdk-adapter.mjs +80 -13
  16. package/server/cursor-worker/cursor-turn-runner.mjs +5 -2
  17. package/server/db-schema.mjs +7 -0
  18. package/server/grok-worker/acp-client.mjs +68 -12
  19. package/server/grok-worker/grok-sdk-adapter.mjs +4 -0
  20. package/server/grok-worker/grok-turn-runner.mjs +6 -2
  21. package/server/public/app/api-client.js +17 -2
  22. package/server/public/app/bootstrap.js +3 -0
  23. package/server/public/app/composer-control-state.mjs +4 -3
  24. package/server/public/app/conversation-view.js +95 -4
  25. package/server/public/app/journal-view.js +6 -0
  26. package/server/public/app/markdown-strikethrough.mjs +31 -0
  27. package/server/public/app/relay-board-inline.mjs +70 -0
  28. package/server/public/app/relay-board-view.js +157 -61
  29. package/server/public/app/relay-error-ctas.mjs +12 -3
  30. package/server/public/app/socket-handlers.js +9 -0
  31. package/server/public/app/store.js +4 -1
  32. package/server/public/app/subagent-fold.mjs +61 -0
  33. package/server/public/app/usage-limit-ui.js +225 -0
  34. package/server/public/index.html +90 -1
  35. package/server/repositories/message-repository.mjs +10 -1
  36. package/server/repositories/question-repository.mjs +1 -0
  37. package/server/repositories/remote-relay-repository.mjs +30 -0
  38. package/server/routes/messages-routes.mjs +85 -14
  39. package/server/routes/relay-board-routes.mjs +56 -2
  40. package/server/routes/sessions-routes.mjs +36 -0
  41. package/server/server-runtime.mjs +33 -2
  42. package/server/services/preview-proxy-server.mjs +64 -36
  43. package/server/services/process-list-service.mjs +122 -0
  44. package/server/services/relay-console-log.mjs +199 -0
  45. package/server/services/remote-relay-caller-context.mjs +37 -17
  46. package/server/services/remote-relay-dispatcher.mjs +72 -8
  47. package/server/services/remote-relay-inbound.mjs +35 -0
  48. package/server/services/session-worker-process-service.mjs +85 -32
  49. package/server/services/session-worker-registry-service.mjs +1 -1
  50. package/server/services/session-worker-supervisor-service.mjs +33 -1
  51. package/server/services/usage-limit-pause-service.mjs +441 -0
  52. package/shared/ask-user-bridge.mjs +14 -39
  53. package/shared/claude-usage-limit.mjs +257 -0
  54. package/shared/control-poller.mjs +69 -20
  55. package/shared/failure-note-text.mjs +38 -0
  56. package/shared/question-wait.mjs +108 -0
  57. package/shared/remote-relay-contract.mjs +16 -2
  58. package/shared/steer-settle-failure.mjs +16 -0
  59. package/shared/steer-settle-markers.mjs +8 -0
  60. package/shared/worker-runtime/process-tree.mjs +483 -0
  61. package/shared/worker-runtime/relay-errors.mjs +31 -0
  62. package/shared/worker-runtime/send-and-wait-errors.mjs +4 -4
  63. 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';
@@ -44,6 +46,8 @@ import { stripRemotePromptHeader } from '../../shared/remote-relay-contract.mjs'
44
46
  import {
45
47
  admitRemoteRelayRequest,
46
48
  findMentionedRemoteRelays,
49
+ findOriginRemoteRelay,
50
+ formatRemoteRelayOriginHint,
47
51
  formatRemoteRelayMentionHint,
48
52
  recordRemoteRelayUnlocks,
49
53
  withRemoteRelayMentionHint,
@@ -450,15 +454,19 @@ function extractTerminalId(text, patterns = []) {
450
454
  return null;
451
455
  }
452
456
 
457
+ /**
458
+ * Reads a failure a sender reported as text only, without a `terminalError`
459
+ * record. The text has to BE a failure note, not merely contain one
460
+ * (shared/failure-note-text.mjs): a reply that quoted a note was stored as a
461
+ * failed turn, cut off at the quoted code.
462
+ */
453
463
  export function parseTerminalFailureText(text) {
454
464
  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);
465
+ const note = matchFailureNote(raw);
466
+ if (!note) return null;
467
+ const code = normalizeTerminalErrorCode(note.stableCode);
460
468
  if (!code) return null;
461
- const message = normalizeTerminalErrorText(raw.slice(0, stableCodeMatch.index).trim().replace(/\s+$/, '.'));
469
+ const message = normalizeTerminalErrorText(note.lead.trim().replace(/\s+$/, '.'));
462
470
  const detailMatch = raw.match(/details:\s*(.+)$/i);
463
471
  return {
464
472
  terminal: true,
@@ -478,7 +486,7 @@ export function parseTerminalFailureText(text) {
478
486
  };
479
487
  }
480
488
 
481
- function buildTerminalFailureTextForChat(terminalFailure, fallbackMessage = null) {
489
+ export function buildTerminalFailureTextForChat(terminalFailure, fallbackMessage = null) {
482
490
  const failure = terminalFailure && typeof terminalFailure === 'object' ? terminalFailure : {};
483
491
  const code = normalizeTerminalErrorCode(failure.code || failure.stableCode) || 'unknown-terminal';
484
492
  const stableCode = `relay.${code}`;
@@ -486,7 +494,7 @@ function buildTerminalFailureTextForChat(terminalFailure, fallbackMessage = null
486
494
  || normalizeTerminalErrorText(fallbackMessage)
487
495
  || 'The relay runtime hit a terminal error and could not complete this turn.';
488
496
  const guidance = normalizeTerminalErrorText(failure.guidance)
489
- || 'Retry the message. If this keeps failing, restart the relay and include the error code.';
497
+ || 'Send the message again to retry. If this keeps failing, include the error code when you report it.';
490
498
  const detail = normalizeTerminalErrorText(failure.detail);
491
499
  const ids = [
492
500
  normalizeTerminalErrorText(failure.functionCallId) ? `functionCallId=${failure.functionCallId}` : null,
@@ -628,8 +636,13 @@ export function resolveTerminalFailurePayload(payload = {}, { fallbackText = nul
628
636
  const code = explicitCode
629
637
  || normalizeTerminalErrorCode(parsedTextFailure?.code)
630
638
  || 'unknown-terminal';
639
+ // Known from the text alone: nothing but the shape of the text says that
640
+ // this is a failure, so the text is kept as it was sent.
641
+ const fromText = !!parsedTextFailure && !direct && !explicitCode
642
+ && body.terminal !== true && body.isTerminal !== true && body.errorKind !== 'terminal';
631
643
  return {
632
644
  terminal: true,
645
+ ...(fromText ? { fromText: true } : {}),
633
646
  code,
634
647
  stableCode: `relay.${code}`,
635
648
  message: explicitMessage || parsedTextFailure?.message || normalizeTerminalErrorText(fallbackText),
@@ -1804,6 +1817,7 @@ export function registerMessagesRoutes(app, deps) {
1804
1817
  resolveSessionStateRoot,
1805
1818
  fetchUsageSummary = null,
1806
1819
  planUsageService = null,
1820
+ usageLimitPauseService = null,
1807
1821
  getCursorPlanAllowanceSettings = () => ({ cursorModelsUsd: null, otherModelsUsd: null, resetDay: 1 }),
1808
1822
  getGrokPlanAllowanceSettings = () => ({ monthlyUsd: null, resetDay: 1 }),
1809
1823
  opaqueResponseRecoveryWaitMs = OPAQUE_RESPONSE_RECOVERY_WAIT_MS,
@@ -4685,13 +4699,19 @@ export function registerMessagesRoutes(app, deps) {
4685
4699
  trimmedText || '(User sent image attachments only.)',
4686
4700
  ].join('\n')
4687
4701
  : trimmedText;
4688
- // Only what a human typed unlocks a remote relay (decision 19). The hint
4702
+ // Only what a human typed unlocks a remote relay by mention (decision 19);
4703
+ // a prompt from a relay's agent opens that one relay and no other. The hint
4689
4704
  // rides in the queued prompt only, so the stored message and the user's
4690
4705
  // bubble stay exactly what was typed.
4691
4706
  const mentionedRemoteRelays = remoteRequest.remote
4692
4707
  ? []
4693
4708
  : findMentionedRemoteRelays(remoteRelayInbound, trimmedText);
4694
- const queueText = withRemoteRelayMentionHint(promptText, formatRemoteRelayMentionHint(mentionedRemoteRelays));
4709
+ // A prompt from another relay's agent opens that relay for this
4710
+ // conversation (it may be answered): the agent is told where it came from.
4711
+ const remoteRelayHint = remoteRequest.remote
4712
+ ? formatRemoteRelayOriginHint(findOriginRemoteRelay(remoteRelayInbound, remoteRequest.origin), remoteRequest.origin)
4713
+ : formatRemoteRelayMentionHint(mentionedRemoteRelays);
4714
+ const queueText = withRemoteRelayMentionHint(promptText, remoteRelayHint);
4695
4715
  // A Resend never came from the composer, and neither did another relay's
4696
4716
  // agent: whatever the user is typing there stays their draft.
4697
4717
  const keepsComposerDraft = !!resendOriginId || remoteRequest.remote;
@@ -5346,6 +5366,26 @@ export function registerMessagesRoutes(app, deps) {
5346
5366
  res.json({ ok: true });
5347
5367
  });
5348
5368
 
5369
+ // POST /api/claude-usage-limit — the Claude worker forwards what the CLI
5370
+ // reported about the subscription's usage limit (a `rate_limit_event`): the
5371
+ // clients warn before the limit and show the reset once it is reached.
5372
+ app.post('/api/claude-usage-limit', auth, (req, res) => {
5373
+ touchCli();
5374
+ if (!usageLimitPauseService) return res.status(501).json({ error: 'Usage limit handling is unavailable' });
5375
+ // The report is the account's, so only a Claude-bound conversation's
5376
+ // worker may write it (same validation as the plan usage card).
5377
+ const conversationId = String(req.body?.conversationId || '').trim();
5378
+ if (conversationId) {
5379
+ const runtimeSession = stmts.getRuntimeSessionByConversation?.get?.(conversationId) || null;
5380
+ const boundProvider = String(runtimeSession?.provider_type || 'github').trim().toLowerCase();
5381
+ if (runtimeSession && boundProvider !== 'claude') {
5382
+ return res.status(409).json({ error: 'Conversation is not bound to the Claude provider' });
5383
+ }
5384
+ }
5385
+ const state = usageLimitPauseService.recordReport({ report: req.body?.report });
5386
+ res.json({ ok: true, state });
5387
+ });
5388
+
5349
5389
  // POST /api/copilot-plan-usage — the Copilot SDK worker reports the turn's
5350
5390
  // billing/token detail.
5351
5391
  //
@@ -6535,8 +6575,8 @@ export function registerMessagesRoutes(app, deps) {
6535
6575
  });
6536
6576
  }
6537
6577
  };
6538
- const trimmedText = String(text || '').trim();
6539
- const terminalFailure = resolveTerminalFailurePayload(req.body, { fallbackText: trimmedText });
6578
+ let trimmedText = String(text || '').trim();
6579
+ let terminalFailure = resolveTerminalFailurePayload(req.body, { fallbackText: trimmedText });
6540
6580
  // Final digests of background workflows that settled during this turn —
6541
6581
  // the transcript's "Finished background task" cards. Same structural
6542
6582
  // sanitizer as the live panel rows; junk entries drop silently (this
@@ -6590,6 +6630,22 @@ export function registerMessagesRoutes(app, deps) {
6590
6630
  }
6591
6631
 
6592
6632
  const relayMode = normalizeRelayMode(mode || q?.relay_mode) || DEFAULT_RELAY_MODE;
6633
+ // A Claude turn refused at the usage limit is paused, not failed: its row
6634
+ // ends with a note like any answered one, and a follow-up is queued that
6635
+ // is held until the reset (usage-limit-pause-service).
6636
+ const usageLimitPause = terminalFailure && q && isUsageLimitTerminalError(req.body?.terminalError)
6637
+ ? (usageLimitPauseService?.planPause?.({
6638
+ terminalError: req.body.terminalError,
6639
+ conversationId: targetConversationId,
6640
+ providerType: stmts.getRuntimeSessionByConversation?.get(targetConversationId)?.provider_type,
6641
+ queueRow: q,
6642
+ partialText: req.body?.partialText,
6643
+ }) || null)
6644
+ : null;
6645
+ if (usageLimitPause) {
6646
+ trimmedText = usageLimitPause.noteText;
6647
+ terminalFailure = null;
6648
+ }
6593
6649
  if (terminalFailure) {
6594
6650
  // Terminal failures carry provenance too: the original hijack incident
6595
6651
  // presented exactly as stolen turns dying on 402 through this path.
@@ -6615,14 +6671,22 @@ export function registerMessagesRoutes(app, deps) {
6615
6671
  if (terminalCrossConversation.crossConversation) {
6616
6672
  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
6673
  }
6618
- const failureText = buildTerminalFailureTextForChat(terminalFailure, trimmedText);
6674
+ // A failure read from the text alone keeps that text, whole: it is the
6675
+ // note already, and should the reading be wrong, nothing of a reply is
6676
+ // lost.
6677
+ const failureText = terminalFailure.fromText
6678
+ ? trimmedText
6679
+ : buildTerminalFailureTextForChat(terminalFailure, trimmedText);
6680
+ // What the turn had written before it failed stays readable: the note
6681
+ // goes below it instead of replacing it.
6682
+ const partialText = String(req.body.partialText || '').trim();
6619
6683
  const failed = failQueueMessage({
6620
6684
  queueRow: q,
6621
6685
  messageId,
6622
6686
  conversationId: targetConversationId,
6623
6687
  relayMode,
6624
6688
  model: model || q?.model || null,
6625
- responseText: failureText,
6689
+ responseText: partialText ? `${partialText}\n\n${failureText}` : failureText,
6626
6690
  executedProvider: terminalExecutedProvider,
6627
6691
  failureRecord: {
6628
6692
  kind: 'terminal',
@@ -7015,6 +7079,13 @@ export function registerMessagesRoutes(app, deps) {
7015
7079
  },
7016
7080
  });
7017
7081
  io.emit('message_status', { messageId, conversationId: targetConversationId, status: 'done' });
7082
+ if (usageLimitPause) {
7083
+ try {
7084
+ usageLimitPauseService.schedulePause(usageLimitPause, { queueRow: q });
7085
+ } catch (error) {
7086
+ console.warn(`[${ts()}] USAGE LIMIT ${messageId?.slice(0, 8)} conv=${targetConversationId?.slice(0, 8)} the follow-up could not be queued: ${error?.message || error}`);
7087
+ }
7088
+ }
7018
7089
  // An absorbed row is not a finished turn — the reply continues on the
7019
7090
  // steered message's row, whose own completion sends the one notification.
7020
7091
  // A stopped steer has no reply at all, and a message answered on a
@@ -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
 
@@ -2093,6 +2093,7 @@ export function registerSessionsRoutes(app, deps) {
2093
2093
  uploadPathForSha,
2094
2094
  hostSuspendService = null,
2095
2095
  collectHostActivity = null,
2096
+ usageLimitPauseService = null,
2096
2097
  hostSuspendPlatform = process.platform,
2097
2098
  // Remote relays (server/services/remote-relay-inbound.mjs): origin on
2098
2099
  // conversations another relay's agent created, and the inbound switch.
@@ -3823,6 +3824,7 @@ export function registerSessionsRoutes(app, deps) {
3823
3824
  // "cancelled" with it.
3824
3825
  activeTurn: Number(stmts.getConversationActiveQueueCount?.get?.(resolvedConversationId)?.count || 0) > 0,
3825
3826
  backgroundTasks: backgroundTaskStore?.get?.(resolvedConversationId) || [],
3827
+ usageLimitPause: usageLimitPauseService?.getPause?.(resolvedConversationId) || null,
3826
3828
  previews: listConversationPreviews?.(resolvedConversationId) || [],
3827
3829
  preferredRelayMode: preferences.preferredRelayMode,
3828
3830
  preferredModel: preferences.preferredModel,
@@ -5103,6 +5105,7 @@ export function registerSessionsRoutes(app, deps) {
5103
5105
  restartOrchestrator: relayRestartOrchestrator?.getState?.() || null,
5104
5106
  relayShutdown: runtimeState.relayShutdown || null,
5105
5107
  hostSuspend: runtimeState.hostSuspend || null,
5108
+ usageLimit: runtimeState.usageLimit || null,
5106
5109
  platform: process.platform,
5107
5110
  features: featureFlags || {},
5108
5111
  sessionWorker: sessionWorkerStatus,
@@ -6250,6 +6253,39 @@ export function registerSessionsRoutes(app, deps) {
6250
6253
  return res.json({ ok: true, cancelled: !!result.cancelled, state: result.state });
6251
6254
  });
6252
6255
 
6256
+ // Turns paused at the Claude usage limit (usage-limit-pause-service): the
6257
+ // held follow-up can be sent before the reset, or dropped.
6258
+ app.get('/api/usage-limit', auth, (req, res) => {
6259
+ if (!usageLimitPauseService) {
6260
+ return res.status(501).json({ ok: false, error: 'Usage limit handling is unavailable' });
6261
+ }
6262
+ return res.json({
6263
+ ok: true,
6264
+ account: usageLimitPauseService.getAccountState(),
6265
+ pauses: usageLimitPauseService.listPauses(),
6266
+ });
6267
+ });
6268
+
6269
+ app.post('/api/usage-limit/resume', auth, (req, res) => {
6270
+ if (!usageLimitPauseService) {
6271
+ return res.status(501).json({ ok: false, error: 'Usage limit handling is unavailable' });
6272
+ }
6273
+ const conversationId = String(req.body?.conversationId || '').trim();
6274
+ if (!conversationId) return res.status(400).json({ ok: false, error: 'Missing conversationId' });
6275
+ const result = usageLimitPauseService.resumeNow({ conversationId });
6276
+ return res.json({ ok: true, resumed: !!result.resumed, messageId: result.messageId || null, pause: result.state });
6277
+ });
6278
+
6279
+ app.post('/api/usage-limit/cancel', auth, (req, res) => {
6280
+ if (!usageLimitPauseService) {
6281
+ return res.status(501).json({ ok: false, error: 'Usage limit handling is unavailable' });
6282
+ }
6283
+ const conversationId = String(req.body?.conversationId || '').trim();
6284
+ if (!conversationId) return res.status(400).json({ ok: false, error: 'Missing conversationId' });
6285
+ const result = usageLimitPauseService.cancel({ conversationId });
6286
+ return res.json({ ok: true, cancelled: !!result.cancelled, messageId: result.messageId || null, pause: result.state });
6287
+ });
6288
+
6253
6289
  app.get('/api/restart-orchestrator', auth, (req, res) => {
6254
6290
  ensureSessionId(req, res);
6255
6291
  return res.json({ orchestrator: relayRestartOrchestrator?.getState?.() || null });
@@ -56,6 +56,7 @@ import { sweepUnreferencedUploads, UNREFERENCED_UPLOADS_QUERY } from './services
56
56
  import webpush from 'web-push';
57
57
  import { createPushDispatchService, ensurePushVapidKeys } from './services/push-dispatch-service.mjs';
58
58
  import { createHostSuspendService } from './services/host-suspend-service.mjs';
59
+ import { USAGE_LIMIT_ACCOUNT_SOCKET_EVENT, createUsageLimitPauseService } from './services/usage-limit-pause-service.mjs';
59
60
  import { createGitHubCiActivityService } from './services/github-ci-activity-service.mjs';
60
61
  import { runHostSuspendToRam } from './services/host-suspend-command.mjs';
61
62
  import { createActiveDeviceTracker } from './services/push-active-device-service.mjs';
@@ -113,6 +114,7 @@ import {
113
114
  resolveLaunchWorkspaceRootPath,
114
115
  } from './services/workspace-root-defaults-service.mjs';
115
116
  import { maybeStartTtyConsole } from './tty-console-bootstrap.mjs';
117
+ import { installRelayConsoleLog, resolveRelayConsoleLogPath } from './services/relay-console-log.mjs';
116
118
  import {
117
119
  computeFeatureFlagState,
118
120
  FEATURE_REGISTRY,
@@ -186,11 +188,18 @@ const PWA_SW_PATH = path.join(PUBLIC_DIR, 'sw.js');
186
188
  const SOCKET_IO_CLIENT_JS_PATH = path.join(__dirname, '..', 'node_modules', 'socket.io', 'client-dist', 'socket.io.js');
187
189
  const APP_CONFIG_PLACEHOLDER = /window\.__COPILOT_APP_CONFIG = \{ basePath: '[^']*' \};/;
188
190
  const PWA_VERSION_PLACEHOLDER = /const __PWA_VERSION = '[^']*';/;
191
+ // The console goes to a terminal, a tmux pane or nowhere: keep a copy to read
192
+ // after a restart or an incident.
193
+ const relayConsoleLog = installRelayConsoleLog({
194
+ logPath: resolveRelayConsoleLogPath({ env: process.env, serverDir: __dirname }),
195
+ });
189
196
  const ttyConsoleRuntime = await maybeStartTtyConsole({
190
197
  serverDir: __dirname,
191
198
  logsDir: path.join(__dirname, 'logs'),
192
199
  logger: console,
193
200
  });
201
+ // The terminal console takes the console's methods over when it starts.
202
+ relayConsoleLog?.rewrap();
194
203
 
195
204
  // ─── Config ────────────────────────────────────────────────────────────────────
196
205
  const CONFIG_PATH = process.env.COPILOT_WEB_RELAY_CONFIG
@@ -1599,6 +1608,7 @@ const CURATED_MODEL_IDS = [
1599
1608
  ];
1600
1609
 
1601
1610
  let config = { ...DEFAULT_CONFIG };
1611
+ relayConsoleLog?.setSecrets(() => [config.authToken]);
1602
1612
  if (fs.existsSync(CONFIG_PATH)) {
1603
1613
  try { config = { ...config, ...JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')) }; }
1604
1614
  catch (e) { console.error('Failed to read config.json, using defaults.'); }
@@ -4964,12 +4974,19 @@ function collectHostActivity({ request = null } = {}) {
4964
4974
  if (!conversationId) continue;
4965
4975
  hostSuspendWatchedConversationIds.add(conversationId);
4966
4976
  const count = Number(row.cnt || 0);
4977
+ // A turn paused at the Claude usage limit is work still owed: the host
4978
+ // stays up for one that resumes by itself, not for one that waits for
4979
+ // the user.
4980
+ const pause = count === 1 ? usageLimitPauseService.getPause(conversationId) : null;
4981
+ if (pause && !pause.auto) continue;
4967
4982
  blockers.push({
4968
4983
  kind: 'turn',
4969
4984
  conversationId,
4970
4985
  title: hostActivityTitle(conversationId, row.title),
4971
4986
  count,
4972
- detail: count > 1 ? `${count} turns running` : 'turn running',
4987
+ detail: pause
4988
+ ? `turn paused at the Claude ${pause.label} until ${pause.resumeAt}`
4989
+ : (count > 1 ? `${count} turns running` : 'turn running'),
4973
4990
  });
4974
4991
  }
4975
4992
  for (const [key, entry] of backgroundTaskStore.sets) {
@@ -5017,6 +5034,15 @@ const hostSuspendService = createHostSuspendService({
5017
5034
  },
5018
5035
  });
5019
5036
 
5037
+ const usageLimitPauseService = createUsageLimitPauseService({
5038
+ db,
5039
+ stmts,
5040
+ emit: (event, payload) => io.emit(event, payload),
5041
+ // A follow-up resumed by hand is due at once: wake its worker.
5042
+ onQueueChanged: (reason) => { void primePendingSessionWorkers(reason); },
5043
+ logger: console,
5044
+ });
5045
+
5020
5046
  // The live turn among a conversation's processing rows. Mid-turn steering can
5021
5047
  // leave several processing at once — the running turn, steered messages the
5022
5048
  // CLI folded into it (no output of their own), a handed-off row still settling
@@ -6182,6 +6208,9 @@ const runtimeState = {
6182
6208
  get ttyConsoleActive() { return Boolean(ttyConsoleRuntime?.tty); },
6183
6209
  get relayShutdown() { return getRelayShutdownState(); },
6184
6210
  get hostSuspend() { return hostSuspendService.getState(); },
6211
+ get usageLimit() {
6212
+ return { account: usageLimitPauseService.getAccountState(), pauses: usageLimitPauseService.listPauses() };
6213
+ },
6185
6214
  get activeBridgeOwner() { return relayBridgeOwnerService.getOwner(); },
6186
6215
  get workerWebSocketStatus() { return sessionWorkerWebSocketService.status(); },
6187
6216
  get tmuxInspectorStatus() { return tmuxInspectorSocketService.status(); },
@@ -6238,7 +6267,7 @@ const remoteRelayPairing = createRemoteRelayPairing({
6238
6267
  });
6239
6268
  // What the inbound routes need: no tokens, only ids, names and state.
6240
6269
  const remoteRelayInbound = {
6241
- listRelays: () => remoteRelayRegistry.list().map(({ id, name, url, lastStatus, version }) => ({ id, name, url, lastStatus, version })),
6270
+ listRelays: () => remoteRelayRegistry.list().map(({ id, relayId, name, url, lastStatus, version }) => ({ id, relayId, name, url, lastStatus, version })),
6242
6271
  selfNames: () => [remoteRelayRegistry.selfName()],
6243
6272
  inboundEnabled: () => remoteRelayRegistry.getSelfSettings().inboundEnabled,
6244
6273
  recordUnlock: (conversationId, relayId, messageId) => remoteRelayRepository?.recordUnlock(conversationId, relayId, messageId) ?? false,
@@ -6461,6 +6490,7 @@ const sharedRouteDeps = {
6461
6490
  relayShutdownStatePayload,
6462
6491
  hostSuspendService,
6463
6492
  collectHostActivity,
6493
+ usageLimitPauseService,
6464
6494
  markSharedViewerPresence,
6465
6495
  getSharedWatcherCount,
6466
6496
  statusEventService,
@@ -6851,6 +6881,7 @@ io.on('connection', (socket) => {
6851
6881
  // Pending host suspend / relay restart banners must survive a reconnect.
6852
6882
  try { socket.emit('host_suspend_state', hostSuspendService.getState()); } catch {}
6853
6883
  try { socket.emit('relay_shutdown_state', relayShutdownStatePayload()); } catch {}
6884
+ try { socket.emit(USAGE_LIMIT_ACCOUNT_SOCKET_EVENT, usageLimitPauseService.getAccountState()); } catch {}
6854
6885
  // Visibility heartbeats for push suppression; also clears a stale
6855
6886
  // deviceVisible flag restored by connectionStateRecovery.
6856
6887
  activeDeviceTracker.registerSocket(socket);
@@ -239,6 +239,11 @@ function guardConnectTimeout(upstreamReq, timeoutMs, onTimeout) {
239
239
  });
240
240
  }
241
241
 
242
+ // Requests the lane sends a second time when the connection it kept to the
243
+ // dev server turns out to be closed, and what such a connection fails with.
244
+ const REPEATABLE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
245
+ const STALE_CONNECTION_CODES = new Set(['ECONNRESET', 'EPIPE']);
246
+
242
247
  export function createPreviewProxyServer({
243
248
  registry,
244
249
  logger = console,
@@ -327,46 +332,69 @@ export function createPreviewProxyServer({
327
332
  bearerTokens: relayBearerTokens,
328
333
  });
329
334
 
330
- const upstreamReq = httpImpl.request({
331
- host: entry.targetHost,
332
- port: entry.targetPort,
333
- method: req.method,
334
- path: upstreamPath,
335
- headers,
336
- agent,
337
- });
338
-
339
- let timedOut = false;
340
- guardConnectTimeout(upstreamReq, connectTimeoutMs, () => { timedOut = true; });
335
+ // A request without a body can be sent a second time.
336
+ const repeatable = REPEATABLE_METHODS.has(String(req.method || '').toUpperCase())
337
+ && !(Number(req.headers['content-length']) > 0)
338
+ && !req.headers['transfer-encoding'];
339
+ let upstreamReq = null;
340
+
341
+ const forward = ({ again = false } = {}) => {
342
+ const current = httpImpl.request({
343
+ host: entry.targetHost,
344
+ port: entry.targetPort,
345
+ method: req.method,
346
+ path: upstreamPath,
347
+ headers,
348
+ agent,
349
+ });
350
+ upstreamReq = current;
351
+
352
+ let timedOut = false;
353
+ guardConnectTimeout(current, connectTimeoutMs, () => { timedOut = true; });
354
+
355
+ current.on('response', (upstreamRes) => {
356
+ const outHeaders = sanitizeResponseHeaders(upstreamRes.headers, {
357
+ basePath: entry.basePath,
358
+ targetHost: entry.targetHost,
359
+ targetPort: entry.targetPort,
360
+ });
361
+ res.writeHead(upstreamRes.statusCode || 502, outHeaders);
362
+ // 204/304 and HEAD carry no body; piping one would desync the connection.
363
+ if (req.method === 'HEAD' || upstreamRes.statusCode === 204 || upstreamRes.statusCode === 304) {
364
+ upstreamRes.resume();
365
+ res.end();
366
+ return;
367
+ }
368
+ upstreamRes.pipe(res);
369
+ upstreamRes.on('error', () => { try { res.destroy(); } catch {} });
370
+ });
341
371
 
342
- upstreamReq.on('response', (upstreamRes) => {
343
- const outHeaders = sanitizeResponseHeaders(upstreamRes.headers, {
344
- basePath: entry.basePath,
345
- targetHost: entry.targetHost,
346
- targetPort: entry.targetPort,
372
+ current.on('error', (error) => {
373
+ if (upstreamReq !== current) return;
374
+ // The connection was kept from an earlier request, and the dev server
375
+ // has closed it since (it exited, or was restarted): that says nothing
376
+ // about the server now. One more try on a connection of its own either
377
+ // reaches the restarted server or says that nothing is listening.
378
+ if (!again && repeatable && current.reusedSocket && !res.headersSent && !res.destroyed
379
+ && STALE_CONNECTION_CODES.has(String(error?.code || ''))) {
380
+ forward({ again: true });
381
+ return;
382
+ }
383
+ const refused = String(error?.code || '') === 'ECONNREFUSED';
384
+ const detail = refused || timedOut
385
+ ? `Nothing is listening on <code>${escapeHtml(entry.targetHost)}:${entry.targetPort}</code>. The dev server behind this preview has probably exited — restart it on the same port and reload; this link stays valid.`
386
+ : `The upstream connection failed: <code>${escapeHtml(error?.code || error?.message || 'unknown error')}</code>.`;
387
+ sendErrorPage(res, 502, { title: 'Preview upstream unreachable', detail });
347
388
  });
348
- res.writeHead(upstreamRes.statusCode || 502, outHeaders);
349
- // 204/304 and HEAD carry no body; piping one would desync the connection.
350
- if (req.method === 'HEAD' || upstreamRes.statusCode === 204 || upstreamRes.statusCode === 304) {
351
- upstreamRes.resume();
352
- res.end();
353
- return;
354
- }
355
- upstreamRes.pipe(res);
356
- upstreamRes.on('error', () => { try { res.destroy(); } catch {} });
357
- });
358
389
 
359
- upstreamReq.on('error', (error) => {
360
- const refused = String(error?.code || '') === 'ECONNREFUSED';
361
- const detail = refused || timedOut
362
- ? `Nothing is listening on <code>${escapeHtml(entry.targetHost)}:${entry.targetPort}</code>. The dev server behind this preview has probably exited — restart it on the same port and reload; this link stays valid.`
363
- : `The upstream connection failed: <code>${escapeHtml(error?.code || error?.message || 'unknown error')}</code>.`;
364
- sendErrorPage(res, 502, { title: 'Preview upstream unreachable', detail });
365
- });
390
+ // The second try has nothing left to read from the request.
391
+ if (again) current.end();
392
+ else req.pipe(current);
393
+ };
366
394
 
367
- req.on('aborted', () => { try { upstreamReq.destroy(); } catch {} });
368
- res.on('close', () => { try { upstreamReq.destroy(); } catch {} });
369
- req.pipe(upstreamReq);
395
+ req.on('aborted', () => { try { upstreamReq?.destroy(); } catch {} });
396
+ res.on('close', () => { try { upstreamReq?.destroy(); } catch {} });
397
+ forward();
370
398
  }
371
399
 
372
400
  // WebSocket upgrades (Vite HMR, an app's own socket) ride the same prefix.