@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.
- package/CHANGELOG.md +332 -0
- package/README.md +10 -10
- package/package.json +1 -1
- package/server/README.md +201 -12
- package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +57 -4
- package/server/claude-worker/claude-session-process.mjs +179 -0
- package/server/claude-worker/claude-session-worker.mjs +17 -0
- package/server/claude-worker/task-output-discovery.mjs +152 -0
- package/server/copilot-worker/copilot-continuation-signals.mjs +11 -5
- package/server/copilot-worker/copilot-sdk-session-process.mjs +169 -3
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +2 -0
- package/server/copilot-worker/copilot-shell-output-mirror.mjs +94 -0
- package/server/copilot-worker/copilot-task-settle.mjs +96 -0
- package/server/db-schema.mjs +19 -0
- package/server/grok-worker/acp-host-services.mjs +48 -6
- package/server/grok-worker/grok-background-tasks.mjs +248 -0
- package/server/grok-worker/grok-sdk-adapter.mjs +3 -1
- package/server/grok-worker/grok-session-worker.mjs +33 -1
- package/server/grok-worker/grok-turn-runner.mjs +11 -0
- package/server/migrations/0017-conversation-history-indexes.mjs +89 -0
- package/server/migrations/0018-background-task-rows.mjs +101 -0
- package/server/public/app/action-confirmations.js +12 -15
- package/server/public/app/api-client.js +70 -3
- package/server/public/app/background-tasks-view.mjs +334 -16
- package/server/public/app/bootstrap.js +102 -5
- package/server/public/app/claude-background-command-limit-options.mjs +21 -0
- package/server/public/app/conversation-payload-cache.mjs +357 -0
- package/server/public/app/conversation-preferences.mjs +10 -4
- package/server/public/app/conversation-turn-state.mjs +1 -1
- package/server/public/app/conversation-view.js +462 -8
- package/server/public/app/conversation-window-retention.mjs +96 -0
- package/server/public/app/host-suspend-ui.js +45 -44
- package/server/public/app/journal-view.js +259 -67
- package/server/public/app/push-settings.js +1 -0
- package/server/public/app/relay-prompt-sanitizer.mjs +20 -6
- package/server/public/app/relay-restart-note-view.mjs +36 -0
- package/server/public/app/relay-restart-ui.js +568 -0
- package/server/public/app/settings-modal.js +162 -2
- package/server/public/app/socket-handlers.js +59 -7
- package/server/public/app/store.js +25 -0
- package/server/public/app/subagent-viewer.mjs +7 -1
- package/server/public/app/task-console-render.mjs +401 -0
- package/server/public/app/task-console-watch.mjs +42 -0
- package/server/public/app/task-console.mjs +730 -0
- package/server/public/app/task-output-text.mjs +243 -0
- package/server/public/app/transcript-island.mjs +73 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +42 -8
- package/server/public/app/transcript-jump-bottom.mjs +68 -11
- package/server/public/app/transcript-reading-anchor.mjs +256 -0
- package/server/public/app/transcript-reload-freshness.mjs +17 -1
- package/server/public/app/transcript-viewport-anchor.mjs +20 -1
- package/server/public/index.html +306 -6
- package/server/relay-tools.md +6 -0
- package/server/repositories/message-repository.mjs +11 -0
- package/server/repositories/question-repository.mjs +4 -0
- package/server/repositories/remote-relay-repository.mjs +4 -2
- package/server/routes/messages-routes.mjs +397 -23
- package/server/routes/relay-board-routes.mjs +19 -2
- package/server/routes/relay-restart-routes.mjs +73 -0
- package/server/routes/sessions-routes.mjs +208 -66
- package/server/server-runtime.mjs +269 -137
- package/server/services/about-service.mjs +13 -12
- package/server/services/background-task-persistence.mjs +133 -0
- package/server/services/background-task-store.mjs +470 -6
- package/server/services/cli-process-runner.mjs +4 -21
- package/server/services/conversation-history-page.mjs +199 -0
- package/server/services/conversation-model-fallback.mjs +57 -0
- package/server/services/host-suspend-command.mjs +2 -1
- package/server/services/preview-proxy-server.mjs +14 -1
- package/server/services/process-list-service.mjs +2 -1
- package/server/services/provider-model-selection.mjs +195 -0
- package/server/services/push-dispatch-service.mjs +9 -2
- package/server/services/relay-event-service.mjs +27 -9
- package/server/services/relay-prompt-sanitizer.mjs +18 -4
- package/server/services/relay-restart-picture.mjs +209 -0
- package/server/services/relay-restart-queue.mjs +380 -0
- package/server/services/relay-shutdown-gate.mjs +4 -1
- package/server/services/remote-relay-dispatcher.mjs +316 -20
- package/server/services/restart-resume-service.mjs +303 -0
- package/server/services/session-activity-tracker.mjs +6 -2
- package/server/services/session-worker-process-service.mjs +6 -4
- package/server/services/task-output-rooms.mjs +259 -0
- package/server/services/task-output-service.mjs +761 -0
- package/shared/claude-background-command-limit.mjs +83 -0
- package/shared/relay-events.mjs +55 -2
- package/shared/remote-relay-contract.mjs +47 -10
- package/shared/task-output-text.mjs +850 -0
- package/shared/worker-runtime/task-output-spool.mjs +181 -0
- 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(
|
|
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,
|