@oar-sh/oar 0.9.5 → 0.9.7

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 +12 -32
  2. package/.github/extensions/web-relay/skills/question-bridge.mjs +14 -39
  3. package/CHANGELOG.md +340 -0
  4. package/README.md +196 -26
  5. package/package.json +2 -1
  6. package/server/README.md +174 -7
  7. package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +340 -0
  8. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +1325 -0
  9. package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +155 -0
  10. package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
  11. package/server/claude-worker/claude-sdk-adapter.mjs +18 -2
  12. package/server/claude-worker/claude-session-process.mjs +151 -1
  13. package/server/claude-worker/claude-session-worker.mjs +6 -0
  14. package/server/claude-worker/claude-turn-failure.mjs +92 -0
  15. package/server/claude-worker/claude-turn-publisher.mjs +52 -3
  16. package/server/claude-worker/sdk-message-normalizer.mjs +13 -1
  17. package/server/copilot-worker/copilot-model-switch.mjs +29 -3
  18. package/server/copilot-worker/copilot-question-bridge.mjs +2 -4
  19. package/server/copilot-worker/copilot-runtime-tree.mjs +91 -0
  20. package/server/copilot-worker/copilot-sdk-adapter.mjs +48 -8
  21. package/server/copilot-worker/copilot-sdk-session-process.mjs +897 -166
  22. package/server/cursor-worker/cursor-sdk-adapter.mjs +80 -13
  23. package/server/cursor-worker/cursor-turn-runner.mjs +5 -2
  24. package/server/db-schema.mjs +24 -0
  25. package/server/grok-worker/acp-client.mjs +68 -12
  26. package/server/grok-worker/grok-sdk-adapter.mjs +4 -0
  27. package/server/grok-worker/grok-turn-runner.mjs +6 -2
  28. package/server/migrations/0006-workspace-root-settings.mjs +67 -0
  29. package/server/migrations/0007-claude-cloud.mjs +82 -0
  30. package/server/public/app/agent-sessions-settings-ui.js +197 -0
  31. package/server/public/app/agent-sessions-settings.mjs +134 -0
  32. package/server/public/app/api-client.js +160 -6
  33. package/server/public/app/attachments-view.js +20 -1
  34. package/server/public/app/attribution-control.mjs +46 -0
  35. package/server/public/app/bootstrap.js +347 -31
  36. package/server/public/app/claude-cloud-conversation-ui.js +110 -0
  37. package/server/public/app/claude-cloud-settings-ui.js +374 -0
  38. package/server/public/app/claude-cloud-ui.mjs +611 -0
  39. package/server/public/app/composer-control-state.mjs +15 -3
  40. package/server/public/app/context-menu.mjs +140 -0
  41. package/server/public/app/context-tier-options.mjs +4 -0
  42. package/server/public/app/conversation-origin-ui.js +27 -0
  43. package/server/public/app/conversation-provider-indicator.mjs +22 -4
  44. package/server/public/app/conversation-view.js +178 -8
  45. package/server/public/app/cwd-picker.js +12 -0
  46. package/server/public/app/journal-view.js +704 -28
  47. package/server/public/app/markdown-strikethrough.mjs +31 -0
  48. package/server/public/app/math-dollars.mjs +89 -0
  49. package/server/public/app/model-selector-options.mjs +1 -1
  50. package/server/public/app/plan-usage-view.mjs +36 -8
  51. package/server/public/app/relay-board-inline.mjs +70 -0
  52. package/server/public/app/relay-board-view.js +157 -61
  53. package/server/public/app/relay-error-ctas.mjs +28 -5
  54. package/server/public/app/remote-relay-origin-view.mjs +120 -4
  55. package/server/public/app/router.js +49 -12
  56. package/server/public/app/settings-modal.js +33 -2
  57. package/server/public/app/settings-tabs.js +1 -1
  58. package/server/public/app/socket-handlers.js +60 -0
  59. package/server/public/app/store.js +4 -1
  60. package/server/public/app/subagent-fold.mjs +61 -0
  61. package/server/public/app/usage-limit-ui.js +225 -0
  62. package/server/public/index.html +471 -8
  63. package/server/relay-tools.md +13 -1
  64. package/server/repositories/message-repository.mjs +10 -1
  65. package/server/repositories/question-repository.mjs +1 -0
  66. package/server/repositories/remote-relay-repository.mjs +72 -1
  67. package/server/repositories/session-repository.mjs +47 -1
  68. package/server/routes/claude-cloud-routes.mjs +140 -0
  69. package/server/routes/git-routes.mjs +37 -0
  70. package/server/routes/messages-routes.mjs +202 -26
  71. package/server/routes/relay-board-routes.mjs +56 -2
  72. package/server/routes/remote-relay-routes.mjs +15 -1
  73. package/server/routes/sessions-routes.mjs +538 -72
  74. package/server/server-runtime.mjs +291 -27
  75. package/server/services/claude-account-usage-service.mjs +176 -0
  76. package/server/services/claude-cloud-repo-service.mjs +408 -0
  77. package/server/services/claude-cloud-session-service.mjs +326 -0
  78. package/server/services/claude-cloud-settings-service.mjs +269 -0
  79. package/server/services/git-remote-service.mjs +115 -0
  80. package/server/services/plan-usage-claude-cloud.mjs +211 -0
  81. package/server/services/plan-usage-claude.mjs +202 -40
  82. package/server/services/plan-usage-contract.mjs +39 -3
  83. package/server/services/plan-usage-service.mjs +82 -2
  84. package/server/services/preview-proxy-server.mjs +64 -36
  85. package/server/services/process-list-service.mjs +122 -0
  86. package/server/services/provider-reasoning-effort.mjs +3 -0
  87. package/server/services/relay-console-log.mjs +199 -0
  88. package/server/services/remote-relay-caller-context.mjs +78 -19
  89. package/server/services/remote-relay-client.mjs +9 -0
  90. package/server/services/remote-relay-dispatcher.mjs +451 -39
  91. package/server/services/remote-relay-inbound.mjs +83 -1
  92. package/server/services/remote-relay-loopback.mjs +245 -0
  93. package/server/services/remote-relay-registry-service.mjs +52 -0
  94. package/server/services/session-worker-launch-service.mjs +100 -15
  95. package/server/services/session-worker-process-service.mjs +410 -138
  96. package/server/services/session-worker-registry-service.mjs +1 -1
  97. package/server/services/session-worker-stop-service.mjs +24 -13
  98. package/server/services/session-worker-supervisor-service.mjs +89 -2
  99. package/server/services/usage-limit-pause-service.mjs +443 -0
  100. package/server/services/worker-launch-note-service.mjs +151 -0
  101. package/shared/ask-user-bridge.mjs +14 -39
  102. package/shared/claude-attribution.mjs +117 -0
  103. package/shared/claude-cloud/api-client.mjs +809 -0
  104. package/shared/claude-cloud/base-url.mjs +47 -0
  105. package/shared/claude-cloud/credentials.mjs +221 -0
  106. package/shared/claude-cloud/repo-url.mjs +111 -0
  107. package/shared/claude-usage-limit.mjs +257 -0
  108. package/shared/control-poller.mjs +69 -20
  109. package/shared/failure-note-text.mjs +38 -0
  110. package/shared/provider-routing.mjs +4 -2
  111. package/shared/question-wait.mjs +108 -0
  112. package/shared/remote-relay-contract.mjs +167 -22
  113. package/shared/remote-relay-tool-core.mjs +11 -8
  114. package/shared/steer-settle-failure.mjs +17 -0
  115. package/shared/steer-settle-markers.mjs +8 -0
  116. package/shared/worker-runtime/process-tree.mjs +483 -0
  117. package/shared/worker-runtime/relay-errors.mjs +31 -0
  118. package/shared/worker-runtime/send-and-wait-errors.mjs +4 -4
  119. package/shared/worker-runtime/turn-liveness.mjs +249 -0
