@oar-sh/oar 0.9.15 → 0.9.17

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 (109) hide show
  1. package/.github/extensions/web-relay/polling/polling-loop.mjs +14 -87
  2. package/CHANGELOG.md +305 -0
  3. package/README.md +12 -11
  4. package/package.json +2 -1
  5. package/server/claude-worker/claude-attachments.mjs +7 -4
  6. package/server/claude-worker/claude-remote-relay-tool.mjs +36 -1
  7. package/server/claude-worker/claude-sdk-adapter.mjs +167 -9
  8. package/server/claude-worker/claude-session-process.mjs +131 -33
  9. package/server/claude-worker/claude-session-worker-main.mjs +266 -0
  10. package/server/claude-worker/claude-session-worker.mjs +2 -253
  11. package/server/claude-worker/claude-turn-failure.mjs +8 -0
  12. package/server/claude-worker/claude-turn-publisher.mjs +282 -13
  13. package/server/claude-worker/claude-usage-totals.mjs +108 -0
  14. package/server/claude-worker/sdk-message-normalizer.mjs +55 -0
  15. package/server/copilot-worker/copilot-sdk-adapter.mjs +18 -4
  16. package/server/copilot-worker/copilot-sdk-session-process.mjs +73 -7
  17. package/server/db-schema.mjs +115 -0
  18. package/server/deepseek-worker/deepseek-claude-backend.mjs +433 -0
  19. package/server/deepseek-worker/deepseek-session-worker.mjs +37 -0
  20. package/server/migrations/0019-relay-event-subscription-options.mjs +106 -0
  21. package/server/migrations/0020-conversation-may-create-sessions.mjs +67 -0
  22. package/server/migrations/0021-conversation-group-agent-marks.mjs +77 -0
  23. package/server/migrations/0022-relay-schedules.mjs +142 -0
  24. package/server/migrations/0023-relay-asks.mjs +141 -0
  25. package/server/migrations/0024-deepseek-usage-totals.mjs +151 -0
  26. package/server/migrations/0025-deepseek-usage-requests-daily.mjs +179 -0
  27. package/server/migrations/0026-deepseek-usage-served-models.mjs +222 -0
  28. package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +101 -0
  29. package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +99 -0
  30. package/server/migrations/0029-deepseek-balance-rises.mjs +99 -0
  31. package/server/migrations/0030-deepseek-usage-actuals.mjs +123 -0
  32. package/server/public/app/api-client.js +53 -0
  33. package/server/public/app/ask-user-view.js +3 -0
  34. package/server/public/app/bootstrap.js +162 -16
  35. package/server/public/app/context-tier-options.mjs +4 -0
  36. package/server/public/app/context-usage-view.mjs +17 -0
  37. package/server/public/app/conversation-group-order.mjs +35 -0
  38. package/server/public/app/conversation-provider-indicator.mjs +7 -0
  39. package/server/public/app/conversation-view.js +2 -0
  40. package/server/public/app/deepseek-settings-ui.js +342 -0
  41. package/server/public/app/inbox-ui.js +367 -0
  42. package/server/public/app/inbox-view.mjs +370 -0
  43. package/server/public/app/journal-view.js +54 -5
  44. package/server/public/app/model-selector-options.mjs +11 -0
  45. package/server/public/app/model-vision-support.mjs +2 -0
  46. package/server/public/app/no-conversation-state.mjs +1 -1
  47. package/server/public/app/plan-usage-view.mjs +165 -13
  48. package/server/public/app/provider-readiness.mjs +4 -1
  49. package/server/public/app/relay-error-ctas.mjs +8 -0
  50. package/server/public/app/relay-event-subscriptions-ui.js +4 -2
  51. package/server/public/app/relay-event-view.mjs +33 -7
  52. package/server/public/app/relay-schedule-view.mjs +119 -0
  53. package/server/public/app/relay-schedules-ui.js +172 -0
  54. package/server/public/app/settings-modal.js +11 -0
  55. package/server/public/app/settings-tabs.js +1 -1
  56. package/server/public/app/socket-handlers.js +37 -3
  57. package/server/public/app/store.js +18 -5
  58. package/server/public/index.html +567 -35
  59. package/server/relay-tools.md +18 -3
  60. package/server/repositories/conversation-group-repository.mjs +110 -17
  61. package/server/repositories/remote-relay-repository.mjs +20 -2
  62. package/server/repositories/session-repository.mjs +7 -1
  63. package/server/routes/ask-user-routes.mjs +57 -3
  64. package/server/routes/cache-routes.mjs +6 -0
  65. package/server/routes/conversation-group-routes.mjs +19 -3
  66. package/server/routes/deepseek-usage-import-routes.mjs +72 -0
  67. package/server/routes/messages-routes.mjs +195 -18
  68. package/server/routes/relay-ask-routes.mjs +387 -0
  69. package/server/routes/relay-event-routes.mjs +3 -1
  70. package/server/routes/relay-schedule-routes.mjs +57 -0
  71. package/server/routes/sessions-routes.mjs +932 -97
  72. package/server/server-runtime.mjs +203 -11
  73. package/server/services/about-service.mjs +1 -0
  74. package/server/services/cache-rebuild-service.mjs +19 -2
  75. package/server/services/claude-session-leftovers.mjs +226 -0
  76. package/server/services/conversation-preferences-service.mjs +17 -0
  77. package/server/services/deepseek-balance-service.mjs +102 -0
  78. package/server/services/deepseek-settings-service.mjs +380 -0
  79. package/server/services/deepseek-usage-import.mjs +471 -0
  80. package/server/services/model-variant-catalog-service.mjs +1 -0
  81. package/server/services/plan-usage-contract.mjs +100 -5
  82. package/server/services/plan-usage-deepseek.mjs +931 -0
  83. package/server/services/plan-usage-service.mjs +431 -0
  84. package/server/services/provider-model-selection.mjs +1 -0
  85. package/server/services/provider-reasoning-effort.mjs +15 -2
  86. package/server/services/relay-ask-link-service.mjs +174 -0
  87. package/server/services/relay-ask-service.mjs +604 -0
  88. package/server/services/relay-event-service.mjs +589 -73
  89. package/server/services/relay-schedule-service.mjs +596 -0
  90. package/server/services/remote-relay-caller-context.mjs +89 -20
  91. package/server/services/remote-relay-dispatcher.mjs +958 -31
  92. package/server/services/session-worker-launch-service.mjs +233 -82
  93. package/server/services/worker-secret-redaction.mjs +318 -0
  94. package/shared/context-window-fallbacks.mjs +3 -0
  95. package/shared/deepseek-api.mjs +137 -0
  96. package/shared/deepseek-models.mjs +114 -0
  97. package/shared/deepseek-pricing.mjs +146 -0
  98. package/shared/host-claude-session-env.mjs +101 -0
  99. package/shared/model-id.mjs +7 -0
  100. package/shared/preview-tool-core.mjs +1 -1
  101. package/shared/provider-routing.mjs +2 -2
  102. package/shared/relay-asks.mjs +145 -0
  103. package/shared/relay-events.mjs +268 -18
  104. package/shared/relay-schedules.mjs +128 -0
  105. package/shared/remote-relay-contract.mjs +554 -26
  106. package/shared/remote-relay-tool-core.mjs +16 -5
  107. package/shared/steer-settle-failure.mjs +1 -0
  108. package/shared/worker-runtime/direct-openai-image.mjs +128 -0
  109. package/shared/worker-runtime/worker-log-file.mjs +60 -0
