@oar-sh/oar 0.9.12 → 0.9.13

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 (62) hide show
  1. package/CHANGELOG.md +206 -0
  2. package/README.md +9 -9
  3. package/package.json +1 -1
  4. package/server/README.md +186 -11
  5. package/server/claude-worker/claude-session-process.mjs +53 -2
  6. package/server/claude-worker/task-output-discovery.mjs +28 -2
  7. package/server/copilot-worker/copilot-continuation-signals.mjs +11 -5
  8. package/server/copilot-worker/copilot-sdk-session-process.mjs +10 -0
  9. package/server/db-schema.mjs +10 -0
  10. package/server/grok-worker/acp-host-services.mjs +6 -3
  11. package/server/grok-worker/grok-background-tasks.mjs +7 -0
  12. package/server/migrations/0018-background-task-rows.mjs +101 -0
  13. package/server/public/app/action-confirmations.js +12 -15
  14. package/server/public/app/api-client.js +57 -3
  15. package/server/public/app/background-tasks-view.mjs +56 -7
  16. package/server/public/app/bootstrap.js +7 -0
  17. package/server/public/app/conversation-preferences.mjs +10 -4
  18. package/server/public/app/conversation-view.js +33 -9
  19. package/server/public/app/host-suspend-ui.js +45 -44
  20. package/server/public/app/journal-view.js +9 -3
  21. package/server/public/app/push-settings.js +1 -0
  22. package/server/public/app/relay-prompt-sanitizer.mjs +9 -2
  23. package/server/public/app/relay-restart-note-view.mjs +36 -0
  24. package/server/public/app/relay-restart-ui.js +568 -0
  25. package/server/public/app/settings-modal.js +52 -0
  26. package/server/public/app/socket-handlers.js +1 -1
  27. package/server/public/app/task-console-render.mjs +171 -7
  28. package/server/public/app/task-console.mjs +111 -11
  29. package/server/public/app/task-output-text.mjs +15 -6
  30. package/server/public/app/transcript-jump-bottom-view.mjs +5 -1
  31. package/server/public/app/transcript-jump-bottom.mjs +20 -0
  32. package/server/public/index.html +67 -0
  33. package/server/relay-tools.md +5 -0
  34. package/server/repositories/message-repository.mjs +3 -0
  35. package/server/repositories/remote-relay-repository.mjs +4 -2
  36. package/server/routes/messages-routes.mjs +155 -27
  37. package/server/routes/relay-board-routes.mjs +19 -2
  38. package/server/routes/relay-restart-routes.mjs +73 -0
  39. package/server/routes/sessions-routes.mjs +75 -3
  40. package/server/server-runtime.mjs +192 -137
  41. package/server/services/about-service.mjs +13 -12
  42. package/server/services/background-task-persistence.mjs +133 -0
  43. package/server/services/background-task-store.mjs +240 -13
  44. package/server/services/conversation-model-fallback.mjs +57 -0
  45. package/server/services/host-suspend-command.mjs +2 -1
  46. package/server/services/preview-proxy-server.mjs +14 -1
  47. package/server/services/process-list-service.mjs +2 -1
  48. package/server/services/provider-model-selection.mjs +195 -0
  49. package/server/services/push-dispatch-service.mjs +9 -2
  50. package/server/services/relay-event-service.mjs +27 -9
  51. package/server/services/relay-prompt-sanitizer.mjs +7 -0
  52. package/server/services/relay-restart-picture.mjs +209 -0
  53. package/server/services/relay-restart-queue.mjs +380 -0
  54. package/server/services/relay-shutdown-gate.mjs +4 -1
  55. package/server/services/remote-relay-dispatcher.mjs +187 -19
  56. package/server/services/restart-resume-service.mjs +303 -0
  57. package/server/services/session-worker-process-service.mjs +6 -4
  58. package/server/services/task-output-service.mjs +217 -18
  59. package/shared/relay-events.mjs +55 -2
  60. package/shared/remote-relay-contract.mjs +15 -8
  61. package/shared/task-output-text.mjs +196 -83
  62. package/shared/worker-runtime/worker-websocket-link.mjs +96 -20
@@ -89,3 +89,198 @@ export function resolveProviderModelSelection({
89
89
  availableModels: Array.from(index.values()),
90
90
  };
91
91
  }