@@ -0,0 +1,443 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Pause and resume at the Claude subscription's usage limit.
5
+ *
6
+ * A Claude turn the CLI refused at the limit is not failed. Its row ends with
7
+ * a note, and the relay queues a follow-up message of its own that is held
8
+ * until the window resets: a pending queue row with `next_attempt_at` at the
9
+ * reset and `usage_limit_pause` set. The ordinary delivery sends it once it
10
+ * matures, so the pause survives a relay restart with no timer to restore.
11
+ *
12
+ * A reset too far away to wait for unattended (a weekly window) holds the
13
+ * follow-up until the user resumes it.
14
+ *
15
+ * The service also keeps the latest report of the limit the workers sent
16
+ * (memory only), for the heads-up before the limit is reached.
17
+ */
18
+
19
+ import { randomUUID } from 'node:crypto';
20
+
21
+ import {
22
+ USAGE_LIMIT_CODE,
23
+ USAGE_LIMIT_KIND,
24
+ USAGE_LIMIT_RESUME_MARGIN_MS,
25
+ normalizeRateLimitInfo,
26
+ usageLimitResumesByItself,
27
+ usageLimitWindowLabel,
28
+ } from '../../shared/claude-usage-limit.mjs';
29
+
30
+ export const USAGE_LIMIT_SOCKET_EVENT = 'usage_limit_pause';
31
+ export const USAGE_LIMIT_ACCOUNT_SOCKET_EVENT = 'claude_usage_limit';
32
+
33
+ // What the held follow-up says. It is a message of the relay's, shown in the
34
+ // transcript like one of the user's. It quotes what was refused: the refused
35
+ // prompt is in the CLI's transcript, but a bare "continue" did not make the
36
+ // model take it up again (captured 2026-09-28, Haiku 4.5).
37
+ export const USAGE_LIMIT_RESUME_LEAD = 'Automatic message from OAR: the work was paused at the Claude usage limit.';
38
+ const RESUME_CLOSE = 'Continue where you left off, and pick up again what the limit cut off, subagents and background tasks included.';
39
+ const RESUME_CONTINUATION = 'A turn you had opened yourself after a background task finished was refused at the limit: report what that task brought.';
40
+ const QUOTE_MAX_CHARS = 4000;
41
+ const QUOTE_MAX_PROMPTS = 5;
42
+
43
+ function quotePrompt(text) {
44
+ const value = String(text || '').trim();
45
+ const cut = value.length > QUOTE_MAX_CHARS ? `${value.slice(0, QUOTE_MAX_CHARS - 1)}…` : value;
46
+ return cut.split(/\r?\n/).map((line) => `> ${line}`.trimEnd()).join('\n');
47
+ }
48
+
49
+ /**
50
+ * The follow-up's text: what was refused, then what to do about it.
51
+ * `prompts` are the user's refused messages in the order they were sent;
52
+ * `continuation` says a turn of the CLI's own was refused, which has no
53
+ * prompt to quote.
54
+ */
55
+ export function buildUsageLimitResumePrompt({ prompts = [], continuation = false } = {}) {
56
+ const quoted = (Array.isArray(prompts) ? prompts : [])
57
+ .map((text) => String(text || '').trim())
58
+ .filter(Boolean)
59
+ .slice(-QUOTE_MAX_PROMPTS);
60
+ const parts = [USAGE_LIMIT_RESUME_LEAD];
61
+ if (quoted.length === 1) {
62
+ parts.push(`This request was refused at the limit:\n\n${quotePrompt(quoted[0])}`);
63
+ } else if (quoted.length > 1) {
64
+ parts.push(`These requests were refused at the limit, in this order:\n\n${quoted.map(quotePrompt).join('\n\n')}`);
65
+ }
66
+ if (continuation) parts.push(RESUME_CONTINUATION);
67
+ parts.push(RESUME_CLOSE);
68
+ return parts.join(quoted.length || continuation ? '\n\n' : ' ');
69
+ }
70
+
71
+ // `next_attempt_at` of a follow-up that waits for the user.
72
+ const HELD_UNTIL_RESUMED = '9999-12-31T00:00:00.000Z';
73
+ // A reset further away than this is not a usage window's.
74
+ const MAX_RESET_AHEAD_MS = 8 * 24 * 60 * 60 * 1000;
75
+ const MIN_WAIT_MS = 60 * 1000;
76
+ // A refusal that names no reset still ahead (the CLI can name the one that
77
+ // has just passed) is tried again after this long, this many times in a row;
78
+ // after that the follow-up waits for the user.
79
+ const BLIND_RETRY_MS = 5 * 60 * 1000;
80
+ const MAX_BLIND_RETRIES = 6;
81
+
82
+ const TERMINAL_CODES = new Set([USAGE_LIMIT_CODE, `claude-${USAGE_LIMIT_CODE}`, `claude.${USAGE_LIMIT_CODE}`, `relay.claude-${USAGE_LIMIT_CODE}`]);
83
+
84
+ function parseJson(value) {
85
+ try {
86
+ const parsed = JSON.parse(String(value || ''));
87
+ return parsed && typeof parsed === 'object' ? parsed : null;
88
+ } catch {
89
+ return null;
90
+ }
91
+ }
92
+
93
+ function defaultFormatResetTime(iso, { now = Date.now() } = {}) {
94
+ const at = new Date(iso);
95
+ const sameDay = new Date(now).toDateString() === at.toDateString();
96
+ const clock = new Intl.DateTimeFormat('en-GB', { hour: '2-digit', minute: '2-digit', timeZoneName: 'short' }).format(at);
97
+ if (sameDay) return clock;
98
+ const day = new Intl.DateTimeFormat('en-GB', { weekday: 'short', day: 'numeric', month: 'short' }).format(at);
99
+ return `${day}, ${clock}`;
100
+ }
101
+
102
+ /** Whether a response's terminal error is the worker's usage-limit refusal. */
103
+ export function isUsageLimitTerminalError(terminalError) {
104
+ if (!terminalError || typeof terminalError !== 'object') return false;
105
+ if (String(terminalError.kind || '') === USAGE_LIMIT_KIND) return true;
106
+ return TERMINAL_CODES.has(String(terminalError.stableCode || '').trim().toLowerCase())
107
+ || TERMINAL_CODES.has(String(terminalError.code || '').trim().toLowerCase());
108
+ }
109
+
110
+ export function createUsageLimitPauseService({
111
+ db,
112
+ stmts,
113
+ emit = () => {},
114
+ onQueueChanged = () => {},
115
+ now = () => Date.now(),
116
+ formatResetTime = defaultFormatResetTime,
117
+ logger = console,
118
+ } = {}) {
119
+ if (!db) throw new Error('db is required');
120
+ if (!stmts) throw new Error('stmts is required');
121
+
122
+ const findPausedRowForConversation = db.prepare(`
123
+ SELECT * FROM queue
124
+ WHERE conversation_id = ? AND status = 'pending' AND usage_limit_pause IS NOT NULL
125
+ ORDER BY timestamp ASC, rowid ASC LIMIT 1
126
+ `);
127
+ const listPausedRows = db.prepare(`
128
+ SELECT * FROM queue
129
+ WHERE status = 'pending' AND usage_limit_pause IS NOT NULL
130
+ ORDER BY timestamp ASC, rowid ASC
131
+ `);
132
+ const setPause = db.prepare(`
133
+ UPDATE queue SET next_attempt_at = ?, usage_limit_pause = ?
134
+ WHERE id = ? AND status = 'pending'
135
+ `);
136
+ const moveFollowUp = db.prepare(`
137
+ UPDATE queue SET timestamp = ? WHERE id = ? AND status = 'pending' AND usage_limit_pause IS NOT NULL
138
+ `);
139
+ const moveFollowUpMessage = db.prepare(`UPDATE messages SET timestamp = ? WHERE id = ? AND role = 'user'`);
140
+ const setFollowUpText = db.prepare(`
141
+ UPDATE queue SET text = ? WHERE id = ? AND status = 'pending' AND usage_limit_pause IS NOT NULL
142
+ `);
143
+ const setFollowUpMessageText = db.prepare(`UPDATE messages SET text = ? WHERE id = ? AND role = 'user'`);
144
+ const deletePausedRow = db.prepare(`
145
+ DELETE FROM queue WHERE id = ? AND status = 'pending' AND usage_limit_pause IS NOT NULL
146
+ `);
147
+ // Only ever the relay's own message: the kind is set by schedulePause alone.
148
+ const deleteFollowUpMessage = db.prepare(`DELETE FROM messages WHERE id = ? AND role = 'user' AND kind = ?`);
149
+
150
+ let account = null;
151
+
152
+ function stateFromRow(row) {
153
+ const pause = parseJson(row?.usage_limit_pause);
154
+ if (!row || !pause) return null;
155
+ // Sent already, or due: the ordinary delivery has it from here.
156
+ const heldUntil = Date.parse(String(row.next_attempt_at || ''));
157
+ if (!Number.isFinite(heldUntil) || heldUntil <= now()) return null;
158
+ return {
159
+ conversationId: String(row.conversation_id),
160
+ messageId: String(row.id),
161
+ rateLimitType: pause.rateLimitType || null,
162
+ label: usageLimitWindowLabel(pause.rateLimitType),
163
+ resetsAt: pause.resetsAt || null,
164
+ resumeAt: pause.auto ? String(row.next_attempt_at) : null,
165
+ auto: pause.auto === true,
166
+ pausedAt: pause.pausedAt || null,
167
+ };
168
+ }
169
+
170
+ function emitPause(conversationId, state) {
171
+ try { emit(USAGE_LIMIT_SOCKET_EVENT, { conversationId, pause: state || null }); } catch {}
172
+ }
173
+
174
+ /**
175
+ * Decide whether a refused turn is paused. Returns the plan, or null when
176
+ * the response is to be handled as the terminal failure it says it is.
177
+ */
178
+ function planPause({ terminalError, conversationId, providerType, queueRow = null, partialText = '' } = {}) {
179
+ if (!isUsageLimitTerminalError(terminalError)) return null;
180
+ // Claude sessions, local and in the cloud: both run Claude Code on the
181
+ // same account and report the same limit.
182
+ if (!['claude', 'claude-cloud'].includes(String(providerType || '').trim().toLowerCase())) return null;
183
+ if (!String(conversationId || '').trim()) return null;
184
+ const at = now();
185
+ const rateLimitType = String(terminalError.rateLimitType || '').trim() || null;
186
+ const label = usageLimitWindowLabel(rateLimitType);
187
+ // What the turn had written before the refusal stays above the note.
188
+ const written = String(partialText || '').trim();
189
+ const withWritten = (note) => (written ? `${written}\n\n${note}` : note);
190
+ const resetsAtMs = Date.parse(String(terminalError.resetsAt || ''));
191
+ if (!Number.isFinite(resetsAtMs) || resetsAtMs <= at) {
192
+ // Counted along the follow-ups: the refused row is one itself when the
193
+ // turn before it was paused the same way.
194
+ const retries = Number(parseJson(queueRow?.usage_limit_pause)?.retries || 0) + 1;
195
+ const auto = retries <= MAX_BLIND_RETRIES;
196
+ const resumeAt = new Date(at + BLIND_RETRY_MS).toISOString();
197
+ return {
198
+ conversationId: String(conversationId),
199
+ rateLimitType,
200
+ resetsAt: null,
201
+ resumeAt: auto ? resumeAt : null,
202
+ auto,
203
+ retries,
204
+ noteText: withWritten(auto
205
+ ? `⏸ Paused: the Claude ${label} is reached. The turn carries on by itself at ${formatResetTime(resumeAt, { now: at })}.`
206
+ : `⏸ Paused: the Claude ${label} is still reached. Resume the turn once it has reset.`),
207
+ };
208
+ }
209
+ if (resetsAtMs - at > MAX_RESET_AHEAD_MS) return null;
210
+ const resetsAt = new Date(resetsAtMs).toISOString();
211
+ const auto = usageLimitResumesByItself(resetsAt, { now: at });
212
+ const resumeAtMs = Math.max(resetsAtMs + USAGE_LIMIT_RESUME_MARGIN_MS, at + MIN_WAIT_MS);
213
+ const when = formatResetTime(auto ? new Date(resumeAtMs).toISOString() : resetsAt, { now: at });
214
+ return {
215
+ conversationId: String(conversationId),
216
+ rateLimitType,
217
+ resetsAt,
218
+ resumeAt: auto ? new Date(resumeAtMs).toISOString() : null,
219
+ auto,
220
+ retries: 0,
221
+ noteText: withWritten(auto
222
+ ? `⏸ Paused: the Claude ${label} is reached. The turn carries on by itself at ${when}.`
223
+ : `⏸ Paused: the Claude ${label} is reached and resets ${when}. Resume the turn once it has.`),
224
+ };
225
+ }
226
+
227
+ /**
228
+ * Queue the held follow-up for a paused turn, once the refused row's note
229
+ * is saved. A conversation holds one: a second refusal before the reset
230
+ * moves the one that is there.
231
+ */
232
+ function schedulePause(plan, { queueRow = null } = {}) {
233
+ if (!plan?.conversationId) return null;
234
+ const at = new Date(now()).toISOString();
235
+ const heldUntil = plan.auto ? plan.resumeAt : HELD_UNTIL_RESUMED;
236
+ // What this refusal cut off. A refused follow-up stands for what it
237
+ // quoted; a turn of the CLI's own has no prompt.
238
+ const refusedFollowUp = parseJson(queueRow?.usage_limit_pause);
239
+ const cutOff = refusedFollowUp
240
+ ? { prompts: Array.isArray(refusedFollowUp.prompts) ? refusedFollowUp.prompts : [], continuation: refusedFollowUp.continuation === true }
241
+ : (String(queueRow?.kind || '') === 'continuation'
242
+ ? { prompts: [], continuation: true }
243
+ : { prompts: [String(queueRow?.text || '').trim()].filter(Boolean), continuation: false });
244
+ const pauseFor = (held) => {
245
+ const before = parseJson(held?.usage_limit_pause) || {};
246
+ const prompts = [...(Array.isArray(before.prompts) ? before.prompts : []), ...cutOff.prompts]
247
+ .map((text) => String(text || '').slice(0, QUOTE_MAX_CHARS))
248
+ .slice(-QUOTE_MAX_PROMPTS);
249
+ return {
250
+ rateLimitType: plan.rateLimitType,
251
+ resetsAt: plan.resetsAt,
252
+ auto: plan.auto,
253
+ retries: Number(plan.retries || 0),
254
+ pausedAt: at,
255
+ refusedMessageId: queueRow?.id ? String(queueRow.id) : null,
256
+ prompts,
257
+ continuation: before.continuation === true || cutOff.continuation,
258
+ };
259
+ };
260
+ let created = null;
261
+ const write = db.transaction(() => {
262
+ const existing = findPausedRowForConversation.get(plan.conversationId);
263
+ if (existing) {
264
+ const pause = pauseFor(existing);
265
+ const text = buildUsageLimitResumePrompt(pause);
266
+ setPause.run(heldUntil, JSON.stringify(pause), existing.id);
267
+ setFollowUpText.run(text, existing.id);
268
+ setFollowUpMessageText.run(text, existing.id);
269
+ // The follow-up comes after what was refused last, in the queue and
270
+ // in the transcript. Left at the time of the first refusal it was
271
+ // sent, and answered, above the messages the user wrote during the
272
+ // pause, where nobody looks for the answer.
273
+ moveFollowUp.run(at, existing.id);
274
+ moveFollowUpMessage.run(at, existing.id);
275
+ return String(existing.id);
276
+ }
277
+ const messageId = randomUUID();
278
+ const pause = pauseFor(null);
279
+ const pauseJson = JSON.stringify(pause);
280
+ const resumePrompt = buildUsageLimitResumePrompt(pause);
281
+ const runtimeSession = stmts.getRuntimeSessionByConversation?.get?.(plan.conversationId) || null;
282
+ const latestModel = stmts.getLatestConversationModel?.get?.(plan.conversationId)?.model || null;
283
+ const model = String(queueRow?.model || '').trim() || latestModel || null;
284
+ const modelVariant = String(queueRow?.model_variant_id || '').trim() || latestModel || model;
285
+ const relayMode = String(queueRow?.relay_mode || '').trim() || 'agent';
286
+ const owner = String(queueRow?.owner_sdk_session_id || '').trim() || null;
287
+ stmts.insertMsg.run(
288
+ messageId,
289
+ plan.conversationId,
290
+ 'user',
291
+ resumePrompt,
292
+ modelVariant,
293
+ relayMode,
294
+ null,
295
+ at,
296
+ modelVariant || null,
297
+ null,
298
+ String(modelVariant || '').trim().toLowerCase() === 'auto' ? 'auto' : 'manual',
299
+ );
300
+ stmts.setMessageKind?.run?.(USAGE_LIMIT_KIND, messageId);
301
+ stmts.updateConvTime?.run?.(at, plan.conversationId);
302
+ const queueArgs = [
303
+ messageId,
304
+ plan.conversationId,
305
+ queueRow?.runtime_session_id || runtimeSession?.id || null,
306
+ 0,
307
+ model,
308
+ modelVariant,
309
+ String(queueRow?.reasoning_effort || '').trim() || null,
310
+ String(queueRow?.context_tier || '').trim() || null,
311
+ relayMode,
312
+ resumePrompt,
313
+ null,
314
+ at,
315
+ owner,
316
+ owner ? at : null,
317
+ null,
318
+ null,
319
+ ];
320
+ // The insert takes an image operation id where the queue has the column.
321
+ if (stmts.insertQ.source?.includes('image_operation_id')) queueArgs.push(null);
322
+ stmts.insertQ.run(...queueArgs);
323
+ setPause.run(heldUntil, pauseJson, messageId);
324
+ created = { messageId, model: modelVariant, relayMode, timestamp: at, text: resumePrompt };
325
+ return messageId;
326
+ });
327
+ const messageId = write();
328
+ if (created) {
329
+ try {
330
+ emit('user_message', {
331
+ conversationId: plan.conversationId,
332
+ messageId: created.messageId,
333
+ senderClientId: null,
334
+ message: {
335
+ role: 'user',
336
+ text: created.text,
337
+ model: created.model,
338
+ modelOrigin: String(created.model || '').trim().toLowerCase() === 'auto' ? 'auto' : 'manual',
339
+ mode: created.relayMode,
340
+ attachments: [],
341
+ timestamp: created.timestamp,
342
+ kind: USAGE_LIMIT_KIND,
343
+ },
344
+ });
345
+ } catch {}
346
+ }
347
+ const state = getPause(plan.conversationId);
348
+ try { logger.log?.(`[usage-limit] conv=${plan.conversationId.slice(0, 8)} paused at the ${usageLimitWindowLabel(plan.rateLimitType)}; ${plan.auto ? `resumes ${plan.resumeAt}` : `resets ${plan.resetsAt}, waits for the user`}`); } catch {}
349
+ emitPause(plan.conversationId, state);
350
+ return { messageId, created: Boolean(created), state };
351
+ }
352
+
353
+ function getPause(conversationId) {
354
+ const id = String(conversationId || '').trim();
355
+ if (!id) return null;
356
+ return stateFromRow(findPausedRowForConversation.get(id));
357
+ }
358
+
359
+ function listPauses() {
360
+ return listPausedRows.all().map(stateFromRow).filter(Boolean);
361
+ }
362
+
363
+ /** Send the held follow-up now, whatever the reset. */
364
+ function resumeNow({ conversationId } = {}) {
365
+ const id = String(conversationId || '').trim();
366
+ const row = id ? findPausedRowForConversation.get(id) : null;
367
+ if (!row || !stateFromRow(row)) return { resumed: false, state: null };
368
+ const pause = parseJson(row.usage_limit_pause) || {};
369
+ const at = new Date(now()).toISOString();
370
+ setPause.run(null, JSON.stringify({ ...pause, resumedAt: at, resumedBy: 'user' }), row.id);
371
+ try { logger.log?.(`[usage-limit] conv=${id.slice(0, 8)} resumed by the user`); } catch {}
372
+ emitPause(id, null);
373
+ try { emit('message_status', { messageId: String(row.id), conversationId: id, status: 'pending' }); } catch {}
374
+ try { onQueueChanged('usage-limit-resume'); } catch {}
375
+ return { resumed: true, messageId: String(row.id), state: null };
376
+ }
377
+
378
+ /** Drop the held follow-up: the paused turn is not carried on. */
379
+ function cancel({ conversationId } = {}) {
380
+ const id = String(conversationId || '').trim();
381
+ const row = id ? findPausedRowForConversation.get(id) : null;
382
+ if (!row) return { cancelled: false, state: null };
383
+ // The message goes with the row. It is the relay's own and was never
384
+ // sent: left in the transcript it would read as a request of the user's
385
+ // that nobody answered.
386
+ const drop = db.transaction(() => {
387
+ const result = deletePausedRow.run(row.id);
388
+ if (result.changes) deleteFollowUpMessage.run(row.id, USAGE_LIMIT_KIND);
389
+ return result.changes;
390
+ });
391
+ if (!drop()) return { cancelled: false, state: getPause(id) };
392
+ try { logger.log?.(`[usage-limit] conv=${id.slice(0, 8)} pause cancelled by the user`); } catch {}
393
+ emitPause(id, null);
394
+ try { emit('message_status', { messageId: String(row.id), conversationId: id, status: 'cancelled' }); } catch {}
395
+ return { cancelled: true, messageId: String(row.id), state: null };
396
+ }
397
+
398
+ function accountState() {
399
+ if (!account) return null;
400
+ const resetsAtMs = Date.parse(account.resetsAt || '');
401
+ // A report is about one window; past its reset it says nothing.
402
+ if (Number.isFinite(resetsAtMs) && resetsAtMs <= now()) {
403
+ account = null;
404
+ return null;
405
+ }
406
+ return { ...account };
407
+ }
408
+
409
+ /** The latest report a worker sent; returns what the clients are told. */
410
+ function recordReport({ report } = {}) {
411
+ const raw = report && typeof report === 'object' ? report : null;
412
+ // Workers send the normalized shape; a raw `rate_limit_info` is taken too.
413
+ const status = String(raw?.status || '').trim();
414
+ const info = raw && (raw.observedAt || Array.isArray(raw.windows))
415
+ ? raw
416
+ : normalizeRateLimitInfo(raw, { now: now() });
417
+ if (!info || !['allowed', 'allowed_warning', 'rejected'].includes(status)) return accountState();
418
+ const utilization = Number(info.utilization);
419
+ account = {
420
+ status,
421
+ rateLimitType: String(info.rateLimitType || '').trim() || null,
422
+ label: usageLimitWindowLabel(info.rateLimitType),
423
+ utilization: Number.isFinite(utilization) ? Math.max(0, Math.min(1, utilization)) : null,
424
+ resetsAt: Number.isFinite(Date.parse(info.resetsAt || '')) ? new Date(Date.parse(info.resetsAt)).toISOString() : null,
425
+ isUsingOverage: info.isUsingOverage === true,
426
+ observedAt: new Date(now()).toISOString(),
427
+ };
428
+ const state = accountState();
429
+ try { emit(USAGE_LIMIT_ACCOUNT_SOCKET_EVENT, state); } catch {}
430
+ return state;
431
+ }
432
+
433
+ return {
434
+ planPause,
435
+ schedulePause,
436
+ getPause,
437
+ listPauses,
438
+ resumeNow,
439
+ cancel,
440
+ recordReport,
441
+ getAccountState: accountState,
442
+ };
443
+ }
@@ -0,0 +1,151 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The note in the conversation when a session's worker cannot be started.
5
+ *
6
+ * Until 2026-10-01 a session whose launches were exhausted showed a yellow
7
+ * dot and nothing else: the message stayed queued, nothing said why, and
8
+ * nothing tried again. The supervisor now retries for a while and tells this
9
+ * service about the episode; the service keeps ONE relay-authored note per
10
+ * episode in the conversation and updates it in place — exhausted, each
11
+ * failed retry, stopped (with a Retry button in the client), started.
12
+ *
13
+ * The note is an assistant message of kind `worker-launch-failed`,
14
+ * `worker-launch-stopped` or `worker-launch-started`; the queued message is
15
+ * left queued throughout, so a launch that works delivers it.
16
+ */
17
+
18
+ import { randomUUID } from 'node:crypto';
19
+
20
+ export const WORKER_LAUNCH_NOTE_KINDS = Object.freeze({
21
+ failed: 'worker-launch-failed',
22
+ stopped: 'worker-launch-stopped',
23
+ started: 'worker-launch-started',
24
+ });
25
+
26
+ const CAUSE_MAX_CHARS = 160;
27
+
28
+ function clipCause(text) {
29
+ const cause = String(text || '').replace(/\s+/g, ' ').trim();
30
+ if (!cause) return 'unknown';
31
+ return cause.length > CAUSE_MAX_CHARS ? `${cause.slice(0, CAUSE_MAX_CHARS - 1)}…` : cause;
32
+ }
33
+
34
+ function defaultFormatTime(iso) {
35
+ const ms = Date.parse(String(iso || ''));
36
+ if (!Number.isFinite(ms)) return 'now';
37
+ return `${new Date(ms).toISOString().slice(11, 16)} UTC`;
38
+ }
39
+
40
+ function minutesOf(ms) {
41
+ const minutes = Math.max(1, Math.round(Number(ms || 0) / 60_000));
42
+ return `${minutes} minute${minutes === 1 ? '' : 's'}`;
43
+ }
44
+
45
+ /** The text of the note for an episode state; exported for the tests and the client copy. */
46
+ export function buildWorkerLaunchNoteText({ type, lifecycle = {}, retryEveryMs = 60_000, retryWindowMs = 10 * 60_000, formatTime = defaultFormatTime } = {}) {
47
+ const tries = Math.max(0, Number(lifecycle?.retryCount || 0));
48
+ const triesText = `${tries} ${tries === 1 ? 'try' : 'tries'}`;
49
+ const cause = clipCause(lifecycle?.lastError);
50
+ if (type === 'launched') {
51
+ return `✅ The worker for this conversation started at ${formatTime(lifecycle?.launchAt || new Date().toISOString())}. The queued message is being delivered.`;
52
+ }
53
+ if (type === 'stopped') {
54
+ return `⛔ The worker for this conversation could not be started; the relay stopped trying after ${minutesOf(retryWindowMs)} (${triesText}). Cause: ${cause}. Your message stays queued — press Retry once the cause is fixed.`;
55
+ }
56
+ const until = lifecycle?.exhaustedSince && retryWindowMs > 0
57
+ ? ` until ${formatTime(new Date(Date.parse(lifecycle.exhaustedSince) + retryWindowMs).toISOString())}`
58
+ : '';
59
+ const last = type === 'retry-failed' && lifecycle?.lastFailureAt ? `, last at ${formatTime(lifecycle.lastFailureAt)}` : '';
60
+ return `⚠️ The worker for this conversation could not be started (${triesText}${last}). Cause: ${cause}. Your message stays queued; the relay tries again every ${minutesOf(retryEveryMs)}${until}.`;
61
+ }
62
+
63
+ export function createWorkerLaunchNoteService({
64
+ db,
65
+ stmts,
66
+ emit,
67
+ resolveConversationId,
68
+ retryEveryMs = 60_000,
69
+ retryWindowMs = 10 * 60_000,
70
+ now = () => Date.now(),
71
+ formatTime = defaultFormatTime,
72
+ logger = console,
73
+ } = {}) {
74
+ if (!db || !stmts) throw new Error('createWorkerLaunchNoteService requires db and stmts');
75
+ const updateNote = db.prepare('UPDATE messages SET text = ?, kind = ? WHERE id = ?');
76
+ // One open episode per session: the note's id and where it is.
77
+ const episodes = new Map();
78
+
79
+ function kindFor(type) {
80
+ if (type === 'launched') return WORKER_LAUNCH_NOTE_KINDS.started;
81
+ if (type === 'stopped') return WORKER_LAUNCH_NOTE_KINDS.stopped;
82
+ return WORKER_LAUNCH_NOTE_KINDS.failed;
83
+ }
84
+
85
+ function conversationFor(sdkSessionId) {
86
+ try {
87
+ return String(resolveConversationId?.(sdkSessionId) || '').trim() || null;
88
+ } catch {
89
+ return null;
90
+ }
91
+ }
92
+
93
+ function messagePayload({ messageId, text, kind, timestamp }) {
94
+ return { id: messageId, role: 'assistant', text, kind, model: null, mode: null, attachments: [], timestamp };
95
+ }
96
+
97
+ function open({ sdkSessionId, conversationId, type, lifecycle }) {
98
+ const at = new Date(now()).toISOString();
99
+ const messageId = randomUUID();
100
+ const kind = kindFor(type);
101
+ const text = buildWorkerLaunchNoteText({ type, lifecycle, retryEveryMs, retryWindowMs, formatTime });
102
+ const write = db.transaction(() => {
103
+ stmts.insertMsg.run(messageId, conversationId, 'assistant', text, null, null, null, at, null, null, null);
104
+ stmts.setMessageKind?.run?.(kind, messageId);
105
+ stmts.updateConvTime?.run?.(at, conversationId);
106
+ });
107
+ write();
108
+ episodes.set(sdkSessionId, { messageId, conversationId, timestamp: at });
109
+ try { emit('assistant_message', { conversationId, messageId, message: messagePayload({ messageId, text, kind, timestamp: at }) }); } catch {}
110
+ try { logger.log?.(`[worker-launch] conv=${conversationId.slice(0, 8)} note opened (${type}): ${clipCause(lifecycle?.lastError)}`); } catch {}
111
+ }
112
+
113
+ function update(episode, sdkSessionId, { type, lifecycle }) {
114
+ const kind = kindFor(type);
115
+ const text = buildWorkerLaunchNoteText({ type, lifecycle, retryEveryMs, retryWindowMs, formatTime });
116
+ updateNote.run(text, kind, episode.messageId);
117
+ try {
118
+ emit('message_updated', {
119
+ conversationId: episode.conversationId,
120
+ messageId: episode.messageId,
121
+ message: messagePayload({ messageId: episode.messageId, text, kind, timestamp: episode.timestamp }),
122
+ });
123
+ } catch {}
124
+ if (type === 'launched') {
125
+ episodes.delete(sdkSessionId);
126
+ try { logger.log?.(`[worker-launch] conv=${episode.conversationId.slice(0, 8)} worker started, note closed`); } catch {}
127
+ }
128
+ }
129
+
130
+ /** The supervisor's onLaunchState listener. */
131
+ function handle({ type, sdkSessionId, lifecycle = null } = {}) {
132
+ const sid = String(sdkSessionId || '').trim();
133
+ if (!sid || !['exhausted', 'retry-failed', 'stopped', 'launched'].includes(type)) return false;
134
+ const episode = episodes.get(sid) || null;
135
+ if (episode) {
136
+ update(episode, sid, { type, lifecycle });
137
+ return true;
138
+ }
139
+ // A launch that works without an open note is the ordinary case.
140
+ if (type === 'launched') return false;
141
+ const conversationId = conversationFor(sid);
142
+ if (!conversationId) return false;
143
+ open({ sdkSessionId: sid, conversationId, type, lifecycle });
144
+ return true;
145
+ }
146
+
147
+ return {
148
+ handle,
149
+ openEpisode: (sdkSessionId) => episodes.get(String(sdkSessionId || '').trim()) || null,
150
+ };
151
+ }
@@ -1,7 +1,5 @@
1
- import {
2
- DEFAULT_QUESTION_TIMEOUT_MS,
3
- QUESTION_TIMEOUT_CONTINUATION_TEXT,
4
- } from './question-timeout.mjs';
1
+ import { DEFAULT_QUESTION_TIMEOUT_MS } from './question-timeout.mjs';
2
+ import { createRelayQuestion, waitForRelayQuestion } from './question-wait.mjs';
5
3
 
