@oar-sh/oar 0.9.12 → 0.9.13
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/CHANGELOG.md +206 -0
- package/README.md +9 -9
- package/package.json +1 -1
- package/server/README.md +186 -11
- package/server/claude-worker/claude-session-process.mjs +53 -2
- package/server/claude-worker/task-output-discovery.mjs +28 -2
- package/server/copilot-worker/copilot-continuation-signals.mjs +11 -5
- package/server/copilot-worker/copilot-sdk-session-process.mjs +10 -0
- package/server/db-schema.mjs +10 -0
- package/server/grok-worker/acp-host-services.mjs +6 -3
- package/server/grok-worker/grok-background-tasks.mjs +7 -0
- package/server/migrations/0018-background-task-rows.mjs +101 -0
- package/server/public/app/action-confirmations.js +12 -15
- package/server/public/app/api-client.js +57 -3
- package/server/public/app/background-tasks-view.mjs +56 -7
- package/server/public/app/bootstrap.js +7 -0
- package/server/public/app/conversation-preferences.mjs +10 -4
- package/server/public/app/conversation-view.js +33 -9
- package/server/public/app/host-suspend-ui.js +45 -44
- package/server/public/app/journal-view.js +9 -3
- package/server/public/app/push-settings.js +1 -0
- package/server/public/app/relay-prompt-sanitizer.mjs +9 -2
- package/server/public/app/relay-restart-note-view.mjs +36 -0
- package/server/public/app/relay-restart-ui.js +568 -0
- package/server/public/app/settings-modal.js +52 -0
- package/server/public/app/socket-handlers.js +1 -1
- package/server/public/app/task-console-render.mjs +171 -7
- package/server/public/app/task-console.mjs +111 -11
- package/server/public/app/task-output-text.mjs +15 -6
- package/server/public/app/transcript-jump-bottom-view.mjs +5 -1
- package/server/public/app/transcript-jump-bottom.mjs +20 -0
- package/server/public/index.html +67 -0
- package/server/relay-tools.md +5 -0
- package/server/repositories/message-repository.mjs +3 -0
- package/server/repositories/remote-relay-repository.mjs +4 -2
- package/server/routes/messages-routes.mjs +155 -27
- package/server/routes/relay-board-routes.mjs +19 -2
- package/server/routes/relay-restart-routes.mjs +73 -0
- package/server/routes/sessions-routes.mjs +75 -3
- package/server/server-runtime.mjs +192 -137
- package/server/services/about-service.mjs +13 -12
- package/server/services/background-task-persistence.mjs +133 -0
- package/server/services/background-task-store.mjs +240 -13
- package/server/services/conversation-model-fallback.mjs +57 -0
- package/server/services/host-suspend-command.mjs +2 -1
- package/server/services/preview-proxy-server.mjs +14 -1
- package/server/services/process-list-service.mjs +2 -1
- package/server/services/provider-model-selection.mjs +195 -0
- package/server/services/push-dispatch-service.mjs +9 -2
- package/server/services/relay-event-service.mjs +27 -9
- package/server/services/relay-prompt-sanitizer.mjs +7 -0
- package/server/services/relay-restart-picture.mjs +209 -0
- package/server/services/relay-restart-queue.mjs +380 -0
- package/server/services/relay-shutdown-gate.mjs +4 -1
- package/server/services/remote-relay-dispatcher.mjs +187 -19
- package/server/services/restart-resume-service.mjs +303 -0
- package/server/services/session-worker-process-service.mjs +6 -4
- package/server/services/task-output-service.mjs +217 -18
- package/shared/relay-events.mjs +55 -2
- package/shared/remote-relay-contract.mjs +15 -8
- package/shared/task-output-text.mjs +196 -83
- package/shared/worker-runtime/worker-websocket-link.mjs +96 -20
|
@@ -12,13 +12,16 @@ import { apiFetch } from './api-client.js';
|
|
|
12
12
|
import { formatTaskModelLabel, formatTaskTokens } from './task-row-format.mjs';
|
|
13
13
|
import { prefixToolActivityEmoji } from './tool-activity-emoji.mjs';
|
|
14
14
|
import {
|
|
15
|
+
TASK_OUTPUT_NONE_MESSAGES,
|
|
15
16
|
endedOutcome,
|
|
16
17
|
normalizeStallThreshold,
|
|
18
|
+
pipeHoldNotice,
|
|
17
19
|
runnerChip,
|
|
18
20
|
stallHint,
|
|
19
21
|
stepsLabel,
|
|
20
22
|
taskHasConsole,
|
|
21
23
|
taskOutputUnavailable,
|
|
24
|
+
taskPipeHold,
|
|
22
25
|
} from './task-console-render.mjs';
|
|
23
26
|
|
|
24
27
|
// Shared with the sub-agent rows (subagent-row.mjs); re-exported for the
|
|
@@ -52,6 +55,12 @@ const stopsInFlight = new Set();
|
|
|
52
55
|
const workflowFoldState = new Map();
|
|
53
56
|
let currentConversationId = null;
|
|
54
57
|
let elapsedTimer = null;
|
|
58
|
+
// Counts the `background_tasks` events per conversation. A reload's payload
|
|
59
|
+
// is the set as the relay had it when the request was answered; one that was
|
|
60
|
+
// asked for before the latest event can land after it and would put the
|
|
61
|
+
// older set back (the panel shrinking under the reader, then growing again
|
|
62
|
+
// with the next poll).
|
|
63
|
+
const taskEventCounts = new Map();
|
|
55
64
|
|
|
56
65
|
const TASK_TYPE_ICONS = {
|
|
57
66
|
local_bash: '⌨️',
|
|
@@ -363,14 +372,35 @@ function notifyChange(conversationId) {
|
|
|
363
372
|
}
|
|
364
373
|
}
|
|
365
374
|
|
|
375
|
+
/**
|
|
376
|
+
* Taken before a reload of the conversation is asked for, and handed to
|
|
377
|
+
* isBackgroundTasksSnapshotCurrent() with its answer.
|
|
378
|
+
*/
|
|
379
|
+
export function backgroundTasksSnapshotMark(conversationId) {
|
|
380
|
+
return taskEventCounts.get(String(conversationId || '').trim()) || 0;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Whether a reload's sets (`backgroundTasks`, `endedBackgroundTasks`) may
|
|
385
|
+
* replace the conversation's on the page: no `background_tasks` event for it
|
|
386
|
+
* arrived since `mark` was taken. Otherwise the event's set is at least as
|
|
387
|
+
* new, and the next reload brings whatever came after it. Without a mark:
|
|
388
|
+
* always.
|
|
389
|
+
*/
|
|
390
|
+
export function isBackgroundTasksSnapshotCurrent(conversationId, mark) {
|
|
391
|
+
return mark === undefined || mark === null || mark === backgroundTasksSnapshotMark(conversationId);
|
|
392
|
+
}
|
|
393
|
+
|
|
366
394
|
/**
|
|
367
395
|
* The live set (REPLACE semantics). `endedTasks`, when the event or payload
|
|
368
396
|
* carries it, replaces the ended set too; an older relay's event without it
|
|
369
|
-
* leaves the ended rows alone.
|
|
397
|
+
* leaves the ended rows alone. `event`: it came as a `background_tasks`
|
|
398
|
+
* event (see isBackgroundTasksSnapshotCurrent).
|
|
370
399
|
*/
|
|
371
|
-
export function setConversationBackgroundTasks(conversationId, tasks, { endedTasks } = {}) {
|
|
400
|
+
export function setConversationBackgroundTasks(conversationId, tasks, { endedTasks, event = false } = {}) {
|
|
372
401
|
const id = String(conversationId || '').trim();
|
|
373
402
|
if (!id) return;
|
|
403
|
+
if (event) taskEventCounts.set(id, backgroundTasksSnapshotMark(id) + 1);
|
|
374
404
|
const normalized = normalizeTaskList(tasks);
|
|
375
405
|
// Drop fold choices for tasks that left this conversation's set so the map
|
|
376
406
|
// cannot grow across long sessions.
|
|
@@ -460,6 +490,20 @@ export function findBackgroundTask(conversationId, taskId) {
|
|
|
460
490
|
return ended ? { task: ended, ended: true } : null;
|
|
461
491
|
}
|
|
462
492
|
|
|
493
|
+
const panelRenderListeners = new Set();
|
|
494
|
+
|
|
495
|
+
/** Called after the panel drew its rows (the task console keeps above its row). */
|
|
496
|
+
export function onBackgroundTasksPanelRendered(listener) {
|
|
497
|
+
panelRenderListeners.add(listener);
|
|
498
|
+
return () => panelRenderListeners.delete(listener);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function notifyPanelRendered() {
|
|
502
|
+
for (const listener of panelRenderListeners) {
|
|
503
|
+
try { listener(); } catch (error) { console.warn('background task panel listener failed', error?.message || error); }
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
463
507
|
/** Called with the conversation id after any change of tasks or health. */
|
|
464
508
|
export function subscribeBackgroundTasks(listener) {
|
|
465
509
|
changeListeners.add(listener);
|
|
@@ -538,7 +582,7 @@ function taskTitleHtml(task) {
|
|
|
538
582
|
return `<button type="button" class="subagent-open bg-task-desc" data-subagent-open="${escHtml(runId)}" title="Open this sub-agent's thoughts, tool calls and reply">${text}</button>`;
|
|
539
583
|
}
|
|
540
584
|
if (taskOutputUnavailable(task)) {
|
|
541
|
-
return `<span class="bg-task-desc bg-task-desc-no-output" title="
|
|
585
|
+
return `<span class="bg-task-desc bg-task-desc-no-output" title="${escHtml(TASK_OUTPUT_NONE_MESSAGES.provider)}">${text}</span>`;
|
|
542
586
|
}
|
|
543
587
|
if (taskHasConsole(task)) {
|
|
544
588
|
return `<button type="button" class="task-console-open bg-task-desc" data-task-console="${escHtml(task.taskId)}" title="Open this task's output">${text}</button>`;
|
|
@@ -553,10 +597,13 @@ function chipHtml(health) {
|
|
|
553
597
|
}
|
|
554
598
|
|
|
555
599
|
// Always present on a live row (hidden while output is fresh) so the 1 s
|
|
556
|
-
// timer can update it in place.
|
|
557
|
-
|
|
600
|
+
// timer can update it in place. A command piped into tail is quiet
|
|
601
|
+
// until it ends: its hover title says so.
|
|
602
|
+
function stallHtml(hint, task) {
|
|
558
603
|
const classes = `bg-task-stall${hint?.warn ? ' bg-task-stall-warn' : ''}`;
|
|
559
|
-
|
|
604
|
+
const notice = pipeHoldNotice(taskPipeHold(task));
|
|
605
|
+
const title = notice ? ` title="${escHtml(notice.title)}"` : '';
|
|
606
|
+
return `<span class="${classes}"${title}${hint ? '' : ' hidden'}>${hint ? escHtml(hint.text) : ''}</span>`;
|
|
560
607
|
}
|
|
561
608
|
|
|
562
609
|
function taskSteps(task) {
|
|
@@ -613,7 +660,7 @@ export function renderTaskRow(conversationId, task) {
|
|
|
613
660
|
${tokens ? `<span class="bg-task-tokens">${escHtml(tokens)}</span>` : ''}
|
|
614
661
|
${steps ? `<span class="bg-task-steps">${escHtml(steps)}</span>` : ''}
|
|
615
662
|
${chipHtml(health)}
|
|
616
|
-
${taskHasConsole(task) ? stallHtml(stall) : ''}
|
|
663
|
+
${taskHasConsole(task) ? stallHtml(stall, task) : ''}
|
|
617
664
|
<span class="bg-task-elapsed">${escHtml(elapsed)}</span>
|
|
618
665
|
${task.stoppable === false ? '' : `<button type="button" class="bg-task-stop" data-task-id="${escHtml(task.taskId)}" ${stopping ? 'disabled' : ''}>${stopping ? 'Stopping…' : 'Stop'}</button>`}
|
|
619
666
|
</span>
|
|
@@ -684,6 +731,7 @@ export function renderBackgroundTasksPanel() {
|
|
|
684
731
|
const staleList = panel.querySelector('#background-tasks-list');
|
|
685
732
|
if (staleList) staleList.innerHTML = '';
|
|
686
733
|
stopElapsedTimer();
|
|
734
|
+
notifyPanelRendered();
|
|
687
735
|
return;
|
|
688
736
|
}
|
|
689
737
|
const wasOpen = panel.hasAttribute('open');
|
|
@@ -733,6 +781,7 @@ export function renderBackgroundTasksPanel() {
|
|
|
733
781
|
if (wasOpen) panel.setAttribute('open', '');
|
|
734
782
|
if (tasks.length) startElapsedTimer();
|
|
735
783
|
else stopElapsedTimer();
|
|
784
|
+
notifyPanelRendered();
|
|
736
785
|
}
|
|
737
786
|
|
|
738
787
|
// A preview created, closed, or flipping online/offline re-renders the panel;
|
|
@@ -149,6 +149,7 @@ import {
|
|
|
149
149
|
canMergeIntoTranscript,
|
|
150
150
|
} from './conversation-view.js';
|
|
151
151
|
import { loadKeepingLiveRows } from './transcript-reload-freshness.mjs';
|
|
152
|
+
import { backgroundTasksSnapshotMark } from './background-tasks-view.mjs';
|
|
152
153
|
import { newerPageReachesEnd } from './transcript-island.mjs';
|
|
153
154
|
import { initPinnedMessagesView, setPinsConversation } from './pinned-messages-view.mjs';
|
|
154
155
|
import { initHiddenMessagesView, setHiddenMessagesConversation } from './hidden-messages-view.mjs';
|
|
@@ -339,6 +340,7 @@ import {
|
|
|
339
340
|
updateBackgroundTaskTimeoutSetting,
|
|
340
341
|
previewTaskStallMinutesSetting,
|
|
341
342
|
updateTaskStallMinutesSetting,
|
|
343
|
+
updateRestartResumeSettingFromToggle,
|
|
342
344
|
updateSubagentRecordRetentionSetting,
|
|
343
345
|
} from './settings-modal.js';
|
|
344
346
|
import {
|
|
@@ -3758,6 +3760,7 @@ async function pollAuthenticatedCurrentConversationLive() {
|
|
|
3758
3760
|
const savedScrollTop = messagesEl?.scrollTop || 0;
|
|
3759
3761
|
const savedLoadedCount = loadConversationLoadedMessageCount(currentId);
|
|
3760
3762
|
const requestLimit = Math.max(20, getConversationLoadedMessageCount() || 0, savedLoadedCount || 0);
|
|
3763
|
+
const tasksMark = backgroundTasksSnapshotMark(currentId);
|
|
3761
3764
|
const jumpEpoch = getTranscriptJumpEpoch();
|
|
3762
3765
|
const { response, stale } = await loadKeepingLiveRows(
|
|
3763
3766
|
() => loadConversation(currentId, { limit: requestLimit }),
|
|
@@ -3783,6 +3786,7 @@ async function pollAuthenticatedCurrentConversationLive() {
|
|
|
3783
3786
|
savedScrollTop: preserveBottom ? null : stableSavedScrollTop,
|
|
3784
3787
|
followLiveUpdates: preserveBottom,
|
|
3785
3788
|
mergeFrom: merge?.mergeFrom || null,
|
|
3789
|
+
backgroundTasksAsOf: tasksMark,
|
|
3786
3790
|
});
|
|
3787
3791
|
// A merge does not scroll as the full render does: a reader at the end
|
|
3788
3792
|
// is taken to the new end here.
|
|
@@ -4061,6 +4065,7 @@ async function refreshCurrentView() {
|
|
|
4061
4065
|
|
|
4062
4066
|
const savedLoadedCount = loadConversationLoadedMessageCount(currentId);
|
|
4063
4067
|
const requestLimit = Math.max(20, getConversationLoadedMessageCount() || 0, savedLoadedCount || 0);
|
|
4068
|
+
const tasksMark = backgroundTasksSnapshotMark(currentId);
|
|
4064
4069
|
const { response: r, stale } = await loadKeepingLiveRows(
|
|
4065
4070
|
() => loadConversation(currentId, { limit: requestLimit }),
|
|
4066
4071
|
getTranscriptLiveRowEpoch,
|
|
@@ -4085,6 +4090,7 @@ async function refreshCurrentView() {
|
|
|
4085
4090
|
followLiveUpdates: preserveBottom && !keepTranscript,
|
|
4086
4091
|
keepTranscript,
|
|
4087
4092
|
mergeFrom: merge?.mergeFrom || null,
|
|
4093
|
+
backgroundTasksAsOf: tasksMark,
|
|
4088
4094
|
});
|
|
4089
4095
|
// As in the live poll: a merge leaves the scrolling to the caller.
|
|
4090
4096
|
if (merge && preserveBottom && messagesEl) messagesEl.scrollTop = messagesEl.scrollHeight;
|
|
@@ -5102,6 +5108,7 @@ window.previewBackgroundTaskTimeoutSetting = previewBackgroundTaskTimeoutSetting
|
|
|
5102
5108
|
window.updateBackgroundTaskTimeoutSetting = updateBackgroundTaskTimeoutSetting;
|
|
5103
5109
|
window.previewTaskStallMinutesSetting = previewTaskStallMinutesSetting;
|
|
5104
5110
|
window.updateTaskStallMinutesSetting = updateTaskStallMinutesSetting;
|
|
5111
|
+
window.updateRestartResumeSettingFromToggle = updateRestartResumeSettingFromToggle;
|
|
5105
5112
|
window.updateSubagentRecordRetentionSetting = updateSubagentRecordRetentionSetting;
|
|
5106
5113
|
window.togglePushOnThisDevice = togglePushOnThisDevice;
|
|
5107
5114
|
window.updatePushPreferencesFromControls = updatePushPreferencesFromControls;
|
|
@@ -89,11 +89,14 @@ function normalizeEffortList(efforts = []) {
|
|
|
89
89
|
* 3. else the lowest supported rung ABOVE it, ignoring 'none'
|
|
90
90
|
* (a remembered 'minimal' on a model whose floor is 'low' → low);
|
|
91
91
|
* 4. else the next candidate.
|
|
92
|
-
* With no candidate usable: the lowest non-'none' rung the model offers
|
|
93
|
-
* 'none' when that is all there is
|
|
94
|
-
*
|
|
92
|
+
* With no candidate usable: the lowest non-'none' rung the model offers (by
|
|
93
|
+
* rung, whatever the list order), or 'none' when that is all there is; a
|
|
94
|
+
* list with names off the ladder keeps its own order there. A caller with a
|
|
95
|
+
* default of its own passes `fallback`, returned instead in that case.
|
|
96
|
+
* Effort names off the ladder (image quality values, unknown providers) only
|
|
97
|
+
* ever match exactly.
|
|
95
98
|
*/
|
|
96
|
-
export function nearestSupportedReasoningEffort(candidates = [], supportedEfforts = []) {
|
|
99
|
+
export function nearestSupportedReasoningEffort(candidates = [], supportedEfforts = [], { fallback } = {}) {
|
|
97
100
|
const options = normalizeEffortList(supportedEfforts);
|
|
98
101
|
if (!options.length) return '';
|
|
99
102
|
const rungOf = (effort) => REASONING_EFFORT_LADDER.indexOf(effort);
|
|
@@ -109,6 +112,9 @@ export function nearestSupportedReasoningEffort(candidates = [], supportedEffort
|
|
|
109
112
|
const above = ranked.find((option) => rungOf(option) > rung && option !== 'ultracode');
|
|
110
113
|
if (above) return above;
|
|
111
114
|
}
|
|
115
|
+
if (fallback !== undefined) return fallback;
|
|
116
|
+
const lowest = ranked.find((option) => option !== 'ultracode');
|
|
117
|
+
if (lowest && options.every((option) => rungOf(option) >= 0)) return lowest;
|
|
112
118
|
return options.find((option) => option !== 'none') || options[0];
|
|
113
119
|
}
|
|
114
120
|
|
|
@@ -67,6 +67,7 @@ import { closeSlashAutocomplete, handleSlashAutocompleteKey, updateSlashAutocomp
|
|
|
67
67
|
import { closeMentionAutocomplete, handleMentionAutocompleteKey, updateMentionAutocomplete } from './mention-autocomplete.mjs';
|
|
68
68
|
import { renderRemoteOriginBadgeHtml } from './remote-relay-origin-view.mjs';
|
|
69
69
|
import { isRelayEventMessage, renderRelayEventRowHtml } from './relay-event-view.mjs';
|
|
70
|
+
import { isRestartResumeNote, renderRestartResumeNoteHtml } from './relay-restart-note-view.mjs';
|
|
70
71
|
import {
|
|
71
72
|
AGENT_EXCHANGE_CLOSED_CLASS,
|
|
72
73
|
AGENT_EXCHANGE_HEAD_CLASS,
|
|
@@ -398,14 +399,18 @@ const conversationHistoryLoader = createInfiniteLoader({
|
|
|
398
399
|
syncSteeredTurnMerge(el);
|
|
399
400
|
// The page's agent exchanges fold before the height delta is measured.
|
|
400
401
|
syncAgentExchangeFolds(el);
|
|
402
|
+
// Here, not a frame later: the position taken above is the reader's only
|
|
403
|
+
// until they scroll again, and a scroll in that frame (a jump to the end
|
|
404
|
+
// and up again) was undone by it.
|
|
405
|
+
el.scrollTop = previousScrollTop + (el.scrollHeight - previousScrollHeight);
|
|
406
|
+
// The page's images have no height yet: the place read is held while
|
|
407
|
+
// they come in, as after a restore.
|
|
408
|
+
const place = captureConversationReadingAnchor();
|
|
409
|
+
if (place?.conversationId === currentId) holdReadingAnchorWhileMediaLoads(el, place.anchor);
|
|
410
|
+
// Once the loader has taken this page's cursor: still near the top, the
|
|
411
|
+
// next page loads.
|
|
401
412
|
requestAnimationFrame(() => {
|
|
402
|
-
if (
|
|
403
|
-
const nextScrollHeight = el.scrollHeight;
|
|
404
|
-
el.scrollTop = previousScrollTop + (nextScrollHeight - previousScrollHeight);
|
|
405
|
-
// The page's images have no height yet: the place read is held while
|
|
406
|
-
// they come in, as after a restore.
|
|
407
|
-
const place = captureConversationReadingAnchor();
|
|
408
|
-
if (place?.conversationId === currentId) holdReadingAnchorWhileMediaLoads(el, place.anchor);
|
|
413
|
+
if (String(currentConvId || '').trim() !== currentId) return;
|
|
409
414
|
void conversationHistoryLoader.handleBoundaryDistance(el.scrollTop);
|
|
410
415
|
});
|
|
411
416
|
},
|
|
@@ -1336,6 +1341,21 @@ function createRelayEventNode(msg, msgId) {
|
|
|
1336
1341
|
return div;
|
|
1337
1342
|
}
|
|
1338
1343
|
|
|
1344
|
+
// The relay's note to carry on after a restart cut a turn off: a folded row
|
|
1345
|
+
// like an event row (relay-restart-note-view.mjs).
|
|
1346
|
+
function createRestartResumeNoteNode(msg, msgId) {
|
|
1347
|
+
const div = document.createElement('div');
|
|
1348
|
+
div.className = 'msg relay-event relay-restart-resume';
|
|
1349
|
+
if (msgId) div.dataset.messageId = msgId;
|
|
1350
|
+
const fingerprint = buildLiveMessageFingerprint({ ...msg, id: msgId || msg?.id || '' });
|
|
1351
|
+
div.dataset.messageRole = fingerprint.role || '';
|
|
1352
|
+
div.dataset.messageTextFingerprint = fingerprint.text || '';
|
|
1353
|
+
div.dataset.messageTimestamp = String(msg?.timestamp || '').trim();
|
|
1354
|
+
div.innerHTML = renderRestartResumeNoteHtml(msg, { escHtml, timeText: msg?.timestamp ? fmtDate(msg.timestamp) : '' });
|
|
1355
|
+
setAgentExchangeRow(div, msg, msgId);
|
|
1356
|
+
return div;
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1339
1359
|
// Agent exchanges (agent-exchange-fold-view.mjs): an agent's prompt folds
|
|
1340
1360
|
// into one row; this session's reply to it always shows. What the fold pass
|
|
1341
1361
|
// needs of a message is kept on its node when the row is built, so the pass
|
|
@@ -1469,6 +1489,7 @@ function createMessageNode(msg, msgId = null, force = false) {
|
|
|
1469
1489
|
// An event message the relay wrote into a watcher's conversation: a
|
|
1470
1490
|
// compact, foldable row, never a bubble of the user's.
|
|
1471
1491
|
if (isRelayEventMessage(msg)) return createRelayEventNode(msg, msgId);
|
|
1492
|
+
if (isRestartResumeNote(msg)) return createRestartResumeNoteNode(msg, msgId);
|
|
1472
1493
|
|
|
1473
1494
|
// A prompt another relay's agent sent carries `origin`: its header line is
|
|
1474
1495
|
// hidden (the badge below says the same), and a stray remote-relay mention
|
|
@@ -4602,7 +4623,10 @@ export async function sendMessage() {
|
|
|
4602
4623
|
: undefined,
|
|
4603
4624
|
};
|
|
4604
4625
|
|
|
4605
|
-
|
|
4626
|
+
// A refusal the user can act on (the conversation's model left the
|
|
4627
|
+
// catalog) is shown as the relay words it.
|
|
4628
|
+
let refusal = '';
|
|
4629
|
+
const r = await sendMessageApi(body, { onRefused: (reason) => { refusal = reason; } });
|
|
4606
4630
|
if (!r) {
|
|
4607
4631
|
// Offline: park the send in the durable outbox instead of bouncing it
|
|
4608
4632
|
// back into the composer. The client-generated messageId makes a replay
|
|
@@ -4636,7 +4660,7 @@ export async function sendMessage() {
|
|
|
4636
4660
|
sendLanded,
|
|
4637
4661
|
});
|
|
4638
4662
|
if (!mobileSend && viewingSendConversation()) input.focus();
|
|
4639
|
-
setModelBanner('⚠️ Message could not be sent. Please try again.');
|
|
4663
|
+
setModelBanner(refusal ? `⚠️ ${refusal}` : '⚠️ Message could not be sent. Please try again.');
|
|
4640
4664
|
return;
|
|
4641
4665
|
}
|
|
4642
4666
|
|
|
@@ -6,8 +6,19 @@
|
|
|
6
6
|
// host action" setting on purpose: a device with the action hidden must still
|
|
7
7
|
// be able to see and cancel a queued suspend.
|
|
8
8
|
|
|
9
|
-
import {
|
|
10
|
-
import { cancelHostSuspend
|
|
9
|
+
import { showTransientRelayNotice } from './store.js';
|
|
10
|
+
import { cancelHostSuspend } from './api-client.js';
|
|
11
|
+
import {
|
|
12
|
+
cancelQueuedRelayRestart,
|
|
13
|
+
isRelayRestartRequestInFlight,
|
|
14
|
+
keepWaitingForQueuedRelayRestart,
|
|
15
|
+
noteRelayRestartStateForDialogs,
|
|
16
|
+
openRestartNowConfirmation,
|
|
17
|
+
refreshRelayRestartWaitedLabels,
|
|
18
|
+
relayRestartHeadline,
|
|
19
|
+
renderRelayRestartRow,
|
|
20
|
+
setRelayRestartStateSink,
|
|
21
|
+
} from './relay-restart-ui.js';
|
|
11
22
|
|
|
12
23
|
const BANNER_ID = 'pending-action-banner';
|
|
13
24
|
const SUSPEND_MENU_ID = 'chat-menu-suspend-host';
|
|
@@ -20,6 +31,7 @@ const RESTART_MENU_PENDING_LABEL = '🌄 Restart pending…';
|
|
|
20
31
|
let hostSuspendState = null;
|
|
21
32
|
let relayShutdownState = null;
|
|
22
33
|
let countdownTimer = null;
|
|
34
|
+
let waitedTimer = null;
|
|
23
35
|
let cancelInFlight = false;
|
|
24
36
|
|
|
25
37
|
export function getHostSuspendState() {
|
|
@@ -96,20 +108,10 @@ export function hostSuspendBannerText(state, nowMs = Date.now()) {
|
|
|
96
108
|
: '💤 Suspend queued — waiting for running agents to finish';
|
|
97
109
|
}
|
|
98
110
|
|
|
111
|
+
/** The restart row's headline (relay-restart-ui.js renders the whole row). */
|
|
99
112
|
export function relayShutdownBannerText(state) {
|
|
100
113
|
if (!isRelayShutdownPending(state)) return '';
|
|
101
|
-
|
|
102
|
-
const icon = action === 'restart' ? '🌄' : '⏻';
|
|
103
|
-
if (String(state.status || '') === 'shutting_down') {
|
|
104
|
-
return `${icon} Relay ${action === 'restart' ? 'restarting' : 'shutting down'} now…`;
|
|
105
|
-
}
|
|
106
|
-
// Only the processing turns hold it; queued messages wait for the restart.
|
|
107
|
-
const queue = state.queue || {};
|
|
108
|
-
const turns = Number(queue.processingCount || 0);
|
|
109
|
-
const held = Number(queue.pendingCount || 0) + Number(queue.parkedCount || 0);
|
|
110
|
-
const waiting = turns > 0 ? `waiting for ${turns} running turn${turns === 1 ? '' : 's'} to finish` : 'nothing is running, going down now';
|
|
111
|
-
const queued = held > 0 ? `; ${held} queued message${held === 1 ? '' : 's'} wait${held === 1 ? 's' : ''} for it` : '';
|
|
112
|
-
return `${icon} Relay ${action} queued — ${waiting}${queued}`;
|
|
114
|
+
return relayRestartHeadline(state);
|
|
113
115
|
}
|
|
114
116
|
|
|
115
117
|
function ensureBanner() {
|
|
@@ -165,30 +167,6 @@ async function runCancelHostSuspend() {
|
|
|
165
167
|
}
|
|
166
168
|
}
|
|
167
169
|
|
|
168
|
-
async function runCancelRelayShutdown() {
|
|
169
|
-
if (cancelInFlight) return;
|
|
170
|
-
cancelInFlight = true;
|
|
171
|
-
renderPendingActionBanner();
|
|
172
|
-
try {
|
|
173
|
-
let result = null;
|
|
174
|
-
try {
|
|
175
|
-
result = await cancelRelayRestart();
|
|
176
|
-
} catch (error) {
|
|
177
|
-
showTransientRelayNotice(`Failed to cancel the queued relay restart: ${error?.message || 'unknown error'}`, 6000);
|
|
178
|
-
return;
|
|
179
|
-
}
|
|
180
|
-
if (!result?.ok) {
|
|
181
|
-
showTransientRelayNotice('Failed to cancel the queued relay restart.', 5000);
|
|
182
|
-
return;
|
|
183
|
-
}
|
|
184
|
-
applyRelayShutdownState(result);
|
|
185
|
-
showTransientRelayNotice(result.cancelled ? 'Queued relay restart cancelled.' : 'No relay restart was pending.', 4000);
|
|
186
|
-
} finally {
|
|
187
|
-
cancelInFlight = false;
|
|
188
|
-
renderPendingActionBanner();
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
|
|
192
170
|
function syncCountdownTimer() {
|
|
193
171
|
const needsTicker = isHostSuspendPending() && String(hostSuspendState?.status || '') === 'countdown' && hostSuspendState?.fireAt;
|
|
194
172
|
if (needsTicker && !countdownTimer) {
|
|
@@ -197,8 +175,27 @@ function syncCountdownTimer() {
|
|
|
197
175
|
clearInterval(countdownTimer);
|
|
198
176
|
countdownTimer = null;
|
|
199
177
|
}
|
|
178
|
+
// The restart's waited time ticks in place: rebuilding the row every
|
|
179
|
+
// second would swallow a click on its buttons.
|
|
180
|
+
const needsWaitedTicker = String(relayShutdownState?.status || '') === 'queued';
|
|
181
|
+
if (needsWaitedTicker && !waitedTimer) {
|
|
182
|
+
waitedTimer = setInterval(() => refreshRelayRestartWaitedLabels(relayShutdownState), 15_000);
|
|
183
|
+
waitedTimer?.unref?.();
|
|
184
|
+
} else if (!needsWaitedTicker && waitedTimer) {
|
|
185
|
+
clearInterval(waitedTimer);
|
|
186
|
+
waitedTimer = null;
|
|
187
|
+
}
|
|
200
188
|
}
|
|
201
189
|
|
|
190
|
+
const relayRestartHandlers = {
|
|
191
|
+
onOpen: (conversationId) => {
|
|
192
|
+
if (conversationId && typeof window.openConversation === 'function') void window.openConversation(conversationId);
|
|
193
|
+
},
|
|
194
|
+
onRestartNow: () => openRestartNowConfirmation(relayShutdownState),
|
|
195
|
+
onKeepWaiting: () => keepWaitingForQueuedRelayRestart(),
|
|
196
|
+
onCancel: () => cancelQueuedRelayRestart(),
|
|
197
|
+
};
|
|
198
|
+
|
|
202
199
|
export function renderPendingActionBanner() {
|
|
203
200
|
if (typeof document === 'undefined') return;
|
|
204
201
|
const rows = [];
|
|
@@ -211,12 +208,9 @@ export function renderPendingActionBanner() {
|
|
|
211
208
|
}));
|
|
212
209
|
}
|
|
213
210
|
if (isRelayShutdownPending()) {
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
text: relayShutdownBannerText(relayShutdownState),
|
|
218
|
-
cancelLabel: restarting ? '' : 'Cancel',
|
|
219
|
-
onCancel: runCancelRelayShutdown,
|
|
211
|
+
rows.push(renderRelayRestartRow(relayShutdownState, {
|
|
212
|
+
handlers: relayRestartHandlers,
|
|
213
|
+
busy: isRelayRestartRequestInFlight(),
|
|
220
214
|
}));
|
|
221
215
|
}
|
|
222
216
|
const banner = document.getElementById(BANNER_ID) || (rows.length ? ensureBanner() : null);
|
|
@@ -246,6 +240,7 @@ export function applyHostSuspendState(state) {
|
|
|
246
240
|
|
|
247
241
|
export function applyRelayShutdownState(state) {
|
|
248
242
|
relayShutdownState = state && typeof state === 'object' ? state : null;
|
|
243
|
+
noteRelayRestartStateForDialogs(relayShutdownState);
|
|
249
244
|
renderPendingActionBanner();
|
|
250
245
|
}
|
|
251
246
|
|
|
@@ -268,6 +263,11 @@ export function applyPendingActionsFromStatus(status) {
|
|
|
268
263
|
}
|
|
269
264
|
|
|
270
265
|
export function initHostSuspendUi() {
|
|
266
|
+
// A cancel, keep-waiting or restart-now answer carries the new state.
|
|
267
|
+
setRelayRestartStateSink((state, { rerenderOnly = false } = {}) => {
|
|
268
|
+
if (rerenderOnly) renderPendingActionBanner();
|
|
269
|
+
else applyRelayShutdownState(state);
|
|
270
|
+
});
|
|
271
271
|
renderPendingActionBanner();
|
|
272
272
|
}
|
|
273
273
|
|
|
@@ -276,6 +276,7 @@ export function __resetHostSuspendUiForTests() {
|
|
|
276
276
|
relayShutdownState = null;
|
|
277
277
|
cancelInFlight = false;
|
|
278
278
|
if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null; }
|
|
279
|
+
if (waitedTimer) { clearInterval(waitedTimer); waitedTimer = null; }
|
|
279
280
|
document.getElementById(BANNER_ID)?.remove();
|
|
280
281
|
syncMenuLabels();
|
|
281
282
|
}
|
|
@@ -82,6 +82,7 @@ import { mergeTailPage } from './transcript-reading-anchor.mjs';
|
|
|
82
82
|
import { readWindowRetention } from './conversation-window-retention.mjs';
|
|
83
83
|
import { MENU_SEPARATOR, bindLongPress, closeContextMenu, openContextMenu } from './context-menu.mjs';
|
|
84
84
|
import {
|
|
85
|
+
isBackgroundTasksSnapshotCurrent,
|
|
85
86
|
setBackgroundTasksConversation,
|
|
86
87
|
setConversationBackgroundTasks,
|
|
87
88
|
setConversationEndedBackgroundTasks,
|
|
@@ -777,6 +778,8 @@ export function toggleConversationGroupFold(event, groupId) {
|
|
|
777
778
|
// rather than drawn anew (mergeMessagesIntoTranscript).
|
|
778
779
|
// windowPayload: with keepTranscript, the window the page keeps, kept for
|
|
779
780
|
// the next visit in place of nothing.
|
|
781
|
+
// backgroundTasksAsOf: backgroundTasksSnapshotMark(id) as the request left;
|
|
782
|
+
// the payload's task sets are left out when an event brought newer ones since.
|
|
780
783
|
export function applyLoadedConversationState(id, response, {
|
|
781
784
|
restoreScroll = false,
|
|
782
785
|
savedScrollTop = null,
|
|
@@ -786,6 +789,7 @@ export function applyLoadedConversationState(id, response, {
|
|
|
786
789
|
fromCache = false,
|
|
787
790
|
mergeFrom = null,
|
|
788
791
|
windowPayload = null,
|
|
792
|
+
backgroundTasksAsOf = null,
|
|
789
793
|
} = {}) {
|
|
790
794
|
if (!response) {
|
|
791
795
|
if (!IS_SHARED_VIEW) conversationPayloadCache.forget(id);
|
|
@@ -892,9 +896,11 @@ export function applyLoadedConversationState(id, response, {
|
|
|
892
896
|
setBackgroundTasksConversation(id);
|
|
893
897
|
// Absent from an older relay's payload: the threshold keeps its default.
|
|
894
898
|
if ('taskStallMinutes' in response) setConversationTaskStallMinutes(id, response.taskStallMinutes);
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
899
|
+
if (isBackgroundTasksSnapshotCurrent(id, backgroundTasksAsOf)) {
|
|
900
|
+
setConversationBackgroundTasks(id, response.backgroundTasks || []);
|
|
901
|
+
// Absent from an older relay's payload: keep what the socket said.
|
|
902
|
+
if (Array.isArray(response.endedBackgroundTasks)) setConversationEndedBackgroundTasks(id, response.endedBackgroundTasks);
|
|
903
|
+
}
|
|
898
904
|
setUsageLimitConversation(id, {
|
|
899
905
|
// Absent from an older relay's payload: keep what the socket said.
|
|
900
906
|
pause: 'usageLimitPause' in response ? response.usageLimitPause : undefined,
|
|
@@ -11,6 +11,7 @@ const PUSH_EVENT_CONTROL_IDS = {
|
|
|
11
11
|
board: 'push-event-board-toggle',
|
|
12
12
|
cliOffline: 'push-event-cli-offline-toggle',
|
|
13
13
|
hostSuspend: 'push-event-host-suspend-toggle',
|
|
14
|
+
relayRestart: 'push-event-relay-restart-toggle',
|
|
14
15
|
};
|
|
15
16
|
|
|
16
17
|
let pushUpdateInFlight = false;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { stripRemotePromptHeader } from './remote-relay-shared.mjs';
|
|
2
2
|
|
|
3
|
-
const
|
|
3
|
+
const RELAY_TOOL_GUIDANCE_LINES = [
|
|
4
4
|
'# Relay Tool Guidance',
|
|
5
5
|
'For any user-facing question or clarification, use the ask_user tool so the web relay can render question cards and buttons. Never ask questions in plain assistant text.',
|
|
6
6
|
'When using ask_user, ALWAYS include a `choices` array with 2-6 answer options so the web relay can render clickable buttons. Only omit choices when the question genuinely requires freeform text input.',
|
|
@@ -8,7 +8,12 @@ const RELAY_TOOL_GUIDANCE = [
|
|
|
8
8
|
'For relay restarts in extension-managed mode, require explicit user permission first, then use the authenticated localhost API `POST /api/relay/shutdown`. Do not restart by killing processes or using respawn scripts.',
|
|
9
9
|
'Note: shutdown is queued and only completes when the current turn finishes, so it is pointless to wait for it to interrupt an active turn.',
|
|
10
10
|
'Use `restart: true` in the request body when the user wants a real relay restart rather than a plain shutdown. Example request body: `{ "reason": "manual-restart", "requestedBy": "localhost-api", "restart": true }`.',
|
|
11
|
-
|
|
11
|
+
'Never pass `immediate` in that request body: it restarts without waiting for the running turns, which only the user may decide, with Restart now in the web UI.',
|
|
12
|
+
];
|
|
13
|
+
const RELAY_TOOL_GUIDANCE = RELAY_TOOL_GUIDANCE_LINES.join(' ');
|
|
14
|
+
// Prompts stored before the `immediate` line was added carry the guidance
|
|
15
|
+
// without it.
|
|
16
|
+
const PREVIOUS_RELAY_TOOL_GUIDANCE = RELAY_TOOL_GUIDANCE_LINES.slice(0, -1).join(' ');
|
|
12
17
|
|
|
13
18
|
const MODE_MARKERS = {
|
|
14
19
|
plan: '[Relay mode: plan]',
|
|
@@ -94,6 +99,8 @@ function buildPromptPrefixPatterns(mode) {
|
|
|
94
99
|
return [
|
|
95
100
|
new RegExp(`^${whitespaceFlexiblePattern(currentFullPrefix)}\\s*`, 'i'),
|
|
96
101
|
new RegExp(`^${whitespaceFlexiblePattern(legacyFullPrefix)}\\s*`, 'i'),
|
|
102
|
+
new RegExp(`^${whitespaceFlexiblePattern(`${marker} ${MODE_INSTRUCTIONS[key]} ${PREVIOUS_RELAY_TOOL_GUIDANCE}`)}\\s*`, 'i'),
|
|
103
|
+
new RegExp(`^${whitespaceFlexiblePattern(`${marker} ${LEGACY_MODE_INSTRUCTIONS[key]} ${PREVIOUS_RELAY_TOOL_GUIDANCE}`)}\\s*`, 'i'),
|
|
97
104
|
new RegExp(`^${whitespaceFlexiblePattern(marker)}\\s*`, 'i'),
|
|
98
105
|
];
|
|
99
106
|
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// The note the relay writes into a conversation whose turn a relay restart
|
|
2
|
+
// cut off ("After a relay restart, tell interrupted sessions to carry on",
|
|
3
|
+
// server/services/restart-resume-service.mjs): a compact row in the look of
|
|
4
|
+
// the relay event rows, never a bubble of the user's.
|
|
5
|
+
//
|
|
6
|
+
// DOM-free, like relay-event-view.mjs: the caller owns the element.
|
|
7
|
+
|
|
8
|
+
export const RESTART_RESUME_KIND = 'relay-restart-resume';
|
|
9
|
+
export const RESTART_RESUME_LEAD = 'Automatic message from OAR, not from the user:';
|
|
10
|
+
|
|
11
|
+
function defaultEscape(value) {
|
|
12
|
+
return String(value ?? '')
|
|
13
|
+
.replace(/&/g, '&')
|
|
14
|
+
.replace(/</g, '<')
|
|
15
|
+
.replace(/>/g, '>')
|
|
16
|
+
.replace(/"/g, '"')
|
|
17
|
+
.replace(/'/g, ''');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function isRestartResumeNote(message) {
|
|
21
|
+
return message?.role === 'user' && String(message?.kind || '').trim() === RESTART_RESUME_KIND;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The row's markup: folded to one line, opened to the note as the agent got it. */
|
|
25
|
+
export function renderRestartResumeNoteHtml(message, { escHtml = defaultEscape, timeText = '' } = {}) {
|
|
26
|
+
const text = String(message?.text ?? '').trim();
|
|
27
|
+
const body = text.startsWith(RESTART_RESUME_LEAD) ? text.slice(RESTART_RESUME_LEAD.length).trim() : text;
|
|
28
|
+
return '<details class="relay-event-row relay-restart-note">'
|
|
29
|
+
+ '<summary class="relay-event-summary">'
|
|
30
|
+
+ '<span class="relay-event-tag" title="An automated message of the relay, not from you.">relay</span>'
|
|
31
|
+
+ '<span class="relay-event-text">Relay restarted · the interrupted turn was asked to carry on</span>'
|
|
32
|
+
+ (timeText ? `<span class="relay-event-time">${escHtml(timeText)}</span>` : '')
|
|
33
|
+
+ '</summary>'
|
|
34
|
+
+ `<div class="relay-event-lines relay-restart-note-text">${escHtml(body)}</div>`
|
|
35
|
+
+ '</details>';
|
|
36
|
+
}
|