@oar-sh/oar 0.9.11 → 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 (90) hide show
  1. package/CHANGELOG.md +332 -0
  2. package/README.md +10 -10
  3. package/package.json +1 -1
  4. package/server/README.md +201 -12
  5. package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
  6. package/server/claude-worker/claude-sdk-adapter.mjs +57 -4
  7. package/server/claude-worker/claude-session-process.mjs +179 -0
  8. package/server/claude-worker/claude-session-worker.mjs +17 -0
  9. package/server/claude-worker/task-output-discovery.mjs +152 -0
  10. package/server/copilot-worker/copilot-continuation-signals.mjs +11 -5
  11. package/server/copilot-worker/copilot-sdk-session-process.mjs +169 -3
  12. package/server/copilot-worker/copilot-sdk-session-worker.mjs +2 -0
  13. package/server/copilot-worker/copilot-shell-output-mirror.mjs +94 -0
  14. package/server/copilot-worker/copilot-task-settle.mjs +96 -0
  15. package/server/db-schema.mjs +19 -0
  16. package/server/grok-worker/acp-host-services.mjs +48 -6
  17. package/server/grok-worker/grok-background-tasks.mjs +248 -0
  18. package/server/grok-worker/grok-sdk-adapter.mjs +3 -1
  19. package/server/grok-worker/grok-session-worker.mjs +33 -1
  20. package/server/grok-worker/grok-turn-runner.mjs +11 -0
  21. package/server/migrations/0017-conversation-history-indexes.mjs +89 -0
  22. package/server/migrations/0018-background-task-rows.mjs +101 -0
  23. package/server/public/app/action-confirmations.js +12 -15
  24. package/server/public/app/api-client.js +70 -3
  25. package/server/public/app/background-tasks-view.mjs +334 -16
  26. package/server/public/app/bootstrap.js +102 -5
  27. package/server/public/app/claude-background-command-limit-options.mjs +21 -0
  28. package/server/public/app/conversation-payload-cache.mjs +357 -0
  29. package/server/public/app/conversation-preferences.mjs +10 -4
  30. package/server/public/app/conversation-turn-state.mjs +1 -1
  31. package/server/public/app/conversation-view.js +462 -8
  32. package/server/public/app/conversation-window-retention.mjs +96 -0
  33. package/server/public/app/host-suspend-ui.js +45 -44
  34. package/server/public/app/journal-view.js +259 -67
  35. package/server/public/app/push-settings.js +1 -0
  36. package/server/public/app/relay-prompt-sanitizer.mjs +20 -6
  37. package/server/public/app/relay-restart-note-view.mjs +36 -0
  38. package/server/public/app/relay-restart-ui.js +568 -0
  39. package/server/public/app/settings-modal.js +162 -2
  40. package/server/public/app/socket-handlers.js +59 -7
  41. package/server/public/app/store.js +25 -0
  42. package/server/public/app/subagent-viewer.mjs +7 -1
  43. package/server/public/app/task-console-render.mjs +401 -0
  44. package/server/public/app/task-console-watch.mjs +42 -0
  45. package/server/public/app/task-console.mjs +730 -0
  46. package/server/public/app/task-output-text.mjs +243 -0
  47. package/server/public/app/transcript-island.mjs +73 -0
  48. package/server/public/app/transcript-jump-bottom-view.mjs +42 -8
  49. package/server/public/app/transcript-jump-bottom.mjs +68 -11
  50. package/server/public/app/transcript-reading-anchor.mjs +256 -0
  51. package/server/public/app/transcript-reload-freshness.mjs +17 -1
  52. package/server/public/app/transcript-viewport-anchor.mjs +20 -1
  53. package/server/public/index.html +306 -6
  54. package/server/relay-tools.md +6 -0
  55. package/server/repositories/message-repository.mjs +11 -0
  56. package/server/repositories/question-repository.mjs +4 -0
  57. package/server/repositories/remote-relay-repository.mjs +4 -2
  58. package/server/routes/messages-routes.mjs +397 -23
  59. package/server/routes/relay-board-routes.mjs +19 -2
  60. package/server/routes/relay-restart-routes.mjs +73 -0
  61. package/server/routes/sessions-routes.mjs +208 -66
  62. package/server/server-runtime.mjs +269 -137
  63. package/server/services/about-service.mjs +13 -12
  64. package/server/services/background-task-persistence.mjs +133 -0
  65. package/server/services/background-task-store.mjs +470 -6
  66. package/server/services/cli-process-runner.mjs +4 -21
  67. package/server/services/conversation-history-page.mjs +199 -0
  68. package/server/services/conversation-model-fallback.mjs +57 -0
  69. package/server/services/host-suspend-command.mjs +2 -1
  70. package/server/services/preview-proxy-server.mjs +14 -1
  71. package/server/services/process-list-service.mjs +2 -1
  72. package/server/services/provider-model-selection.mjs +195 -0
  73. package/server/services/push-dispatch-service.mjs +9 -2
  74. package/server/services/relay-event-service.mjs +27 -9
  75. package/server/services/relay-prompt-sanitizer.mjs +18 -4
  76. package/server/services/relay-restart-picture.mjs +209 -0
  77. package/server/services/relay-restart-queue.mjs +380 -0
  78. package/server/services/relay-shutdown-gate.mjs +4 -1
  79. package/server/services/remote-relay-dispatcher.mjs +316 -20
  80. package/server/services/restart-resume-service.mjs +303 -0
  81. package/server/services/session-activity-tracker.mjs +6 -2
  82. package/server/services/session-worker-process-service.mjs +6 -4
  83. package/server/services/task-output-rooms.mjs +259 -0
  84. package/server/services/task-output-service.mjs +761 -0
  85. package/shared/claude-background-command-limit.mjs +83 -0
  86. package/shared/relay-events.mjs +55 -2
  87. package/shared/remote-relay-contract.mjs +47 -10
  88. package/shared/task-output-text.mjs +850 -0
  89. package/shared/worker-runtime/task-output-spool.mjs +181 -0
  90. package/shared/worker-runtime/worker-websocket-link.mjs +96 -20