6
4
  function sleepDefault(ms) {
7
5
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -56,38 +54,17 @@ export function createAskUserBridge({
56
54
  // `timeoutMs` overrides the bridge-wide default for one wait (a structured
57
55
  // elicitation may carry its own deadline). Additive: existing callers pass
58
56
  // only `signal` and behave exactly as before.
59
- async function waitForRelayQuestionAnswer(questionId, { signal, timeoutMs } = {}) {
60
- const started = Date.now();
61
- const deadlineMs = timeoutMs ?? questionTimeoutMs;
62
- while (true) {
63
- if (signal?.aborted) {
64
- await api('POST', `/api/relay-question/${questionId}/timeout`, {}).catch(() => {});
65
- return { answer: QUESTION_TIMEOUT_CONTINUATION_TEXT, timedOut: true, aborted: true };
66
- }
67
- const { question } = await api('GET', `/api/relay-question/${questionId}`);
68
- if (!question) throw new Error('Relay question missing');
69
- if (question.status === 'answered') {
70
- return {
71
- answer: String(question.answer || '').trim(),
72
- // The validated structured submission, when the card carried a
73
- // `requestedSchema` and the relay stored one. Null otherwise — flat
74
- // consumers read `answer` and never see a shape change.
75
- structuredAnswer: question.structuredAnswer && typeof question.structuredAnswer === 'object'
76
- && !Array.isArray(question.structuredAnswer)
77
- ? question.structuredAnswer
78
- : null,
79
- timedOut: false,
80
- };
81
- }
82
- if (question.status === 'timed_out' || question.status === 'cancelled') {
83
- return { answer: QUESTION_TIMEOUT_CONTINUATION_TEXT, timedOut: true };
84
- }
85
- if (Date.now() - started >= deadlineMs) {
86
- await api('POST', `/api/relay-question/${questionId}/timeout`, {}).catch(() => {});
87
- return { answer: QUESTION_TIMEOUT_CONTINUATION_TEXT, timedOut: true };
88
- }
89
- await sleep(questionPollMs);
90
- }
57
+ // The wait survives a relay that is briefly not there (question-wait.mjs).
58
+ function waitForRelayQuestionAnswer(questionId, { signal, timeoutMs } = {}) {
59
+ return waitForRelayQuestion({
60
+ api,
61
+ questionId,
62
+ deadlineMs: timeoutMs ?? questionTimeoutMs,
63
+ pollMs: questionPollMs,
64
+ sleep,
65
+ signal,
66
+ dbg,
67
+ });
91
68
  }
92
69
 
93
70
  async function askSingleQuestion(entry, { signal } = {}) {
@@ -124,9 +101,7 @@ export function createAskUserBridge({
124
101
  multiSelect: entry.multiSelect || undefined,
125
102
  },
126
103
  };
127
- const created = await api('POST', '/api/relay-question', questionPayload);
128
- const questionId = created?.question?.id;
129
- if (!questionId) throw new Error('Relay question could not be created');
104
+ const questionId = await createRelayQuestion({ api, payload: questionPayload, sleep, signal, dbg });
130
105
  dbg('relay question created', questionId, 'prompt=', entry.question.slice(0, 80));
131
106
  return waitForRelayQuestionAnswer(questionId, { signal });
132
107
  }