@oar-sh/oar 0.9.4 → 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.
- package/.github/extensions/web-relay/polling/polling-loop.mjs +12 -32
- package/.github/extensions/web-relay/skills/question-bridge.mjs +14 -39
- package/CHANGELOG.md +199 -0
- package/README.md +35 -6
- package/package.json +2 -1
- package/server/claude-worker/claude-remote-relay-tool.mjs +1 -0
- package/server/claude-worker/claude-session-process.mjs +411 -19
- package/server/claude-worker/claude-session-worker.mjs +5 -0
- package/server/claude-worker/claude-turn-failure.mjs +92 -0
- package/server/claude-worker/claude-turn-publisher.mjs +52 -3
- package/server/claude-worker/sdk-message-normalizer.mjs +13 -1
- package/server/copilot-worker/copilot-model-switch.mjs +29 -3
- package/server/copilot-worker/copilot-question-bridge.mjs +2 -4
- package/server/copilot-worker/copilot-runtime-tree.mjs +91 -0
- package/server/copilot-worker/copilot-sdk-adapter.mjs +48 -8
- package/server/copilot-worker/copilot-sdk-session-process.mjs +897 -166
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +5 -0
- package/server/cursor-worker/cursor-sdk-adapter.mjs +80 -13
- package/server/cursor-worker/cursor-session-worker.mjs +5 -0
- package/server/cursor-worker/cursor-turn-runner.mjs +5 -2
- package/server/db-schema.mjs +7 -0
- package/server/grok-worker/acp-client.mjs +68 -12
- package/server/grok-worker/grok-sdk-adapter.mjs +4 -0
- package/server/grok-worker/grok-session-worker.mjs +5 -0
- package/server/grok-worker/grok-turn-runner.mjs +6 -2
- package/server/public/app/api-client.js +17 -2
- package/server/public/app/bootstrap.js +3 -0
- package/server/public/app/composer-control-state.mjs +4 -3
- package/server/public/app/conversation-view.js +97 -4
- package/server/public/app/journal-view.js +6 -0
- package/server/public/app/markdown-strikethrough.mjs +31 -0
- package/server/public/app/relay-board-inline.mjs +70 -0
- package/server/public/app/relay-board-view.js +157 -61
- package/server/public/app/relay-error-ctas.mjs +12 -3
- package/server/public/app/socket-handlers.js +9 -0
- package/server/public/app/steered-turn-merge.mjs +10 -1
- package/server/public/app/store.js +4 -1
- package/server/public/app/subagent-fold.mjs +61 -0
- package/server/public/app/usage-limit-ui.js +225 -0
- package/server/public/index.html +97 -4
- package/server/repositories/message-repository.mjs +10 -1
- package/server/repositories/question-repository.mjs +1 -0
- package/server/repositories/remote-relay-repository.mjs +30 -0
- package/server/routes/messages-routes.mjs +93 -17
- package/server/routes/relay-board-routes.mjs +56 -2
- package/server/routes/sessions-routes.mjs +40 -2
- package/server/server-runtime.mjs +33 -2
- package/server/services/preview-proxy-server.mjs +64 -36
- package/server/services/process-list-service.mjs +122 -0
- package/server/services/relay-console-log.mjs +199 -0
- package/server/services/remote-relay-caller-context.mjs +45 -20
- package/server/services/remote-relay-dispatcher.mjs +328 -21
- package/server/services/remote-relay-inbound.mjs +35 -0
- package/server/services/session-worker-launch-service.mjs +17 -2
- package/server/services/session-worker-process-service.mjs +85 -32
- package/server/services/session-worker-registry-service.mjs +1 -1
- package/server/services/session-worker-supervisor-service.mjs +33 -1
- package/server/services/usage-limit-pause-service.mjs +441 -0
- package/shared/ask-user-bridge.mjs +14 -39
- package/shared/claude-usage-limit.mjs +257 -0
- package/shared/control-poller.mjs +69 -20
- package/shared/failure-note-text.mjs +38 -0
- package/shared/question-wait.mjs +108 -0
- package/shared/remote-relay-contract.mjs +33 -2
- package/shared/steer-settle-failure.mjs +22 -0
- package/shared/steer-settle-markers.mjs +24 -0
- package/shared/worker-runtime/process-tree.mjs +483 -0
- package/shared/worker-runtime/relay-errors.mjs +31 -0
- package/shared/worker-runtime/send-and-wait-errors.mjs +4 -4
- package/shared/worker-runtime/turn-liveness.mjs +249 -0
- package/shared/worker-runtime/worker-log-file.mjs +97 -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,19 +32,22 @@ 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';
|
|
37
39
|
import { DEFAULT_CLAUDE_REASONING_EFFORTS } from '../services/provider-reasoning-effort.mjs';
|
|
38
40
|
import { claudeBaseModelId, claudeLongContextModelId } from '../../shared/model-id.mjs';
|
|
39
41
|
import { sanitizeSubagentRunId } from '../../shared/subagent-run-id.mjs';
|
|
40
|
-
import { STEER_FOLDED_TEXT } from '../../shared/steer-settle-markers.mjs';
|
|
42
|
+
import { ANSWERED_ELSEWHERE_KIND, STEER_FOLDED_TEXT } from '../../shared/steer-settle-markers.mjs';
|
|
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';
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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(
|
|
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
|
-
|| '
|
|
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)
|
|
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
|
-
|
|
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
|
//
|
|
@@ -6488,7 +6528,11 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
6488
6528
|
});
|
|
6489
6529
|
});
|
|
6490
6530
|
|
|
6491
|
-
|
|
6531
|
+
// Responses that are settle markers, not replies: stamped with their kind,
|
|
6532
|
+
// and no "turn completed" push. The last one is not a steer's: it closes a
|
|
6533
|
+
// message whose answer was published on a continuation row, and that reply
|
|
6534
|
+
// already sent the push.
|
|
6535
|
+
const SETTLED_STEER_KINDS = new Set(['folded', 'stopped', ANSWERED_ELSEWHERE_KIND]);
|
|
6492
6536
|
|
|
6493
6537
|
// POST /api/response — CLI submits response
|
|
6494
6538
|
app.post('/api/response', auth, async (req, res) => {
|
|
@@ -6531,8 +6575,8 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
6531
6575
|
});
|
|
6532
6576
|
}
|
|
6533
6577
|
};
|
|
6534
|
-
|
|
6535
|
-
|
|
6578
|
+
let trimmedText = String(text || '').trim();
|
|
6579
|
+
let terminalFailure = resolveTerminalFailurePayload(req.body, { fallbackText: trimmedText });
|
|
6536
6580
|
// Final digests of background workflows that settled during this turn —
|
|
6537
6581
|
// the transcript's "Finished background task" cards. Same structural
|
|
6538
6582
|
// sanitizer as the live panel rows; junk entries drop silently (this
|
|
@@ -6586,6 +6630,22 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
6586
6630
|
}
|
|
6587
6631
|
|
|
6588
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
|
+
}
|
|
6589
6649
|
if (terminalFailure) {
|
|
6590
6650
|
// Terminal failures carry provenance too: the original hijack incident
|
|
6591
6651
|
// presented exactly as stolen turns dying on 402 through this path.
|
|
@@ -6611,14 +6671,22 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
6611
6671
|
if (terminalCrossConversation.crossConversation) {
|
|
6612
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`);
|
|
6613
6673
|
}
|
|
6614
|
-
|
|
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();
|
|
6615
6683
|
const failed = failQueueMessage({
|
|
6616
6684
|
queueRow: q,
|
|
6617
6685
|
messageId,
|
|
6618
6686
|
conversationId: targetConversationId,
|
|
6619
6687
|
relayMode,
|
|
6620
6688
|
model: model || q?.model || null,
|
|
6621
|
-
responseText: failureText,
|
|
6689
|
+
responseText: partialText ? `${partialText}\n\n${failureText}` : failureText,
|
|
6622
6690
|
executedProvider: terminalExecutedProvider,
|
|
6623
6691
|
failureRecord: {
|
|
6624
6692
|
kind: 'terminal',
|
|
@@ -7011,9 +7079,17 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7011
7079
|
},
|
|
7012
7080
|
});
|
|
7013
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
|
+
}
|
|
7014
7089
|
// An absorbed row is not a finished turn — the reply continues on the
|
|
7015
7090
|
// steered message's row, whose own completion sends the one notification.
|
|
7016
|
-
// A stopped steer has no reply at all
|
|
7091
|
+
// A stopped steer has no reply at all, and a message answered on a
|
|
7092
|
+
// continuation row was notified by that row.
|
|
7017
7093
|
if (!settledKind) {
|
|
7018
7094
|
void pushDispatchService?.notifyTurnComplete?.({
|
|
7019
7095
|
conversationId: targetConversationId,
|
|
@@ -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
|
-
|
|
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
|
|
|
@@ -1772,8 +1772,10 @@ export function buildConversationMessages({
|
|
|
1772
1772
|
executedProvider: message?.executed_provider || message?.executedProvider || undefined,
|
|
1773
1773
|
// 'continuation' badges a self-started turn; 'absorbed' marks a
|
|
1774
1774
|
// reply that continues through the next (steered) user message;
|
|
1775
|
-
// 'folded'/'stopped' are a steer's settle markers
|
|
1776
|
-
//
|
|
1775
|
+
// 'folded'/'stopped' are a steer's settle markers, and
|
|
1776
|
+
// 'answered-elsewhere' marks a message whose answer is on a
|
|
1777
|
+
// continuation row. Without this the live-appended
|
|
1778
|
+
// badge/merge/marker vanished on reload.
|
|
1777
1779
|
kind: message?.kind || undefined,
|
|
1778
1780
|
resendOfMessageId: message?.role === 'user' ? (message?.resend_of_message_id || undefined) : undefined,
|
|
1779
1781
|
resentAs: (message?.role === 'assistant' && message?.kind === 'stopped' && sourceMessageId)
|
|
@@ -2091,6 +2093,7 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2091
2093
|
uploadPathForSha,
|
|
2092
2094
|
hostSuspendService = null,
|
|
2093
2095
|
collectHostActivity = null,
|
|
2096
|
+
usageLimitPauseService = null,
|
|
2094
2097
|
hostSuspendPlatform = process.platform,
|
|
2095
2098
|
// Remote relays (server/services/remote-relay-inbound.mjs): origin on
|
|
2096
2099
|
// conversations another relay's agent created, and the inbound switch.
|
|
@@ -3821,6 +3824,7 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
3821
3824
|
// "cancelled" with it.
|
|
3822
3825
|
activeTurn: Number(stmts.getConversationActiveQueueCount?.get?.(resolvedConversationId)?.count || 0) > 0,
|
|
3823
3826
|
backgroundTasks: backgroundTaskStore?.get?.(resolvedConversationId) || [],
|
|
3827
|
+
usageLimitPause: usageLimitPauseService?.getPause?.(resolvedConversationId) || null,
|
|
3824
3828
|
previews: listConversationPreviews?.(resolvedConversationId) || [],
|
|
3825
3829
|
preferredRelayMode: preferences.preferredRelayMode,
|
|
3826
3830
|
preferredModel: preferences.preferredModel,
|
|
@@ -5101,6 +5105,7 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
5101
5105
|
restartOrchestrator: relayRestartOrchestrator?.getState?.() || null,
|
|
5102
5106
|
relayShutdown: runtimeState.relayShutdown || null,
|
|
5103
5107
|
hostSuspend: runtimeState.hostSuspend || null,
|
|
5108
|
+
usageLimit: runtimeState.usageLimit || null,
|
|
5104
5109
|
platform: process.platform,
|
|
5105
5110
|
features: featureFlags || {},
|
|
5106
5111
|
sessionWorker: sessionWorkerStatus,
|
|
@@ -6248,6 +6253,39 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
6248
6253
|
return res.json({ ok: true, cancelled: !!result.cancelled, state: result.state });
|
|
6249
6254
|
});
|
|
6250
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
|
+
|
|
6251
6289
|
app.get('/api/restart-orchestrator', auth, (req, res) => {
|
|
6252
6290
|
ensureSessionId(req, res);
|
|
6253
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:
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
|
368
|
-
res.on('close', () => { try { upstreamReq
|
|
369
|
-
|
|
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.
|