@@ -0,0 +1,370 @@
1
+ // The Inbox, as DOM: every pending question across this relay and its paired
2
+ // relays (GET /api/inbox), grouped by relay, newest first; what was answered
3
+ // or dismissed in the last day under a divider. And the card a question that
4
+ // does not block (remote_relay `ask`) shows in its own session.
5
+ //
6
+ // Every text here is written by an agent, some on other relays: it goes into
7
+ // the page through textContent only, never as HTML or a link. Buttons carry
8
+ // what they act on in data attributes; inbox-ui.js handles the clicks.
9
+ //
10
+ // The browser cannot import shared/ (only server/public is served): the few
11
+ // values of shared/relay-asks.mjs this needs are kept here, and
12
+ // inbox-view.test.mjs asserts they match.
13
+
14
+ export const RELAY_ASK_SOCKET_EVENT = 'relay_ask_updated';
15
+ export const INBOX_ANSWERED_KEEP_MS = 24 * 60 * 60_000;
16
+ export const INBOX_FILTERS = Object.freeze(['all', 'mine', 'agents']);
17
+
18
+ const CLOSED_STATUSES = new Set(['answered', 'dismissed']);
19
+
20
+ function toText(value) {
21
+ return String(value ?? '').trim();
22
+ }
23
+
24
+ function el(doc, tag, className = '', text = null) {
25
+ const node = doc.createElement(tag);
26
+ if (className) node.className = className;
27
+ if (text !== null && text !== undefined) node.textContent = String(text);
28
+ return node;
29
+ }
30
+
31
+ function button(doc, className, label, data = {}) {
32
+ const node = el(doc, 'button', className, label);
33
+ node.type = 'button';
34
+ for (const [key, value] of Object.entries(data)) {
35
+ if (value !== undefined && value !== null && value !== '') node.dataset[key] = String(value);
36
+ }
37
+ return node;
38
+ }
39
+
40
+ /** "just now", "5 min", "3 h", "2 d" since `createdAt`. */
41
+ export function formatInboxAge(createdAt, now = Date.now()) {
42
+ const at = Date.parse(toText(createdAt));
43
+ if (!Number.isFinite(at)) return '';
44
+ const minutes = Math.max(0, Math.floor((now - at) / 60_000));
45
+ if (minutes < 1) return 'just now';
46
+ if (minutes < 60) return `${minutes} min`;
47
+ const hours = Math.floor(minutes / 60);
48
+ if (hours < 48) return `${hours} h`;
49
+ return `${Math.floor(hours / 24)} d`;
50
+ }
51
+
52
+ /** A link that opens one session in a relay's web UI (remoteConversationUrl's copy). */
53
+ export function inboxSessionUrl(relayUrl, sessionId) {
54
+ const base = toText(relayUrl).replace(/\/+$/, '');
55
+ const id = toText(sessionId);
56
+ if (!base || !/^https?:\/\//i.test(base)) return '';
57
+ return id ? `${base}/?conv=${encodeURIComponent(id)}` : `${base}/`;
58
+ }
59
+
60
+ /**
61
+ * The filter: "mine" are the questions in sessions the owner started,
62
+ * "agents" those in sessions an agent started.
63
+ */
64
+ export function inboxFilterMatches(item, filter = 'all') {
65
+ if (filter === 'mine') return item?.session?.startedBy !== 'agent';
66
+ if (filter === 'agents') return item?.session?.startedBy === 'agent';
67
+ return true;
68
+ }
69
+
70
+ function isClosed(item) {
71
+ return CLOSED_STATUSES.has(toText(item?.status));
72
+ }
73
+
74
+ function newestFirst(a, b) {
75
+ return toText(b?.createdAt).localeCompare(toText(a?.createdAt));
76
+ }
77
+
78
+ /**
79
+ * The Inbox's groups: per relay (this one first, then the paired ones as
80
+ * listed), its pending items newest first and its items closed in the last
81
+ * day; a relay that did not answer shows as such, with nothing listed.
82
+ */
83
+ export function inboxSections(relays, { filter = 'all', now = Date.now() } = {}) {
84
+ return (Array.isArray(relays) ? relays : []).map((relay) => {
85
+ const items = (Array.isArray(relay?.items) ? relay.items : []).filter((item) => item && toText(item.id) && inboxFilterMatches(item, filter));
86
+ const pending = items.filter((item) => toText(item.status) === 'pending').sort(newestFirst);
87
+ const closed = items
88
+ .filter((item) => isClosed(item) && now - (Date.parse(toText(item.answeredAt || item.createdAt)) || 0) <= INBOX_ANSWERED_KEEP_MS)
89
+ .sort((a, b) => toText(b.answeredAt || b.createdAt).localeCompare(toText(a.answeredAt || a.createdAt)));
90
+ return {
91
+ relay: {
92
+ id: toText(relay?.id),
93
+ name: toText(relay?.name) || toText(relay?.id),
94
+ self: relay?.self === true,
95
+ url: toText(relay?.url),
96
+ reachable: relay?.reachable !== false,
97
+ error: toText(relay?.error),
98
+ },
99
+ pending,
100
+ closed,
101
+ };
102
+ });
103
+ }
104
+
105
+ /**
106
+ * A load that never runs twice at once, and never misses a change: a call
107
+ * while one runs is answered by that one and asks for one more after it
108
+ * (once, however many calls came meanwhile). Resolves with the last load's
109
+ * result.
110
+ */
111
+ export function createCoalescedRefresh(load) {
112
+ let running = null;
113
+ let again = false;
114
+ function refresh() {
115
+ if (running) {
116
+ again = true;
117
+ return running;
118
+ }
119
+ running = (async () => {
120
+ let result;
121
+ try {
122
+ do {
123
+ again = false;
124
+ result = await load();
125
+ } while (again);
126
+ return result;
127
+ } finally {
128
+ running = null;
129
+ }
130
+ })();
131
+ return running;
132
+ }
133
+ return refresh;
134
+ }
135
+
136
+ /**
137
+ * This relay's asks out of an Inbox load (GET /api/inbox), by id: what the
138
+ * session cards show. Built anew from each load, so an ask that was
139
+ * withdrawn or cancelled (no longer listed) drops out.
140
+ */
141
+ export function sessionAsksFromInbox(relays) {
142
+ const own = (Array.isArray(relays) ? relays : []).find((relay) => relay?.self === true);
143
+ const asks = new Map();
144
+ for (const item of Array.isArray(own?.items) ? own.items : []) {
145
+ if (item?.kind === 'ask' && toText(item.id)) asks.set(item.id, { ...item, conversationId: item.session?.id });
146
+ }
147
+ return asks;
148
+ }
149
+
150
+ /** Pending items across every relay (the sidebar's badge), whatever the filter. */
151
+ export function inboxPendingCount(relays) {
152
+ return (Array.isArray(relays) ? relays : [])
153
+ .reduce((sum, relay) => sum + (Array.isArray(relay?.items) ? relay.items : []).filter((item) => toText(item?.status) === 'pending').length, 0);
154
+ }
155
+
156
+ function headLine(doc, item, { relay, now, label }) {
157
+ const head = el(doc, 'div', 'inbox-item-head');
158
+ head.append(el(doc, 'span', 'inbox-item-kind', label));
159
+ if (item.urgency === 'now') head.append(el(doc, 'span', 'inbox-item-urgent', 'urgent'));
160
+ const age = formatInboxAge(item.createdAt, now);
161
+ if (age) head.append(el(doc, 'span', 'inbox-item-age', age));
162
+ if (relay && !relay.self) head.append(el(doc, 'span', 'inbox-item-relay', relay.name));
163
+ return head;
164
+ }
165
+
166
+ function sessionLine(doc, item, relay, { withLink = true } = {}) {
167
+ const line = el(doc, 'div', 'inbox-item-session');
168
+ const title = toText(item?.session?.title) || 'Untitled session';
169
+ if (withLink && relay?.self) {
170
+ line.append(button(doc, 'inbox-open-session', title, { inboxAction: 'open', sessionId: item?.session?.id }));
171
+ } else if (withLink && relay && !relay.self) {
172
+ const url = inboxSessionUrl(relay.url, item?.session?.id);
173
+ if (url) {
174
+ const link = el(doc, 'a', 'inbox-open-there', title);
175
+ link.href = url;
176
+ link.target = '_blank';
177
+ link.rel = 'noopener noreferrer';
178
+ line.append(link);
179
+ } else {
180
+ line.append(el(doc, 'span', 'inbox-session-title', title));
181
+ }
182
+ } else {
183
+ line.append(el(doc, 'span', 'inbox-session-title', title));
184
+ }
185
+ const asker = item?.askedBy;
186
+ if (item?.onBehalf && asker) {
187
+ const who = [toText(asker.title) || toText(asker.conversationId), toText(asker.relay) ? `on ${toText(asker.relay)}` : ''].filter(Boolean).join(' ');
188
+ if (who) line.append(el(doc, 'span', 'inbox-item-asker', `asked by ${who}`));
189
+ }
190
+ return line;
191
+ }
192
+
193
+ function outcomeLine(doc, item) {
194
+ if (item.status === 'dismissed') return el(doc, 'div', 'inbox-item-outcome', 'Dismissed');
195
+ if (item.status !== 'answered') return null;
196
+ const answer = toText(item.answer) || toText(item.choice);
197
+ const via = toText(item.answeredVia);
198
+ return el(doc, 'div', 'inbox-item-outcome', `Answered: ${answer}${via ? ` (from ${via})` : ''}`);
199
+ }
200
+
201
+ /**
202
+ * A question that does not block, as a card: in the Inbox (`mode` "inbox",
203
+ * with its session and relay) or in its session ("session"). `answerable`:
204
+ * the buttons answer it (this relay's, or a paired relay's ask).
205
+ */
206
+ export function buildAskCard(doc, item, { relay = null, mode = 'inbox', now = Date.now(), answerable = true } = {}) {
207
+ const card = el(doc, 'article', `inbox-item inbox-ask${mode === 'session' ? ' relay-ask-card' : ''}`);
208
+ card.dataset.itemId = toText(item.id);
209
+ card.dataset.kind = 'ask';
210
+ if (relay?.id) card.dataset.relayId = relay.id;
211
+ card.dataset.status = toText(item.status) || 'pending';
212
+ card.append(headLine(doc, item, { relay, now, label: mode === 'session' ? 'Question for you · does not block this session' : 'Question' }));
213
+ if (mode === 'inbox') card.append(sessionLine(doc, item, relay));
214
+ else if (item.onBehalf && item.askedBy) card.append(sessionLine(doc, item, relay, { withLink: false }));
215
+ card.append(el(doc, 'p', 'inbox-item-question', item.question || item.prompt || ''));
216
+ const options = Array.isArray(item.options) ? item.options.map(toText).filter(Boolean) : [];
217
+ const recommendation = toText(item.recommendation);
218
+ const recommendedOption = options.find((option) => option === recommendation) || '';
219
+ if (recommendation && !recommendedOption) card.append(el(doc, 'p', 'inbox-item-recommendation', `Recommended: ${recommendation}`));
220
+ if (toText(item.detail)) {
221
+ const details = el(doc, 'details', 'inbox-item-detail');
222
+ details.append(el(doc, 'summary', '', 'Details'));
223
+ // Plain text, line breaks kept: never parsed as markup.
224
+ details.append(el(doc, 'pre', 'inbox-item-detail-text', item.detail));
225
+ card.append(details);
226
+ }
227
+ const outcome = outcomeLine(doc, item);
228
+ if (outcome) card.append(outcome);
229
+ if (item.status === 'pending' && answerable) {
230
+ const data = { relayId: relay?.id || 'self', itemId: item.id };
231
+ if (options.length) {
232
+ const row = el(doc, 'div', 'inbox-item-options');
233
+ for (const option of options) {
234
+ const choice = button(doc, `inbox-choice${option === recommendedOption ? ' recommended' : ''}`, option, { ...data, inboxAction: 'choice', choice: option });
235
+ if (option === recommendedOption) {
236
+ choice.append(el(doc, 'span', 'inbox-recommended-tag', 'recommended'));
237
+ choice.setAttribute('aria-label', `${option} (recommended)`);
238
+ }
239
+ row.append(choice);
240
+ }
241
+ card.append(row);
242
+ }
243
+ const reply = el(doc, 'div', 'inbox-item-reply');
244
+ const field = el(doc, 'textarea', 'inbox-answer-text');
245
+ field.rows = 2;
246
+ field.placeholder = options.length ? 'Or type an answer…' : 'Your answer…';
247
+ field.setAttribute('aria-label', 'Answer');
248
+ field.dataset.itemId = toText(item.id);
249
+ reply.append(field);
250
+ const actions = el(doc, 'div', 'inbox-item-actions');
251
+ actions.append(button(doc, 'inbox-answer', 'Answer', { ...data, inboxAction: 'answer' }));
252
+ actions.append(button(doc, 'inbox-dismiss', 'Dismiss', { ...data, inboxAction: 'dismiss' }));
253
+ reply.append(actions);
254
+ card.append(reply);
255
+ }
256
+ return card;
257
+ }
258
+
259
+ /**
260
+ * A card a turn waits on (ask_user) or an approval card, in the Inbox. Here:
261
+ * a plain one is answered like in its session (choices, or typed text); one
262
+ * with fields or several picks opens its session. A paired relay's is never
263
+ * answered from here: "Open there".
264
+ */
265
+ export function buildCardItem(doc, item, { relay = null, now = Date.now() } = {}) {
266
+ const approval = item.kind === 'approval';
267
+ const card = el(doc, 'article', `inbox-item inbox-card${approval ? ' inbox-approval' : ''}`);
268
+ card.dataset.itemId = toText(item.id);
269
+ card.dataset.kind = toText(item.kind) || 'ask_user';
270
+ if (relay?.id) card.dataset.relayId = relay.id;
271
+ card.dataset.status = toText(item.status) || 'pending';
272
+ card.append(headLine(doc, item, { relay, now, label: approval ? 'Approval · the session waits' : 'Question · the session waits' }));
273
+ card.append(sessionLine(doc, item, relay));
274
+ if (toText(item.header)) card.append(el(doc, 'div', 'inbox-item-header', item.header));
275
+ if (toText(item.question)) card.append(el(doc, 'p', 'inbox-item-question', item.question));
276
+ else if (approval) card.append(el(doc, 'p', 'inbox-item-question', 'An Allow / Deny card on that relay.'));
277
+ const outcome = outcomeLine(doc, item);
278
+ if (outcome) card.append(outcome);
279
+ if (item.status !== 'pending') return card;
280
+ if (!relay?.self) {
281
+ const url = inboxSessionUrl(relay?.url, item?.session?.id);
282
+ const actions = el(doc, 'div', 'inbox-item-actions');
283
+ if (url) {
284
+ const link = el(doc, 'a', 'inbox-open-there inbox-open-there-button', 'Open there');
285
+ link.href = url;
286
+ link.target = '_blank';
287
+ link.rel = 'noopener noreferrer';
288
+ actions.append(link);
289
+ } else {
290
+ actions.append(el(doc, 'span', 'inbox-item-note', 'Answer it on that relay.'));
291
+ }
292
+ card.append(actions);
293
+ return card;
294
+ }
295
+ const options = Array.isArray(item.options) ? item.options.map(toText).filter(Boolean) : [];
296
+ const data = { relayId: 'self', itemId: item.id, sessionId: item?.session?.id, sdkSessionId: item.sdkSessionId || '' };
297
+ if (item.structured || item.multiSelect) {
298
+ const actions = el(doc, 'div', 'inbox-item-actions');
299
+ actions.append(button(doc, 'inbox-open-session-button', 'Open the session to answer', { inboxAction: 'open', sessionId: item?.session?.id }));
300
+ card.append(actions);
301
+ return card;
302
+ }
303
+ if (options.length) {
304
+ const row = el(doc, 'div', 'inbox-item-options');
305
+ for (const option of options) row.append(button(doc, 'inbox-choice', option, { ...data, inboxAction: 'card-choice', choice: option }));
306
+ card.append(row);
307
+ }
308
+ if (item.allowFreeform !== false || !options.length) {
309
+ const reply = el(doc, 'div', 'inbox-item-reply');
310
+ const field = el(doc, 'textarea', 'inbox-answer-text');
311
+ field.rows = 2;
312
+ field.placeholder = 'Your answer…';
313
+ field.setAttribute('aria-label', 'Answer');
314
+ field.dataset.itemId = toText(item.id);
315
+ reply.append(field);
316
+ const actions = el(doc, 'div', 'inbox-item-actions');
317
+ actions.append(button(doc, 'inbox-answer', 'Answer', { ...data, inboxAction: 'card-answer' }));
318
+ reply.append(actions);
319
+ card.append(reply);
320
+ }
321
+ return card;
322
+ }
323
+
324
+ function buildItem(doc, item, relay, now) {
325
+ if (item.kind === 'ask') return buildAskCard(doc, item, { relay, now, mode: 'inbox', answerable: true });
326
+ return buildCardItem(doc, item, { relay, now });
327
+ }
328
+
329
+ /** The whole Inbox: the filter, then one group per relay. */
330
+ export function renderInbox(doc, { relays = [], filter = 'all', now = Date.now(), loading = false } = {}) {
331
+ const root = el(doc, 'section', 'status-view inbox-view');
332
+ root.setAttribute('aria-label', 'Inbox');
333
+ const bar = el(doc, 'div', 'inbox-filter');
334
+ bar.setAttribute('role', 'group');
335
+ bar.setAttribute('aria-label', 'Show');
336
+ for (const [value, label] of [['all', 'All'], ['mine', 'Mine'], ['agents', 'Agents']]) {
337
+ const option = button(doc, `inbox-filter-option${filter === value ? ' active' : ''}`, label, { inboxFilter: value });
338
+ option.setAttribute('aria-pressed', filter === value ? 'true' : 'false');
339
+ bar.append(option);
340
+ }
341
+ root.append(bar);
342
+ const sections = inboxSections(relays, { filter, now });
343
+ let anything = false;
344
+ for (const section of sections) {
345
+ const group = el(doc, 'section', 'inbox-relay');
346
+ group.dataset.relayId = section.relay.id;
347
+ const heading = el(doc, 'h2', 'inbox-relay-name', section.relay.self ? `${section.relay.name} (this relay)` : section.relay.name);
348
+ group.append(heading);
349
+ if (!section.relay.reachable) {
350
+ group.append(el(doc, 'p', 'inbox-relay-unreachable', `Not reachable${section.relay.error ? `: ${section.relay.error}` : ''}`));
351
+ root.append(group);
352
+ continue;
353
+ }
354
+ if (!section.pending.length && !section.closed.length) {
355
+ if (!section.relay.self) continue;
356
+ group.append(el(doc, 'p', 'inbox-empty', loading ? 'Loading…' : 'Nothing waits for you here.'));
357
+ }
358
+ for (const item of section.pending) group.append(buildItem(doc, item, section.relay, now));
359
+ if (section.closed.length) {
360
+ const divider = el(doc, 'div', 'inbox-divider', 'Answered in the last day');
361
+ divider.setAttribute('role', 'separator');
362
+ group.append(divider);
363
+ for (const item of section.closed) group.append(buildItem(doc, item, section.relay, now));
364
+ }
365
+ anything = anything || section.pending.length > 0 || section.closed.length > 0;
366
+ root.append(group);
367
+ }
368
+ if (!anything && filter !== 'all') root.append(el(doc, 'p', 'inbox-empty', 'Nothing matches this filter.'));
369
+ return root;
370
+ }
@@ -29,6 +29,7 @@ import {
29
29
  setSummaryModalLeaveHandler,
30
30
  conversationGroups,
31
31
  mergeConversationGroups,
32
+ forgetKeptEmptyConversationGroups,
32
33
  readConversationGroupFoldChoices,
33
34
  pruneConversationGroups,
34
35
  hasSessionWorkerStatus,
@@ -71,6 +72,7 @@ import {
71
72
  loadClaudeCloudBranches,
72
73
  loadClaudeCloudRepos,
73
74
  loadGrokSettings,
75
+ loadDeepSeekSettings,
74
76
  loadOpenAISettings,
75
77
  } from './api-client.js';
76
78
  import { isSetupRequired, updateProviderReadinessInputs } from './provider-readiness.mjs';
@@ -113,6 +115,7 @@ import {
113
115
  conversationProviderIndicatorLabel,
114
116
  } from './conversation-provider-indicator.mjs';
115
117
  import { leaveStatusView } from './status-view.mjs';
118
+ import { leaveInboxView } from './inbox-ui.js';
116
119
  import {
117
120
  CLAUDE_CLOUD_PROVIDER,
118
121
  buildCloudSourceWarnings,
@@ -132,6 +135,7 @@ import {
132
135
  } from './claude-cloud-ui.mjs';
133
136
  import { renderConversationOriginMarkerHtml } from './remote-relay-origin-view.mjs';
134
137
  import { relayEventWatchedMarkerHtml } from './relay-event-subscriptions-ui.js';
138
+ import { relayScheduleMarkerHtml } from './relay-schedules-ui.js';
135
139
  import {
136
140
  normalizeConversationFilter,
137
141
  conversationMatchesFilter,
@@ -186,6 +190,7 @@ let newConversationOpenAISettingsCache = null;
186
190
  let newConversationClaudeSettingsCache = null;
187
191
  let newConversationCursorSettingsCache = null;
188
192
  let newConversationGrokSettingsCache = null;
193
+ let newConversationDeepSeekSettingsCache = null;
189
194
  let newConversationClaudeCloudSettingsCache = null;
190
195
  // Claude Cloud fields of the New Chat modal: what the selected folder's git
191
196
  // remote said, and what was written into Repository/Branch from it (so a later
@@ -378,6 +383,9 @@ export async function refreshConversations(options = {}) {
378
383
  if (!knownConversationIds.has(id)) delete conversations[id];
379
384
  }
380
385
  }
386
+ // The owner's kept empty groups come with the first page only: the page
387
+ // names the ones still kept.
388
+ forgetKeptEmptyConversationGroups();
381
389
  mergeConversationGroups(r.groups);
382
390
  applyConversationPage(r.conversations || []);
383
391
  conversationRowsFetchedSinceRefresh = Array.isArray(r.conversations) ? r.conversations.length : 0;
@@ -613,6 +621,8 @@ function paintConvList() {
613
621
  const items = arrangeConversationList(inView, conversationGroups, {
614
622
  matches: activeFilter ? (row) => conversationMatchesFilter(row, activeFilter) : null,
615
623
  groupNameMatches: activeFilter ? (info) => conversationMatchesFilter({ title: info?.displayName || info?.name }, activeFilter) : null,
624
+ // An owner's group an agent emptied shows in the live list, empty.
625
+ emptyGroups: !conversationListArchivedView,
616
626
  isOpen: (groupId, group) => {
617
627
  const described = describeGroupForHead(group, viewOf);
618
628
  const holdsOpenConversation = !!currentConvId && group.members.some((row) => row.id === currentConvId);
@@ -690,6 +700,8 @@ function paintConvList() {
690
700
  : renderConversationOriginMarkerHtml(c, { conversations });
691
701
  // "watched": a session another conversation watches by id (relay events).
692
702
  const watchedMarkerHtml = relayEventWatchedMarkerHtml(c.id);
703
+ // "scheduled": a schedule fires into this session (remote_relay schedule).
704
+ const scheduledMarkerHtml = relayScheduleMarkerHtml(c.id);
693
705
  const groupClass = item.kind === 'head' ? ' conv-group-head' : (item.kind === 'member' ? ' conv-group-member' : '');
694
706
  const groupAttr = item.kind === 'head' || item.kind === 'member' ? ` data-group-id="${escHtml(item.group.id)}"` : '';
695
707
  // A head row shows the name the group was given by hand; until then the
@@ -700,7 +712,7 @@ function paintConvList() {
700
712
  return `
701
713
  <div class="conv-item worker-ui-${view.visualState}${view.working ? ' conv-working' : ''}${c.id === currentConvId ? ' active' : ''}${c.archived ? ' archived' : ''}${groupClass}" data-conversation-id="${c.id}"${groupAttr} onclick="openConversation('${c.id}')">
702
714
  <div class="conv-title"${titleAttr}>${item.kind === 'head' ? foldButtonHtml(item) : ''}${escHtml(rowTitle)}${pendingByConversation[c.id] ? ` <span class="conv-open-questions">${pendingByConversation[c.id]} open</span>` : ''}</div>
703
- <div class="conv-meta"><span class="conv-meta-primary">${fmtDate(c.updatedAt)} · ${c.messageCount} msg${c.messageCount !== 1 ? 's' : ''}</span>${item.kind === 'head' ? groupPillHtml(item) : ''}${originMarkerHtml}${watchedMarkerHtml}${providerIndicatorHtml}</div>
715
+ <div class="conv-meta"><span class="conv-meta-primary">${fmtDate(c.updatedAt)} · ${c.messageCount} msg${c.messageCount !== 1 ? 's' : ''}</span>${item.kind === 'head' ? groupPillHtml(item) : ''}${originMarkerHtml}${watchedMarkerHtml}${scheduledMarkerHtml}${providerIndicatorHtml}</div>
704
716
  ${c.archived
705
717
  ? `<button class="conv-archive" onclick="unarchiveConv(event,'${c.id}')" title="Unarchive" aria-label="Unarchive">📂</button>`
706
718
  : `<button class="conv-archive" onclick="archiveConv(event,'${c.id}')" title="Archive" aria-label="Archive">🗄</button>`}
@@ -711,6 +723,15 @@ function paintConvList() {
711
723
  // archived or deleted): a head line of its own, with the name and the pill.
712
724
  const syntheticHeadHtml = (item) => {
713
725
  const name = groupDisplayName(item.group.id);
726
+ // An owner's group an agent emptied: no members, nothing to fold; its
727
+ // menu (right-click or long-press) renames or dissolves it.
728
+ if (item.group.empty) {
729
+ return `
730
+ <div class="conv-item conv-group-head conv-group-synthetic conv-group-empty worker-ui-offline" data-group-id="${escHtml(item.group.id)}" title="An agent moved the last session out of this group; rename or dissolve it from its menu.">
731
+ <div class="conv-title">${escHtml(name)}</div>
732
+ <div class="conv-meta"><span class="conv-meta-primary">group · empty</span></div>
733
+ </div>`;
734
+ }
714
735
  const described = groupDescriptions.get(item.group.id) || describeGroupForHead(item.group, viewOf);
715
736
  return `
716
737
  <div class="conv-item conv-group-head conv-group-synthetic worker-ui-${described.summary.strongest === 'waiting' ? 'question' : 'offline'}" data-group-id="${escHtml(item.group.id)}" onclick="toggleConversationGroupFold(event,'${escHtml(item.group.id)}')">
@@ -1059,7 +1080,9 @@ function applyFreshOverCachedConversation(id, response, { cached, keepTranscript
1059
1080
  }
1060
1081
 
1061
1082
  export async function openConversation(id, options = {}) {
1062
- const didLeaveStatusView = leaveStatusView();
1083
+ // The Inbox owns the transcript like the status view does.
1084
+ const didLeaveInboxView = leaveInboxView();
1085
+ const didLeaveStatusView = leaveStatusView() || didLeaveInboxView;
1063
1086
  document.getElementById('input-area')?.removeAttribute('hidden');
1064
1087
  const previousConversationId = String(currentConvId || '').trim();
1065
1088
  const nextConversationId = String(id || '').trim();
@@ -1216,6 +1239,7 @@ function normalizeNewConversationProviderType(value = '') {
1216
1239
  if (normalized === CLAUDE_CLOUD_PROVIDER) return CLAUDE_CLOUD_PROVIDER;
1217
1240
  if (normalized === 'cursor') return 'cursor';
1218
1241
  if (normalized === 'grok') return 'grok';
1242
+ if (normalized === 'deepseek') return 'deepseek';
1219
1243
  return 'github';
1220
1244
  }
1221
1245
 
@@ -1253,6 +1277,7 @@ function modelMatchesNewConversationProvider(catalog = {}, modelId = '', provide
1253
1277
  const hasClaude = providers.includes('claude');
1254
1278
  const hasCursor = providers.includes('cursor');
1255
1279
  const hasGrok = providers.includes('grok');
1280
+ const hasDeepSeek = providers.includes('deepseek');
1256
1281
  if (wantsOpenAI) {
1257
1282
  if (hasOpenAIByok) return true;
1258
1283
  const settingsModel = String(newConversationOpenAISettingsCache?.model || '').trim();
@@ -1288,16 +1313,26 @@ function modelMatchesNewConversationProvider(catalog = {}, modelId = '', provide
1288
1313
  : [];
1289
1314
  return normalizedModelId === grokModel || grokModels.includes(normalizedModelId);
1290
1315
  }
1316
+ if (normalizedProvider === 'deepseek') {
1317
+ if (hasDeepSeek) return true;
1318
+ const deepseekModel = String(newConversationDeepSeekSettingsCache?.model || '').trim();
1319
+ const deepseekModels = Array.isArray(newConversationDeepSeekSettingsCache?.models)
1320
+ ? newConversationDeepSeekSettingsCache.models.map((entry) => String(entry || '').trim()).filter(Boolean)
1321
+ : [];
1322
+ return normalizedModelId === deepseekModel || deepseekModels.includes(normalizedModelId);
1323
+ }
1291
1324
  const claudeOnly = hasClaude && providers.every((provider) => provider === 'claude');
1292
1325
  if (claudeOnly) return false;
1293
1326
  const cursorOnly = hasCursor && providers.every((provider) => provider === 'cursor');
1294
1327
  if (cursorOnly) return false;
1295
1328
  const grokOnly = hasGrok && providers.every((provider) => provider === 'grok');
1296
1329
  if (grokOnly) return false;
1330
+ const deepseekOnly = hasDeepSeek && providers.every((provider) => provider === 'deepseek');
1331
+ if (deepseekOnly) return false;
1297
1332
  // Should a relay ever tag catalog rows for Claude Cloud, they are no more
1298
1333
  // Copilot's than the other runtimes' rows are.
1299
1334
  const exclusiveOnly = providers.length > 0 && providers.every((provider) => (
1300
- provider === 'claude' || provider === 'cursor' || provider === 'grok' || provider === CLAUDE_CLOUD_PROVIDER
1335
+ provider === 'claude' || provider === 'cursor' || provider === 'grok' || provider === 'deepseek' || provider === CLAUDE_CLOUD_PROVIDER
1301
1336
  ));
1302
1337
  if (exclusiveOnly) return false;
1303
1338
  return providers.some((provider) => (
@@ -1305,6 +1340,7 @@ function modelMatchesNewConversationProvider(catalog = {}, modelId = '', provide
1305
1340
  && provider !== 'claude'
1306
1341
  && provider !== 'cursor'
1307
1342
  && provider !== 'grok'
1343
+ && provider !== 'deepseek'
1308
1344
  && provider !== CLAUDE_CLOUD_PROVIDER
1309
1345
  )) || !hasOpenAIByok;
1310
1346
  }
@@ -1422,8 +1458,10 @@ async function populateNewConversationReasoningSelect(selectedModel = '') {
1422
1458
  if (optionTitle) option.title = optionTitle;
1423
1459
  select.appendChild(option);
1424
1460
  }
1461
+ // A DeepSeek chat with nothing remembered starts at DeepSeek's own default.
1462
+ const storedEffort = localStorage.getItem(REASONING_STORAGE_KEY);
1425
1463
  const preferred = resolvePreferredReasoningEffort(efforts, [
1426
- localStorage.getItem(REASONING_STORAGE_KEY),
1464
+ storedEffort || (provider === 'deepseek' ? 'high' : ''),
1427
1465
  ]);
1428
1466
  select.value = preferred || efforts[0];
1429
1467
  select.disabled = false;
@@ -1466,6 +1504,10 @@ function updateNewConversationProviderHelp(provider = 'github') {
1466
1504
  help.textContent = "Grok chats use the host machine's logged-in Grok credentials (grok login or XAI_API_KEY).";
1467
1505
  return;
1468
1506
  }
1507
+ if (normalizedProvider === 'deepseek') {
1508
+ help.textContent = "DeepSeek chats run Claude Code on the relay host against DeepSeek's API, billed to your saved DeepSeek key.";
1509
+ return;
1510
+ }
1469
1511
  help.textContent = 'Copilot models use your GitHub Copilot runtime.';
1470
1512
  }
1471
1513
 
@@ -2073,7 +2115,7 @@ function applyNewConversationSetupState(setupRequired) {
2073
2115
  }
2074
2116
 
2075
2117
  async function openNewConversationModelModal() {
2076
- const [catalog, settings, claudeSettings, cursorSettings, grokSettings, claudeCloudSettings] = await Promise.all([
2118
+ const [catalog, settings, claudeSettings, cursorSettings, grokSettings, claudeCloudSettings, deepseekSettings] = await Promise.all([
2077
2119
  loadModelCatalog(),
2078
2120
  loadOpenAISettings(),
2079
2121
  loadClaudeSettings(),
@@ -2081,6 +2123,8 @@ async function openNewConversationModelModal() {
2081
2123
  loadGrokSettings(),
2082
2124
  // null on a relay without Claude Cloud: the option is simply not offered.
2083
2125
  loadClaudeCloudSettings(),
2126
+ // The same for a relay without DeepSeek.
2127
+ loadDeepSeekSettings(),
2084
2128
  ]);
2085
2129
  newConversationCatalogCache = catalog || null;
2086
2130
  newConversationOpenAISettingsCache = settings || null;
@@ -2088,6 +2132,7 @@ async function openNewConversationModelModal() {
2088
2132
  newConversationCursorSettingsCache = cursorSettings || null;
2089
2133
  newConversationGrokSettingsCache = grokSettings || null;
2090
2134
  newConversationClaudeCloudSettingsCache = claudeCloudSettings || null;
2135
+ newConversationDeepSeekSettingsCache = deepseekSettings || null;
2091
2136
  // Fresh answers for the readiness question, shared with the empty screen
2092
2137
  // and the composer. With no provider known to be ready the modal opens in
2093
2138
  // its setup state: Settings is the main button, Start stays as "anyway".
@@ -2097,6 +2142,7 @@ async function openNewConversationModelModal() {
2097
2142
  claudeCloud: claudeCloudSettings || null,
2098
2143
  cursor: cursorSettings || null,
2099
2144
  grok: grokSettings || null,
2145
+ deepseek: deepseekSettings || null,
2100
2146
  catalog: catalog || null,
2101
2147
  });
2102
2148
  applyNewConversationSetupState(isSetupRequired(readiness));
@@ -2122,6 +2168,9 @@ async function openNewConversationModelModal() {
2122
2168
  if (grokSettings?.enabled === true) {
2123
2169
  options.push({ value: 'grok', label: 'Grok' });
2124
2170
  }
2171
+ if (deepseekSettings?.enabled === true) {
2172
+ options.push({ value: 'deepseek', label: 'DeepSeek' });
2173
+ }
2125
2174
  providerSelect.innerHTML = '';
2126
2175
  for (const option of options) {
2127
2176
  const entry = document.createElement('option');
@@ -16,11 +16,13 @@ export function composerPlaceholderFor({ modelId = '', providerType = '' } = {})
16
16
  if (provider === 'grok') return 'Message Grok…';
17
17
  if (provider === 'openai' || provider === 'openai-byok') return 'Message OpenAI…';
18
18
  if (provider === 'cursor') return 'Message Cursor…';
19
+ if (provider === 'deepseek') return 'Message DeepSeek…';
19
20
  return 'Message Copilot…';
20
21
  };
21
22
  if (!id || id === 'auto') return providerFallback();
22
23
  if (id.startsWith('claude-')) return 'Message Claude…';
23
24
  if (id.startsWith('grok-')) return 'Message Grok…';
25
+ if (id.startsWith('deepseek-')) return 'Message DeepSeek…';
24
26
  if (id.startsWith('gpt-')) return 'Message GPT…';
25
27
  if (id.startsWith('gemini-')) return 'Message Gemini…';
26
28
  // Composer is Cursor's house model family.
@@ -65,6 +67,15 @@ export function humanizeModelLabel(modelId = '') {
65
67
  const label = words.map((word) => word.text).join(' ');
66
68
  return suffixMatch ? `${label} [${suffixMatch[1]}]` : label;
67
69
  }
70
+ // "deepseek-flash" → "DeepSeek Flash", "deepseek-v4-pro" → "DeepSeek V4 Pro".
71
+ if (/^deepseek-/i.test(text)) {
72
+ return `DeepSeek ${text
73
+ .replace(/^deepseek-/i, '')
74
+ .split('-')
75
+ .filter(Boolean)
76
+ .map((part) => (/^v\d/i.test(part) ? part.toUpperCase() : part.charAt(0).toUpperCase() + part.slice(1)))
77
+ .join(' ')}`;
78
+ }
68
79
  if (/^gemini-/i.test(text)) {
69
80
  return text
70
81
  .replace(/^gemini-/i, 'Gemini ')
@@ -14,7 +14,9 @@ const KNOWN_TEXT_ONLY_PATTERNS = [
14
14
  /^code-davinci/,
15
15
  /^text-davinci/,
16
16
  /-instruct-text$/,
17
+ // DeepSeek's text-only models (deepseek-flash takes images).
17
18
  /^deepseek-coder/,
19
+ /^deepseek-v4-pro/,
18
20
  /^qwen[\w.-]*-coder/,
19
21
  ];
20
22
 
@@ -10,7 +10,7 @@
10
10
  import { isSetupRequired } from './provider-readiness.mjs';
11
11
 
12
12
  export const SETUP_HEADING = 'Set up a provider';
13
- export const SETUP_TEXT = 'Turn on Claude, Cursor, OpenAI or Grok in Settings → Providers, or sign in to the Copilot CLI on the relay host.';
13
+ export const SETUP_TEXT = 'Turn on Claude, Cursor, OpenAI, Grok or DeepSeek in Settings → Providers, or sign in to the Copilot CLI on the relay host.';
14
14
 
15
15
  const ACTIONS = Object.freeze({
16
16
  openSettings: (primary) => ({ id: 'empty-state-open-settings', action: 'open-settings', label: primary ? 'Open Settings' : 'Settings', primary, link: !primary }),