@oar-sh/oar 0.9.1 → 0.9.3
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/.github/extensions/web-relay/extension.mjs +0 -3
- package/.github/extensions/web-relay/polling/heartbeat.mjs +2 -55
- package/.github/extensions/web-relay/polling/polling-loop.mjs +0 -1
- package/.github/extensions/web-relay/runtime/api-client.mjs +2 -34
- package/.github/extensions/web-relay/runtime/config-loader.mjs +2 -104
- package/.github/extensions/web-relay/runtime/send-and-wait-errors.mjs +2 -170
- package/.github/extensions/web-relay/runtime/session-io.mjs +1 -71
- package/.github/extensions/web-relay/runtime/worker-websocket-link.mjs +2 -325
- package/.github/extensions/web-relay/skills/prompt-context.mjs +2 -160
- package/CHANGELOG.md +269 -0
- package/README.md +407 -424
- package/bin/oar.js +142 -11
- package/package.json +8 -4
- package/server/README.md +59 -22
- package/server/claude-worker/claude-sdk-adapter.mjs +7 -2
- package/server/claude-worker/claude-session-process.mjs +1013 -45
- package/server/claude-worker/claude-session-worker.mjs +54 -9
- package/server/claude-worker/claude-turn-publisher.mjs +110 -19
- package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
- package/server/copilot-sdk-runtime.mjs +12 -3
- package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
- package/server/copilot-worker/copilot-attachments.mjs +35 -2
- package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
- package/server/copilot-worker/copilot-model-switch.mjs +425 -0
- package/server/copilot-worker/copilot-prompt-context.mjs +20 -8
- package/server/copilot-worker/copilot-question-bridge.mjs +129 -16
- package/server/copilot-worker/copilot-sdk-adapter.mjs +59 -11
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +136 -12
- package/server/copilot-worker/copilot-sdk-session-process.mjs +2753 -527
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +94 -25
- package/server/cursor-worker/cursor-session-worker.mjs +5 -5
- package/server/cursor-worker/cursor-turn-runner.mjs +50 -4
- package/server/db-schema.mjs +61 -1
- package/server/features.mjs +155 -41
- package/server/grok-worker/grok-session-worker.mjs +5 -5
- package/server/grok-worker/grok-turn-runner.mjs +45 -4
- package/server/migrations/0004-steer-settle-markers.mjs +119 -0
- package/server/public/app/annotate-core.mjs +181 -0
- package/server/public/app/annotate-editor.js +617 -0
- package/server/public/app/api-client.js +86 -5
- package/server/public/app/ask-user-view.js +102 -13
- package/server/public/app/attachments-view.js +172 -3
- package/server/public/app/background-task-timeout-options.mjs +9 -0
- package/server/public/app/background-tasks-view.mjs +34 -19
- package/server/public/app/bootstrap.js +153 -69
- package/server/public/app/composer-control-state.mjs +52 -34
- package/server/public/app/context-tier-options.mjs +24 -4
- package/server/public/app/conversation-draft-sync.mjs +187 -0
- package/server/public/app/conversation-list-filter.mjs +86 -0
- package/server/public/app/conversation-preferences.mjs +47 -10
- package/server/public/app/conversation-provider-indicator.mjs +8 -0
- package/server/public/app/conversation-view.js +1032 -218
- package/server/public/app/emoji-view.js +2 -0
- package/server/public/app/font-scaling.js +4 -37
- package/server/public/app/journal-view.js +190 -24
- package/server/public/app/model-selector-options.mjs +185 -1
- package/server/public/app/new-conversation-model-choice.mjs +29 -9
- package/server/public/app/pwa-install.js +64 -136
- package/server/public/app/question-multi-select.mjs +52 -0
- package/server/public/app/reasoning-effort-labels.mjs +13 -3
- package/server/public/app/relay-prompt-sanitizer.mjs +26 -3
- package/server/public/app/router.js +120 -11
- package/server/public/app/settings-modal.js +225 -20
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +47 -4
- package/server/public/app/steered-turn-merge.mjs +100 -0
- package/server/public/app/store.js +159 -16
- package/server/public/app/sync-outbox.mjs +16 -8
- package/server/public/app/tool-activity-emoji.mjs +36 -0
- package/server/public/app/transcript-viewport-anchor.mjs +210 -0
- package/server/public/app/update-ui.js +194 -0
- package/server/public/index.html +483 -24
- package/server/public/sw.js +7 -0
- package/server/relay-tools.md +2 -0
- package/server/relay.mjs +23 -21
- package/server/repositories/message-repository.mjs +78 -21
- package/server/repositories/question-repository.mjs +19 -1
- package/server/repositories/session-repository.mjs +7 -31
- package/server/routes/ask-user-routes.mjs +10 -0
- package/server/routes/messages-routes.mjs +689 -159
- package/server/routes/sessions-routes.mjs +574 -80
- package/server/server-runtime.mjs +393 -925
- package/server/services/claude-auth-service.mjs +7 -1
- package/server/services/cli-install-service.mjs +8 -27
- package/server/services/cli-process-runner.mjs +30 -0
- package/server/services/copilot-model-discovery-service.mjs +202 -0
- package/server/services/data-dir-resolution.mjs +47 -0
- package/server/services/grok-auth-service.mjs +9 -1
- package/server/services/live-turn-picker.mjs +47 -0
- package/server/services/model-variant-catalog-service.mjs +1183 -0
- package/server/services/oar-cli-helpers.mjs +6 -2
- package/server/services/relay-prompt-sanitizer.mjs +30 -3
- package/server/services/relay-question-context.mjs +46 -0
- package/server/services/sdk-session-import-service.mjs +144 -16
- package/server/services/sdk-session-sync-service.mjs +84 -27
- package/server/services/session-worker-registry-service.mjs +116 -3
- package/server/services/session-worker-supervisor-service.mjs +7 -1
- package/server/services/session-worker-websocket-service.mjs +119 -24
- package/server/services/status-event-service.mjs +8 -2
- package/server/services/steer-resend.mjs +45 -0
- package/server/services/update-check-service.mjs +220 -0
- package/server/services/update-install-service.mjs +279 -0
- package/server/services/windows-autostart-service.mjs +42 -6
- package/server/services/windows-boot-autostart-service.mjs +487 -0
- package/shared/ask-user-bridge.mjs +37 -5
- package/shared/background-task-timeout.mjs +12 -4
- package/shared/claude-model-catalog.mjs +49 -0
- package/shared/context-window-fallbacks.mjs +1 -0
- package/shared/control-poller.mjs +4 -1
- package/shared/copilot-model-order.mjs +182 -0
- package/shared/media-embed-instructions.mjs +35 -0
- package/shared/model-descriptors.mjs +181 -30
- package/shared/model-id.mjs +6 -0
- package/shared/pwa-app-name.mjs +44 -0
- package/shared/question-multi-select.mjs +16 -0
- package/shared/relay-stop-failure.mjs +15 -0
- package/shared/slash-command-guard.mjs +76 -0
- package/shared/steer-note.mjs +66 -0
- package/shared/steer-settle-failure.mjs +89 -0
- package/shared/steer-settle-markers.mjs +18 -0
- package/shared/update-semver.mjs +50 -0
- package/shared/worker-crash-guard.mjs +40 -14
- package/shared/worker-runtime/api-client.mjs +34 -0
- package/shared/worker-runtime/config-loader.mjs +104 -0
- package/shared/worker-runtime/heartbeat.mjs +109 -0
- package/shared/worker-runtime/prompt-context.mjs +160 -0
- package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
- package/shared/worker-runtime/send-and-wait-errors.mjs +170 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +436 -0
- package/server/claude-worker/claude-session-test-harness.mjs +0 -236
- package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
- package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -329
- package/server/copilot-worker/fixtures/abort-turn.json +0 -588
- package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
- package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
- package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
- package/server/copilot-worker/fixtures/happy-turn.json +0 -566
- package/server/copilot-worker/fixtures/quota-turn.json +0 -647
- package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
- package/server/copilot-worker/fixtures/resume-turn.json +0 -700
- package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
- package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
- package/server/routes/messages-routes-test-harness.mjs +0 -111
- package/server/services/fixtures/claude-auth-stub.sh +0 -62
- package/server/services/fixtures/cli-install-stub.sh +0 -75
- package/server/services/fixtures/grok-stub.sh +0 -86
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { execFile, spawn } from 'child_process';
|
|
4
|
+
|
|
5
|
+
import { resolveOarRoot } from './oar-state-migration-service.mjs';
|
|
6
|
+
import {
|
|
7
|
+
LEGACY_WINDOWS_AUTOSTART_FILENAME,
|
|
8
|
+
WINDOWS_AUTOSTART_FILENAME,
|
|
9
|
+
escapeBatchValue,
|
|
10
|
+
resolveWindowsStartupDirectory,
|
|
11
|
+
} from './windows-autostart-service.mjs';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* "At system startup" autostart: a boot-trigger Scheduled Task running the
|
|
15
|
+
* relay as the current user via S4U ("run whether user is logged on or not",
|
|
16
|
+
* no stored password), headless, reachable before anyone signs in.
|
|
17
|
+
*
|
|
18
|
+
* The task's settings are a verbatim port of the hand-made task this design
|
|
19
|
+
* was proven on (docs/plans/2026-09-02-windows-boot-autostart.md): boot
|
|
20
|
+
* trigger +30s, no execution time limit, restart 5x/1min, StartWhenAvailable,
|
|
21
|
+
* IgnoreNew. The task executes a generated launcher .cmd rather than node
|
|
22
|
+
* directly, so a config-path change only regenerates a file — it never needs
|
|
23
|
+
* the task re-registered (which would mean another elevation).
|
|
24
|
+
*
|
|
25
|
+
* Elevation is THE constraint here: registering or deleting a boot-trigger
|
|
26
|
+
* task needs administrator rights and the relay runs unelevated. enable() and
|
|
27
|
+
* disable() therefore spawn `Start-Process -Verb RunAs` around a generated
|
|
28
|
+
* sweep script; the UAC prompt renders on the PC's physical desktop only, so
|
|
29
|
+
* both report `pendingElevation` and the caller polls getState() until the
|
|
30
|
+
* task appears/disappears (or shows `manualCommand` after a decline/timeout).
|
|
31
|
+
* The elevated script does nothing but the schtasks calls — every file write
|
|
32
|
+
* and cleanup stays in this unelevated process.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
export const BOOT_TASK_NAME = 'oar-relay';
|
|
36
|
+
// The hand-made pre-rebrand task. Recognized so the UI can offer migration,
|
|
37
|
+
// and deleted inside the same elevated sweep that registers the new name —
|
|
38
|
+
// one UAC covers the whole transition.
|
|
39
|
+
export const LEGACY_BOOT_TASK_NAME = 'copilot-remote-relay';
|
|
40
|
+
export const BOOT_LAUNCHER_FILENAME = 'oar-web-relay-launcher.cmd';
|
|
41
|
+
export const BOOT_TASK_XML_FILENAME = 'oar-relay-task.xml';
|
|
42
|
+
export const BOOT_ELEVATION_SCRIPT_FILENAME = 'oar-relay-elevate.ps1';
|
|
43
|
+
const ELEVATION_TIMEOUT_MS = 120_000;
|
|
44
|
+
|
|
45
|
+
function normalizeText(value) {
|
|
46
|
+
return String(value == null ? '' : value).trim();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function escapeXml(value) {
|
|
50
|
+
return String(value == null ? '' : value)
|
|
51
|
+
.replaceAll('&', '&')
|
|
52
|
+
.replaceAll('<', '<')
|
|
53
|
+
.replaceAll('>', '>')
|
|
54
|
+
.replaceAll('"', '"')
|
|
55
|
+
.replaceAll("'", ''');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The launcher the task executes. Same shape as the sign-in .cmd minus the
|
|
60
|
+
* `title` line: a boot task runs in session 0 where no console is ever shown.
|
|
61
|
+
*/
|
|
62
|
+
export function buildBootLauncherScript({
|
|
63
|
+
packageRoot,
|
|
64
|
+
nodePath = process.execPath,
|
|
65
|
+
configPath = '',
|
|
66
|
+
// Where the relay process lives, NOT where its code lives: on Windows a
|
|
67
|
+
// process pins its cwd, and self-update must be able to replace the package
|
|
68
|
+
// directory. Callers pass the OAR state root; the package-root default only
|
|
69
|
+
// serves builders that predate self-update.
|
|
70
|
+
workingDirectory = '',
|
|
71
|
+
pathImpl = path,
|
|
72
|
+
} = {}) {
|
|
73
|
+
if (!normalizeText(packageRoot)) {
|
|
74
|
+
throw new Error('Windows boot autostart requires the package root.');
|
|
75
|
+
}
|
|
76
|
+
if (!normalizeText(nodePath)) {
|
|
77
|
+
throw new Error('Windows boot autostart requires the Node.js executable path.');
|
|
78
|
+
}
|
|
79
|
+
const resolvedPackageRoot = pathImpl.resolve(String(packageRoot));
|
|
80
|
+
const serverPath = pathImpl.join(resolvedPackageRoot, 'server', 'server.js');
|
|
81
|
+
const lines = [
|
|
82
|
+
'@echo off',
|
|
83
|
+
'rem Generated by OAR settings (system-startup autostart). Regenerated on',
|
|
84
|
+
'rem every settings change - do not edit by hand.',
|
|
85
|
+
];
|
|
86
|
+
if (normalizeText(configPath)) {
|
|
87
|
+
lines.push(`set "COPILOT_WEB_RELAY_CONFIG=${escapeBatchValue(pathImpl.resolve(configPath))}"`);
|
|
88
|
+
}
|
|
89
|
+
const cwdTarget = normalizeText(workingDirectory)
|
|
90
|
+
? pathImpl.resolve(String(workingDirectory))
|
|
91
|
+
: resolvedPackageRoot;
|
|
92
|
+
lines.push(
|
|
93
|
+
`cd /d "${escapeBatchValue(cwdTarget)}"`,
|
|
94
|
+
`"${escapeBatchValue(pathImpl.resolve(nodePath))}" "${escapeBatchValue(serverPath)}"`,
|
|
95
|
+
'',
|
|
96
|
+
);
|
|
97
|
+
return lines.join('\r\n');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Task Scheduler XML, settings ported verbatim from the proven manual task.
|
|
102
|
+
* The S4U principal is registered by SID, not account name: a by-name
|
|
103
|
+
* principal breaks if the user is renamed, a SID does not.
|
|
104
|
+
*/
|
|
105
|
+
export function buildBootTaskXml({
|
|
106
|
+
sid,
|
|
107
|
+
launcherPath,
|
|
108
|
+
packageRoot,
|
|
109
|
+
// See buildBootLauncherScript: the task's cwd must survive a self-update
|
|
110
|
+
// replacing the package directory, so callers pass the OAR state root.
|
|
111
|
+
workingDirectory = '',
|
|
112
|
+
description = 'Starts the OAR web relay at system startup (S4U, no logon required). Managed by OAR settings.',
|
|
113
|
+
} = {}) {
|
|
114
|
+
if (!/^S-1-[\d-]+$/.test(normalizeText(sid))) {
|
|
115
|
+
throw new Error(`Windows boot autostart requires the current user's SID (got: ${JSON.stringify(sid)}).`);
|
|
116
|
+
}
|
|
117
|
+
if (!normalizeText(launcherPath)) {
|
|
118
|
+
throw new Error('Windows boot autostart requires the launcher path.');
|
|
119
|
+
}
|
|
120
|
+
if (!normalizeText(packageRoot)) {
|
|
121
|
+
throw new Error('Windows boot autostart requires the package root.');
|
|
122
|
+
}
|
|
123
|
+
return [
|
|
124
|
+
'<?xml version="1.0" encoding="UTF-16"?>',
|
|
125
|
+
'<Task version="1.3" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">',
|
|
126
|
+
' <RegistrationInfo>',
|
|
127
|
+
` <Description>${escapeXml(description)}</Description>`,
|
|
128
|
+
` <URI>\\${BOOT_TASK_NAME}</URI>`,
|
|
129
|
+
' </RegistrationInfo>',
|
|
130
|
+
' <Principals>',
|
|
131
|
+
' <Principal id="Author">',
|
|
132
|
+
` <UserId>${escapeXml(normalizeText(sid))}</UserId>`,
|
|
133
|
+
' <LogonType>S4U</LogonType>',
|
|
134
|
+
' </Principal>',
|
|
135
|
+
' </Principals>',
|
|
136
|
+
' <Settings>',
|
|
137
|
+
' <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>',
|
|
138
|
+
' <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>',
|
|
139
|
+
' <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>',
|
|
140
|
+
' <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>',
|
|
141
|
+
' <RestartOnFailure>',
|
|
142
|
+
' <Count>5</Count>',
|
|
143
|
+
' <Interval>PT1M</Interval>',
|
|
144
|
+
' </RestartOnFailure>',
|
|
145
|
+
' <StartWhenAvailable>true</StartWhenAvailable>',
|
|
146
|
+
' <UseUnifiedSchedulingEngine>true</UseUnifiedSchedulingEngine>',
|
|
147
|
+
' </Settings>',
|
|
148
|
+
' <Triggers>',
|
|
149
|
+
' <BootTrigger>',
|
|
150
|
+
' <Delay>PT30S</Delay>',
|
|
151
|
+
' </BootTrigger>',
|
|
152
|
+
' </Triggers>',
|
|
153
|
+
' <Actions Context="Author">',
|
|
154
|
+
' <Exec>',
|
|
155
|
+
' <Command>C:\\Windows\\System32\\cmd.exe</Command>',
|
|
156
|
+
` <Arguments>/d /c "${escapeXml(launcherPath)}"</Arguments>`,
|
|
157
|
+
` <WorkingDirectory>${escapeXml(normalizeText(workingDirectory) || packageRoot)}</WorkingDirectory>`,
|
|
158
|
+
' </Exec>',
|
|
159
|
+
' </Actions>',
|
|
160
|
+
'</Task>',
|
|
161
|
+
'',
|
|
162
|
+
].join('\r\n');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* schtasks rejects a UTF-8 file whose XML header declares UTF-16 (and the
|
|
167
|
+
* header must declare UTF-16 for /create /xml) — so the bytes on disk are
|
|
168
|
+
* UTF-16LE with a BOM, matching what `schtasks /query /xml` itself exports.
|
|
169
|
+
*/
|
|
170
|
+
export function encodeTaskXml(xml) {
|
|
171
|
+
return Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(String(xml), 'utf16le')]);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Minimal field scrape of a task's exported XML; no XML parser dependency. */
|
|
175
|
+
export function parseTaskXml(xmlText) {
|
|
176
|
+
const text = String(xmlText == null ? '' : xmlText);
|
|
177
|
+
const grab = (tag) => {
|
|
178
|
+
const match = text.match(new RegExp(`<${tag}>([^<]*)</${tag}>`));
|
|
179
|
+
return match ? match[1].trim() : null;
|
|
180
|
+
};
|
|
181
|
+
return {
|
|
182
|
+
command: grab('Command'),
|
|
183
|
+
argumentsLine: grab('Arguments'),
|
|
184
|
+
workingDirectory: grab('WorkingDirectory'),
|
|
185
|
+
logonType: grab('LogonType'),
|
|
186
|
+
userId: grab('UserId'),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Ours or someone else's? A task counts as ours when its action runs our
|
|
192
|
+
* launcher path; anything else under our name is `foreign` and the UI offers
|
|
193
|
+
* an explicit "Replace", never a silent overwrite.
|
|
194
|
+
*/
|
|
195
|
+
export function classifyTaskXml(xmlText, { launcherPath } = {}) {
|
|
196
|
+
const parsed = parseTaskXml(xmlText);
|
|
197
|
+
const needle = normalizeText(launcherPath).toLowerCase();
|
|
198
|
+
if (needle && String(parsed.argumentsLine || '').toLowerCase().includes(needle)) {
|
|
199
|
+
return 'ready';
|
|
200
|
+
}
|
|
201
|
+
return 'foreign';
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The elevated sweep. Everything the admin token is actually needed for —
|
|
206
|
+
* and nothing else — in one script, so one UAC covers the whole transition
|
|
207
|
+
* out of any mixed state (legacy-named task, hand-made task, fresh install).
|
|
208
|
+
*/
|
|
209
|
+
export function buildElevationScript({ action, xmlPath } = {}) {
|
|
210
|
+
if (action === 'enable') {
|
|
211
|
+
if (!normalizeText(xmlPath)) {
|
|
212
|
+
throw new Error('The enable sweep needs the task XML path.');
|
|
213
|
+
}
|
|
214
|
+
return [
|
|
215
|
+
`schtasks /delete /tn "${LEGACY_BOOT_TASK_NAME}" /f 2>$null`,
|
|
216
|
+
`schtasks /delete /tn "${BOOT_TASK_NAME}" /f 2>$null`,
|
|
217
|
+
`schtasks /create /tn "${BOOT_TASK_NAME}" /xml "${xmlPath}"`,
|
|
218
|
+
'exit $LASTEXITCODE',
|
|
219
|
+
'',
|
|
220
|
+
].join('\r\n');
|
|
221
|
+
}
|
|
222
|
+
if (action === 'disable') {
|
|
223
|
+
return [
|
|
224
|
+
`schtasks /delete /tn "${BOOT_TASK_NAME}" /f 2>$null`,
|
|
225
|
+
`schtasks /delete /tn "${LEGACY_BOOT_TASK_NAME}" /f 2>$null`,
|
|
226
|
+
'exit 0',
|
|
227
|
+
'',
|
|
228
|
+
].join('\r\n');
|
|
229
|
+
}
|
|
230
|
+
throw new Error(`Unknown elevation action: ${JSON.stringify(action)}`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** `whoami /user /fo csv` → the current user's SID. */
|
|
234
|
+
export function parseSidFromWhoami(stdout) {
|
|
235
|
+
const match = String(stdout || '').match(/"(S-1-[\d-]+)"/);
|
|
236
|
+
return match ? match[1] : null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function defaultExecFileImpl(file, args) {
|
|
240
|
+
return new Promise((resolve) => {
|
|
241
|
+
execFile(file, args, { windowsHide: true }, (error, stdout, stderr) => {
|
|
242
|
+
resolve({
|
|
243
|
+
code: error ? (typeof error.code === 'number' ? error.code : 1) : 0,
|
|
244
|
+
stdout: String(stdout || ''),
|
|
245
|
+
stderr: String(stderr || ''),
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function createWindowsBootAutostartService({
|
|
252
|
+
platform = process.platform,
|
|
253
|
+
env = process.env,
|
|
254
|
+
packageRoot,
|
|
255
|
+
nodePath = process.execPath,
|
|
256
|
+
configPath = '',
|
|
257
|
+
fsImpl = fs,
|
|
258
|
+
pathImpl = path,
|
|
259
|
+
execFileImpl = defaultExecFileImpl,
|
|
260
|
+
spawnImpl = spawn,
|
|
261
|
+
setTimeoutImpl = setTimeout,
|
|
262
|
+
clearTimeoutImpl = clearTimeout,
|
|
263
|
+
now = () => Date.now(),
|
|
264
|
+
logger = console,
|
|
265
|
+
} = {}) {
|
|
266
|
+
const supported = platform === 'win32';
|
|
267
|
+
// One elevation in flight at a time; getState() reports it and the
|
|
268
|
+
// completion callback resolves it.
|
|
269
|
+
let pending = null; // { action, startedAt, timer, child }
|
|
270
|
+
let lastError = null;
|
|
271
|
+
|
|
272
|
+
const oarRoot = () => resolveOarRoot(env, platform);
|
|
273
|
+
const launcherPath = () => pathImpl.join(oarRoot(), BOOT_LAUNCHER_FILENAME);
|
|
274
|
+
const xmlPath = () => pathImpl.join(oarRoot(), BOOT_TASK_XML_FILENAME);
|
|
275
|
+
const elevationScriptPath = () => pathImpl.join(oarRoot(), BOOT_ELEVATION_SCRIPT_FILENAME);
|
|
276
|
+
|
|
277
|
+
function log(message) {
|
|
278
|
+
try { logger?.log?.(`[boot-autostart] ${message}`); } catch {}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function queryTaskXml(taskName) {
|
|
282
|
+
const result = await execFileImpl('schtasks', ['/query', '/tn', taskName, '/xml']);
|
|
283
|
+
return result.code === 0 ? result.stdout : null;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function startupCmdPaths() {
|
|
287
|
+
const dir = resolveWindowsStartupDirectory(env, pathImpl);
|
|
288
|
+
return [
|
|
289
|
+
pathImpl.join(dir, WINDOWS_AUTOSTART_FILENAME),
|
|
290
|
+
pathImpl.join(dir, LEGACY_WINDOWS_AUTOSTART_FILENAME),
|
|
291
|
+
];
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function removeStartupCmds() {
|
|
295
|
+
let removed = 0;
|
|
296
|
+
for (const target of startupCmdPaths()) {
|
|
297
|
+
try {
|
|
298
|
+
if (fsImpl.existsSync(target)) {
|
|
299
|
+
fsImpl.unlinkSync(target);
|
|
300
|
+
removed += 1;
|
|
301
|
+
}
|
|
302
|
+
} catch (error) {
|
|
303
|
+
log(`could not remove ${target}: ${error?.message || error}`);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return removed;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function manualCommandFor(action) {
|
|
310
|
+
return action === 'disable'
|
|
311
|
+
? `schtasks /delete /tn "${BOOT_TASK_NAME}" /f`
|
|
312
|
+
: `schtasks /create /tn "${BOOT_TASK_NAME}" /xml "${xmlPath()}" /f`;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function spawnsDisabled() {
|
|
316
|
+
// The e2e harness's kill switch. schtasks is not a provider CLI, but the
|
|
317
|
+
// point is identical: an isolated relay must not read (let alone mutate)
|
|
318
|
+
// host machine state.
|
|
319
|
+
return Boolean(normalizeText(env?.COPILOT_WEB_RELAY_DISABLE_CLI_SPAWN));
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function getState() {
|
|
323
|
+
if (!supported) {
|
|
324
|
+
return { supported: false, platform };
|
|
325
|
+
}
|
|
326
|
+
if (spawnsDisabled()) {
|
|
327
|
+
return {
|
|
328
|
+
supported: true,
|
|
329
|
+
platform,
|
|
330
|
+
taskStatus: 'unknown',
|
|
331
|
+
taskName: BOOT_TASK_NAME,
|
|
332
|
+
legacyTaskPresent: false,
|
|
333
|
+
launcherPath: null,
|
|
334
|
+
pendingElevation: null,
|
|
335
|
+
lastError: null,
|
|
336
|
+
manualCommand: manualCommandFor('enable'),
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
const ownXml = await queryTaskXml(BOOT_TASK_NAME);
|
|
340
|
+
const legacyXml = await queryTaskXml(LEGACY_BOOT_TASK_NAME);
|
|
341
|
+
const taskStatus = ownXml === null
|
|
342
|
+
? (legacyXml !== null ? 'legacy' : 'missing')
|
|
343
|
+
: classifyTaskXml(ownXml, { launcherPath: launcherPath() });
|
|
344
|
+
return {
|
|
345
|
+
supported: true,
|
|
346
|
+
platform,
|
|
347
|
+
taskStatus,
|
|
348
|
+
taskName: BOOT_TASK_NAME,
|
|
349
|
+
legacyTaskPresent: legacyXml !== null,
|
|
350
|
+
launcherPath: fsImpl.existsSync(launcherPath()) ? launcherPath() : null,
|
|
351
|
+
pendingElevation: pending ? pending.action : null,
|
|
352
|
+
lastError,
|
|
353
|
+
manualCommand: manualCommandFor(pending?.action || 'enable'),
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function writeFileAtomic(targetPath, contents) {
|
|
358
|
+
fsImpl.mkdirSync(pathImpl.dirname(targetPath), { recursive: true });
|
|
359
|
+
const temporaryPath = `${targetPath}.tmp`;
|
|
360
|
+
fsImpl.writeFileSync(temporaryPath, contents);
|
|
361
|
+
fsImpl.renameSync(temporaryPath, targetPath);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function settlePending(outcome) {
|
|
365
|
+
if (!pending) return;
|
|
366
|
+
if (pending.timer) clearTimeoutImpl(pending.timer);
|
|
367
|
+
pending = null;
|
|
368
|
+
lastError = outcome || null;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* The elevated child is powershell running Start-Process -Verb RunAs -Wait:
|
|
373
|
+
* the outer process lives exactly as long as the UAC prompt plus the sweep
|
|
374
|
+
* script, so its exit is the "elevation finished (or was declined)" signal.
|
|
375
|
+
*/
|
|
376
|
+
function spawnElevated(action, onSettled) {
|
|
377
|
+
const child = spawnImpl('powershell.exe', [
|
|
378
|
+
'-NoProfile',
|
|
379
|
+
'-NonInteractive',
|
|
380
|
+
'-Command',
|
|
381
|
+
[
|
|
382
|
+
'$p = Start-Process -FilePath powershell.exe -Verb RunAs -Wait -PassThru -WindowStyle Hidden -ArgumentList',
|
|
383
|
+
`'-NoProfile','-ExecutionPolicy','Bypass','-File','${elevationScriptPath()}'`,
|
|
384
|
+
'; exit $p.ExitCode',
|
|
385
|
+
].join(' '),
|
|
386
|
+
], {
|
|
387
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
388
|
+
windowsHide: true,
|
|
389
|
+
});
|
|
390
|
+
let stderr = '';
|
|
391
|
+
child.stderr?.on?.('data', (chunk) => { stderr = `${stderr}${chunk}`.slice(-2000); });
|
|
392
|
+
const timer = setTimeoutImpl(() => {
|
|
393
|
+
try { child.kill('SIGTERM'); } catch {}
|
|
394
|
+
settlePending(`${action} timed out waiting for the admin confirmation on the PC.`);
|
|
395
|
+
onSettled?.(false);
|
|
396
|
+
}, ELEVATION_TIMEOUT_MS);
|
|
397
|
+
timer?.unref?.();
|
|
398
|
+
pending = { action, startedAt: now(), timer, child };
|
|
399
|
+
child.on('error', (error) => {
|
|
400
|
+
settlePending(`${action} could not request elevation: ${error?.message || error}`);
|
|
401
|
+
onSettled?.(false);
|
|
402
|
+
});
|
|
403
|
+
child.on('close', (code) => {
|
|
404
|
+
if (!pending) return; // timeout already settled it
|
|
405
|
+
if (code === 0) {
|
|
406
|
+
settlePending(null);
|
|
407
|
+
onSettled?.(true);
|
|
408
|
+
} else {
|
|
409
|
+
// 1 from Start-Process usually means the UAC prompt was declined.
|
|
410
|
+
const detail = normalizeText(stderr).split('\n')[0] || `exit code ${code}`;
|
|
411
|
+
settlePending(`${action} was not confirmed (${detail}). Run the command below from an elevated shell instead.`);
|
|
412
|
+
onSettled?.(false);
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
async function requestEnable() {
|
|
418
|
+
if (!supported) {
|
|
419
|
+
throw new Error('Windows boot autostart is only available on Windows.');
|
|
420
|
+
}
|
|
421
|
+
if (spawnsDisabled()) {
|
|
422
|
+
throw new Error('Windows boot autostart is disabled on this relay (CLI spawns are off).');
|
|
423
|
+
}
|
|
424
|
+
if (pending) {
|
|
425
|
+
return { ...(await getState()), accepted: false };
|
|
426
|
+
}
|
|
427
|
+
lastError = null;
|
|
428
|
+
writeFileAtomic(launcherPath(), buildBootLauncherScript({ packageRoot, nodePath, configPath, workingDirectory: oarRoot(), pathImpl }));
|
|
429
|
+
const who = await execFileImpl('whoami', ['/user', '/fo', 'csv']);
|
|
430
|
+
const sid = parseSidFromWhoami(who.stdout);
|
|
431
|
+
if (!sid) {
|
|
432
|
+
lastError = 'Could not determine the current user SID (whoami /user failed).';
|
|
433
|
+
return { ...(await getState()), accepted: false };
|
|
434
|
+
}
|
|
435
|
+
writeFileAtomic(xmlPath(), encodeTaskXml(buildBootTaskXml({
|
|
436
|
+
sid,
|
|
437
|
+
launcherPath: launcherPath(),
|
|
438
|
+
packageRoot: pathImpl.resolve(String(packageRoot)),
|
|
439
|
+
workingDirectory: oarRoot(),
|
|
440
|
+
})));
|
|
441
|
+
writeFileAtomic(elevationScriptPath(), buildElevationScript({ action: 'enable', xmlPath: xmlPath() }));
|
|
442
|
+
spawnElevated('enable', (ok) => {
|
|
443
|
+
if (ok) {
|
|
444
|
+
// Mutual exclusion: boot mode owns startup now, so the sign-in
|
|
445
|
+
// entries (either name) must not race it at the next logon.
|
|
446
|
+
const removed = removeStartupCmds();
|
|
447
|
+
log(`enabled; removed ${removed} sign-in autostart entr${removed === 1 ? 'y' : 'ies'}`);
|
|
448
|
+
} else {
|
|
449
|
+
log(`enable did not complete: ${lastError}`);
|
|
450
|
+
}
|
|
451
|
+
});
|
|
452
|
+
return { ...(await getState()), accepted: true };
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
async function requestDisable() {
|
|
456
|
+
if (!supported) {
|
|
457
|
+
throw new Error('Windows boot autostart is only available on Windows.');
|
|
458
|
+
}
|
|
459
|
+
if (spawnsDisabled()) {
|
|
460
|
+
throw new Error('Windows boot autostart is disabled on this relay (CLI spawns are off).');
|
|
461
|
+
}
|
|
462
|
+
if (pending) {
|
|
463
|
+
return { ...(await getState()), accepted: false };
|
|
464
|
+
}
|
|
465
|
+
lastError = null;
|
|
466
|
+
writeFileAtomic(elevationScriptPath(), buildElevationScript({ action: 'disable' }));
|
|
467
|
+
spawnElevated('disable', (ok) => {
|
|
468
|
+
if (ok) {
|
|
469
|
+
try {
|
|
470
|
+
if (fsImpl.existsSync(launcherPath())) fsImpl.unlinkSync(launcherPath());
|
|
471
|
+
} catch (error) {
|
|
472
|
+
log(`could not remove launcher: ${error?.message || error}`);
|
|
473
|
+
}
|
|
474
|
+
log('disabled');
|
|
475
|
+
} else {
|
|
476
|
+
log(`disable did not complete: ${lastError}`);
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
return { ...(await getState()), accepted: true };
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
return {
|
|
483
|
+
getState,
|
|
484
|
+
requestEnable,
|
|
485
|
+
requestDisable,
|
|
486
|
+
};
|
|
487
|
+
}
|
|
@@ -17,6 +17,12 @@ function normalizeQuestions(input) {
|
|
|
17
17
|
rawQuestion: String(entry?.question || ''),
|
|
18
18
|
header: String(entry?.header || '').trim(),
|
|
19
19
|
multiSelect: entry?.multiSelect === true,
|
|
20
|
+
// Structured-elicitation parity: a schema object rides through to the
|
|
21
|
+
// create payload untouched (the server normalizes and validates it).
|
|
22
|
+
requestedSchema: entry?.requestedSchema && typeof entry.requestedSchema === 'object'
|
|
23
|
+
&& !Array.isArray(entry.requestedSchema)
|
|
24
|
+
? entry.requestedSchema
|
|
25
|
+
: null,
|
|
20
26
|
options: (Array.isArray(entry?.options) ? entry.options : [])
|
|
21
27
|
.map((option) => ({
|
|
22
28
|
label: String(option?.label || '').trim(),
|
|
@@ -47,8 +53,12 @@ export function createAskUserBridge({
|
|
|
47
53
|
questionRationale = 'Claude requested clarification to continue this turn.',
|
|
48
54
|
dbg = () => {},
|
|
49
55
|
} = {}) {
|
|
50
|
-
|
|
56
|
+
// `timeoutMs` overrides the bridge-wide default for one wait (a structured
|
|
57
|
+
// elicitation may carry its own deadline). Additive: existing callers pass
|
|
58
|
+
// only `signal` and behave exactly as before.
|
|
59
|
+
async function waitForRelayQuestionAnswer(questionId, { signal, timeoutMs } = {}) {
|
|
51
60
|
const started = Date.now();
|
|
61
|
+
const deadlineMs = timeoutMs ?? questionTimeoutMs;
|
|
52
62
|
while (true) {
|
|
53
63
|
if (signal?.aborted) {
|
|
54
64
|
await api('POST', `/api/relay-question/${questionId}/timeout`, {}).catch(() => {});
|
|
@@ -57,12 +67,22 @@ export function createAskUserBridge({
|
|
|
57
67
|
const { question } = await api('GET', `/api/relay-question/${questionId}`);
|
|
58
68
|
if (!question) throw new Error('Relay question missing');
|
|
59
69
|
if (question.status === 'answered') {
|
|
60
|
-
return {
|
|
70
|
+
return {
|
|
71
|
+
answer: String(question.answer || '').trim(),
|
|
72
|
+
// The validated structured submission, when the card carried a
|
|
73
|
+
// `requestedSchema` and the relay stored one. Null otherwise — flat
|
|
74
|
+
// consumers read `answer` and never see a shape change.
|
|
75
|
+
structuredAnswer: question.structuredAnswer && typeof question.structuredAnswer === 'object'
|
|
76
|
+
&& !Array.isArray(question.structuredAnswer)
|
|
77
|
+
? question.structuredAnswer
|
|
78
|
+
: null,
|
|
79
|
+
timedOut: false,
|
|
80
|
+
};
|
|
61
81
|
}
|
|
62
82
|
if (question.status === 'timed_out' || question.status === 'cancelled') {
|
|
63
83
|
return { answer: QUESTION_TIMEOUT_CONTINUATION_TEXT, timedOut: true };
|
|
64
84
|
}
|
|
65
|
-
if (Date.now() - started >=
|
|
85
|
+
if (Date.now() - started >= deadlineMs) {
|
|
66
86
|
await api('POST', `/api/relay-question/${questionId}/timeout`, {}).catch(() => {});
|
|
67
87
|
return { answer: QUESTION_TIMEOUT_CONTINUATION_TEXT, timedOut: true };
|
|
68
88
|
}
|
|
@@ -86,7 +106,13 @@ export function createAskUserBridge({
|
|
|
86
106
|
prompt: promptParts.join('\n\n'),
|
|
87
107
|
choices,
|
|
88
108
|
allowFreeform: true,
|
|
109
|
+
// Top-level, as the create route reads it (elicitation parity; absent
|
|
110
|
+
// for the flat question shape every existing caller sends).
|
|
111
|
+
...(entry.requestedSchema ? { requestedSchema: entry.requestedSchema } : {}),
|
|
89
112
|
sdk_session_id: sdkSessionId || undefined,
|
|
113
|
+
// Fences the card to the delivering attempt: the server refuses creation
|
|
114
|
+
// once the row has been requeued to a newer attempt.
|
|
115
|
+
attemptId: activeMsg?.attemptId || undefined,
|
|
90
116
|
timeout_ms: questionTimeoutMs,
|
|
91
117
|
context: {
|
|
92
118
|
source: questionSource,
|
|
@@ -108,20 +134,26 @@ export function createAskUserBridge({
|
|
|
108
134
|
async function handleAskUserQuestion(input, { signal } = {}) {
|
|
109
135
|
const questions = normalizeQuestions(input);
|
|
110
136
|
if (!questions.length) {
|
|
111
|
-
return { answers: {}, timedOut: false };
|
|
137
|
+
return { answers: {}, structuredAnswers: {}, timedOut: false };
|
|
112
138
|
}
|
|
113
139
|
const answers = {};
|
|
140
|
+
// Validated structured submissions, keyed like `answers`, present only for
|
|
141
|
+
// questions that carried a `requestedSchema`. Flat consumers keep reading
|
|
142
|
+
// `answers` unchanged.
|
|
143
|
+
const structuredAnswers = {};
|
|
114
144
|
let timedOut = false;
|
|
115
145
|
for (const entry of questions) {
|
|
116
146
|
const result = await askSingleQuestion(entry, { signal });
|
|
117
147
|
answers[entry.question] = result.answer;
|
|
148
|
+
if (result.structuredAnswer) structuredAnswers[entry.question] = result.structuredAnswer;
|
|
118
149
|
if (entry.rawQuestion && entry.rawQuestion !== entry.question) {
|
|
119
150
|
answers[entry.rawQuestion] = result.answer;
|
|
151
|
+
if (result.structuredAnswer) structuredAnswers[entry.rawQuestion] = result.structuredAnswer;
|
|
120
152
|
}
|
|
121
153
|
if (result.timedOut) timedOut = true;
|
|
122
154
|
if (result.aborted) break;
|
|
123
155
|
}
|
|
124
|
-
return { answers, timedOut };
|
|
156
|
+
return { answers, structuredAnswers, timedOut };
|
|
125
157
|
}
|
|
126
158
|
|
|
127
159
|
return {
|
|
@@ -2,14 +2,22 @@
|
|
|
2
2
|
// persistent Claude CLI process alive, shared so the settings slider and the
|
|
3
3
|
// worker enforcing it agree on bounds and formatting.
|
|
4
4
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
5
|
+
// The default is 4 hours: long enough for real long-running work to finish,
|
|
6
|
+
// short enough that a forgotten shell or agent does not keep its runtime
|
|
7
|
+
// alive indefinitely. 0 (chosen explicitly) means no limit: a process holding
|
|
8
|
+
// live tasks stays up until the tasks finish, the user stops them from the
|
|
9
|
+
// composer panel, or the worker/relay goes down. Mirrors
|
|
10
|
+
// shared/turn-ceiling.mjs.
|
|
11
|
+
//
|
|
12
|
+
// The browser mirrors the default in
|
|
13
|
+
// server/public/app/background-task-timeout-options.mjs (only server/public is
|
|
14
|
+
// served), and the slider's static markup in server/public/index.html starts
|
|
15
|
+
// at it; unit tests pin both to this module.
|
|
8
16
|
|
|
9
17
|
export const BACKGROUND_TASK_TIMEOUT_MIN_MINUTES = 0;
|
|
10
18
|
export const BACKGROUND_TASK_TIMEOUT_MAX_MINUTES = 600;
|
|
11
19
|
export const BACKGROUND_TASK_TIMEOUT_STEP_MINUTES = 5;
|
|
12
|
-
export const DEFAULT_BACKGROUND_TASK_TIMEOUT_MINUTES =
|
|
20
|
+
export const DEFAULT_BACKGROUND_TASK_TIMEOUT_MINUTES = 240;
|
|
13
21
|
|
|
14
22
|
/** 0 disables the timeout entirely (unlimited). */
|
|
15
23
|
export function normalizeBackgroundTaskTimeoutMinutes(value, fallback = DEFAULT_BACKGROUND_TASK_TIMEOUT_MINUTES) {
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// How the relay's Claude model list survives Anthropic rotating its lineup.
|
|
2
|
+
//
|
|
3
|
+
// `supportedModels()` reports only what the CLI currently advertises, so a
|
|
4
|
+
// straight replace silently drops a model you are using the moment a successor
|
|
5
|
+
// appears (Fable 5 -> Fable 5.1, 2026-09-05). Previous ids keep working through
|
|
6
|
+
// `--model` until they are actually retired server-side, so the catalog keeps
|
|
7
|
+
// them and lets the user decide when to stop using one.
|
|
8
|
+
|
|
9
|
+
export const MAX_CLAUDE_CATALOG_MODELS = 32;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Freshly discovered models first (they are the current lineup), then models a
|
|
13
|
+
* previous discovery found and this one no longer advertises. The default model
|
|
14
|
+
* always leads so the composer's selection can never fall out of its own list.
|
|
15
|
+
*/
|
|
16
|
+
export function mergeDiscoveredClaudeModels({
|
|
17
|
+
defaultModel = '',
|
|
18
|
+
discovered = [],
|
|
19
|
+
previouslyKnown = [],
|
|
20
|
+
max = MAX_CLAUDE_CATALOG_MODELS,
|
|
21
|
+
} = {}) {
|
|
22
|
+
const merged = [];
|
|
23
|
+
const seen = new Set();
|
|
24
|
+
const add = (value) => {
|
|
25
|
+
const id = String(value || '').trim();
|
|
26
|
+
if (!id) return;
|
|
27
|
+
// Ids are conventionally lowercase; dedupe case-insensitively but keep the
|
|
28
|
+
// first spelling seen so discovery's casing wins over a stored variant.
|
|
29
|
+
const key = id.toLowerCase();
|
|
30
|
+
if (seen.has(key)) return;
|
|
31
|
+
seen.add(key);
|
|
32
|
+
merged.push(id);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
add(defaultModel);
|
|
36
|
+
for (const id of Array.isArray(discovered) ? discovered : []) add(id);
|
|
37
|
+
for (const id of Array.isArray(previouslyKnown) ? previouslyKnown : []) add(id);
|
|
38
|
+
|
|
39
|
+
const limit = Number.isInteger(max) && max > 0 ? max : MAX_CLAUDE_CATALOG_MODELS;
|
|
40
|
+
// Retained ids trail the current lineup, so the cap sheds the stalest first.
|
|
41
|
+
return merged.slice(0, limit);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Discovered effort ladders win; retained models keep the ladder they had. */
|
|
45
|
+
export function mergeClaudeModelEfforts(previousEfforts = {}, discoveredEfforts = {}) {
|
|
46
|
+
const previous = previousEfforts && typeof previousEfforts === 'object' ? previousEfforts : {};
|
|
47
|
+
const discoveredMap = discoveredEfforts && typeof discoveredEfforts === 'object' ? discoveredEfforts : {};
|
|
48
|
+
return { ...previous, ...discoveredMap };
|
|
49
|
+
}
|
|
@@ -63,7 +63,10 @@ export function createControlPoller({
|
|
|
63
63
|
|
|
64
64
|
dbg('abort_turn control received', control.id);
|
|
65
65
|
try {
|
|
66
|
-
|
|
66
|
+
// The control names the row the user stopped. A runner that owns several
|
|
67
|
+
// rows at once (a turn plus the messages steered into it) needs it to
|
|
68
|
+
// tell the stopped row from the ones it must settle itself.
|
|
69
|
+
await onAbortTurn(control);
|
|
67
70
|
await api('POST', `/api/control/${encodeURIComponent(control.id)}/result`, {
|
|
68
71
|
ok: true,
|
|
69
72
|
note: abortAckNote,
|