92
+
93
+ // ─── Checking a model an agent names (remote_relay create_session / send) ───
94
+
95
+ const PROVIDER_LABELS = Object.freeze({
96
+ github: 'Copilot',
97
+ openai: 'OpenAI',
98
+ claude: 'Claude',
99
+ cursor: 'Cursor',
100
+ grok: 'Grok',
101
+ 'claude-cloud': 'Claude Cloud',
102
+ });
103
+ const CLAUDE_PROVIDERS = new Set(['claude', 'claude-cloud']);
104
+ const LONG_CONTEXT_SUFFIX = /\[1m\]$/i;
105
+ // A Copilot model with its effort, as the composer sends it ("gpt-5.4-high").
106
+ const EFFORT_VARIANT_SUFFIX = /-(none|minimal|low|medium|high|xhigh|max)$/i;
107
+ // A Claude alias ("opus", "sonnet[1m]"): one word, no version.
108
+ const CLAUDE_ALIAS = /^([a-z]+)(\[1m\])?$/i;
109
+ const SUGGESTIONS_MAX = 3;
110
+
111
+ /** How a relay calls a provider in its messages. */
112
+ export function providerModelLabel(provider) {
113
+ return PROVIDER_LABELS[String(provider || '').trim().toLowerCase()] || String(provider || '').trim() || 'this provider';
114
+ }
115
+
116
+ /** Lower case, no tier suffix, letters and digits only: "GPT-5.4" and "gpt-5-4" meet as "gpt54". */
117
+ function looseModelKey(value) {
118
+ return normalizeProviderModelId(value).toLowerCase().replace(LONG_CONTEXT_SUFFIX, '').replace(/[^a-z0-9]+/g, '');
119
+ }
120
+
121
+ function editDistance(a, b) {
122
+ if (a === b) return 0;
123
+ let previous = Array.from({ length: b.length + 1 }, (_, index) => index);
124
+ for (let i = 1; i <= a.length; i += 1) {
125
+ const current = [i];
126
+ for (let j = 1; j <= b.length; j += 1) {
127
+ current[j] = Math.min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
128
+ }
129
+ previous = current;
130
+ }
131
+ return previous[b.length];
132
+ }
133
+
134
+ /**
135
+ * The ids of `availableModels` closest to `requestedModel`, best first, at
136
+ * most `max`: the same id written with other separators or casing, then ids
137
+ * a few edits away, then ids that contain what was asked for.
138
+ */
139
+ export function suggestProviderModelIds(requestedModel, availableModels = [], { max = SUGGESTIONS_MAX } = {}) {
140
+ const requested = normalizeProviderModelId(requestedModel).toLowerCase().replace(LONG_CONTEXT_SUFFIX, '');
141
+ const loose = looseModelKey(requestedModel);
142
+ if (!requested || !loose) return [];
143
+ const scored = [];
144
+ for (const candidate of buildCanonicalModelIndex(availableModels).values()) {
145
+ const plain = candidate.toLowerCase().replace(LONG_CONTEXT_SUFFIX, '');
146
+ const candidateLoose = looseModelKey(candidate);
147
+ let score = null;
148
+ if (candidateLoose === loose) score = 0;
149
+ else {
150
+ // A few edits for a real id, none for a stub ("x" is no typo of "o3").
151
+ const allowed = requested.length >= 4 ? Math.max(1, Math.floor(requested.length / 4)) : 0;
152
+ const distance = allowed ? editDistance(requested, plain) : Infinity;
153
+ if (distance <= allowed) score = 1 + distance;
154
+ else if (loose.length >= 3 && candidateLoose.includes(loose)) score = 100 + candidateLoose.length - loose.length;
155
+ }
156
+ if (score !== null) scored.push({ candidate, score });
157
+ }
158
+ return scored
159
+ .sort((a, b) => a.score - b.score || a.candidate.localeCompare(b.candidate))
160
+ .slice(0, Math.max(0, max))
161
+ .map((entry) => entry.candidate);
162
+ }
163
+
164
+ const DATE_SUFFIX = /-\d{8}$/;
165
+
166
+ /** "claude-opus-4-1-20250805" → { version: [4, 1], dated: true }. */
167
+ function claudeVersionOf(model, prefix) {
168
+ const rest = model.toLowerCase().slice(prefix.length);
169
+ const dated = DATE_SUFFIX.test(rest);
170
+ const version = rest.replace(DATE_SUFFIX, '').split(/[^0-9]+/).filter(Boolean).map(Number);
171
+ return { version, dated };
172
+ }
173
+
174
+ function compareVersionsDescending(a, b) {
175
+ for (let index = 0; index < Math.max(a.length, b.length); index += 1) {
176
+ const difference = (b[index] ?? -1) - (a[index] ?? -1);
177
+ if (difference) return difference;
178
+ }
179
+ return 0;
180
+ }
181
+
182
+ /**
183
+ * The newest listed Claude model of an alias's family ("opus" →
184
+ * "claude-opus-5-5" over "claude-opus-5" and "claude-opus-4-1-20250805"):
185
+ * by version number, a date suffix ignored, the undated id before a dated
186
+ * one of the same version. "[1m]" ids are not the family's; with `longContext`
187
+ * the chosen model's "[1m]" id is taken when it is listed. Returns
188
+ * `{ model, tierDropped }`, or null for a family with no listed model.
189
+ */
190
+ function resolveClaudeAlias(word, index, longContext) {
191
+ const prefix = `claude-${word.toLowerCase()}-`;
192
+ const members = (tiered) => [...index.values()]
193
+ .filter((model) => LONG_CONTEXT_SUFFIX.test(model) === tiered && model.toLowerCase().startsWith(prefix))
194
+ .map((model) => ({ model, ...claudeVersionOf(model.replace(LONG_CONTEXT_SUFFIX, ''), prefix) }))
195
+ .filter((entry) => entry.version.length);
196
+ let family = members(false);
197
+ // A list that holds the family only as "[1m]" ids: those are its models.
198
+ if (!family.length) {
199
+ family = members(true);
200
+ if (!family.length) return null;
201
+ family.sort((a, b) => compareVersionsDescending(a.version, b.version) || Number(a.dated) - Number(b.dated) || a.model.localeCompare(b.model));
202
+ return { model: family[0].model, tierDropped: false };
203
+ }
204
+ family.sort((a, b) => compareVersionsDescending(a.version, b.version) || Number(a.dated) - Number(b.dated) || a.model.localeCompare(b.model));
205
+ const chosen = family[0].model;
206
+ if (!longContext) return { model: chosen, tierDropped: false };
207
+ const tiered = index.get(`${chosen}[1m]`.toLowerCase());
208
+ return tiered ? { model: tiered, tierDropped: false } : { model: chosen, tierDropped: true };
209
+ }
210
+
211
+ /**
212
+ * Whether a provider offers the model an agent asked for, by the list New
213
+ * Chat offers for it. `models` is that list, or null when it is not known
214
+ * (discovery has not run, the relay did not answer). Returns one of:
215
+ *
216
+ * - `{ status: 'known', model }`: offered; `model` is the provider's own
217
+ * casing, the same Claude model without its "[1m]" tier when only the
218
+ * base is listed (`tierDropped`), or "auto". A Copilot model with its
219
+ * effort ("gpt-5.4-high") also has `base` (the listed id) and `effort`:
220
+ * the bootstrap knows base ids only, so callers pass them apart. Taken so
221
+ * only when `effortsFor(base)` (the efforts the catalogue lists for it)
222
+ * holds that effort; else the id is unknown, with the base suggested.
223
+ * - `{ status: 'alias', model, tierDropped }`: a Claude alias ("opus",
224
+ * "sonnet[1m]"), taken as the newest listed model of its family.
225
+ * - `{ status: 'unchecked', model }`: no list to check against; the request
226
+ * goes on as asked.
227
+ * - `{ status: 'unknown', model, suggestions, models }`: not offered.
228
+ */
229
+ export function checkRequestedProviderModel({ requestedModel = '', provider = '', models = null, defaultModel = '', effortsFor = null } = {}) {
230
+ const requested = normalizeProviderModelId(requestedModel);
231
+ if (!requested || isAutoModelId(requested)) return { status: 'known', model: requested };
232
+ if (!Array.isArray(models)) return { status: 'unchecked', model: requested };
233
+ // "auto" is listed before discovery has run too: it makes no list.
234
+ const list = [...buildCanonicalModelIndex([defaultModel, ...models]).values()].filter((model) => !isAutoModelId(model));
235
+ if (!list.length) return { status: 'unchecked', model: requested };
236
+ const index = buildCanonicalModelIndex(list);
237
+ const exact = index.get(requested.toLowerCase());
238
+ if (exact) return { status: 'known', model: exact };
239
+ const claude = CLAUDE_PROVIDERS.has(String(provider || '').trim().toLowerCase());
240
+ if (claude && LONG_CONTEXT_SUFFIX.test(requested)) {
241
+ const base = index.get(requested.replace(LONG_CONTEXT_SUFFIX, '').toLowerCase());
242
+ if (base) return { status: 'known', model: base, tierDropped: true };
243
+ }
244
+ if (claude) {
245
+ const alias = CLAUDE_ALIAS.exec(requested);
246
+ const resolved = alias ? resolveClaudeAlias(alias[1], index, !!alias[2]) : null;
247
+ if (resolved) return { status: 'alias', ...resolved };
248
+ }
249
+ const variant = EFFORT_VARIANT_SUFFIX.exec(requested);
250
+ const base = variant ? requested.slice(0, variant.index) : '';
251
+ // Only Copilot takes a model with its effort in the id, and only an effort
252
+ // the listed model is known to take: "gpt-5.1-codex-max" is a model of its
253
+ // own, not gpt-5.1-codex at effort max.
254
+ const listedBase = base && String(provider || '').trim().toLowerCase() === 'github' ? index.get(base.toLowerCase()) : '';
255
+ const effort = variant ? variant[1].toLowerCase() : '';
256
+ if (listedBase) {
257
+ const efforts = typeof effortsFor === 'function' ? effortsFor(listedBase) : null;
258
+ if (Array.isArray(efforts) && efforts.map((value) => String(value || '').trim().toLowerCase()).includes(effort)) {
259
+ return { status: 'known', model: `${listedBase}-${effort}`, base: listedBase, effort };
260
+ }
261
+ }
262
+ const suggestions = suggestProviderModelIds(requested, list);
263
+ const baseFirst = listedBase ? [listedBase, ...suggestions.filter((model) => model !== listedBase)] : suggestions;
264
+ return {
265
+ status: 'unknown',
266
+ model: requested,
267
+ suggestions: (baseFirst.length || !base ? baseFirst : suggestProviderModelIds(base, list)).slice(0, SUGGESTIONS_MAX),
268
+ models: list,
269
+ };
270
+ }
271
+
272
+ const MODELS_LISTED_MAX = 20;
273
+
274
+ /** The refusal of an unknown model: "Unknown model 'gpt-5-4' for Copilot; did you mean 'gpt-5.4'? …". */
275
+ export function unknownProviderModelText({ model, provider, suggestions = [], models = [], where = '' } = {}) {
276
+ const label = providerModelLabel(provider);
277
+ const quoted = (value) => `'${value}'`;
278
+ const parts = [`Unknown model ${quoted(model)} for ${label}${where ? ` ${where}` : ''}`];
279
+ if (suggestions.length) parts.push(`; did you mean ${suggestions.map(quoted).join(' or ')}?`);
280
+ else parts.push('.');
281
+ const shown = models.slice(0, MODELS_LISTED_MAX);
282
+ if (shown.length) {
283
+ parts.push(` Valid ids: ${shown.join(', ')}${models.length > shown.length ? ` (and ${models.length - shown.length} more)` : ''}.`);
284
+ }
285
+ return parts.join('');
286
+ }
@@ -10,7 +10,7 @@
10
10
  * reports outcomes through the status-events store instead.
