@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.
Files changed (119) hide show
  1. package/.github/extensions/web-relay/polling/polling-loop.mjs +2 -26
  2. package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +2 -26
  3. package/CHANGELOG.md +317 -0
  4. package/README.md +10 -10
  5. package/bin/oar.js +391 -97
  6. package/package.json +4 -2
  7. package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +60 -0
  8. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +27 -0
  9. package/server/claude-worker/claude-remote-relay-tool.mjs +1 -0
  10. package/server/claude-worker/claude-session-process.mjs +3 -0
  11. package/server/claude-worker/claude-turn-publisher.mjs +163 -8
  12. package/server/claude-worker/claude-usage-totals.mjs +58 -0
  13. package/server/copilot-worker/copilot-plan-board.mjs +4 -7
  14. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +86 -3
  15. package/server/copilot-worker/copilot-sdk-session-process.mjs +57 -4
  16. package/server/cursor-worker/cursor-turn-runner.mjs +42 -6
  17. package/server/db-schema.mjs +18 -0
  18. package/server/deepseek-worker/deepseek-claude-backend.mjs +14 -0
  19. package/server/grok-worker/grok-turn-runner.mjs +44 -6
  20. package/server/migrations/0001-add-structured-answer.mjs +5 -7
  21. package/server/migrations/0002-recent-workspace-roots-path-key.mjs +5 -7
  22. package/server/migrations/0003-push-subscriptions.mjs +5 -7
  23. package/server/migrations/0004-steer-settle-markers.mjs +5 -7
  24. package/server/migrations/0005-remote-relays.mjs +5 -7
  25. package/server/migrations/0006-workspace-root-settings.mjs +5 -7
  26. package/server/migrations/0007-claude-cloud.mjs +5 -7
  27. package/server/migrations/0008-message-pins.mjs +5 -7
  28. package/server/migrations/0009-subagent-timeline.mjs +5 -7
  29. package/server/migrations/0010-subagent-timeline-pruning.mjs +5 -7
  30. package/server/migrations/0011-relay-event-subscriptions.mjs +5 -7
  31. package/server/migrations/0012-cross-relay-event-subscriptions.mjs +5 -7
  32. package/server/migrations/0013-previews.mjs +5 -7
  33. package/server/migrations/0014-conversation-groups.mjs +5 -7
  34. package/server/migrations/0015-conversation-group-position.mjs +5 -7
  35. package/server/migrations/0016-share-access-log.mjs +5 -7
  36. package/server/migrations/0017-conversation-history-indexes.mjs +5 -7
  37. package/server/migrations/0018-background-task-rows.mjs +5 -7
  38. package/server/migrations/0019-relay-event-subscription-options.mjs +5 -7
  39. package/server/migrations/0020-conversation-may-create-sessions.mjs +5 -7
  40. package/server/migrations/0021-conversation-group-agent-marks.mjs +5 -7
  41. package/server/migrations/0022-relay-schedules.mjs +5 -7
  42. package/server/migrations/0023-relay-asks.mjs +5 -7
  43. package/server/migrations/0024-deepseek-usage-totals.mjs +5 -7
  44. package/server/migrations/0025-deepseek-usage-requests-daily.mjs +5 -7
  45. package/server/migrations/0026-deepseek-usage-served-models.mjs +5 -7
  46. package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +5 -7
  47. package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +5 -7
  48. package/server/migrations/0029-deepseek-balance-rises.mjs +5 -7
  49. package/server/migrations/0030-deepseek-usage-actuals.mjs +5 -7
  50. package/server/migrations/0031-queue-provider.mjs +5 -7
  51. package/server/migrations/0032-runtime-session-billing-switch.mjs +5 -7
  52. package/server/migrations/0033-claude-api-usage-checkpoints.mjs +5 -7
  53. package/server/migrations/0034-claude-api-usage-totals.mjs +5 -7
  54. package/server/migrations/0035-conversation-plan-only.mjs +87 -0
  55. package/server/migrations/0036-token-usage.mjs +267 -0
  56. package/server/public/app/about-view.mjs +87 -3
  57. package/server/public/app/api-client.js +2 -1
  58. package/server/public/app/bootstrap.js +55 -2
  59. package/server/public/app/composer-control-state.mjs +12 -2
  60. package/server/public/app/context-usage-view.mjs +16 -0
  61. package/server/public/app/conversation-view.js +17 -2
  62. package/server/public/app/inbox-ui.js +32 -0
  63. package/server/public/app/inbox-view.mjs +141 -5
  64. package/server/public/app/plan-board-shared.mjs +49 -0
  65. package/server/public/app/plan-usage-view.mjs +1 -1
  66. package/server/public/app/relay-board-inline.mjs +4 -1
  67. package/server/public/app/relay-board-view.js +38 -10
  68. package/server/public/app/relay-event-view.mjs +4 -0
  69. package/server/public/app/socket-handlers.js +10 -2
  70. package/server/public/index.html +14 -0
  71. package/server/public/sw.js +8 -0
  72. package/server/relay-heap-guard.mjs +14 -1
  73. package/server/relay-self-restart.mjs +83 -2
  74. package/server/relay-tools.md +4 -3
  75. package/server/repositories/message-repository.mjs +31 -1
  76. package/server/repositories/remote-relay-repository.mjs +10 -0
  77. package/server/repositories/session-repository.mjs +6 -1
  78. package/server/routes/messages-routes.mjs +87 -0
  79. package/server/routes/relay-ask-routes.mjs +138 -8
  80. package/server/routes/relay-board-routes.mjs +55 -40
  81. package/server/routes/sessions-routes.mjs +103 -7
  82. package/server/server-runtime.mjs +132 -11
  83. package/server/services/about-service.mjs +59 -0
  84. package/server/services/cache-rebuild-service.mjs +7 -0
  85. package/server/services/conversation-preferences-service.mjs +31 -0
  86. package/server/services/database-file.mjs +413 -0
  87. package/server/services/db-backup-service.mjs +809 -0
  88. package/server/services/deepseek-usage-import.mjs +6 -9
  89. package/server/services/delete-archive-service.mjs +3 -0
  90. package/server/services/earlier-database-service.mjs +69 -126
  91. package/server/services/oar-cli-helpers.mjs +214 -12
  92. package/server/services/oar-cli-menu.mjs +1 -0
  93. package/server/services/oar-state-migration-service.mjs +33 -13
  94. package/server/services/package-data-rescue.mjs +4 -2
  95. package/server/services/plan-usage-claude-api.mjs +14 -15
  96. package/server/services/plan-usage-contract.mjs +6 -2
  97. package/server/services/plan-usage-deepseek.mjs +1 -1
  98. package/server/services/plan-usage-tokens.mjs +248 -0
  99. package/server/services/push-dispatch-service.mjs +134 -9
  100. package/server/services/relay-cli-launcher-service.mjs +3 -0
  101. package/server/services/relay-event-service.mjs +30 -0
  102. package/server/services/relay-question-context.mjs +11 -0
  103. package/server/services/relay-restart-picture.mjs +67 -22
  104. package/server/services/remote-inbox-watch-service.mjs +204 -0
  105. package/server/services/remote-relay-caller-context.mjs +80 -7
  106. package/server/services/remote-relay-dispatcher.mjs +40 -5
  107. package/server/services/restart-resume-service.mjs +5 -2
  108. package/server/services/session-worker-launch-service.mjs +12 -3
  109. package/server/services/session-worker-stop-service.mjs +27 -6
  110. package/server/services/token-usage-service.mjs +430 -0
  111. package/server/services/update-install-service.mjs +13 -0
  112. package/server/services/usage-limit-pause-service.mjs +8 -1
  113. package/server/services/worker-scope.mjs +77 -0
  114. package/shared/plan-board-actions.mjs +130 -0
  115. package/shared/plan-only.mjs +43 -0
  116. package/shared/relay-asks.mjs +3 -0
  117. package/shared/relay-events.mjs +38 -4
  118. package/shared/remote-relay-contract.mjs +66 -11
  119. 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
- function eventTitle(type, providerName) {
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
- * @param {{ type: string, conversationTitle?: string|null, providerName?: string|null, body?: string|null, data?: object }} event
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 baseTitle = eventTitle(type, event?.providerName);
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
- const text = String(event?.body || '').trim();
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
- return dispatch({
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
  }