@oar-sh/oar 0.9.19 → 0.9.20
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 +2 -26
- package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +2 -26
- package/CHANGELOG.md +317 -0
- package/README.md +10 -10
- package/bin/oar.js +391 -97
- package/package.json +4 -2
- package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +60 -0
- package/server/claude-cloud-worker/claude-cloud-session-process.mjs +27 -0
- package/server/claude-worker/claude-remote-relay-tool.mjs +1 -0
- package/server/claude-worker/claude-session-process.mjs +3 -0
- package/server/claude-worker/claude-turn-publisher.mjs +163 -8
- package/server/claude-worker/claude-usage-totals.mjs +58 -0
- package/server/copilot-worker/copilot-plan-board.mjs +4 -7
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +86 -3
- package/server/copilot-worker/copilot-sdk-session-process.mjs +57 -4
- package/server/cursor-worker/cursor-turn-runner.mjs +42 -6
- package/server/db-schema.mjs +18 -0
- package/server/deepseek-worker/deepseek-claude-backend.mjs +14 -0
- package/server/grok-worker/grok-turn-runner.mjs +44 -6
- package/server/migrations/0001-add-structured-answer.mjs +5 -7
- package/server/migrations/0002-recent-workspace-roots-path-key.mjs +5 -7
- package/server/migrations/0003-push-subscriptions.mjs +5 -7
- package/server/migrations/0004-steer-settle-markers.mjs +5 -7
- package/server/migrations/0005-remote-relays.mjs +5 -7
- package/server/migrations/0006-workspace-root-settings.mjs +5 -7
- package/server/migrations/0007-claude-cloud.mjs +5 -7
- package/server/migrations/0008-message-pins.mjs +5 -7
- package/server/migrations/0009-subagent-timeline.mjs +5 -7
- package/server/migrations/0010-subagent-timeline-pruning.mjs +5 -7
- package/server/migrations/0011-relay-event-subscriptions.mjs +5 -7
- package/server/migrations/0012-cross-relay-event-subscriptions.mjs +5 -7
- package/server/migrations/0013-previews.mjs +5 -7
- package/server/migrations/0014-conversation-groups.mjs +5 -7
- package/server/migrations/0015-conversation-group-position.mjs +5 -7
- package/server/migrations/0016-share-access-log.mjs +5 -7
- package/server/migrations/0017-conversation-history-indexes.mjs +5 -7
- package/server/migrations/0018-background-task-rows.mjs +5 -7
- package/server/migrations/0019-relay-event-subscription-options.mjs +5 -7
- package/server/migrations/0020-conversation-may-create-sessions.mjs +5 -7
- package/server/migrations/0021-conversation-group-agent-marks.mjs +5 -7
- package/server/migrations/0022-relay-schedules.mjs +5 -7
- package/server/migrations/0023-relay-asks.mjs +5 -7
- package/server/migrations/0024-deepseek-usage-totals.mjs +5 -7
- package/server/migrations/0025-deepseek-usage-requests-daily.mjs +5 -7
- package/server/migrations/0026-deepseek-usage-served-models.mjs +5 -7
- package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +5 -7
- package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +5 -7
- package/server/migrations/0029-deepseek-balance-rises.mjs +5 -7
- package/server/migrations/0030-deepseek-usage-actuals.mjs +5 -7
- package/server/migrations/0031-queue-provider.mjs +5 -7
- package/server/migrations/0032-runtime-session-billing-switch.mjs +5 -7
- package/server/migrations/0033-claude-api-usage-checkpoints.mjs +5 -7
- package/server/migrations/0034-claude-api-usage-totals.mjs +5 -7
- package/server/migrations/0035-conversation-plan-only.mjs +87 -0
- package/server/migrations/0036-token-usage.mjs +267 -0
- package/server/public/app/about-view.mjs +87 -3
- package/server/public/app/api-client.js +2 -1
- package/server/public/app/bootstrap.js +55 -2
- package/server/public/app/composer-control-state.mjs +12 -2
- package/server/public/app/context-usage-view.mjs +16 -0
- package/server/public/app/conversation-view.js +17 -2
- package/server/public/app/inbox-ui.js +32 -0
- package/server/public/app/inbox-view.mjs +141 -5
- package/server/public/app/plan-board-shared.mjs +49 -0
- package/server/public/app/plan-usage-view.mjs +1 -1
- package/server/public/app/relay-board-inline.mjs +4 -1
- package/server/public/app/relay-board-view.js +38 -10
- package/server/public/app/relay-event-view.mjs +4 -0
- package/server/public/app/socket-handlers.js +10 -2
- package/server/public/index.html +14 -0
- package/server/public/sw.js +8 -0
- package/server/relay-heap-guard.mjs +14 -1
- package/server/relay-self-restart.mjs +83 -2
- package/server/relay-tools.md +4 -3
- package/server/repositories/message-repository.mjs +31 -1
- package/server/repositories/remote-relay-repository.mjs +10 -0
- package/server/repositories/session-repository.mjs +6 -1
- package/server/routes/messages-routes.mjs +87 -0
- package/server/routes/relay-ask-routes.mjs +138 -8
- package/server/routes/relay-board-routes.mjs +55 -40
- package/server/routes/sessions-routes.mjs +103 -7
- package/server/server-runtime.mjs +132 -11
- package/server/services/about-service.mjs +59 -0
- package/server/services/cache-rebuild-service.mjs +7 -0
- package/server/services/conversation-preferences-service.mjs +31 -0
- package/server/services/database-file.mjs +413 -0
- package/server/services/db-backup-service.mjs +809 -0
- package/server/services/deepseek-usage-import.mjs +6 -9
- package/server/services/delete-archive-service.mjs +3 -0
- package/server/services/earlier-database-service.mjs +69 -126
- package/server/services/oar-cli-helpers.mjs +214 -12
- package/server/services/oar-cli-menu.mjs +1 -0
- package/server/services/oar-state-migration-service.mjs +33 -13
- package/server/services/package-data-rescue.mjs +4 -2
- package/server/services/plan-usage-claude-api.mjs +14 -15
- package/server/services/plan-usage-contract.mjs +6 -2
- package/server/services/plan-usage-deepseek.mjs +1 -1
- package/server/services/plan-usage-tokens.mjs +248 -0
- package/server/services/push-dispatch-service.mjs +134 -9
- package/server/services/relay-cli-launcher-service.mjs +3 -0
- package/server/services/relay-event-service.mjs +30 -0
- package/server/services/relay-question-context.mjs +11 -0
- package/server/services/relay-restart-picture.mjs +67 -22
- package/server/services/remote-inbox-watch-service.mjs +204 -0
- package/server/services/remote-relay-caller-context.mjs +80 -7
- package/server/services/remote-relay-dispatcher.mjs +40 -5
- package/server/services/restart-resume-service.mjs +5 -2
- package/server/services/session-worker-launch-service.mjs +12 -3
- package/server/services/session-worker-stop-service.mjs +27 -6
- package/server/services/token-usage-service.mjs +430 -0
- package/server/services/update-install-service.mjs +13 -0
- package/server/services/usage-limit-pause-service.mjs +8 -1
- package/server/services/worker-scope.mjs +77 -0
- package/shared/plan-board-actions.mjs +130 -0
- package/shared/plan-only.mjs +43 -0
- package/shared/relay-asks.mjs +3 -0
- package/shared/relay-events.mjs +38 -4
- package/shared/remote-relay-contract.mjs +66 -11
- package/shared/turn-token-usage.mjs +144 -0
|
@@ -13,12 +13,18 @@
|
|
|
13
13
|
import { apiFetch, answerRelayQuestion } from './api-client.js';
|
|
14
14
|
import { currentConvId, IS_SHARED_VIEW, setCurrentConv, showTransientRelayNotice } from './store.js';
|
|
15
15
|
import { isStatusViewActive, leaveStatusView } from './status-view.mjs';
|
|
16
|
+
import { actOnRelayBoard } from './relay-board-view.js';
|
|
16
17
|
import {
|
|
17
18
|
buildAskCard,
|
|
19
|
+
captureInboxFolds,
|
|
18
20
|
createCoalescedRefresh,
|
|
19
21
|
inboxPendingCount,
|
|
20
22
|
INBOX_FILTERS,
|
|
23
|
+
INBOX_FOLD_SELECTOR,
|
|
24
|
+
noteInboxFold,
|
|
25
|
+
pruneInboxFolds,
|
|
21
26
|
renderInbox,
|
|
27
|
+
restoreInboxFolds,
|
|
22
28
|
sessionAsksFromInbox,
|
|
23
29
|
} from './inbox-view.mjs';
|
|
24
30
|
|
|
@@ -37,6 +43,10 @@ let clickBound = false;
|
|
|
37
43
|
let started = false;
|
|
38
44
|
// Typed answers, kept across re-renders: `${relayId}:${itemId}` → text.
|
|
39
45
|
const drafts = new Map();
|
|
46
|
+
// The open folds (a card's Details, a plan card's plan), kept across
|
|
47
|
+
// re-renders the same way: every socket change and the minute's poll draw
|
|
48
|
+
// the cards anew, closed. Forgotten when the item leaves the list.
|
|
49
|
+
const openFolds = new Set();
|
|
40
50
|
// This relay's asks for the session cards: id → ask (each Inbox load
|
|
41
51
|
// replaces it; the socket updates single asks in between).
|
|
42
52
|
let sessionAsks = new Map();
|
|
@@ -104,9 +114,11 @@ function renderInboxNow({ opening = false } = {}) {
|
|
|
104
114
|
const host = document.getElementById('messages');
|
|
105
115
|
if (!host) return;
|
|
106
116
|
const focused = captureDrafts(host);
|
|
117
|
+
if (host.querySelector('.inbox-view')) captureInboxFolds(openFolds, host);
|
|
107
118
|
const top = opening ? inboxScrollTop : host.scrollTop;
|
|
108
119
|
host.replaceChildren(renderInbox(document, { relays, filter, now: Date.now(), loading: !loaded }));
|
|
109
120
|
restoreDrafts(host, focused);
|
|
121
|
+
restoreInboxFolds(openFolds, host);
|
|
110
122
|
host.scrollTop = top;
|
|
111
123
|
const title = document.getElementById('chat-title');
|
|
112
124
|
if (title) {
|
|
@@ -122,6 +134,7 @@ async function loadInbox() {
|
|
|
122
134
|
loaded = true;
|
|
123
135
|
// Built anew from each load: an ask withdrawn or cancelled meanwhile goes.
|
|
124
136
|
sessionAsks = sessionAsksFromInbox(relays);
|
|
137
|
+
pruneInboxFolds(openFolds, relays);
|
|
125
138
|
}
|
|
126
139
|
syncInboxBadge();
|
|
127
140
|
renderInboxNow();
|
|
@@ -268,6 +281,15 @@ async function answerCard(card, data, { choice = '' } = {}) {
|
|
|
268
281
|
return true;
|
|
269
282
|
}
|
|
270
283
|
|
|
284
|
+
// A plan card's button in the Inbox: the card's own action, with
|
|
285
|
+
// the plan-only question first; a card settled meanwhile just shows so.
|
|
286
|
+
async function actOnPlanItem(data) {
|
|
287
|
+
const own = relays.find((relay) => relay?.self === true);
|
|
288
|
+
const item = (Array.isArray(own?.items) ? own.items : []).find((entry) => entry?.kind === 'plan' && entry.id === data.boardId) || null;
|
|
289
|
+
const board = item ? { ...item, conversationId: item.session?.id } : null;
|
|
290
|
+
return !!(await actOnRelayBoard(data.boardId, data.actionId, { board }));
|
|
291
|
+
}
|
|
292
|
+
|
|
271
293
|
async function handleAction(target) {
|
|
272
294
|
const data = { ...target.dataset };
|
|
273
295
|
const card = target.closest('.inbox-item');
|
|
@@ -294,6 +316,7 @@ async function handleAction(target) {
|
|
|
294
316
|
else if (data.inboxAction === 'dismiss') done = await answerAsk(card, data, { dismiss: true });
|
|
295
317
|
else if (data.inboxAction === 'card-choice') done = await answerCard(card, data, { choice: data.choice });
|
|
296
318
|
else if (data.inboxAction === 'card-answer') done = await answerCard(card, data);
|
|
319
|
+
else if (data.inboxAction === 'plan-action') done = await actOnPlanItem(data);
|
|
297
320
|
} finally {
|
|
298
321
|
if (!done) for (const node of buttons) node.disabled = false;
|
|
299
322
|
}
|
|
@@ -318,6 +341,13 @@ function bindClicks() {
|
|
|
318
341
|
const key = field ? draftKey(field) : '';
|
|
319
342
|
if (key) drafts.set(key, field.value);
|
|
320
343
|
});
|
|
344
|
+
// A fold opened or closed by hand. `toggle` does not bubble: caught on the
|
|
345
|
+
// way down. Also covers a session's card that a transcript render dropped
|
|
346
|
+
// before renderSessionAskCards drew it again.
|
|
347
|
+
host?.addEventListener('toggle', (event) => {
|
|
348
|
+
const details = event.target instanceof Element && event.target.matches(INBOX_FOLD_SELECTOR) ? event.target : null;
|
|
349
|
+
if (details && details.isConnected) noteInboxFold(openFolds, details);
|
|
350
|
+
}, true);
|
|
321
351
|
}
|
|
322
352
|
|
|
323
353
|
// ─── The card in the session ──────────────────────────────────────────────────
|
|
@@ -341,6 +371,7 @@ export function renderSessionAskCards() {
|
|
|
341
371
|
if (key === sessionCardKey && existing.length === shown.length && [...existing].every((node) => node.isConnected)) return;
|
|
342
372
|
sessionCardKey = key;
|
|
343
373
|
const focused = captureDrafts(host);
|
|
374
|
+
existing.forEach((node) => captureInboxFolds(openFolds, node));
|
|
344
375
|
existing.forEach((node) => node.remove());
|
|
345
376
|
const firstQuestion = host.querySelector('.relay-question-container');
|
|
346
377
|
for (const ask of shown) {
|
|
@@ -350,6 +381,7 @@ export function renderSessionAskCards() {
|
|
|
350
381
|
wrapper.append(buildAskCard(document, { ...ask, session: ask.session || { id } }, { relay: { id: 'self', self: true }, mode: 'session', now: Date.now() }));
|
|
351
382
|
if (firstQuestion) host.insertBefore(wrapper, firstQuestion);
|
|
352
383
|
else host.append(wrapper);
|
|
384
|
+
restoreInboxFolds(openFolds, wrapper);
|
|
353
385
|
}
|
|
354
386
|
restoreDrafts(host, focused);
|
|
355
387
|
}
|
|
@@ -11,11 +11,26 @@
|
|
|
11
11
|
// values of shared/relay-asks.mjs this needs are kept here, and
|
|
12
12
|
// inbox-view.test.mjs asserts they match.
|
|
13
13
|
|
|
14
|
+
import { boardOutcomeLine } from './relay-board-inline.mjs';
|
|
15
|
+
|
|
14
16
|
export const RELAY_ASK_SOCKET_EVENT = 'relay_ask_updated';
|
|
17
|
+
export const INBOX_REMOTE_CHANGED_EVENT = 'inbox_remote_changed';
|
|
15
18
|
export const INBOX_ANSWERED_KEEP_MS = 24 * 60 * 60_000;
|
|
16
19
|
export const INBOX_FILTERS = Object.freeze(['all', 'mine', 'agents']);
|
|
17
20
|
|
|
18
|
-
|
|
21
|
+
// The head label of a paired relay's approval card by its `variant`
|
|
22
|
+
// (REMOTE_RELAY_APPROVAL_VARIANTS of shared/remote-relay-contract.mjs).
|
|
23
|
+
export const INBOX_APPROVAL_VARIANT_LABELS = Object.freeze({
|
|
24
|
+
create_session: 'New session',
|
|
25
|
+
loop_guard: 'Loop guard',
|
|
26
|
+
write: 'Write',
|
|
27
|
+
session_settings: 'Session settings',
|
|
28
|
+
watch: 'Watch',
|
|
29
|
+
local_sessions: 'Start sessions',
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// A plan card that was acted on is closed too.
|
|
33
|
+
const CLOSED_STATUSES = new Set(['answered', 'dismissed', 'acted']);
|
|
19
34
|
|
|
20
35
|
function toText(value) {
|
|
21
36
|
return String(value ?? '').trim();
|
|
@@ -71,6 +86,11 @@ function isClosed(item) {
|
|
|
71
86
|
return CLOSED_STATUSES.has(toText(item?.status));
|
|
72
87
|
}
|
|
73
88
|
|
|
89
|
+
// When an item was closed: answered, a plan card acted on, else made.
|
|
90
|
+
function closedAt(item) {
|
|
91
|
+
return item?.answeredAt || item?.actedAt || item?.createdAt;
|
|
92
|
+
}
|
|
93
|
+
|
|
74
94
|
function newestFirst(a, b) {
|
|
75
95
|
return toText(b?.createdAt).localeCompare(toText(a?.createdAt));
|
|
76
96
|
}
|
|
@@ -85,8 +105,8 @@ export function inboxSections(relays, { filter = 'all', now = Date.now() } = {})
|
|
|
85
105
|
const items = (Array.isArray(relay?.items) ? relay.items : []).filter((item) => item && toText(item.id) && inboxFilterMatches(item, filter));
|
|
86
106
|
const pending = items.filter((item) => toText(item.status) === 'pending').sort(newestFirst);
|
|
87
107
|
const closed = items
|
|
88
|
-
.filter((item) => isClosed(item) && now - (Date.parse(toText(item
|
|
89
|
-
.sort((a, b) => toText(b
|
|
108
|
+
.filter((item) => isClosed(item) && now - (Date.parse(toText(closedAt(item))) || 0) <= INBOX_ANSWERED_KEEP_MS)
|
|
109
|
+
.sort((a, b) => toText(closedAt(b)).localeCompare(toText(closedAt(a))));
|
|
90
110
|
return {
|
|
91
111
|
relay: {
|
|
92
112
|
id: toText(relay?.id),
|
|
@@ -147,6 +167,59 @@ export function sessionAsksFromInbox(relays) {
|
|
|
147
167
|
return asks;
|
|
148
168
|
}
|
|
149
169
|
|
|
170
|
+
// The folds a card has: an ask's `Details` and a plan card's `The plan`.
|
|
171
|
+
export const INBOX_FOLD_SELECTOR = 'details.inbox-item-detail, details.inbox-plan-body';
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* A fold's key: its card's relay and id, and which fold it is. Every render
|
|
175
|
+
* builds the cards anew, closed; the keys of the open ones carry them over.
|
|
176
|
+
*/
|
|
177
|
+
export function inboxFoldKey(details) {
|
|
178
|
+
const card = details?.closest?.('.inbox-item');
|
|
179
|
+
const itemId = toText(card?.dataset?.itemId);
|
|
180
|
+
if (!itemId) return '';
|
|
181
|
+
const fold = details.classList?.contains('inbox-plan-body') ? 'plan' : 'detail';
|
|
182
|
+
return `${toText(card.dataset.relayId) || 'self'}\n${itemId}\n${fold}`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Notes whether `details` is open in `openFolds` (a Set of fold keys). */
|
|
186
|
+
export function noteInboxFold(openFolds, details) {
|
|
187
|
+
const key = inboxFoldKey(details);
|
|
188
|
+
if (!key) return;
|
|
189
|
+
if (details.open) openFolds.add(key);
|
|
190
|
+
else openFolds.delete(key);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Notes every fold under `root`, as it is now. */
|
|
194
|
+
export function captureInboxFolds(openFolds, root) {
|
|
195
|
+
for (const details of root?.querySelectorAll?.(INBOX_FOLD_SELECTOR) || []) noteInboxFold(openFolds, details);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Opens the folds under `root` that were open before its re-render. */
|
|
199
|
+
export function restoreInboxFolds(openFolds, root) {
|
|
200
|
+
for (const details of root?.querySelectorAll?.(INBOX_FOLD_SELECTOR) || []) {
|
|
201
|
+
if (openFolds.has(inboxFoldKey(details))) details.open = true;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Forgets the folds of items no longer listed (an Inbox load, GET
|
|
207
|
+
* /api/inbox). A relay that did not answer keeps its folds: its items did
|
|
208
|
+
* not leave, they are just not shown.
|
|
209
|
+
*/
|
|
210
|
+
export function pruneInboxFolds(openFolds, relays, { now = Date.now() } = {}) {
|
|
211
|
+
const listed = new Set();
|
|
212
|
+
const kept = new Set();
|
|
213
|
+
for (const section of inboxSections(relays, { now })) {
|
|
214
|
+
if (!section.relay.reachable) kept.add(section.relay.id || 'self');
|
|
215
|
+
for (const item of [...section.pending, ...section.closed]) listed.add(`${section.relay.id || 'self'}\n${toText(item.id)}`);
|
|
216
|
+
}
|
|
217
|
+
for (const key of [...openFolds]) {
|
|
218
|
+
const [relayId, itemId] = key.split('\n');
|
|
219
|
+
if (!kept.has(relayId) && !listed.has(`${relayId}\n${itemId}`)) openFolds.delete(key);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
150
223
|
/** Pending items across every relay (the sidebar's badge), whatever the filter. */
|
|
151
224
|
export function inboxPendingCount(relays) {
|
|
152
225
|
return (Array.isArray(relays) ? relays : [])
|
|
@@ -269,10 +342,17 @@ export function buildCardItem(doc, item, { relay = null, now = Date.now() } = {}
|
|
|
269
342
|
card.dataset.kind = toText(item.kind) || 'ask_user';
|
|
270
343
|
if (relay?.id) card.dataset.relayId = relay.id;
|
|
271
344
|
card.dataset.status = toText(item.status) || 'pending';
|
|
272
|
-
|
|
345
|
+
// A paired relay's approval says what it is about; one from a
|
|
346
|
+
// relay without summaries (0.9.19) keeps the plain text.
|
|
347
|
+
const summary = approval && !relay?.self ? toText(item.summary) : '';
|
|
348
|
+
const variant = toText(item.variant);
|
|
349
|
+
const variantLabel = Object.hasOwn(INBOX_APPROVAL_VARIANT_LABELS, variant) ? INBOX_APPROVAL_VARIANT_LABELS[variant] : '';
|
|
350
|
+
const kindLabel = summary ? (variantLabel || 'Approval') : (approval ? 'Approval' : 'Question');
|
|
351
|
+
card.append(headLine(doc, item, { relay, now, label: `${kindLabel} · the session waits` }));
|
|
273
352
|
card.append(sessionLine(doc, item, relay));
|
|
274
353
|
if (toText(item.header)) card.append(el(doc, 'div', 'inbox-item-header', item.header));
|
|
275
|
-
if (
|
|
354
|
+
if (summary) card.append(el(doc, 'p', 'inbox-item-question inbox-item-summary', `${toText(relay?.name) || 'that relay'} · ${summary}`));
|
|
355
|
+
else if (toText(item.question)) card.append(el(doc, 'p', 'inbox-item-question', item.question));
|
|
276
356
|
else if (approval) card.append(el(doc, 'p', 'inbox-item-question', 'An Allow / Deny card on that relay.'));
|
|
277
357
|
const outcome = outcomeLine(doc, item);
|
|
278
358
|
if (outcome) card.append(outcome);
|
|
@@ -321,8 +401,64 @@ export function buildCardItem(doc, item, { relay = null, now = Date.now() } = {}
|
|
|
321
401
|
return card;
|
|
322
402
|
}
|
|
323
403
|
|
|
404
|
+
/**
|
|
405
|
+
* A plan card in the Inbox: the plan folded, and the card's own
|
|
406
|
+
* buttons, which act as the card in its session does (a plan-only session
|
|
407
|
+
* asks first). Settled, it says what was chosen. A paired relay's shows its
|
|
408
|
+
* title alone: "Open there".
|
|
409
|
+
*/
|
|
410
|
+
export function buildPlanItem(doc, item, { relay = null, now = Date.now() } = {}) {
|
|
411
|
+
const card = el(doc, 'article', 'inbox-item inbox-card inbox-plan');
|
|
412
|
+
card.dataset.itemId = toText(item.id);
|
|
413
|
+
card.dataset.kind = 'plan';
|
|
414
|
+
if (relay?.id) card.dataset.relayId = relay.id;
|
|
415
|
+
card.dataset.status = toText(item.status) || 'pending';
|
|
416
|
+
card.append(headLine(doc, item, { relay, now, label: 'Plan · ready for review' }));
|
|
417
|
+
card.append(sessionLine(doc, item, relay));
|
|
418
|
+
card.append(el(doc, 'div', 'inbox-item-header', toText(item.title) || 'Plan ready for review'));
|
|
419
|
+
if (relay?.self && toText(item.body)) {
|
|
420
|
+
const details = el(doc, 'details', 'inbox-plan-body');
|
|
421
|
+
details.append(el(doc, 'summary', '', 'The plan'));
|
|
422
|
+
details.append(el(doc, 'div', 'inbox-plan-text', item.body));
|
|
423
|
+
card.append(details);
|
|
424
|
+
}
|
|
425
|
+
if (item.status !== 'pending') {
|
|
426
|
+
const outcome = boardOutcomeLine(item);
|
|
427
|
+
if (outcome) card.append(el(doc, 'div', 'inbox-item-outcome', outcome));
|
|
428
|
+
return card;
|
|
429
|
+
}
|
|
430
|
+
if (!relay?.self) {
|
|
431
|
+
const url = inboxSessionUrl(relay?.url, item?.session?.id);
|
|
432
|
+
const actions = el(doc, 'div', 'inbox-item-actions');
|
|
433
|
+
if (url) {
|
|
434
|
+
const link = el(doc, 'a', 'inbox-open-there inbox-open-there-button', 'Open there');
|
|
435
|
+
link.href = url;
|
|
436
|
+
link.target = '_blank';
|
|
437
|
+
link.rel = 'noopener noreferrer';
|
|
438
|
+
actions.append(link);
|
|
439
|
+
} else {
|
|
440
|
+
actions.append(el(doc, 'span', 'inbox-item-note', 'Answer it on that relay.'));
|
|
441
|
+
}
|
|
442
|
+
card.append(actions);
|
|
443
|
+
return card;
|
|
444
|
+
}
|
|
445
|
+
const recommended = toText(item.recommendedAction).toLowerCase();
|
|
446
|
+
const row = el(doc, 'div', 'inbox-item-options inbox-plan-actions');
|
|
447
|
+
for (const action of Array.isArray(item.actions) ? item.actions : []) {
|
|
448
|
+
const actionId = toText(action?.id).toLowerCase();
|
|
449
|
+
if (!actionId) continue;
|
|
450
|
+
const node = button(doc, `inbox-choice inbox-plan-action${actionId === recommended ? ' inbox-plan-action-recommended' : ''}`, toText(action?.label) || actionId, {
|
|
451
|
+
relayId: 'self', inboxAction: 'plan-action', boardId: item.id, actionId, sessionId: item?.session?.id,
|
|
452
|
+
});
|
|
453
|
+
row.append(node);
|
|
454
|
+
}
|
|
455
|
+
card.append(row);
|
|
456
|
+
return card;
|
|
457
|
+
}
|
|
458
|
+
|
|
324
459
|
function buildItem(doc, item, relay, now) {
|
|
325
460
|
if (item.kind === 'ask') return buildAskCard(doc, item, { relay, now, mode: 'inbox', answerable: true });
|
|
461
|
+
if (item.kind === 'plan') return buildPlanItem(doc, item, { relay, now });
|
|
326
462
|
return buildCardItem(doc, item, { relay, now });
|
|
327
463
|
}
|
|
328
464
|
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Browser mirror of the pieces of shared/plan-board-actions.mjs the web UI
|
|
2
|
+
// needs. The browser cannot import shared/ (only server/public is served), so
|
|
3
|
+
// these are hand-kept copies; plan-board-shared.test.mjs asserts they match.
|
|
4
|
+
// Below them, the web UI's own plan-only wording.
|
|
5
|
+
|
|
6
|
+
/** The actions that take the session out of plan mode for good. */
|
|
7
|
+
export const PLAN_BOARD_LEAVES_PLAN = Object.freeze(['autopilot', 'autopilot_fleet', 'interactive']);
|
|
8
|
+
|
|
9
|
+
export const PLAN_BOARD_LABELS = Object.freeze({
|
|
10
|
+
approve_only: 'Approve plan',
|
|
11
|
+
autopilot: 'Implement in autopilot',
|
|
12
|
+
autopilot_fleet: 'Implement with autopilot fleet',
|
|
13
|
+
interactive: 'Stop here and prompt myself',
|
|
14
|
+
exit_only: 'Stop here',
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
/** The button's label; an id the table does not know is spelled out. */
|
|
18
|
+
export function planBoardActionLabel(id) {
|
|
19
|
+
const key = String(id || '').trim().toLowerCase();
|
|
20
|
+
if (Object.prototype.hasOwnProperty.call(PLAN_BOARD_LABELS, key)) return PLAN_BOARD_LABELS[key];
|
|
21
|
+
return key.replace(/[_-]+/g, ' ').replace(/\b\w/g, (char) => char.toUpperCase()) || 'Select';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* On a plan-only session the actions that leave plan mode for good ask
|
|
26
|
+
* first; "Approve plan", "Stop here" and an action the relay does not know
|
|
27
|
+
* never do. The question, or '' when there is none.
|
|
28
|
+
*/
|
|
29
|
+
export function planBoardConfirmText({ planOnly = false, actionId = '', label = '' } = {}) {
|
|
30
|
+
const id = String(actionId || '').trim().toLowerCase();
|
|
31
|
+
if (planOnly !== true || !PLAN_BOARD_LEAVES_PLAN.includes(id)) return '';
|
|
32
|
+
const name = String(label || '').trim() || planBoardActionLabel(id);
|
|
33
|
+
return `This session is plan-only. ${name} leaves plan mode. Go on?`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The mode select's hint on a plan-only session (title and the plan option's text). */
|
|
37
|
+
export function planOnlyModeHint(planOnly) {
|
|
38
|
+
return planOnly === true
|
|
39
|
+
? { title: 'Mode · plan-only: agents can\'t take this session out of plan mode', planLabel: 'Plan 🔒' }
|
|
40
|
+
: { title: 'Mode', planLabel: 'Plan' };
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The plan-only flag after a conversation_preferences_updated broadcast: the
|
|
44
|
+
* one it carries, else the one already known (an older relay, or the mode
|
|
45
|
+
* follow-up of a plan card, says nothing about it).
|
|
46
|
+
*/
|
|
47
|
+
export function planOnlyAfterBroadcast(broadcast, known) {
|
|
48
|
+
return typeof broadcast === 'boolean' ? broadcast : known === true;
|
|
49
|
+
}
|
|
@@ -280,7 +280,7 @@ function renderChart(chart) {
|
|
|
280
280
|
|
|
281
281
|
function renderDetailSection(section) {
|
|
282
282
|
const rows = section.rows.map((row) => `
|
|
283
|
-
<tr>
|
|
283
|
+
<tr${row.title ? ` title="${escapeHtml(row.title)}"` : ''}>
|
|
284
284
|
<td class="plan-usage-detail-label">${escapeHtml(row.label)}${row.hint ? `<span class="plan-usage-detail-hint">${escapeHtml(row.hint)}</span>` : ''}</td>
|
|
285
285
|
<td class="plan-usage-detail-value">${String(row.value).split(' · ').map((part) => `<span class="plan-usage-detail-part">${escapeHtml(part)}</span>`).join(' · ')}</td>
|
|
286
286
|
</tr>
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
// board when the agent wrote its plan as the answer. Then the text is shown
|
|
12
12
|
// once, as the reply, and the board adds only its buttons.
|
|
13
13
|
|
|
14
|
+
import { PLAN_BOARD_LABELS } from './plan-board-shared.mjs';
|
|
15
|
+
|
|
14
16
|
function plain(text) {
|
|
15
17
|
return String(text || '').replace(/\s+/g, ' ').trim().toLowerCase();
|
|
16
18
|
}
|
|
@@ -32,7 +34,8 @@ export function chosenActionLabel(board) {
|
|
|
32
34
|
if (!chosen) return '';
|
|
33
35
|
const actions = Array.isArray(board?.actions) ? board.actions : [];
|
|
34
36
|
const action = actions.find((item) => String(item?.id || '').trim().toLowerCase() === chosen);
|
|
35
|
-
|
|
37
|
+
// A card stored before "Approve plan" existed does not list it.
|
|
38
|
+
return String(action?.label || '').trim() || (Object.hasOwn(PLAN_BOARD_LABELS, chosen) ? PLAN_BOARD_LABELS[chosen] : chosen.replace(/[_-]+/g, ' '));
|
|
36
39
|
}
|
|
37
40
|
|
|
38
41
|
/** The line that replaces the buttons once the board is settled; '' while it is pending. */
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
IS_SHARED_VIEW,
|
|
3
|
+
conversations,
|
|
3
4
|
currentConvId,
|
|
4
5
|
escHtml,
|
|
5
6
|
relayBoards,
|
|
@@ -12,9 +13,11 @@ import {
|
|
|
12
13
|
boardsOfConversation,
|
|
13
14
|
} from './relay-board-inline.mjs';
|
|
14
15
|
import {
|
|
16
|
+
loadRelayBoard as loadRelayBoardApi,
|
|
15
17
|
loadRelayBoards as loadRelayBoardsApi,
|
|
16
18
|
submitRelayBoardAction as submitRelayBoardActionApi,
|
|
17
19
|
} from './api-client.js';
|
|
20
|
+
import { planBoardConfirmText } from './plan-board-shared.mjs';
|
|
18
21
|
import { renderMarkdownPreview } from './router.js';
|
|
19
22
|
import { attachCodeCopyButtons } from './code-copy.mjs';
|
|
20
23
|
import { beginOwnSend, followsTranscriptEnd, refreshJumpButton, scrollAfterOwnSend } from './transcript-jump-bottom-view.mjs';
|
|
@@ -203,27 +206,52 @@ export function renderRelayBoards() {
|
|
|
203
206
|
return added;
|
|
204
207
|
}
|
|
205
208
|
|
|
206
|
-
|
|
209
|
+
/**
|
|
210
|
+
* Settle a board, from its card or from the Inbox (`board`: what the caller
|
|
211
|
+
* shows, when the store has no copy; `controls`: the buttons to hold while
|
|
212
|
+
* it goes). On a plan-only session an action that leaves plan mode asks
|
|
213
|
+
* first. A board the other view settled meanwhile is shown as settled, with
|
|
214
|
+
* no alert. Returns the board as it is now, or null when nothing was done.
|
|
215
|
+
*/
|
|
216
|
+
export async function actOnRelayBoard(boardId, actionId, { board: shown = null, controls = [] } = {}) {
|
|
207
217
|
const id = String(boardId || '').trim();
|
|
208
218
|
const nextActionId = String(actionId || '').trim();
|
|
209
|
-
if (!id || !nextActionId) return;
|
|
210
|
-
const
|
|
211
|
-
|
|
219
|
+
if (!id || !nextActionId) return null;
|
|
220
|
+
const board = relayBoards.get(id) || shown;
|
|
221
|
+
// The flag as the page knows it now; the board's own copy otherwise.
|
|
222
|
+
const conversation = conversations[String(board?.conversationId || shown?.session?.id || '').trim()] || null;
|
|
223
|
+
const planOnly = typeof conversation?.planOnly === 'boolean' ? conversation.planOnly : board?.planOnly === true;
|
|
224
|
+
const action = (Array.isArray(board?.actions) ? board.actions : [])
|
|
225
|
+
.find((item) => String(item?.id || '').trim().toLowerCase() === nextActionId.toLowerCase());
|
|
226
|
+
const question = planBoardConfirmText({ planOnly, actionId: nextActionId, label: action?.label });
|
|
227
|
+
if (question && !window.confirm(question)) return null;
|
|
212
228
|
controls.forEach((control) => { control.disabled = true; });
|
|
213
|
-
|
|
214
|
-
|
|
229
|
+
let status = 0;
|
|
215
230
|
try {
|
|
216
|
-
const r = await submitRelayBoardActionApi(id, nextActionId);
|
|
217
|
-
|
|
231
|
+
const r = await submitRelayBoardActionApi(id, nextActionId, { onErrorResponse: (response) => { status = response.status; } });
|
|
232
|
+
let settled = r?.board || null;
|
|
233
|
+
// Settled by the other view (or another page) meanwhile: show what was chosen.
|
|
234
|
+
if (!settled && status === 409) settled = (await loadRelayBoardApi(id))?.board || null;
|
|
235
|
+
if (!settled) throw new Error('Failed to submit board action');
|
|
218
236
|
// Kept once it is settled: the reply goes on showing the plan and says
|
|
219
237
|
// what was chosen.
|
|
220
|
-
relayBoards.set(id,
|
|
238
|
+
relayBoards.set(id, settled);
|
|
221
239
|
window.renderConvList?.();
|
|
222
240
|
renderRelayBoards();
|
|
223
|
-
|
|
241
|
+
return settled;
|
|
224
242
|
} catch (error) {
|
|
225
243
|
controls.forEach((control) => { control.disabled = false; });
|
|
226
244
|
alert(error.message || 'Failed to submit board action');
|
|
245
|
+
return null;
|
|
227
246
|
}
|
|
228
247
|
}
|
|
229
248
|
|
|
249
|
+
export async function submitRelayBoardAction(boardId, actionId) {
|
|
250
|
+
const id = String(boardId || '').trim();
|
|
251
|
+
if (!id || !String(actionId || '').trim()) return;
|
|
252
|
+
const card = document.querySelector(`.relay-board-inline[data-board-id="${CSS.escape(id)}"]`);
|
|
253
|
+
const controls = card ? [...card.querySelectorAll('button')] : [];
|
|
254
|
+
const send = beginOwnSend();
|
|
255
|
+
if (await actOnRelayBoard(id, actionId, { controls })) scrollAfterOwnSend(send);
|
|
256
|
+
}
|
|
257
|
+
|
|
@@ -14,6 +14,7 @@ export const RELAY_EVENT_SOCKET_EVENT = 'relay_event_subscriptions';
|
|
|
14
14
|
|
|
15
15
|
const EVENT_TYPES = new Set([
|
|
16
16
|
'question_asked',
|
|
17
|
+
'question_answered',
|
|
17
18
|
'error',
|
|
18
19
|
'turn_end',
|
|
19
20
|
'task_started',
|
|
@@ -21,6 +22,7 @@ const EVENT_TYPES = new Set([
|
|
|
21
22
|
'task_failed',
|
|
22
23
|
'stalled',
|
|
23
24
|
'all_idle',
|
|
25
|
+
'board_settled',
|
|
24
26
|
'heartbeat',
|
|
25
27
|
'link_lost',
|
|
26
28
|
'link_restored',
|
|
@@ -35,6 +37,7 @@ const LINE_PREFIX = '[relay event] ';
|
|
|
35
37
|
|
|
36
38
|
const TYPE_LABELS = Object.freeze({
|
|
37
39
|
question_asked: 'question',
|
|
40
|
+
question_answered: 'answer',
|
|
38
41
|
error: 'error',
|
|
39
42
|
turn_end: 'reply',
|
|
40
43
|
task_started: 'task started',
|
|
@@ -42,6 +45,7 @@ const TYPE_LABELS = Object.freeze({
|
|
|
42
45
|
task_failed: 'task failed',
|
|
43
46
|
stalled: 'stalled',
|
|
44
47
|
all_idle: 'all idle',
|
|
48
|
+
board_settled: 'plan card settled',
|
|
45
49
|
heartbeat: 'heartbeat',
|
|
46
50
|
link_lost: 'link lost',
|
|
47
51
|
link_restored: 'link back',
|
|
@@ -40,7 +40,7 @@ import { applyBackgroundTasksToConversation, applyTurnStatusToConversation } fro
|
|
|
40
40
|
import { applyRelayEventSubscriptions, loadRelayEventSubscriptions } from './relay-event-subscriptions-ui.js';
|
|
41
41
|
import { applyRelaySchedules, loadRelaySchedules } from './relay-schedules-ui.js';
|
|
42
42
|
import { noteInboxQuestionsChanged, noteRelayAskUpdated, refreshInbox } from './inbox-ui.js';
|
|
43
|
-
import { RELAY_ASK_SOCKET_EVENT } from './inbox-view.mjs';
|
|
43
|
+
import { INBOX_REMOTE_CHANGED_EVENT, RELAY_ASK_SOCKET_EVENT } from './inbox-view.mjs';
|
|
44
44
|
import {
|
|
45
45
|
upsertRelayQuestion,
|
|
46
46
|
loadRelayQuestions,
|
|
@@ -104,6 +104,7 @@ import { stripRelayPromptContext } from './relay-prompt-sanitizer.mjs';
|
|
|
104
104
|
import { isLikelyLiveDuplicateMessage } from './live-message-dedupe.mjs';
|
|
105
105
|
import { mergeRelayThoughts } from './relay-thoughts.mjs';
|
|
106
106
|
import { conversationPayloadCache } from './conversation-payload-cache.mjs';
|
|
107
|
+
import { planOnlyAfterBroadcast } from './plan-board-shared.mjs';
|
|
107
108
|
|
|
108
109
|
// A live row of the conversation on screen joins its kept window, so a
|
|
109
110
|
// switch away and back still shows it. Rows of other conversations do not:
|
|
@@ -664,12 +665,16 @@ export async function connectSocket(overrideDeps) {
|
|
|
664
665
|
loadRelayQuestions(currentConvId);
|
|
665
666
|
noteInboxQuestionsChanged();
|
|
666
667
|
});
|
|
668
|
+
// A paired relay's pending cards changed (this relay checks them every ~20 s).
|
|
669
|
+
socket.on(INBOX_REMOTE_CHANGED_EVENT, () => noteInboxQuestionsChanged());
|
|
667
670
|
// Questions that don't block: one ask, after every change of it.
|
|
668
671
|
socket.on(RELAY_ASK_SOCKET_EVENT, (payload) => noteRelayAskUpdated(payload));
|
|
669
672
|
socket.on('relay_board', ({ board }) => upsertRelayBoard(board));
|
|
670
673
|
socket.on('relay_board_updated', ({ board }) => upsertRelayBoard(board));
|
|
671
674
|
socket.on('relay_board_changed', () => {
|
|
672
675
|
loadRelayBoards();
|
|
676
|
+
// Plan cards are in the Inbox too.
|
|
677
|
+
noteInboxQuestionsChanged();
|
|
673
678
|
});
|
|
674
679
|
// Relay event subscriptions: the whole active list, after every change.
|
|
675
680
|
socket.on('relay_event_subscriptions', ({ subscriptions }) => {
|
|
@@ -846,7 +851,7 @@ export async function connectSocket(overrideDeps) {
|
|
|
846
851
|
applyConversationTitleUpdate(conversationId, title, updatedAt);
|
|
847
852
|
syncChatTitleControls();
|
|
848
853
|
});
|
|
849
|
-
socket.on('conversation_preferences_updated', ({ conversationId, preferredRelayMode, preferredModel, preferredReasoningEffort, autoCompactWindow, thinkingEnabled, thinkingDisplay, mayCreateSessions, senderClientId }) => {
|
|
854
|
+
socket.on('conversation_preferences_updated', ({ conversationId, preferredRelayMode, preferredModel, preferredReasoningEffort, autoCompactWindow, thinkingEnabled, thinkingDisplay, mayCreateSessions, planOnly, senderClientId }) => {
|
|
850
855
|
if (senderClientId && senderClientId === CLIENT_ID) return;
|
|
851
856
|
const id = String(conversationId || '').trim();
|
|
852
857
|
if (!id || !conversations[id]) return;
|
|
@@ -872,6 +877,9 @@ export async function connectSocket(overrideDeps) {
|
|
|
872
877
|
mayCreateSessions: typeof mayCreateSessions === 'boolean'
|
|
873
878
|
? mayCreateSessions
|
|
874
879
|
: conversations[id].mayCreateSessions === true,
|
|
880
|
+
// Plan-only: a broadcast without it (an older relay, a board's mode
|
|
881
|
+
// follow-up) keeps the known value.
|
|
882
|
+
planOnly: planOnlyAfterBroadcast(planOnly, conversations[id].planOnly),
|
|
875
883
|
};
|
|
876
884
|
if (String(currentConvId || '').trim() === id) {
|
|
877
885
|
applyConversationPreferencesForConversation(id, {
|
package/server/public/index.html
CHANGED
|
@@ -937,6 +937,10 @@
|
|
|
937
937
|
gap: 6px;
|
|
938
938
|
}
|
|
939
939
|
.inbox-choice.recommended { border-color: var(--accent); }
|
|
940
|
+
/* A plan card in the Inbox: the plan folded, its own buttons. */
|
|
941
|
+
.inbox-choice.inbox-plan-action-recommended { border-color: var(--accent); }
|
|
942
|
+
.inbox-plan-body summary { cursor: pointer; color: var(--muted); font-size: var(--fs-meta-sm); }
|
|
943
|
+
.inbox-plan-text { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 40vh; overflow: auto; margin-top: 4px; }
|
|
940
944
|
.inbox-recommended-tag { color: var(--accent); font-size: var(--fs-meta-xs); }
|
|
941
945
|
.inbox-answer { border-color: var(--accent); color: var(--accent); }
|
|
942
946
|
.inbox-dismiss { color: var(--muted); }
|
|
@@ -3193,6 +3197,10 @@
|
|
|
3193
3197
|
.about-usage-provider { font-size: 0.82rem; font-weight: 600; margin-top: 8px; }
|
|
3194
3198
|
.about-usage-provider:first-child { margin-top: 0; }
|
|
3195
3199
|
.about-state { font-weight: 600; }
|
|
3200
|
+
/* A path (the database file, the backup folder) wraps anywhere rather than
|
|
3201
|
+
pushing the card wider than a phone's screen. */
|
|
3202
|
+
.about-section .plan-usage-detail-value { white-space: normal; overflow-wrap: anywhere; }
|
|
3203
|
+
.about-section .plan-usage-detail-hint { overflow-wrap: anywhere; }
|
|
3196
3204
|
.about-state[data-state="online"] { color: var(--success); }
|
|
3197
3205
|
.about-state[data-state="offline"] { color: var(--warn); }
|
|
3198
3206
|
.about-state[data-state="idle"] { color: var(--muted); }
|
|
@@ -4417,6 +4425,12 @@
|
|
|
4417
4425
|
min-width: 84px; width: 84px; outline: none;
|
|
4418
4426
|
}
|
|
4419
4427
|
#mode-select:focus { border-color: var(--accent); }
|
|
4428
|
+
/* A plan-only session: agents can't take it out of plan mode. */
|
|
4429
|
+
#mode-select[data-plan-only="true"] { border-style: dashed; border-color: var(--accent); }
|
|
4430
|
+
/* Room for "Plan 🔒" beside the arrow; the phone layouts size the select themselves. */
|
|
4431
|
+
@media (min-width: 681px) {
|
|
4432
|
+
#mode-select[data-plan-only="true"] { min-width: 104px; width: 104px; }
|
|
4433
|
+
}
|
|
4420
4434
|
#send-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
4421
4435
|
#send-btn:not(:disabled):hover { opacity: 0.85; }
|
|
4422
4436
|
|
package/server/public/sw.js
CHANGED
|
@@ -191,6 +191,14 @@ async function handleNotificationClick(event) {
|
|
|
191
191
|
if (ok) return;
|
|
192
192
|
}
|
|
193
193
|
}
|
|
194
|
+
// A card on a paired relay: it carries no question id, only the
|
|
195
|
+
// link of its session there, and is answered there ("Open there").
|
|
196
|
+
const url = String(data.url || '');
|
|
197
|
+
if (!data.questionId && /^https?:\/\//i.test(url)) {
|
|
198
|
+
event.notification.close();
|
|
199
|
+
await self.clients.openWindow(url);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
194
202
|
event.notification.close();
|
|
195
203
|
await focusOrOpenConversation(String(data.conversationId || ''));
|
|
196
204
|
}
|
|
@@ -141,7 +141,9 @@ export function configuredHeapLimit({ configPath, fsImpl = fs } = {}) {
|
|
|
141
141
|
return Number.isInteger(mb) && mb >= MIN_HEAP_LIMIT_MB ? { mb } : { invalid: value };
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
-
|
|
144
|
+
// Free bytes on the disk of `dir`, or null when unknown. Also used by the
|
|
145
|
+
// database backup's space check (db-backup-service.mjs), with diskBytes.
|
|
146
|
+
export function freeBytes(dir, fsImpl) {
|
|
145
147
|
try {
|
|
146
148
|
if (typeof fsImpl.statfsSync !== 'function') return null;
|
|
147
149
|
const stats = fsImpl.statfsSync(dir);
|
|
@@ -151,6 +153,17 @@ function freeBytes(dir, fsImpl) {
|
|
|
151
153
|
}
|
|
152
154
|
}
|
|
153
155
|
|
|
156
|
+
// The size of the disk of `dir`, or null when unknown.
|
|
157
|
+
export function diskBytes(dir, fsImpl) {
|
|
158
|
+
try {
|
|
159
|
+
if (typeof fsImpl.statfsSync !== 'function') return null;
|
|
160
|
+
const stats = fsImpl.statfsSync(dir);
|
|
161
|
+
return Number(stats.blocks) * Number(stats.bsize);
|
|
162
|
+
} catch {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
154
167
|
/** A path for the log: `~` for the home folder, so a log line names no user. */
|
|
155
168
|
export function displayPath(target, homeDir = os.homedir()) {
|
|
156
169
|
const resolved = path.resolve(String(target || ''));
|