11
11
  */
12
12
 
13
- export const PUSH_EVENT_TYPES = Object.freeze(['question', 'turnComplete', 'turnFailed', 'board', 'cliOffline', 'hostSuspend']);
13
+ export const PUSH_EVENT_TYPES = Object.freeze(['question', 'turnComplete', 'turnFailed', 'board', 'cliOffline', 'hostSuspend', 'relayRestart']);
14
14
 
15
15
  export const VAPID_PUBLIC_KEY_SETTING = 'push_vapid_public_key';
16
16
  export const VAPID_PRIVATE_KEY_SETTING = 'push_vapid_private_key';
@@ -35,11 +35,12 @@ const EVENT_LABELS = Object.freeze({
35
35
  board: { title: 'Plan ready for review', generic: 'The agent posted a board for review.' },
36
36
  cliOffline: { title: 'CLI offline', generic: 'The relay lost its connection to the CLI host.' },
37
37
  hostSuspend: { title: 'Host suspend', generic: 'The queued host suspend changed state.' },
38
+ relayRestart: { title: 'Relay restart waiting', generic: 'The queued relay restart has waited its maximum time.' },
38
39
  });
39
40
 
40
41
  // System events carry their own body: no conversation text to preview, and
41
42
  // the body is the message itself regardless of the preview preference.
