@oar-sh/oar 0.9.18 → 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/server-lifecycle/managed-server.mjs +13 -1
- package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +2 -26
- package/CHANGELOG.md +359 -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 +3 -2
- 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 +351 -0
- package/server/relay-self-restart.mjs +116 -9
- package/server/relay-tools.md +5 -4
- 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 +120 -4
- package/server/routes/relay-ask-routes.mjs +138 -8
- package/server/routes/relay-board-routes.mjs +55 -40
- package/server/routes/sessions-routes.mjs +141 -31
- package/server/server-runtime.mjs +142 -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 +68 -8
- 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 +98 -21
- package/shared/turn-token-usage.mjs +144 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The Tokens sections of every Check Usage tab: what the turns on
|
|
5
|
+
* a provider ran, from the token booking (token-usage-service.mjs,
|
|
6
|
+
* `readPeriods`), with fresh input, output, cache writes and cache reads
|
|
7
|
+
* apart.
|
|
8
|
+
*
|
|
9
|
+
* Per billing group (the plan, then each own key on Copilot), two sections,
|
|
10
|
+
* `<provider>-token-usage` and `<provider>-token-usage-by-model` (an own
|
|
11
|
+
* key's with `-byok-<type>` after; DeepSeek's own `deepseek-tokens-by-model`
|
|
12
|
+
* stays beside them):
|
|
13
|
+
* - "Tokens": today, this month and all time, with the share of input the
|
|
14
|
+
* cache served as the hint;
|
|
15
|
+
* - "Tokens by model, this month": one row per model, today and all time in
|
|
16
|
+
* the hint.
|
|
17
|
+
* A field nobody reported is "—", and the note says why; it also says since
|
|
18
|
+
* when the tab counts, and, on a tab with days copied at the upgrade, that
|
|
19
|
+
* their input includes cache writes. Values stay within the agent card's
|
|
20
|
+
* 120 characters (remote-relay-dispatcher.mjs).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { buildDetailSection } from './plan-usage-contract.mjs';
|
|
24
|
+
import { partlyReportedFields } from './token-usage-service.mjs';
|
|
25
|
+
|
|
26
|
+
export const TOKEN_SHOWN_FIELDS = Object.freeze(['inputTokens', 'outputTokens', 'cacheWriteTokens', 'cacheReadTokens']);
|
|
27
|
+
|
|
28
|
+
const FIELD_SHORT = Object.freeze({
|
|
29
|
+
inputTokens: 'in',
|
|
30
|
+
outputTokens: 'out',
|
|
31
|
+
cacheWriteTokens: 'cache w',
|
|
32
|
+
cacheReadTokens: 'cache r',
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const FIELD_NAME = Object.freeze({
|
|
36
|
+
inputTokens: 'fresh input',
|
|
37
|
+
outputTokens: 'output',
|
|
38
|
+
cacheWriteTokens: 'cache writes',
|
|
39
|
+
cacheReadTokens: 'cache reads',
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const PROVIDER_NAME = Object.freeze({
|
|
43
|
+
github: 'Copilot',
|
|
44
|
+
claude: 'Claude',
|
|
45
|
+
'claude-api': 'The Claude API',
|
|
46
|
+
'claude-cloud': 'Claude cloud',
|
|
47
|
+
deepseek: 'DeepSeek',
|
|
48
|
+
cursor: 'Cursor',
|
|
49
|
+
grok: 'Grok',
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const KEY_NAME = Object.freeze({
|
|
53
|
+
openai: 'OpenAI',
|
|
54
|
+
anthropic: 'Anthropic',
|
|
55
|
+
azure: 'Azure OpenAI',
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Only these tabs carry days copied at the upgrade (migration 0036).
|
|
59
|
+
const BACKFILLED_PROVIDERS = Object.freeze(['claude-api', 'deepseek']);
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A token count for a card: "—" for unknown (null), else 999, 1.2k, 3.4M or
|
|
63
|
+
* 5.6B. Unlike the older card formatters it does not stop at millions and
|
|
64
|
+
* never shows an unknown count as 0.
|
|
65
|
+
*/
|
|
66
|
+
export function formatTokenCount(value) {
|
|
67
|
+
if (value === null || value === undefined) return '—';
|
|
68
|
+
const number = Number(value);
|
|
69
|
+
if (!Number.isFinite(number) || number < 0) return '—';
|
|
70
|
+
const scaled = (divisor, unit) => `${(number / divisor).toFixed(1).replace(/\.0$/, '')}${unit}`;
|
|
71
|
+
if (number < 1000) return String(Math.round(number));
|
|
72
|
+
if (number < 999_950) return scaled(1000, 'k');
|
|
73
|
+
if (number < 999_950_000) return scaled(1_000_000, 'M');
|
|
74
|
+
return scaled(1_000_000_000, 'B');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** "in 1.2M · out 340k · cache w 1.1M · cache r 45.3M". */
|
|
78
|
+
export function formatTokenSplit(totals) {
|
|
79
|
+
return TOKEN_SHOWN_FIELDS.map((field) => `${FIELD_SHORT[field]} ${formatTokenCount(totals?.[field])}`).join(' · ');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function totalTokens(totals) {
|
|
83
|
+
let sum = null;
|
|
84
|
+
for (const field of TOKEN_SHOWN_FIELDS) {
|
|
85
|
+
const value = totals?.[field];
|
|
86
|
+
if (value !== null && value !== undefined) sum = (sum ?? 0) + value;
|
|
87
|
+
}
|
|
88
|
+
return sum;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** "45% of input from the cache", or null when there is nothing to say. */
|
|
92
|
+
function cacheShareHint(totals) {
|
|
93
|
+
if (totals?.cacheReadTokens === null || totals?.cacheReadTokens === undefined) return null;
|
|
94
|
+
const input = (totals.inputTokens || 0) + (totals.cacheReadTokens || 0) + (totals.cacheWriteTokens || 0);
|
|
95
|
+
if (input <= 0) return null;
|
|
96
|
+
return `${Math.round((totals.cacheReadTokens / input) * 100)}% of input from the cache`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** "Oct 1, 2026" for a `YYYY-MM-DD` day (a calendar date, no zone). */
|
|
100
|
+
export function tokenDayLabel(day) {
|
|
101
|
+
const date = new Date(`${day}T00:00:00.000Z`);
|
|
102
|
+
if (Number.isNaN(date.getTime())) return String(day || '');
|
|
103
|
+
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function keyName(billing) {
|
|
107
|
+
const type = String(billing || '').replace(/^byok-/, '');
|
|
108
|
+
return KEY_NAME[type] || (type ? type.charAt(0).toUpperCase() + type.slice(1) : 'Own key');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function capitalize(text) {
|
|
112
|
+
return text.charAt(0).toUpperCase() + text.slice(1);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The note under a group's "Tokens" section: why a field is "—", which
|
|
117
|
+
* fields some turns left out, the copied days, since when it counts.
|
|
118
|
+
*/
|
|
119
|
+
export function tokenSectionNote(group) {
|
|
120
|
+
const who = group.billing ? keyName(group.billing) : (PROVIDER_NAME[group.provider] || group.provider);
|
|
121
|
+
const backfill = BACKFILLED_PROVIDERS.includes(group.provider) && group.backfilled?.allTime === true;
|
|
122
|
+
const allTime = group.periods.allTime;
|
|
123
|
+
const partly = new Set(partlyReportedFields(allTime));
|
|
124
|
+
const lines = [];
|
|
125
|
+
for (const field of TOKEN_SHOWN_FIELDS) {
|
|
126
|
+
// The copied days never split cache writes out; the backfill line says so.
|
|
127
|
+
const explainedByBackfill = backfill && field === 'cacheWriteTokens';
|
|
128
|
+
if (allTime[field] === null && !explainedByBackfill) {
|
|
129
|
+
lines.push(`${who} does not report ${FIELD_NAME[field]}.`);
|
|
130
|
+
} else if (partly.has(field) && !explainedByBackfill) {
|
|
131
|
+
lines.push(`${capitalize(FIELD_NAME[field])} not reported for some turns.`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (backfill) {
|
|
135
|
+
lines.push(group.firstTurnsDay
|
|
136
|
+
? `Before ${tokenDayLabel(group.firstTurnsDay)}, input includes cache writes.`
|
|
137
|
+
: 'On the days copied from before the upgrade, input includes cache writes.');
|
|
138
|
+
}
|
|
139
|
+
if (group.since) lines.push(`Counting since ${tokenDayLabel(group.since)}.`);
|
|
140
|
+
return lines.join(' ');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// `a` and `b` (a model's totals of one period, emptyTotals() of
|
|
144
|
+
// token-usage-service.mjs) added up as the booking adds its rows: a field
|
|
145
|
+
// nobody reported stays null.
|
|
146
|
+
function addTotals(a, b) {
|
|
147
|
+
const sum = { ...a, reported: { ...(a.reported || {}) } };
|
|
148
|
+
for (const field of Object.keys(b || {})) {
|
|
149
|
+
if (field === 'reported') {
|
|
150
|
+
for (const [name, count] of Object.entries(b.reported || {})) sum.reported[name] = (sum.reported[name] || 0) + count;
|
|
151
|
+
} else if (b[field] !== null && b[field] !== undefined) {
|
|
152
|
+
sum[field] = (sum[field] ?? 0) + b[field];
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return sum;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* A group's models by the name the tab shows: ids with one name (a dated and
|
|
160
|
+
* an undated Claude id) become one entry, with their totals added up and the
|
|
161
|
+
* ids in `models`; sorted by that name.
|
|
162
|
+
*/
|
|
163
|
+
export function mergeModelsByLabel(models, modelLabel = null) {
|
|
164
|
+
const byLabel = new Map();
|
|
165
|
+
for (const entry of models || []) {
|
|
166
|
+
const label = (typeof modelLabel === 'function' ? String(modelLabel(entry.model) || '').trim() : '') || entry.model;
|
|
167
|
+
const merged = byLabel.get(label);
|
|
168
|
+
if (!merged) {
|
|
169
|
+
byLabel.set(label, { label, models: [entry.model], today: entry.today, month: entry.month, allTime: entry.allTime });
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
merged.models.push(entry.model);
|
|
173
|
+
for (const period of ['today', 'month', 'allTime']) merged[period] = addTotals(merged[period], entry[period]);
|
|
174
|
+
}
|
|
175
|
+
return [...byLabel.values()]
|
|
176
|
+
.map((entry) => ({ ...entry, models: [...entry.models].sort() }))
|
|
177
|
+
.sort((a, b) => a.label.localeCompare(b.label, 'en'));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The two sections of one billing group, open. `modelLabel(model)` names a
|
|
182
|
+
* model as the tab does elsewhere (the Claude API tab: the Console's display
|
|
183
|
+
* name); the raw id where it has none. Ids with one name are one row, the ids
|
|
184
|
+
* in its title.
|
|
185
|
+
*/
|
|
186
|
+
export function buildTokenSections(group, { modelLabel = null } = {}) {
|
|
187
|
+
if (!group || !group.periods) return [];
|
|
188
|
+
const suffix = group.billing ? `-${group.billing}` : '';
|
|
189
|
+
const labelSuffix = group.billing ? ` — own ${keyName(group.billing)} key` : '';
|
|
190
|
+
const periodRow = (label, totals) => ({
|
|
191
|
+
label,
|
|
192
|
+
value: totals.rows ? formatTokenSplit(totals) : 'no turns',
|
|
193
|
+
hint: totals.rows ? cacheShareHint(totals) : null,
|
|
194
|
+
});
|
|
195
|
+
const sections = [
|
|
196
|
+
buildDetailSection({
|
|
197
|
+
id: `${group.provider}-token-usage${suffix}`,
|
|
198
|
+
label: `Tokens${labelSuffix}`,
|
|
199
|
+
rows: [
|
|
200
|
+
periodRow('Today', group.periods.today),
|
|
201
|
+
periodRow('This month', group.periods.month),
|
|
202
|
+
periodRow('All time', group.periods.allTime),
|
|
203
|
+
],
|
|
204
|
+
note: tokenSectionNote(group),
|
|
205
|
+
open: true,
|
|
206
|
+
}),
|
|
207
|
+
buildDetailSection({
|
|
208
|
+
id: `${group.provider}-token-usage-by-model${suffix}`,
|
|
209
|
+
label: `Tokens by model, this month${labelSuffix}`,
|
|
210
|
+
rows: mergeModelsByLabel(group.models, modelLabel)
|
|
211
|
+
.filter((entry) => entry.month.rows > 0)
|
|
212
|
+
.map((entry) => ({
|
|
213
|
+
label: entry.label,
|
|
214
|
+
value: formatTokenSplit(entry.month),
|
|
215
|
+
hint: `today ${entry.today.rows ? formatTokenCount(totalTokens(entry.today)) : 'none'} · all time ${formatTokenCount(totalTokens(entry.allTime))}`,
|
|
216
|
+
...(entry.models.length > 1 ? { title: `Model ids: ${entry.models.join(', ')}` } : {}),
|
|
217
|
+
})),
|
|
218
|
+
open: true,
|
|
219
|
+
}),
|
|
220
|
+
];
|
|
221
|
+
return sections.filter(Boolean);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Append the Tokens sections to every provider card the report holds,
|
|
226
|
+
* from `readPeriods({ provider })`; `modelLabelFor(provider)` may give a
|
|
227
|
+
* tab its model names. A provider with nothing booked gets
|
|
228
|
+
* none; the plan's group comes before the own keys'. Cards are changed in
|
|
229
|
+
* place; a failing read leaves them as they were.
|
|
230
|
+
*/
|
|
231
|
+
export function appendTokenSections(cards, readPeriods, { modelLabelFor = null } = {}) {
|
|
232
|
+
if (!Array.isArray(cards) || typeof readPeriods !== 'function') return cards;
|
|
233
|
+
for (const card of cards) {
|
|
234
|
+
if (!card || !card.provider) continue;
|
|
235
|
+
let periods = null;
|
|
236
|
+
try {
|
|
237
|
+
periods = readPeriods({ provider: card.provider });
|
|
238
|
+
} catch {
|
|
239
|
+
periods = null;
|
|
240
|
+
}
|
|
241
|
+
const groups = periods?.groups || [];
|
|
242
|
+
if (!groups.length) continue;
|
|
243
|
+
const modelLabel = typeof modelLabelFor === 'function' ? modelLabelFor(card.provider) : null;
|
|
244
|
+
const sections = groups.flatMap((group) => buildTokenSections(group, { modelLabel }));
|
|
245
|
+
if (sections.length) card.details = [...(Array.isArray(card.details) ? card.details : []), ...sections];
|
|
246
|
+
}
|
|
247
|
+
return cards;
|
|
248
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
import { providerDisplayName } from '../public/app/conversation-provider-indicator.mjs';
|
|
4
|
+
import { DEFAULT_QUESTION_TIMEOUT_MS } from '../../shared/question-timeout.mjs';
|
|
5
|
+
import { REMOTE_RELAY_APPROVAL_VARIANT_LABELS, remoteConversationUrl } from '../../shared/remote-relay-contract.mjs';
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
8
|
* Web Push dispatch: decides whether a push should go out at all (suppressed
|
|
@@ -29,6 +31,17 @@ const MAX_TITLE_CHARS = 120;
|
|
|
29
31
|
// weight; drop it after this many consecutive failures.
|
|
30
32
|
const FAILURE_PRUNE_THRESHOLD = 8;
|
|
31
33
|
const PUSH_TTL_SECONDS = 12 * 60 * 60;
|
|
34
|
+
// A push service that stops answering fails the send after this long (the
|
|
35
|
+
// request's socket timeout), so no dispatch waits on it for good.
|
|
36
|
+
export const PUSH_SEND_TIMEOUT_MS = 30_000;
|
|
37
|
+
// The notice ledger: whether a question's own push reached a device,
|
|
38
|
+
// kept as long as a question lives. A push still `pending` after this long
|
|
39
|
+
// counts as missed, so a paired relay may push instead. With the send timeout
|
|
40
|
+
// above, every send has settled (sent, or failed and so `missed`) well before
|
|
41
|
+
// this: a late send of this relay's cannot land after a paired relay pushed.
|
|
42
|
+
// The guard is for a dispatch that hangs anywhere else.
|
|
43
|
+
const NOTICE_KEEP_MS = DEFAULT_QUESTION_TIMEOUT_MS;
|
|
44
|
+
const NOTICE_PENDING_MAX_MS = 2 * 60_000;
|
|
32
45
|
|
|
33
46
|
const EVENT_LABELS = Object.freeze({
|
|
34
47
|
// A question's title names the provider that asks (questionTitle).
|
|
@@ -41,7 +54,14 @@ const EVENT_LABELS = Object.freeze({
|
|
|
41
54
|
relayRestart: { title: 'Relay restart waiting', generic: 'The queued relay restart has waited its maximum time.' },
|
|
42
55
|
});
|
|
43
56
|
|
|
44
|
-
|
|
57
|
+
// A card on a paired relay: named by its kind and that relay.
|
|
58
|
+
const REMOTE_GENERIC_APPROVAL = 'An Allow / Deny card waits there.';
|
|
59
|
+
|
|
60
|
+
function eventTitle(type, providerName, { relayName = null, remoteKind = null } = {}) {
|
|
61
|
+
const relay = String(relayName || '').trim();
|
|
62
|
+
if (type === 'question' && relay) {
|
|
63
|
+
return remoteKind === 'approval' ? `Approval waiting on relay "${relay}"` : `A question waits on relay "${relay}"`;
|
|
64
|
+
}
|
|
45
65
|
const name = String(providerName || '').trim();
|
|
46
66
|
if (type === 'question' && name) return `${name} needs your input`;
|
|
47
67
|
return EVENT_LABELS[type].title;
|
|
@@ -88,7 +108,15 @@ export function normalizePushPreferences(raw) {
|
|
|
88
108
|
* Render the notification payload for one subscription. Pure so every content
|
|
89
109
|
* preference combination is unit-testable.
|
|
90
110
|
*
|
|
91
|
-
*
|
|
111
|
+
* A card on a paired relay (`relayName`, `remoteKind` 'approval' |
|
|
112
|
+
* 'ask_user') is titled by that relay. Its body follows the preview
|
|
113
|
+
* preference like any question's: an approval shows its `summary` (kind,
|
|
114
|
+
* requesting session, reason: no prompt text, but session titles), and with
|
|
115
|
+
* the preview off only its kind label ("New session · the session waits"),
|
|
116
|
+
* so no title reaches a lock screen against the device's setting. It never
|
|
117
|
+
* carries a question id or choices, so it shows no answer buttons.
|
|
118
|
+
*
|
|
119
|
+
* @param {{ type: string, conversationTitle?: string|null, providerName?: string|null, relayName?: string|null, remoteKind?: string|null, variant?: string|null, summary?: string|null, body?: string|null, data?: object }} event
|
|
92
120
|
* @param {object} preferences canonical shape from normalizePushPreferences
|
|
93
121
|
*/
|
|
94
122
|
export function renderPushNotification(event, preferences) {
|
|
@@ -96,7 +124,8 @@ export function renderPushNotification(event, preferences) {
|
|
|
96
124
|
const prefs = normalizePushPreferences(preferences);
|
|
97
125
|
const labels = EVENT_LABELS[type];
|
|
98
126
|
|
|
99
|
-
const
|
|
127
|
+
const remote = type === 'question' && String(event?.relayName || '').trim() !== '';
|
|
128
|
+
const baseTitle = eventTitle(type, event?.providerName, { relayName: event?.relayName, remoteKind: event?.remoteKind }).slice(0, MAX_TITLE_CHARS);
|
|
100
129
|
let title = baseTitle;
|
|
101
130
|
const conversationTitle = String(event?.conversationTitle || '').trim();
|
|
102
131
|
if (prefs.content.includeTitle && conversationTitle) {
|
|
@@ -104,7 +133,13 @@ export function renderPushNotification(event, preferences) {
|
|
|
104
133
|
}
|
|
105
134
|
|
|
106
135
|
let body = labels.generic;
|
|
107
|
-
|
|
136
|
+
if (remote && event?.remoteKind === 'approval') {
|
|
137
|
+
const variant = String(event?.variant || '');
|
|
138
|
+
const kindLabel = Object.hasOwn(REMOTE_RELAY_APPROVAL_VARIANT_LABELS, variant) ? REMOTE_RELAY_APPROVAL_VARIANT_LABELS[variant] : '';
|
|
139
|
+
body = kindLabel ? `${kindLabel} · the session waits` : REMOTE_GENERIC_APPROVAL;
|
|
140
|
+
}
|
|
141
|
+
// A remote approval's preview is its summary, never the card's text.
|
|
142
|
+
const text = String((remote && event?.remoteKind === 'approval' ? event?.summary : event?.body) || '').trim();
|
|
108
143
|
// cliOffline has no conversation text to preview; the generic body is the message.
|
|
109
144
|
if (text && SYSTEM_BODY_TYPES.has(type)) {
|
|
110
145
|
body = text.slice(0, MAX_FULL_PREVIEW_CHARS);
|
|
@@ -119,10 +154,14 @@ export function renderPushNotification(event, preferences) {
|
|
|
119
154
|
|
|
120
155
|
const data = event?.data && typeof event.data === 'object' ? { ...event.data } : {};
|
|
121
156
|
data.type = type;
|
|
157
|
+
if (remote) {
|
|
158
|
+
delete data.questionId;
|
|
159
|
+
delete data.choices;
|
|
160
|
+
}
|
|
122
161
|
|
|
123
162
|
// A distinct tag per event so notifications stack instead of replacing each
|
|
124
163
|
// other (decision: one notification per event, no coalescing).
|
|
125
|
-
const tagSeed = String(data.questionId || data.boardId || data.messageId || data.conversationId || Date.now());
|
|
164
|
+
const tagSeed = String(data.tag || data.questionId || data.boardId || data.messageId || data.conversationId || Date.now());
|
|
126
165
|
return {
|
|
127
166
|
title,
|
|
128
167
|
body,
|
|
@@ -195,6 +234,7 @@ export function createPushDispatchService({
|
|
|
195
234
|
recordStatusEvent = () => {},
|
|
196
235
|
uuid,
|
|
197
236
|
logger = console,
|
|
237
|
+
now = () => Date.now(),
|
|
198
238
|
}) {
|
|
199
239
|
const sql = {
|
|
200
240
|
listAll: db.prepare(`
|
|
@@ -374,7 +414,7 @@ export function createPushDispatchService({
|
|
|
374
414
|
await webpush.sendNotification(
|
|
375
415
|
{ endpoint: row.endpoint, keys },
|
|
376
416
|
JSON.stringify(payload),
|
|
377
|
-
{ TTL: PUSH_TTL_SECONDS, urgency: 'high' },
|
|
417
|
+
{ TTL: PUSH_TTL_SECONDS, urgency: 'high', timeout: PUSH_SEND_TIMEOUT_MS },
|
|
378
418
|
);
|
|
379
419
|
sql.markSuccess.run(new Date().toISOString(), row.id);
|
|
380
420
|
return { sent: true };
|
|
@@ -409,7 +449,7 @@ export function createPushDispatchService({
|
|
|
409
449
|
* Fire-and-forget push for one event. Never throws; suppression failures
|
|
410
450
|
* fail closed (no push when the active-device state is unknown).
|
|
411
451
|
*/
|
|
412
|
-
async function dispatch({ type, conversationId = null, body = null, data = {}, providerName = null }) {
|
|
452
|
+
async function dispatch({ type, conversationId = null, body = null, data = {}, providerName = null, relayName = null, remoteKind = null, variant = null, summary = null, conversationTitle: titleOf = null }) {
|
|
413
453
|
try {
|
|
414
454
|
if (!PUSH_EVENT_TYPES.includes(type)) return { outcome: 'invalid-type' };
|
|
415
455
|
|
|
@@ -434,8 +474,12 @@ export function createPushDispatchService({
|
|
|
434
474
|
|
|
435
475
|
const event = {
|
|
436
476
|
type,
|
|
437
|
-
conversationTitle: conversationTitle(conversationId),
|
|
477
|
+
conversationTitle: titleOf || conversationTitle(conversationId),
|
|
438
478
|
providerName,
|
|
479
|
+
relayName,
|
|
480
|
+
remoteKind,
|
|
481
|
+
variant,
|
|
482
|
+
summary,
|
|
439
483
|
body,
|
|
440
484
|
data: { ...data, conversationId: conversationId || undefined },
|
|
441
485
|
};
|
|
@@ -458,6 +502,48 @@ export function createPushDispatchService({
|
|
|
458
502
|
}
|
|
459
503
|
}
|
|
460
504
|
|
|
505
|
+
/** True when some device takes question pushes: enabled, with its "questions" toggle on. */
|
|
506
|
+
function hasQuestionSubscribers() {
|
|
507
|
+
try {
|
|
508
|
+
return sql.listAll.all().some((row) => {
|
|
509
|
+
const preferences = normalizePushPreferences(parseJsonObject(row.preferences_json));
|
|
510
|
+
return preferences.enabled && preferences.events.question === true;
|
|
511
|
+
});
|
|
512
|
+
} catch {
|
|
513
|
+
return false;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// question id → { state: 'pending' | 'reached' | 'missed', at }. In memory:
|
|
518
|
+
// after a restart a question has no entry, and a paired relay may push it.
|
|
519
|
+
const notices = new Map();
|
|
520
|
+
|
|
521
|
+
function pruneNotices() {
|
|
522
|
+
const cutoff = now() - NOTICE_KEEP_MS;
|
|
523
|
+
for (const [id, entry] of notices) {
|
|
524
|
+
if (entry.at < cutoff) notices.delete(id);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function noteNotice(id, state) {
|
|
529
|
+
pruneNotices();
|
|
530
|
+
notices.set(id, { state, at: now() });
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Whether this relay's own push of a question reached a device:
|
|
535
|
+
* `reached` (sent to one, or a device is active here), `missed`, `pending`
|
|
536
|
+
* while it is not settled, or null when this relay never pushed it (a card
|
|
537
|
+
* from before a restart). Read by a paired relay's Inbox (`?push=1`).
|
|
538
|
+
*/
|
|
539
|
+
function questionNotice(questionId) {
|
|
540
|
+
const id = String(questionId || '').trim();
|
|
541
|
+
const entry = id ? notices.get(id) : null;
|
|
542
|
+
if (!entry || entry.at < now() - NOTICE_KEEP_MS) return null;
|
|
543
|
+
if (entry.state === 'pending' && now() - entry.at > NOTICE_PENDING_MAX_MS) return 'missed';
|
|
544
|
+
return entry.state;
|
|
545
|
+
}
|
|
546
|
+
|
|
461
547
|
// Semantic wrappers so each io.emit call site stays a single line and the
|
|
462
548
|
// payload construction lives in one place.
|
|
463
549
|
function notifyQuestion(question) {
|
|
@@ -468,7 +554,9 @@ export function createPushDispatchService({
|
|
|
468
554
|
.map((choice) => String(choice || '').trim())
|
|
469
555
|
.filter((choice) => choice && choice.length <= 120)
|
|
470
556
|
.slice(0, 3);
|
|
471
|
-
|
|
557
|
+
const id = String(question?.id || '').trim();
|
|
558
|
+
if (id) noteNotice(id, 'pending');
|
|
559
|
+
const pushed = dispatch({
|
|
472
560
|
type: 'question',
|
|
473
561
|
conversationId: question?.conversationId || null,
|
|
474
562
|
providerName: conversationProviderName(question?.conversationId),
|
|
@@ -478,6 +566,40 @@ export function createPushDispatchService({
|
|
|
478
566
|
choices: choices.length ? choices : undefined,
|
|
479
567
|
},
|
|
480
568
|
});
|
|
569
|
+
if (!id) return pushed;
|
|
570
|
+
return pushed.then((result) => {
|
|
571
|
+
const reached = result?.outcome === 'suppressed' || (result?.outcome === 'dispatched' && Number(result.sent) > 0);
|
|
572
|
+
noteNotice(id, reached ? 'reached' : 'missed');
|
|
573
|
+
return result;
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* A card a turn on a paired relay waits on, which this relay claimed
|
|
579
|
+
* (remote-inbox-watch-service.mjs): the "questions" toggle and the
|
|
580
|
+
* active-device suppression apply as to this relay's own. The tap opens
|
|
581
|
+
* its session there; no question id, no choices: it is answered there.
|
|
582
|
+
*/
|
|
583
|
+
function notifyRemoteCard({ relay, item } = {}) {
|
|
584
|
+
const relayId = String(relay?.id || '').trim();
|
|
585
|
+
const id = String(item?.id || '').trim();
|
|
586
|
+
const approval = item?.kind === 'approval';
|
|
587
|
+
const url = remoteConversationUrl(relay?.url, item?.session?.id);
|
|
588
|
+
return dispatch({
|
|
589
|
+
type: 'question',
|
|
590
|
+
relayName: String(relay?.name || '').trim() || relayId || 'another relay',
|
|
591
|
+
remoteKind: approval ? 'approval' : 'ask_user',
|
|
592
|
+
variant: approval ? String(item?.variant || '').trim() || null : null,
|
|
593
|
+
summary: approval ? String(item?.summary || '').trim() || null : null,
|
|
594
|
+
// An ask_user card's question, which paired relays already get, goes through the preview preference.
|
|
595
|
+
body: approval ? null : String(item?.question || '').trim() || null,
|
|
596
|
+
conversationTitle: String(item?.session?.title || '').trim() || null,
|
|
597
|
+
data: {
|
|
598
|
+
url: /^https?:\/\//i.test(url) ? url : undefined,
|
|
599
|
+
remoteRelayId: relayId || undefined,
|
|
600
|
+
tag: `remote-${relayId}-${id}`,
|
|
601
|
+
},
|
|
602
|
+
});
|
|
481
603
|
}
|
|
482
604
|
|
|
483
605
|
// `messageId` is the reply; `sourceMessageId` the user message that started
|
|
@@ -536,6 +658,9 @@ export function createPushDispatchService({
|
|
|
536
658
|
deleteDevice,
|
|
537
659
|
dispatch,
|
|
538
660
|
notifyQuestion,
|
|
661
|
+
questionNotice,
|
|
662
|
+
hasQuestionSubscribers,
|
|
663
|
+
notifyRemoteCard,
|
|
539
664
|
notifyTurnComplete,
|
|
540
665
|
notifyTurnFailed,
|
|
541
666
|
notifyBoard,
|
|
@@ -30,6 +30,8 @@ export function createRelayCliLauncherService({
|
|
|
30
30
|
killWaitMs = 700,
|
|
31
31
|
maxKillAttempts = 10,
|
|
32
32
|
log = () => {},
|
|
33
|
+
// worker-scope.mjs: a scope of its own under a systemd user service.
|
|
34
|
+
workerScope = null,
|
|
33
35
|
} = {}) {
|
|
34
36
|
let activeJob = null;
|
|
35
37
|
const processInspector = createSessionWorkerProcessInspector({
|
|
@@ -119,6 +121,7 @@ export function createRelayCliLauncherService({
|
|
|
119
121
|
execFileSyncImpl,
|
|
120
122
|
processInspector,
|
|
121
123
|
log,
|
|
124
|
+
workerScope,
|
|
122
125
|
});
|
|
123
126
|
return launched;
|
|
124
127
|
}
|
|
@@ -1775,6 +1775,13 @@ export function createRelayEventService({
|
|
|
1775
1775
|
choice: 'text',
|
|
1776
1776
|
dismissed: 'bool',
|
|
1777
1777
|
via: 'text',
|
|
1778
|
+
// board_settled
|
|
1779
|
+
boardId: 'text',
|
|
1780
|
+
boardType: 'text',
|
|
1781
|
+
action: 'text',
|
|
1782
|
+
actionLabel: 'text',
|
|
1783
|
+
title: 'text',
|
|
1784
|
+
queuedMessageId: 'text',
|
|
1778
1785
|
description: 'text',
|
|
1779
1786
|
summary: 'text',
|
|
1780
1787
|
taskId: 'text',
|
|
@@ -2370,6 +2377,28 @@ export function createRelayEventService({
|
|
|
2370
2377
|
return told.size + recorded;
|
|
2371
2378
|
}
|
|
2372
2379
|
|
|
2380
|
+
/**
|
|
2381
|
+
* The user settled a plan card (board) of a session (routes/relay-board-routes.mjs):
|
|
2382
|
+
* the subscriptions on that session that take board_settled are told, as
|
|
2383
|
+
* for any event (paired relays' through their fetch). `queuedMessageId` is
|
|
2384
|
+
* the follow-up message the action queued, null for exit_only. Returns how
|
|
2385
|
+
* many watchers got it.
|
|
2386
|
+
*/
|
|
2387
|
+
function noteBoardSettled(conversationId, detail = {}) {
|
|
2388
|
+
if (!available) return 0;
|
|
2389
|
+
const boardId = toText(detail?.boardId);
|
|
2390
|
+
if (!toText(conversationId) || !boardId) return 0;
|
|
2391
|
+
const queuedMessageId = toText(detail.queuedMessageId);
|
|
2392
|
+
return record('board_settled', conversationId, {
|
|
2393
|
+
boardId,
|
|
2394
|
+
boardType: toText(detail.boardType).slice(0, 80) || null,
|
|
2395
|
+
action: toText(detail.action).slice(0, 80),
|
|
2396
|
+
actionLabel: toText(detail.actionLabel).slice(0, 200),
|
|
2397
|
+
title: String(detail.title ?? '').slice(0, 500),
|
|
2398
|
+
queuedMessageId: queuedMessageId || null,
|
|
2399
|
+
});
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2373
2402
|
/** The tracker's edge: a background task started or ended. */
|
|
2374
2403
|
function noteTaskEdge({ type, conversationId, task } = {}) {
|
|
2375
2404
|
if (!available || !task) return;
|
|
@@ -2651,6 +2680,7 @@ export function createRelayEventService({
|
|
|
2651
2680
|
noteError,
|
|
2652
2681
|
noteQuestion,
|
|
2653
2682
|
noteQuestionAnswered,
|
|
2683
|
+
noteBoardSettled,
|
|
2654
2684
|
noteTaskEdge,
|
|
2655
2685
|
noteConversationGone,
|
|
2656
2686
|
noteRestartRequested,
|
|
@@ -5,6 +5,11 @@
|
|
|
5
5
|
// dropped, so a new hint the client reads must be added here too (the
|
|
6
6
|
// multi-select flag was once silently stripped this way).
|
|
7
7
|
|
|
8
|
+
import {
|
|
9
|
+
REMOTE_RELAY_APPROVAL_SUMMARY_MAX,
|
|
10
|
+
REMOTE_RELAY_APPROVAL_VARIANTS,
|
|
11
|
+
} from '../../shared/remote-relay-contract.mjs';
|
|
12
|
+
|
|
8
13
|
/**
|
|
9
14
|
* @param {unknown} rawContext
|
|
10
15
|
* @param {{ normalizeRelayMode?: (mode: string) => string | null, defaultRelayMode?: string }} [options]
|
|
@@ -42,5 +47,11 @@ export function sanitizeRelayQuestionContext(rawContext, {
|
|
|
42
47
|
if (typeof rawContext.header === 'string' && rawContext.header.trim()) {
|
|
43
48
|
context.header = rawContext.header.trim().slice(0, 120);
|
|
44
49
|
}
|
|
50
|
+
// An approval card's kind and one-line summary, what a paired
|
|
51
|
+
// relay's Inbox shows of it: a listed variant only, the summary bounded.
|
|
52
|
+
if (REMOTE_RELAY_APPROVAL_VARIANTS.includes(rawContext.variant)) context.variant = rawContext.variant;
|
|
53
|
+
if (typeof rawContext.summary === 'string' && rawContext.summary.trim()) {
|
|
54
|
+
context.summary = rawContext.summary.trim().replace(/\s+/g, ' ').slice(0, REMOTE_RELAY_APPROVAL_SUMMARY_MAX);
|
|
55
|
+
}
|
|
45
56
|
return Object.keys(context).length ? context : null;
|
|
46
57
|
}
|