@@ -0,0 +1,199 @@
1
+ 'use strict';
2
+
3
+ // One page of a conversation's history, selected in SQL.
4
+ //
5
+ // The history route used to load every message, hydrate every reply and only
6
+ // then cut the page out (selectConversationHistoryPage in sessions-routes.mjs),
7
+ // so each page and each live poll paid for the whole conversation. This module
8
+ // selects the page's rows in SQL with the same cursor rules and the same
9
+ // pageInfo, so only the page is hydrated.
10
+ //
11
+ // The JS order is (timestamp in ms, then id by localeCompare). SQL orders by
12
+ // (timestamp text, id text). The two agree when every timestamp has the fixed
13
+ // ISO shape the relay writes (YYYY-MM-DDTHH:MM:SS.mmmZ) and every id is made
14
+ // of lowercase letters, digits and dashes (the relay's UUIDs). A conversation
15
+ // with any other row is not paged here: the selector returns null and the
16
+ // caller keeps the in-memory path, which handles every shape.
17
+
18
+ const ISO_TIMESTAMP_GLOB = '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9].[0-9][0-9][0-9]Z';
19
+ const ISO_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
20
+ const ORDERABLE_ID_PATTERN = /^[-0-9a-z]+$/;
21
+
22
+ export function normalizeConversationTimestampMs(value) {
23
+ const text = String(value || '').trim();
24
+ if (!text) return 0;
25
+ const sqliteUtcLike = /^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?)$/;
26
+ const hasExplicitTimezone = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(text);
27
+ let normalized = text;
28
+ if (!hasExplicitTimezone && sqliteUtcLike.test(text)) {
29
+ const [, day, time] = text.match(sqliteUtcLike) || [];
30
+ normalized = `${day}T${time}Z`;
31
+ }
32
+ const parsed = Date.parse(normalized);
33
+ return Number.isFinite(parsed) ? parsed : 0;
34
+ }
35
+
36
+ export function normalizeConversationHistoryLimit(value, fallback = 20) {
37
+ const numeric = Number(value);
38
+ if (!Number.isFinite(numeric)) return fallback;
39
+ return Math.min(100, Math.max(1, Math.trunc(numeric)));
40
+ }
41
+
42
+ /**
43
+ * The pageInfo of a history page: `pageMessages` in conversation order, and
44
+ * whether the conversation has rows before the first and after the last.
45
+ */
46
+ export function buildConversationHistoryPageInfo(pageMessages = [], { hasMoreOlder = false, hasMoreNewer = false } = {}) {
47
+ const firstMessage = pageMessages[0] || null;
48
+ const lastMessage = pageMessages[pageMessages.length - 1] || null;
49
+ return {
50
+ hasMore: hasMoreOlder,
51
+ hasMoreOlder,
52
+ hasMoreNewer,
53
+ nextCursor: firstMessage ? {
54
+ beforeMessageId: String(firstMessage.id || '').trim(),
55
+ beforeTimestamp: String(firstMessage.timestamp || '').trim() || null,
56
+ } : null,
57
+ olderCursor: firstMessage ? {
58
+ beforeMessageId: String(firstMessage.id || '').trim(),
59
+ beforeTimestamp: String(firstMessage.timestamp || '').trim() || null,
60
+ } : null,
61
+ newerCursor: lastMessage ? {
62
+ afterMessageId: String(lastMessage.id || '').trim(),
63
+ afterTimestamp: String(lastMessage.timestamp || '').trim() || null,
64
+ } : null,
65
+ };
66
+ }
67
+
68
+ /**
69
+ * The prepared statements for paging one conversation. `sharedOnly` pages
70
+ * over the rows a shared view shows (not hidden from shares).
71
+ */
72
+ export function createConversationHistoryPageStatements(db) {
73
+ const messageColumns = new Set(db.prepare(`PRAGMA table_info(messages)`).all().map((column) => column.name));
74
+ const sharedFilter = messageColumns.has('hidden_from_shares') ? 'AND hidden_from_shares = 0' : '';
75
+ const hasResendColumns = messageColumns.has('source_message_id') && messageColumns.has('resend_of_message_id');
76
+ const build = (visibility) => {
77
+ const base = `FROM messages WHERE conversation_id = @conversationId ${visibility}`;
78
+ const older = `(timestamp < @timestamp OR (timestamp = @timestamp AND @id IS NOT NULL AND id < @id))`;
79
+ const newer = `(timestamp > @timestamp OR (timestamp = @timestamp AND @id IS NOT NULL AND id > @id))`;
80
+ return {
81
+ irregular: db.prepare(`
82
+ SELECT EXISTS (
83
+ SELECT 1 ${base}
84
+ AND NOT (timestamp GLOB '${ISO_TIMESTAMP_GLOB}' AND id <> '' AND id NOT GLOB '*[^-0-9a-z]*')
85
+ ) AS irregular
86
+ `),
87
+ findById: db.prepare(`SELECT id, timestamp ${base} AND id = @id`),
88
+ older: db.prepare(`SELECT * ${base} AND ${older} ORDER BY timestamp DESC, id DESC LIMIT @limit`),
89
+ newer: db.prepare(`SELECT * ${base} AND ${newer} ORDER BY timestamp ASC, id ASC LIMIT @limit`),
90
+ fromInclusive: db.prepare(`
91
+ SELECT * ${base} AND (timestamp > @timestamp OR (timestamp = @timestamp AND id >= @id))
92
+ ORDER BY timestamp ASC, id ASC LIMIT @limit
93
+ `),
94
+ tail: db.prepare(`SELECT * ${base} ORDER BY timestamp DESC, id DESC LIMIT @limit`),
95
+ existsOlder: db.prepare(`SELECT EXISTS (SELECT 1 ${base} AND ${older}) AS found`),
96
+ existsNewer: db.prepare(`SELECT EXISTS (SELECT 1 ${base} AND ${newer}) AS found`),
97
+ // What liveResendsByOriginal reads, for the whole conversation: a
98
+ // stopped marker on the page may have its Resend outside it.
99
+ resendIndex: hasResendColumns
100
+ ? db.prepare(`SELECT id, role, source_message_id, resend_of_message_id ${base} ORDER BY timestamp ASC`)
101
+ : null,
102
+ };
103
+ };
104
+ const all = build('');
105
+ const shared = build(sharedFilter);
106
+ return {
107
+ selectConversationHistoryPage(conversationId, options = {}) {
108
+ return selectConversationHistoryPageFromStatements(options.sharedOnly ? shared : all, conversationId, options);
109
+ },
110
+ listConversationResendIndex(conversationId, { sharedOnly = false } = {}) {
111
+ const statement = (sharedOnly ? shared : all).resendIndex;
112
+ return statement ? statement.all({ conversationId }) : [];
113
+ },
114
+ };
115
+ }
116
+
117
+ // A cursor as the in-memory selector resolves it, in the SQL shape: the
118
+ // message's own timestamp when its id is in the conversation, else the given
119
+ // timestamp. Null when there is no cursor; `unsupported` when the given
120
+ // values cannot be compared in SQL the way the selector compares them.
121
+ function resolveCursor(statements, conversationId, { messageId = '', timestamp = '' } = {}) {
122
+ const cursorMessageId = String(messageId || '').trim();
123
+ const cursorTimestampText = String(timestamp || '').trim();
124
+ if (cursorMessageId) {
125
+ const row = statements.findById.get({ conversationId, id: cursorMessageId });
126
+ if (row) return { id: cursorMessageId, timestamp: String(row.timestamp) };
127
+ }
128
+ if (!cursorTimestampText) return null;
129
+ if (cursorMessageId && !ORDERABLE_ID_PATTERN.test(cursorMessageId)) return 'unsupported';
130
+ let canonical = cursorTimestampText;
131
+ if (!ISO_TIMESTAMP_PATTERN.test(canonical)) {
132
+ canonical = new Date(normalizeConversationTimestampMs(cursorTimestampText)).toISOString();
133
+ if (!ISO_TIMESTAMP_PATTERN.test(canonical)) return 'unsupported';
134
+ }
135
+ return { id: cursorMessageId || null, timestamp: canonical };
136
+ }
137
+
138
+ /**
139
+ * The page of rows (raw message rows, in conversation order) and its
140
+ * pageInfo, by the rules of selectConversationHistoryPage; null when the
141
+ * conversation or the cursor cannot be paged in SQL (see the top note).
142
+ */
143
+ function selectConversationHistoryPageFromStatements(statements, conversationId, {
144
+ limit = 20,
145
+ beforeMessageId = '',
146
+ beforeTimestamp = '',
147
+ afterMessageId = '',
148
+ afterTimestamp = '',
149
+ aroundMessageId = '',
150
+ } = {}) {
151
+ if (statements.irregular.get({ conversationId })?.irregular) return null;
152
+ const pageLimit = normalizeConversationHistoryLimit(limit);
153
+ const aroundId = String(aroundMessageId || '').trim();
154
+ const requestedAroundWindow = !!aroundId;
155
+ const requestedAfterWindow = !!String(afterMessageId || '').trim();
156
+ const beforeCursor = resolveCursor(statements, conversationId, { messageId: beforeMessageId, timestamp: beforeTimestamp });
157
+ const afterCursor = resolveCursor(statements, conversationId, { messageId: afterMessageId, timestamp: afterTimestamp });
158
+ if (beforeCursor === 'unsupported' || afterCursor === 'unsupported') return null;
159
+
160
+ let rows = [];
161
+ if (aroundId) {
162
+ const anchor = statements.findById.get({ conversationId, id: aroundId });
163
+ if (anchor) {
164
+ // Enough rows on both sides for the in-memory window rules to give the
165
+ // same window as over the whole conversation.
166
+ const before = statements.older
167
+ .all({ conversationId, timestamp: anchor.timestamp, id: anchor.id, limit: pageLimit - 1 })
168
+ .reverse();
169
+ const fromAnchor = statements.fromInclusive
170
+ .all({ conversationId, timestamp: anchor.timestamp, id: anchor.id, limit: pageLimit });
171
+ const local = [...before, ...fromAnchor];
172
+ const aroundIndex = before.length;
173
+ const halfWindow = Math.floor((pageLimit - 1) / 2);
174
+ let start = Math.max(0, aroundIndex - halfWindow);
175
+ const end = Math.min(local.length, start + pageLimit);
176
+ if ((end - start) < pageLimit) start = Math.max(0, end - pageLimit);
177
+ rows = local.slice(start, end);
178
+ }
179
+ }
180
+ if (!rows.length && !requestedAroundWindow && afterCursor) {
181
+ rows = statements.newer.all({ conversationId, ...afterCursor, limit: pageLimit });
182
+ }
183
+ if (!rows.length && !requestedAfterWindow && !requestedAroundWindow) {
184
+ rows = (beforeCursor
185
+ ? statements.older.all({ conversationId, ...beforeCursor, limit: pageLimit })
186
+ : statements.tail.all({ conversationId, limit: pageLimit })
187
+ ).reverse();
188
+ }
189
+
190
+ const first = rows[0] || null;
191
+ const last = rows[rows.length - 1] || null;
192
+ const hasMoreOlder = first
193
+ ? !!statements.existsOlder.get({ conversationId, timestamp: first.timestamp, id: first.id })?.found
194
+ : false;
195
+ const hasMoreNewer = last
196
+ ? !!statements.existsNewer.get({ conversationId, timestamp: last.timestamp, id: last.id })?.found
197
+ : false;
198
+ return { rows, pageInfo: buildConversationHistoryPageInfo(rows, { hasMoreOlder, hasMoreNewer }) };
199
+ }
@@ -0,0 +1,57 @@
1
+ 'use strict';
2
+
3
+ // What a send to an existing conversation runs with when the request names no
4
+ // model or no effort. The answer is the conversation's own choice, never the
5
+ // relay-wide catalog default: a plain POST /api/message from a script or an
6
+ // agent used to switch a Copilot conversation to the catalog's first model and
7
+ // to effort 'none', and then saved that as the conversation's preference.
8
+ //
9
+ // The effort clamp is the composer's own (public/app/conversation-preferences.mjs),
10
+ // so the relay and the page settle on the same level for the same model.
11
+ import { nearestSupportedReasoningEffort } from '../public/app/conversation-preferences.mjs';
12
+
13
+ const AUTO_MODEL = 'auto';
14
+
15
+ function toText(value) {
16
+ return String(value ?? '').trim();
17
+ }
18
+
19
+ /**
20
+ * The model an existing conversation was last set to, in this order:
21
+ * 1. the conversation's saved preference (what the composer shows);
22
+ * 2. the runtime session's model (provider_model, else model);
23
+ * 3. the model of the latest user message.
24
+ * 'auto' is not a model a later turn can run on, so it is skipped. Null when
25
+ * the conversation has none of these: the caller falls back to the catalog.
26
+ */
27
+ export function inheritedConversationModel({
28
+ conversation = null,
29
+ runtimeSession = null,
30
+ latestUserMessageModel = '',
31
+ } = {}) {
32
+ // provider_model and model are looked at one by one, so an 'auto' in one
33
+ // does not hide a real model in the other.
34
+ const candidates = [
35
+ { source: 'conversation', model: toText(conversation?.preferred_model) },
36
+ { source: 'runtime', model: toText(runtimeSession?.provider_model) },
37
+ { source: 'runtime', model: toText(runtimeSession?.model) },
38
+ { source: 'message', model: toText(latestUserMessageModel) },
39
+ ];
40
+ return candidates.find((entry) => entry.model && entry.model.toLowerCase() !== AUTO_MODEL) || null;
41
+ }
42
+
43
+ /**
44
+ * The effort for a turn that names none: the first candidate the model
45
+ * supports (or the nearest level to it), else the model's lowest level that
46
+ * is not 'none'. 'none' comes back only when it is the only level, or when a
47
+ * candidate asks for it and the model has it. '' when the model lists no
48
+ * levels at all. A provider whose own default is better than "the lowest
49
+ * level" passes `fallback`, returned when no candidate matches or clamps.
50
+ */
51
+ export function fallbackReasoningEffort(candidates = [], supportedEfforts = [], { fallback } = {}) {
52
+ return nearestSupportedReasoningEffort(
53
+ (Array.isArray(candidates) ? candidates : [candidates]).map(toText).filter(Boolean),
54
+ supportedEfforts,
55
+ { fallback },
56
+ );
57
+ }
@@ -14,6 +14,7 @@ import { windowsSystemToolPath } from '../../shared/windows-system-tools.mjs';
14
14
  export function runHostSuspendToRam({
15
15
  platform = process.platform,
16
16
  spawnImpl = spawn,
17
+ systemToolPath = windowsSystemToolPath,
17
18
  dryRun = /^(1|true|yes|on)$/i.test(String(process.env.OAR_HOST_SUSPEND_DRY_RUN || '')),
18
19
  logger = console,
19
20
  } = {}) {
@@ -26,7 +27,7 @@ export function runHostSuspendToRam({
26
27
  return { ok: true, command, dryRun: true };
27
28
  }
28
29
  try {
29
- const child = spawnImpl(windowsSystemToolPath('rundll32.exe'), ['powrprof.dll,SetSuspendState', '0,0,0'], {
30
+ const child = spawnImpl(systemToolPath('rundll32.exe'), ['powrprof.dll,SetSuspendState', '0,0,0'], {
30
31
  detached: true,
31
32
  stdio: 'ignore',
32
33
  windowsHide: true,
@@ -261,6 +261,10 @@ export function createPreviewProxyServer({
261
261
  // open HMR connection would therefore hang shutdown forever unless the lane
262
262
  // tracks the pairs itself.
263
263
  const upgradedSockets = new Set();
264
+ // Upgrades still waiting on the dev server's answer, by their teardown: the
265
+ // client socket is detached from the http.Server already, so it would hold
266
+ // close() open just the same.
267
+ const pendingUpgrades = new Set();
264
268
 
265
269
  function trackUpgradedSocket(socket) {
266
270
  if (!socket) return;
@@ -411,14 +415,20 @@ export function createPreviewProxyServer({
411
415
  let upstreamReq = null;
412
416
  let upstreamSocket = null;
413
417
  const teardown = () => {
418
+ pendingUpgrades.delete(teardown);
414
419
  try { socket.destroy(); } catch {}
415
420
  try { upstreamSocket?.destroy(); } catch {}
416
421
  try { upstreamReq?.destroy(); } catch {}
417
422
  };
423
+ pendingUpgrades.add(teardown);
418
424
  socket.on('error', teardown);
419
425
  // A client that leaves before the dev server answers abandons the
420
426
  // handshake. Once both sides are piped, an orderly close travels through
421
- // the pipes, so the pending writes still reach the other side.
427
+ // the pipes, so the pending writes still reach the other side. Leaving
428
+ // can arrive as a bare FIN: the server's sockets allow half-open
429
+ // connections, so 'end' alone never closes this one, and nothing reads
430
+ // it again to notice a reset that follows the FIN.
431
+ socket.once('end', teardown);
422
432
  socket.once('close', teardown);
423
433
  const reply = (text) => {
424
434
  if (socket.destroyed) return;
@@ -477,7 +487,9 @@ export function createPreviewProxyServer({
477
487
  reply(`${lines.join('\r\n')}\r\n\r\n`);
478
488
  if (upstreamHead?.length) reply(upstreamHead);
479
489
  if (head?.length) upstreamSocket.write(head);
490
+ socket.removeListener('end', teardown);
480
491
  socket.removeListener('close', teardown);
492
+ pendingUpgrades.delete(teardown);
481
493
  trackUpgradedSocket(socket);
482
494
  trackUpgradedSocket(upstreamSocket);
483
495
  upstreamSocket.pipe(socket);
@@ -527,6 +539,7 @@ export function createPreviewProxyServer({
527
539
  try { socket.destroy(); } catch {}
528
540
  }
529
541
  upgradedSockets.clear();
542
+ for (const teardown of [...pendingUpgrades]) teardown();
530
543
  return new Promise((resolve) => {
531
544
  if (!server) return resolve();
532
545
  const current = server;
@@ -67,10 +67,11 @@ export function createProcessLister({
67
67
  readdirSyncImpl = fs.readdirSync,
68
68
  readFileSyncImpl = fs.readFileSync,
69
69
  execFileImpl = execFile,
70
+ systemToolPath,
70
71
  inspector = null,
71
72
  } = {}) {
72
73
  if (platform === 'win32') {
73
- const windows = inspector || createSessionWorkerProcessInspector({ platform, execFileImpl });
74
+ const windows = inspector || createSessionWorkerProcessInspector({ platform, execFileImpl, systemToolPath });
74
75
  return {
75
76
  // A child keeps the pid of its parent after the parent has ended.
76
77
  orphansKeepParent: true,
@@ -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,