42
- const SYSTEM_BODY_TYPES = new Set(['hostSuspend']);
43
+ const SYSTEM_BODY_TYPES = new Set(['hostSuspend', 'relayRestart']);
43
44
 
44
45
  function clampInt(value, min, max, fallback) {
45
46
  const numeric = Number(value);
@@ -493,8 +494,14 @@ export function createPushDispatchService({
493
494
  return dispatch({ type: 'hostSuspend', body: body || null, data: { hostSuspendAt: Date.now() } });
494
495
  }
495
496
 
497
+ // The queued restart's maximum wait ran out: it asks, it does not restart.
498
+ function notifyRelayRestart({ body = null } = {}) {
499
+ return dispatch({ type: 'relayRestart', body: body || null, data: { relayRestartAt: Date.now() } });
500
+ }
501
+
496
502
  return {
497
503
  notifyHostSuspend,
504
+ notifyRelayRestart,
498
505
  listDevices,
499
506
  getDevice,
500
507
  upsertSubscription,
@@ -57,13 +57,18 @@ import {
57
57
  RELAY_EVENT_LINK_LOST_TYPE,
58
58
  RELAY_EVENT_LINK_RESTORED_TYPE,
59
59
  RELAY_EVENT_MESSAGE_KIND,
60
+ RELAY_EVENT_SUBAGENT_FINISHED_TYPE,
61
+ RELAY_EVENT_SUBSCRIBABLE_TYPES,
60
62
  RELAY_EVENT_TYPES,
61
63
  RELAY_EVENT_VERSION,
62
64
  buildRelayEventMessageText,
63
65
  compileRelayEventMatch,
66
+ effectiveRelayEventTypes,
67
+ isLegacyDefaultRelayEventTypes,
64
68
  isRelayEventType,
65
69
  isUrgentRelayEventType,
66
70
  relayEventMatchText,
71
+ relayEventTypesTake,
67
72
  } from '../../shared/relay-events.mjs';
68
73
 
69
74
  export const RELAY_EVENT_SUBSCRIPTIONS_SOCKET_EVENT = 'relay_event_subscriptions';
@@ -403,6 +408,11 @@ export function createRelayEventService({
403
408
  return !!row && Number(row.archived || 0) !== 1 && toText(row.status) !== 'deleted';
404
409
  }
405
410
 
411
+ /** A subscription's events; the old default list counts as today's default. */
412
+ function storedTypes(row) {
413
+ return effectiveRelayEventTypes(parseJson(row?.events_json, []));
414
+ }
415
+
406
416
  function shapeSubscription(row) {
407
417
  if (!row) return null;
408
418
  const scope = toText(row.scope) === 'all' ? 'all' : 'sessions';
@@ -431,7 +441,10 @@ export function createRelayEventService({
431
441
  sessions: scope === 'all'
432
442
  ? 'all'
433
443
  : sessionIds.map((id) => ({ id, title: mirror ? (remoteTitles.get(id) || null) : (toText(conversation(id)?.title) || null) })),
434
- events: parseJson(row.events_json, []).filter((type) => RELAY_EVENT_TYPES.includes(type)),
444
+ // A mirror shows what the paired relay applies; a subscription here,
445
+ // stored with the old default, the default that applies now.
446
+ events: (mirror ? parseJson(row.events_json, []) : storedTypes(row))
447
+ .filter((type) => RELAY_EVENT_SUBSCRIBABLE_TYPES.includes(type)),
435
448
  once: Number(row.once || 0) === 1,
436
449
  ...(toText(row.match) ? { match: row.match } : {}),
437
450
  stalledMinutes: Number(row.stalled_minutes) || RELAY_EVENT_LIMITS.stalledMinutesDefault,
@@ -504,9 +517,13 @@ export function createRelayEventService({
504
517
  throw new RelayEventError('notFound', `No live session with id ${missing.join(', ')} on this relay (archived sessions cannot be watched).`, { sessions: missing });
505
518
  }
506
519
  const types = Array.isArray(events) && events.length
507
- ? [...new Set(events.map(toText).filter((type) => RELAY_EVENT_TYPES.includes(type)))]
520
+ ? [...new Set(events.map(toText).filter((type) => RELAY_EVENT_SUBSCRIBABLE_TYPES.includes(type)))]
508
521
  : [...RELAY_EVENT_DEFAULT_TYPES];
509
- if (!types.length) throw new RelayEventError('invalid', `events must name some of: ${RELAY_EVENT_TYPES.join(', ')}`);
522
+ if (!types.length) throw new RelayEventError('invalid', `events must name some of: ${RELAY_EVENT_SUBSCRIBABLE_TYPES.join(', ')}`);
523
+ // Named exactly as the old default was stored, this list would be read
524
+ // as today's default and lose its task_finished (effectiveRelayEventTypes);
525
+ // subagent_finished beside it changes nothing it takes and keeps it named.
526
+ if (isLegacyDefaultRelayEventTypes(types)) types.push(RELAY_EVENT_SUBAGENT_FINISHED_TYPE);
510
527
  const compiled = compileRelayEventMatch(match);
511
528
  if (!compiled.ok) throw new RelayEventError('invalid', compiled.error);
512
529
  const stalled = Number.isFinite(Number(stalledMinutes)) && stalledMinutes !== null && stalledMinutes !== ''
@@ -771,8 +788,9 @@ export function createRelayEventService({
771
788
  return parseJson(row.session_ids_json, []).map(toText).includes(sessionId);
772
789
  }
773
790
 
774
- function wants(row, type) {
775
- return parseJson(row.events_json, []).includes(type);
791
+ /** `event` is `{ type, detail }`: a task_finished's kind decides under subagent_finished. */
792
+ function wants(row, event) {
793
+ return relayEventTypesTake(storedTypes(row), event);
776
794
  }
777
795
 
778
796
  /** `match` filters the events it can be tried on; the others pass. */
@@ -807,7 +825,7 @@ export function createRelayEventService({
807
825
  if (!available || !RELAY_EVENT_TYPES.includes(type)) return 0;
808
826
  const id = toText(sessionId);
809
827
  if (!id) return 0;
810
- const rows = localWatchRows().filter((row) => wants(row, type) && subscriptionCovers(row, id));
828
+ const rows = localWatchRows().filter((row) => wants(row, { type, detail }) && subscriptionCovers(row, id));
811
829
  if (!rows.length) return 0;
812
830
  const event = buildEvent(type, id, detail);
813
831
  return enqueueForRows(rows, event, id);
@@ -1271,7 +1289,7 @@ export function createRelayEventService({
1271
1289
  const sessions = all ? [] : (Array.isArray(remote.sessions) ? remote.sessions : [])
1272
1290
  .map((session) => ({ id: toText(session?.id), title: toText(session?.title) || null }))
1273
1291
  .filter((session) => session.id);
1274
- const types = (Array.isArray(remote.events) ? remote.events : []).filter((type) => RELAY_EVENT_TYPES.includes(type));
1292
+ const types = (Array.isArray(remote.events) ? remote.events : []).filter((type) => RELAY_EVENT_SUBSCRIBABLE_TYPES.includes(type));
1275
1293
  const id = `sub_${randomBytes(5).toString('hex')}`;
1276
1294
  const at = isoNow();
1277
1295
  remoteSql.insertMirror.run(
@@ -1814,7 +1832,7 @@ export function createRelayEventService({
1814
1832
  }
1815
1833
  busyBySubscription.set(row.id, false);
1816
1834
  idleSinceBySubscription.delete(row.id);
1817
- if (!wants(row, 'all_idle')) continue;
1835
+ if (!wants(row, { type: 'all_idle' })) continue;
1818
1836
  const count = toText(row.scope) === 'all' ? null : parseJson(row.session_ids_json, []).length;
1819
1837
  const event = buildEvent('all_idle', '', { scope: toText(row.scope), sessionCount: count, subscriptionId: row.id });
1820
1838
  enqueueForRows([row], event, '');
@@ -1832,7 +1850,7 @@ export function createRelayEventService({
1832
1850
  function evaluateStalled() {
1833
1851
  const at = now();
1834
1852
  for (const row of localWatchRows()) {
1835
- if (!wants(row, 'stalled')) continue;
1853
+ if (!wants(row, { type: 'stalled' })) continue;
1836
1854
  const limitMs = (Number(row.stalled_minutes) || RELAY_EVENT_LIMITS.stalledMinutesDefault) * 60_000;
1837
1855
  for (const sessionId of candidateSessions(row)) {
1838
1856
  const view = describe(sessionId);
@@ -14,6 +14,7 @@ const DEFAULT_RELAY_TOOL_GUIDANCE = [
14
14
  'For relay restarts in extension-managed mode, require explicit user permission first, then use the authenticated localhost API `POST /api/relay/shutdown`. Do not restart by killing processes or using respawn scripts.',
15
15
  'Note: shutdown is queued and only completes when the current turn finishes, so it is pointless to wait for it to interrupt an active turn.',
16
16
  'Use `restart: true` in the request body when the user wants a real relay restart rather than a plain shutdown. Example request body: `{ "reason": "manual-restart", "requestedBy": "localhost-api", "restart": true }`.',
17
+ 'Never pass `immediate` in that request body: it restarts without waiting for the running turns, which only the user may decide, with Restart now in the web UI.',
17
18
  ].join(' ');
18
19
 
19
20
  function loadRelayToolGuidance() {
@@ -32,6 +33,10 @@ function loadRelayToolGuidance() {
32
33
  }
33
34
 
34
35
  const RELAY_TOOL_GUIDANCE = loadRelayToolGuidance();
36
+ // Prompts stored before the `immediate` line was added carry the guidance
37
+ // without it.
38
+ const IMMEDIATE_GUIDANCE_LINE = 'Never pass `immediate` in that request body: it restarts without waiting for the running turns, which only the user may decide, with Restart now in the web UI.';
39
+ const PREVIOUS_RELAY_TOOL_GUIDANCE = RELAY_TOOL_GUIDANCE.replace(` ${IMMEDIATE_GUIDANCE_LINE}`, '');
35
40
 
36
41
  const MODE_MARKERS = {
37
42
  plan: '[Relay mode: plan]',
@@ -117,6 +122,8 @@ function buildPromptPrefixPatterns(mode) {
117
122
  return [
118
123
  new RegExp(`^${whitespaceFlexiblePattern(currentFullPrefix)}\\s*`, 'i'),
119
124
  new RegExp(`^${whitespaceFlexiblePattern(legacyFullPrefix)}\\s*`, 'i'),
125
+ new RegExp(`^${whitespaceFlexiblePattern(`${marker} ${MODE_INSTRUCTIONS[key]} ${PREVIOUS_RELAY_TOOL_GUIDANCE}`)}\\s*`, 'i'),
126
+ new RegExp(`^${whitespaceFlexiblePattern(`${marker} ${LEGACY_MODE_INSTRUCTIONS[key]} ${PREVIOUS_RELAY_TOOL_GUIDANCE}`)}\\s*`, 'i'),
120
127
  new RegExp(`^${whitespaceFlexiblePattern(marker)}\\s*`, 'i'),
121
128
  ];
122
129
  });
@@ -0,0 +1,209 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * What a relay restart (or shutdown) waits for, holds and leaves running,
5
+ * read from the state the relay already has:
6
+ *
7
+ * - waitingFor: the sessions with a processing turn. A queued restart waits
8
+ * for these and only these (relay-shutdown-gate.mjs); "Restart now" does
9
+ * not wait for them.
10
+ * - held: pending and parked messages. While a restart is queued none of
11
+ * them is handed to a worker; they run after it.
12
+ * - backgroundTasks: sessions with running background work of a live
13
+ * worker (shell, monitor, sub-agents; the task panel's data, through
14
+ * background-work-view.mjs), each with whether it survives.
15
+ *
16
+ * Whether background work survives is a property of the session workers,
17
+ * not of the relay runtime: every worker is its own process (tmux, a
18
+ * detached process, a console window on Windows) and keeps its tasks when
19
+ * the runtime exits. A restart (exit 75) is taken by the relay's own
20
+ * supervisor or by the Copilot CLI extension, which start a new runtime;
21
+ * the workers report back to it. A shutdown under a systemd service is
22
+ * different: the service stops, and systemd stops every process of the
23
+ * service, which takes the workers with it unless they live outside it (a
24
+ * tmux session on a tmux server the user started elsewhere survives). The
25
+ * relay cannot tell which, so they "may stop". A Claude Cloud session runs at
26
+ * Anthropic and is never affected. `oar restart` and `oar stop` through
27
+ * systemd do not come through here at all.
28
+ */
29
+
30
+ import fs from 'node:fs';
31
+
32
+ const MAX_ROWS = 50;
33
+
34
+ function toText(value) {
35
+ return String(value ?? '').trim();
36
+ }
37
+
38
+ /**
39
+ * How this relay process is run, from its environment and argv: whether a
40
+ * systemd service runs it, and whether an outer supervisor (the Copilot CLI
41
+ * extension, `--supervised`) owns its restarts.
42
+ */
43
+ export function detectRelayHosting({
44
+ env = process.env,
45
+ argv = process.argv,
46
+ platform = process.platform,
47
+ readCgroup = () => fs.readFileSync('/proc/self/cgroup', 'utf8'),
48
+ } = {}) {
49
+ // systemd sets INVOCATION_ID for the processes of a unit, and a service's
50
+ // cgroup ends in `.service` (a terminal's shell sits in a `.scope`).
51
+ let systemd = false;
52
+ if (platform === 'linux' && toText(env?.INVOCATION_ID)) {
53
+ let cgroup = '';
54
+ try { cgroup = String(readCgroup() || ''); } catch {}
55
+ systemd = cgroup.split('\n').some((line) => /\.service\/?$/.test(line.trim()));
56
+ }
57
+ const supervised = Array.isArray(argv) && argv.includes('--supervised');
58
+ return { systemd, supervised, platform };
59
+ }
60
+
61
+ /** Whether the session workers outlive this action, and why, in one line each. */
62
+ export function describeRestartSurvival({ action = 'restart', hosting = {} } = {}) {
63
+ const restart = action === 'restart';
64
+ if (restart) {
65
+ return {
66
+ action: 'restart',
67
+ workersSurvive: true,
68
+ summary: 'Session workers are separate processes and keep running through a relay restart, with their background tasks; '
69
+ + 'they report back to the new relay within a minute.',
70
+ };
71
+ }
72
+ if (hosting.systemd) {
73
+ return {
74
+ action: 'shutdown',
75
+ workersSurvive: false,
76
+ summary: 'The relay runs as a systemd service: shutting it down stops the service and every process in it, '
77
+ + 'so session workers and their background tasks may stop too (a worker in a tmux session the service did not start keeps running).',
78
+ };
79
+ }
80
+ return {
81
+ action: 'shutdown',
82
+ workersSurvive: true,
83
+ summary: 'Session workers are separate processes and keep running while the relay is down, with their background tasks; '
84
+ + 'they report back when it starts again.',
85
+ };
86
+ }
87
+
88
+ /**
89
+ * The picture for one action. `queueRows` is the queue grouped by
90
+ * conversation and status ({ conversation_id, status, cnt, title });
91
+ * `backgroundBlockers` the `background` rows of background-work-view.mjs.
92
+ */
93
+ export function buildRestartPicture({
94
+ action = 'restart',
95
+ queueRows = [],
96
+ backgroundBlockers = [],
97
+ // Tasks held since a relay restart (background-task-store.mjs
98
+ // `listRestored`, mapped to conversations): `{ conversationId, description }`.
99
+ heldTasks = [],
100
+ hosting = {},
101
+ providerOf = () => '',
102
+ titleOf = (conversationId) => toText(conversationId).slice(0, 8),
103
+ } = {}) {
104
+ const survival = describeRestartSurvival({ action, hosting });
105
+ const turns = new Map();
106
+ const held = new Map();
107
+ for (const row of Array.isArray(queueRows) ? queueRows : []) {
108
+ const conversationId = toText(row?.conversation_id);
109
+ const count = Number(row?.cnt || 0);
110
+ if (!conversationId || !(count > 0)) continue;
111
+ const status = toText(row?.status);
112
+ const target = status === 'processing' ? turns : (status === 'pending' || status === 'parked') ? held : null;
113
+ if (!target) continue;
114
+ const entry = target.get(conversationId) || { conversationId, title: toText(row?.title) || titleOf(conversationId), count: 0 };
115
+ entry.count += count;
116
+ target.set(conversationId, entry);
117
+ }
118
+ const waitingFor = [...turns.values()].slice(0, MAX_ROWS).map((entry) => ({
119
+ conversationId: entry.conversationId,
120
+ title: entry.title,
121
+ turns: entry.count,
122
+ }));
123
+ const heldConversations = [...held.values()];
124
+ const backgroundTasks = (Array.isArray(backgroundBlockers) ? backgroundBlockers : [])
125
+ .filter((blocker) => blocker && blocker.kind === 'background' && toText(blocker.conversationId))
126
+ .slice(0, MAX_ROWS)
127
+ .map((blocker) => {
128
+ const conversationId = toText(blocker.conversationId);
129
+ const cloud = toText(providerOf(conversationId)).toLowerCase() === 'claude-cloud';
130
+ const survives = cloud || survival.workersSurvive;
131
+ return {
132
+ conversationId,
133
+ title: toText(blocker.title) || titleOf(conversationId),
134
+ count: Number(blocker.count) > 0 ? Math.trunc(Number(blocker.count)) : 1,
135
+ detail: toText(blocker.detail).slice(0, 400),
136
+ survives,
137
+ why: cloud
138
+ ? 'runs in the cloud'
139
+ : survives ? 'its worker keeps running' : 'its worker may stop with the service',
140
+ };
141
+ });
142
+ // Held since the last restart: neither running nor ended as far as the
143
+ // relay knows, until the worker's set confirms them. Whether they survive
144
+ // the next one is not known either.
145
+ const listed = new Set(backgroundTasks.map((task) => task.conversationId));
146
+ const unconfirmed = new Map();
147
+ for (const entry of Array.isArray(heldTasks) ? heldTasks : []) {
148
+ const conversationId = toText(entry?.conversationId);
149
+ if (!conversationId || listed.has(conversationId)) continue;
150
+ const list = unconfirmed.get(conversationId) || [];
151
+ list.push(toText(entry?.description) || 'task');
152
+ unconfirmed.set(conversationId, list);
153
+ }
154
+ for (const [conversationId, descriptions] of unconfirmed) {
155
+ if (backgroundTasks.length >= MAX_ROWS) break;
156
+ const count = descriptions.length;
157
+ backgroundTasks.push({
158
+ conversationId,
159
+ title: titleOf(conversationId),
160
+ count,
161
+ detail: `${count} task${count === 1 ? '' : 's'} not yet confirmed since the last relay restart: ${descriptions.slice(0, 3).map((text) => text.slice(0, 80)).join('; ')}`,
162
+ survives: null,
163
+ why: 'its worker has not confirmed it since the last restart',
164
+ });
165
+ }
166
+ return {
167
+ waitingFor,
168
+ held: {
169
+ count: heldConversations.reduce((sum, entry) => sum + entry.count, 0),
170
+ conversations: heldConversations.slice(0, MAX_ROWS).map((entry) => ({
171
+ conversationId: entry.conversationId,
172
+ title: entry.title,
173
+ count: entry.count,
174
+ })),
175
+ },
176
+ backgroundTasks,
177
+ survival,
178
+ };
179
+ }
180
+
181
+ /** The live collector over the relay's database and background-work view. */
182
+ export function createRestartPictureCollector({
183
+ db,
184
+ backgroundWorkView = null,
185
+ // () => [{ conversationId, description }]: tasks held since a restart.
186
+ listHeldTasks = () => [],
187
+ hosting = detectRelayHosting(),
188
+ providerOf = () => '',
189
+ titleOf = undefined,
190
+ } = {}) {
191
+ let listQueue = null;
192
+ try {
193
+ listQueue = db?.prepare(`
194
+ SELECT q.conversation_id, q.status, COUNT(*) AS cnt, MAX(c.title) AS title
195
+ FROM queue q LEFT JOIN conversations c ON c.id = q.conversation_id
196
+ WHERE q.status IN ('pending','processing','parked')
197
+ GROUP BY q.conversation_id, q.status
198
+ `) || null;
199
+ } catch {}
200
+ return function collectRestartPicture({ action = 'restart' } = {}) {
201
+ let queueRows = [];
202
+ try { queueRows = listQueue?.all() || []; } catch {}
203
+ let backgroundBlockers = [];
204
+ try { backgroundBlockers = backgroundWorkView?.backgroundBlockers?.() || []; } catch {}
205
+ let heldTasks = [];
206
+ try { heldTasks = listHeldTasks() || []; } catch {}
207
+ return buildRestartPicture({ action, queueRows, backgroundBlockers, heldTasks, hosting, providerOf, ...(titleOf ? { titleOf } : {}) });
208
+ };
209
+ }