ouroboros/web/modules/onboarding_wizard.js
Ouroboros d3d7592849 Close the round-2 review findings on the Z.ai provider
Four independent reviewers (Fable 5.1, Opus 5.5, Codex gpt-6-sol, Grok 4.7)
ran the Ouroboros scope-review brief on the reworked candidate; all four
returned "merge after small fixes" and no round-1 blocker survived. The
accepted findings, each mirroring the MiniMax region pattern:

- The one-click Colab collector never asked for ZAI_API_KEY, so a Z.ai-only
  notebook was prompted for OpenRouter (colab_bootstrap.provider_keys).
- ZAI_PLAN is a transport choice, not a credential: it no longer satisfies the
  onboarding "has a provider" checks (settings_setup_contract, the wizard's
  two lists), and an unknown plan value is refused at settings save and by the
  provider Test instead of silently selecting pay-as-you-go.
- The Capability Evidence route readers (gateway/settings._active_main_route,
  reviewer_window.reviewer_route) resolve the plan's endpoint, so the two
  Z.ai plans no longer share one route fingerprint.
- The task loop classifies Z.ai's HTTP 429 code 1113 as quota_exhausted
  (typed code, exact match) instead of retrying it as a transient rate limit.
- OpenRouter's GLM namespace is z-ai/, so the catalog label key follows it.
- Docs: Z.ai joins the exclusive-direct-provider list in 02-startup-onboarding
  and the forbidden skill settings list in CREATING_SKILLS; the 02-naming
  sentence names the two provider-specific projections instead of implying
  direct OpenAI carries no effort; glm ids join the onboarding suggestions.

Declined as disproportionate or out of scope, with the reason recorded in
the review ledger: the CHECKLISTS.md prose parenthetical (protected file;
the runtime deny list is authoritative), the GAIA/Terminal-Bench launcher
key lists (benchmark-only), a recorded live wire fixture (no key), and
GLM-5.2's skip-thinking on none/minimal (owner-accepted, disclosed in the
external-fact inventory).
2026-09-25 18:19:15 +03:00

1599 lines
84 KiB
JavaScript

// The served wizard shares the SPA's escaping, request, login and status owners.
import { fetchJson } from './api_client.js';
import {
agentsStepHtml,
completionFailureNotice,
createAgentsStep,
familyLabels,
onboardingSettingsDraft,
readCompletionAnswer,
} from './onboarding_agents_step.js';
import { escapeHtmlAttr as escapeHtml } from './utils.js';
import { installAltMenuSuppression, installDesktopShellLinkInterceptor } from './ui_helpers.js';
import { createModelRolesEditor, modelRolesHost, modelRoleMap, parseModelSource } from './model_roles.js';
import { availableSubagentsEditorHost } from './subagents_settings.js';
import { adoptSubagentRoster, applyReviewerSlotsDraft, collectReviewerSlots,
destroyReviewerSlots, initReviewerSlots, renderReviewerSlotsSection, setReviewerProcessingPreference,
setReviewerSourceContext } from './reviewer_slots.js';
import { PROCESSING_PREFERENCE_KEY, MODEL_PROCESSING_PREFERENCES_KEY, processingIntentLabel,
configuredApiProviders, apiProviderLabel } from './route_editor_primitives.js';
import { MODEL_CATALOG_TIMEOUT_MS, mergeModelCatalog, catalogReadState, summarizeReadErrors } from './settings_catalog.js';
import { accountRows } from './claudexor_status_store.js';
import { accountRowFacts } from './harness_accounts.js';
(() => {
// The served wizard document needs its own menu-key binding.
installAltMenuSuppression();
// Shell interception makes sign-in links work inside the embedded WebView.
// When framed, the pywebview bridge lives on the PARENT window; the
// installer resolves it lazily and stays inert in ordinary browsers.
installDesktopShellLinkInterceptor();
const bootstrap = window.__OURO_ONBOARDING_BOOTSTRAP__ || {};
const SETUP_CONTRACT = bootstrap.contract || {};
const HOST_MODE = bootstrap.hostMode || 'desktop';
const LOCAL_RUNTIME_CONTROLS = Boolean(bootstrap.supportsLocalRuntimeControls);
const STEP_ORDER = bootstrap.stepOrder || (SETUP_CONTRACT.steps || []).map((step) => step.id);
const STEP_META = Object.fromEntries((SETUP_CONTRACT.steps || []).map((step) => [step.id, step]));
const PROVIDER_FIELDS = SETUP_CONTRACT.providerFields || [];
const PROVIDER_PROFILES = SETUP_CONTRACT.providerProfiles || {};
// The backend contract exports active slots only. Heavy remains a
// bounded stored-value migration input and never enters this editor.
const MODEL_SLOTS = SETUP_CONTRACT.modelSlots || [];
const REVIEW_MODES = SETUP_CONTRACT.reviewModes || [];
const RUNTIME_MODES = SETUP_CONTRACT.runtimeModes || [];
// The setup contract owns the access ladder. Pick the matching layout
// for its current number of choices so adding Cyber Pro does not leave
// the fourth card stranded under a three-column presentation.
const RUNTIME_MODE_GRID_CLASS = RUNTIME_MODES.length > 3 ? 'four' : 'three';
const LOCAL_ROUTING_MODES = SETUP_CONTRACT.localRoutingModes || [];
const BUDGET_FIELDS = SETUP_CONTRACT.budgetFields || [];
const LOCAL_FIELDS = [
['local-source', 'localSource', 'Model Source', 'Qwen/Qwen2.5-7B-Instruct-GGUF or /absolute/path/model.gguf', 'Use either a HuggingFace repo ID or a local absolute GGUF path.', 'field field-full'],
['local-filename', 'localFilename', 'GGUF Filename', 'qwen2.5-7b-instruct-q3_k_m.gguf', 'Required only for HuggingFace repo IDs. Leave empty when the source is a direct filesystem path.', 'field field-full'],
['local-context', 'localContextLength', 'Context Length', '', '', 'field', 'number', '2048', '1024'],
['local-gpu-layers', 'localGpuLayers', 'GPU Layers', '', '', 'field', 'number', '', '1'],
['local-chat-format', 'localChatFormat', 'Chat Format', 'Leave empty for auto-detect', '', 'field field-full'],
];
const MODEL_DEFAULTS = bootstrap.modelDefaults || {};
const LOCAL_PRESETS = bootstrap.localPresets || {};
const INITIAL_STATE = bootstrap.initialState || {};
// Credential placeholders retain stored values through the shared validator.
const SECRET_PLACEHOLDER = bootstrap.secretPlaceholder || '';
const root = document.getElementById('root');
const state = Object.assign({
currentStep: STEP_ORDER[0],
error: '',
saving: false,
modelsDirty: false,
reviewerDraftDirty: false,
reviewerSlots: null,
modelAccounts: {},
modelContextWindows: {},
apiAccessOpen: false,
apiBudgetOpen: false,
subagentsOpen: false,
reviewersOpen: false,
localSourceOpen: Boolean(INITIAL_STATE.localSource),
moreProvidersOpen: Boolean(INITIAL_STATE.cloudruKey || INITIAL_STATE.minimaxKey || INITIAL_STATE.deepseekKey
|| INITIAL_STATE.zaiKey || INITIAL_STATE.compatibleBaseUrl || INITIAL_STATE.compatibleApiKey),
localStatusText: 'Status: Offline',
localStatusTone: 'muted',
localTestResult: '',
localTestTone: 'muted',
localRuntimeReady: false,
// Agents step: what the step OBSERVED (never a guess), the owner's
// explicit "finish without agent defaults" choice, and the typed reason
// a completion attempt refused to write the preset.
agentsConnected: [],
availableSubagents: null,
skipSubscriptionPresets: false,
preparingRecovery: false,
recoveryPrepared: false,
recoveryMain: '',
presetFailure: null,
// Set when completion answered 503 `settings_save_timeout`: the save is
// still running in the server, so the wizard offers "Check status"
// instead of a blind retry (a second write over an unknown first).
saveUnknown: false,
// Set once completion SUCCEEDED but the receipt says the saved runtime
// mode needs a restart, in the one shell that cannot restart anything.
completedRestartMode: '',
}, INITIAL_STATE);
let localStatusPollStarted = false;
let agentsStep = null;
let modelSources = [];
let modelCatalog = {};
let catalogRequest = null;
let disposed = false;
let catalogGeneration = 0;
const stepScrollPositions = new Map();
const modelRoles = createModelRolesEditor({ hostId: 'onboarding-model-roles', onChange: (settings) => {
adoptModelSettings(settings);
state.modelsDirty = true;
markStepEdited();
} });
function draftSettings() {
return onboardingSettingsDraft({ state, providerFields: PROVIDER_FIELDS,
budgetFields: BUDGET_FIELDS, modelSlots: MODEL_SLOTS, trim });
}
function adoptModelSettings(settings = {}) {
MODEL_SLOTS.forEach((slot) => {
if (slot.settingKey in settings) state[slot.stateKey] = settings[slot.settingKey];
});
if ('OUROBOROS_MODEL_ACCOUNTS' in settings) state.modelAccounts = modelRoleMap(settings.OUROBOROS_MODEL_ACCOUNTS);
if ('OUROBOROS_MODEL_CONTEXT_WINDOWS' in settings) state.modelContextWindows = modelRoleMap(settings.OUROBOROS_MODEL_CONTEXT_WINDOWS);
if (PROCESSING_PREFERENCE_KEY in settings) state.processingPreference = settings[PROCESSING_PREFERENCE_KEY];
if (MODEL_PROCESSING_PREFERENCES_KEY in settings) state.modelProcessingPreferences = modelRoleMap(settings[MODEL_PROCESSING_PREFERENCES_KEY]);
agentsStep?.setProcessingPreference(state.processingPreference);
setReviewerProcessingPreference(state.processingPreference, state.modelProcessingPreferences || {});
}
function hasApiAccess() {
return PROVIDER_FIELDS.some((field) => !['MINIMAX_REGION', 'ZAI_PLAN', 'OPENAI_COMPATIBLE_API_KEY'].includes(field.settingKey)
&& trim(state[field.stateKey]));
}
function hasModelSubscription() {
return modelSources.some((source) => state.agentsConnected.includes(source.credentialHarness));
}
function clearFreshSubscriptionDefaults() {
if (bootstrap.freshInstall !== true || state.modelsDirty || !hasModelSubscription() || hasApiAccess() || hasLocalModel()) return;
const defaults = MODEL_DEFAULTS.openrouter || {};
for (const [key, name] of [['mainModel', 'main'], ['lightModel', 'light'], ['fallbackModel', 'fallback']]) {
if (state[key] === defaults[name]) state[key] = '';
}
}
function refreshModelSources() {
const connected = state.agentsConnected.join(',');
if (catalogRequest?.connected === connected) return catalogRequest.promise;
catalogRequest?.controller.abort();
const generation = ++catalogGeneration;
if (!connected) { catalogRequest = null; return Promise.resolve(); }
const controller = new AbortController();
const request = { connected, controller };
catalogRequest = request;
const timer = setTimeout(() => controller.abort(), MODEL_CATALOG_TIMEOUT_MS);
request.promise = fetchJson('/api/model-catalog', { cache: 'no-store', signal: controller.signal })
.then((catalog) => {
if (!catalog || typeof catalog !== 'object') throw new Error('Model catalog response is unreadable.');
if (generation !== catalogGeneration) return;
modelCatalog = mergeModelCatalog(modelCatalog, catalog);
}).catch((error) => {
if (generation !== catalogGeneration) return;
modelCatalog = mergeModelCatalog(modelCatalog, { read_state: 'transport',
errors: [{ error: error.name === 'AbortError' ? 'Model catalog request timed out.' : error.message }] });
}).finally(() => {
clearTimeout(timer);
if (generation !== catalogGeneration) return;
catalogRequest = null;
modelSources = (modelCatalog.model_sources || []).filter((source) => state.agentsConnected.includes(source.credentialHarness));
clearFreshSubscriptionDefaults();
modelRoles.adoptCatalog({ ...modelCatalog, model_sources: modelSources });
refreshSummary();
});
syncCurrentStepActionState();
return request.promise;
}
function applySetupPreview(response, { replaceReviewers = false } = {}) {
if (!state.skipSubscriptionPresets) state.presetFailure = null;
if (!state.modelsDirty && response.model_settings) {
const previous = JSON.stringify(draftSettings());
adoptModelSettings(response.model_settings);
if (JSON.stringify(draftSettings()) !== previous) loadModelRoles();
}
if (response.reviewer_slots && (replaceReviewers || (!state.reviewerDraftDirty && !state.skipSubscriptionPresets))) {
state.reviewerSlots = typeof response.reviewer_slots === 'string'
? JSON.parse(response.reviewer_slots) : response.reviewer_slots;
if (state.currentStep === 'review_mode') {
setReviewerSourceContext({ settings: draftSettings(), providerProfiles: PROVIDER_PROFILES });
applyReviewerSlotsDraft(state.reviewerSlots);
}
}
modelRoles.adoptCatalog(response);
if (state.agentsConnected.length) void refreshModelSources();
refreshSummary();
}
function loadModelRoles() {
modelRoles.load(draftSettings(), { ...SETUP_CONTRACT,
modelSlots: MODEL_SLOTS.map((slot) => ({ ...slot, settingsToggleId: '' })) });
}
function trim(value) {
return String(value || '').trim();
}
function formatUsd(value) {
const num = Number(value);
return Number.isFinite(num) ? `$${num.toFixed(2)}` : '$0.00';
}
function hasLocalModel() {
return trim(state.localSource).length > 0;
}
// "group" comes from the shared setup contract: "more" fields live inside
// the collapsed "More options" disclosure; everything else stays in the
// always-visible access grid. Inputs are mounted in the DOM either way.
function primaryProviderFields() {
return PROVIDER_FIELDS.filter((field) => field.group !== 'more');
}
function moreProviderFields() {
return PROVIDER_FIELDS.filter((field) => field.group === 'more');
}
function hasMoreProviderValue() {
return moreProviderFields().some((field) => trim(state[field.stateKey]).length > 0);
}
// A credential prefilled from disk arrives as SECRET_PLACEHOLDER, never as
// the value, so "is this configured?" can no longer be answered by length.
// The length rule stays for what the owner types here: it is the client
// mirror of the server's too-short check.
function isConfiguredCredential(value) {
const text = trim(value);
if (!text) return false;
return text === SECRET_PLACEHOLDER || text.length >= 10;
}
function isLocalFilesystemSource(value) {
const text = trim(value);
return text.startsWith('/') || text.startsWith('~');
}
function optionByValue(items, value) {
return (items || []).find((item) => item.value === value) || {};
}
function detectProviderProfile() {
const configured = Object.fromEntries(PROVIDER_FIELDS.map((field) => [
field.settingKey,
isConfiguredCredential(state[field.stateKey]),
]));
const hasOpenrouter = configured.OPENROUTER_API_KEY;
const hasCompatible = trim(state.compatibleBaseUrl).length > 0;
const direct = [
['OPENAI_API_KEY', 'openai'],
['CLOUDRU_FOUNDATION_MODELS_API_KEY', 'cloudru'],
['MINIMAX_API_KEY', 'minimax'],
['DEEPSEEK_API_KEY', 'deepseek'], ['ZAI_API_KEY', 'zai'],
['ANTHROPIC_API_KEY', 'anthropic'],
].filter(([settingKey]) => configured[settingKey]);
if (hasOpenrouter) return 'openrouter';
if (hasCompatible) return 'openai-compatible';
if (direct.length > 1) return 'direct-multi';
if (direct.length === 1) return direct[0][1];
if (hasLocalModel()) return 'local';
return 'openrouter';
}
function activeProviderProfile() {
const profile = detectProviderProfile();
state.providerProfile = profile;
return profile;
}
function profileLabel(profile) {
return PROVIDER_PROFILES[profile]?.label || PROVIDER_PROFILES.openrouter?.label || 'OpenRouter';
}
function reviewLabel(mode) {
return optionByValue(REVIEW_MODES, mode).label || 'Advisory';
}
function runtimeModeLabel(mode) {
return optionByValue(RUNTIME_MODES, mode).label || 'Advanced';
}
function localRoutingLabel(mode) {
return optionByValue(LOCAL_ROUTING_MODES, mode).label || 'Cloud models only';
}
function nextButtonShouldBeDisabled() {
if (state.saving || state.preparingRecovery) return true;
if (state.currentStep === 'summary') return false;
return Boolean(validateCurrentStep());
}
function syncCurrentStepActionState() {
const next = document.getElementById('next-btn');
if (next) next.disabled = nextButtonShouldBeDisabled();
const quick = document.getElementById('quick-start-btn');
if (quick) quick.hidden = !hasModelSubscription();
const error = root.querySelector('.wizard-error');
if (error) error.textContent = state.error || (['accounts', 'models'].includes(state.currentStep) ? validateCurrentStep() : '');
const note = document.getElementById('onboarding-access-note');
if (note) {
const read = catalogReadState(modelCatalog);
const messages = [catalogRequest ? 'Checking model sources…' : '',
!catalogRequest && read !== 'ok' && state.agentsConnected.length
? `Model discovery is ${read === 'not_read' ? 'not available yet' : read}. ${summarizeReadErrors(modelCatalog.errors)} Your choices are kept.` : '',
agentsStep?.previewError ? `Automatic assignments could not be prepared: ${agentsStep.previewError} Retry, or Continue to choose models yourself.` : ''];
note.textContent = messages.filter(Boolean).join(' ');
note.hidden = !note.textContent;
}
const retry = document.getElementById('onboarding-access-retry');
if (retry) {
retry.hidden = !state.agentsConnected.length && !agentsStep?.previewError;
retry.disabled = Boolean(catalogRequest || agentsStep?.previewPending);
}
const recover = document.getElementById('skip-presets-btn');
if (recover) {
recover.hidden = !shouldOfferPresetSkip();
recover.disabled = state.saving || state.preparingRecovery;
recover.textContent = state.preparingRecovery ? 'Preparing reviewer assignments…' : 'Use Main for reviewers';
}
const back = document.getElementById('back-btn');
if (back) back.disabled = state.saving || state.preparingRecovery || STEP_ORDER.indexOf(state.currentStep) === 0;
}
function markStepEdited() {
state.error = '';
// The preview owns generated rows only. Invalidate any response that
// started against the previous provider/local/model/settings draft;
// an owner-edited actor list ignores this through its own dirty gate.
agentsStep?.invalidateGeneratedPreview();
syncCurrentStepActionState();
}
function applyPresetSelection(presetId) {
state.localPreset = presetId;
state.localSourceOpen = Boolean(presetId);
if (!presetId) {
state.localSource = '';
state.localFilename = '';
state.localContextLength = 16384;
state.localGpuLayers = -1;
state.localChatFormat = '';
state.localRoutingMode = 'cloud';
return;
}
if (presetId === 'custom') {
if (!trim(state.localSource)) {
state.localSource = '';
state.localFilename = '';
}
return;
}
const preset = LOCAL_PRESETS[presetId];
if (!preset) return;
state.localSource = preset.source;
state.localFilename = preset.filename;
state.localContextLength = preset.contextLength;
state.localChatFormat = preset.chatFormat || '';
if (activeProviderProfile() === 'local') {
state.localRoutingMode = 'all';
} else if (state.localRoutingMode === 'cloud') {
state.localRoutingMode = 'fallback';
}
}
function detectLocalPresetSelection() {
const source = trim(state.localSource);
const filename = trim(state.localFilename);
if (!source && !filename) return '';
for (const [presetId, preset] of Object.entries(LOCAL_PRESETS)) {
if (source === trim(preset.source) && filename === trim(preset.filename)) {
return presetId;
}
}
return 'custom';
}
function applyModelDefaults(force) {
if (state.modelsDirty && !force) return;
if (!force && bootstrap.freshInstall !== true && MODEL_SLOTS.some((slot) => trim(INITIAL_STATE[slot.stateKey]))) return;
if (hasModelSubscription()) return;
const defaults = MODEL_DEFAULTS[activeProviderProfile()] || MODEL_DEFAULTS.openrouter || {};
state.mainModel = defaults.main || '';
state.lightModel = defaults.light || '';
state.fallbackModel = defaults.fallback || '';
state.modelsDirty = false;
}
function validateProvidersStep() {
const keyValues = PROVIDER_FIELDS.map((field) => [field, trim(state[field.stateKey])]);
const localSource = trim(state.localSource);
const localFilename = trim(state.localFilename);
// Only a credential the owner authored in THIS form is length-checked —
// the same authorship rule validate_setup_payload applies server-side.
// The wizard prefills every provider field from disk, so a stored value
// the owner never touched arrives here unchanged; rejecting it blocks
// Next/Save on the providers step and the offending value becomes the
// one value the wizard can never replace.
const shortKey = keyValues.find(([field, value]) => value && (field.inputType || 'password') === 'password' && value.length < 10 && value !== trim(INITIAL_STATE[field.stateKey]));
if (shortKey) return `${shortKey[0].label.replace(' API Key', '')} API key looks too short.`;
const hasRemote = keyValues.some(([field, value]) => value && !['OPENAI_COMPATIBLE_API_KEY', 'MINIMAX_REGION', 'ZAI_PLAN'].includes(field.settingKey));
if (!hasRemote && !localSource && !hasModelSubscription()) {
return state.agentsConnected.length
? 'A Main model source has not been confirmed. Retry discovery, add an API key, or choose a local model.'
: 'Connect Codex, enter an API key, or choose a local model before continuing.';
}
if (trim(state.minimaxRegion) && !['global_en', 'cn_zh'].includes(trim(state.minimaxRegion).toLowerCase())) {
return 'MiniMax Region must be global_en or cn_zh.';
}
if (localSource && !hasRemote && trim(state.localRoutingMode) === 'cloud') {
return 'Local-only setups must route at least one model to the local runtime.';
}
if (localSource && localSource.includes('/') && !isLocalFilesystemSource(localSource) && !localFilename) {
return 'Local HuggingFace sources need a GGUF filename.';
}
if (localSource && (!Number.isInteger(Number(state.localContextLength)) || Number(state.localContextLength) <= 0)) {
return 'Local context length must be a positive integer.';
}
if (localSource && !Number.isInteger(Number(state.localGpuLayers))) {
return 'Local GPU layers must be an integer.';
}
return '';
}
function validateModelsStep() {
// Only Main is required; the remaining active slots are optional or
// already carry a default. Don't force the owner to fill every slot.
if (!trim(state.mainModel)) {
return 'Confirm the Main model before starting Ouroboros.';
}
const { source } = parseModelSource(state.mainModel);
const supported = source.startsWith('subscription:')
? modelSources.some((entry) => entry.id === source.slice(13) && state.agentsConnected.includes(entry.credentialHarness))
: configuredApiProviders(draftSettings(), PROVIDER_PROFILES).some((entry) => entry.id === source);
if (!supported && !(hasLocalModel() && state.localRoutingMode === 'all')) {
return `Main uses ${source.startsWith('subscription:') ? source.slice(13) : apiProviderLabel(source, PROVIDER_PROFILES)}, which has no configured access in this setup.`;
}
return state.currentStep === 'models' ? modelRoles.validate() : '';
}
function validateReviewStep() {
if (!['advisory', 'blocking'].includes(trim(state.reviewEnforcement))) {
return 'Choose advisory or blocking review mode.';
}
return '';
}
function validateBudgetStep() {
for (const field of BUDGET_FIELDS) {
if (hasModelSubscription() && !hasApiAccess() && state[field.stateKey] === '') continue;
const value = Number(state[field.stateKey]);
const min = Number(field.min || 0.01);
if (!Number.isFinite(value) || value < min) {
return `${field.title || field.label || 'Budget'} must be greater than zero.`;
}
}
return '';
}
function validateCurrentStep() {
if (['accounts', 'providers'].includes(state.currentStep)) return validateProvidersStep();
if (state.currentStep === 'models') return validateModelsStep();
if (state.currentStep === 'review_mode') return validateReviewStep();
if (state.currentStep === 'budget') return validateBudgetStep();
return '';
}
function nextStep() {
const error = validateCurrentStep();
state.error = error;
if (error) return syncCurrentStepActionState();
if (['accounts', 'providers'].includes(state.currentStep)) applyModelDefaults(false);
if (['accounts', 'providers', 'models', 'review_mode', 'budget'].includes(state.currentStep)) {
// Preview is enrichment, never a navigation gate. Refresh in the
// background after the step has a valid complete draft; Finish
// checks the receipt only if generated rows still own the editor.
if (agentsStep) void agentsStep.refreshSubagentsPreview({ force: true });
}
const index = STEP_ORDER.indexOf(state.currentStep);
if (index >= 0 && index < STEP_ORDER.length - 1) {
navigateStep(STEP_ORDER[index + 1]);
}
}
function previousStep() {
const index = STEP_ORDER.indexOf(state.currentStep);
if (index > 0) navigateStep(STEP_ORDER[index - 1], { restorePosition: true });
}
// Navigation owns the viewport. Status/catalog repaints never reset it or
// take focus from an edited field; Back returns to the retained step draft.
function navigateStep(stepId, { restorePosition = false } = {}) {
if (stepId === state.currentStep) return;
stepScrollPositions.set(state.currentStep, window.scrollY);
state.currentStep = stepId;
state.error = '';
render();
const heading = root.querySelector('.step-title');
heading?.setAttribute('tabindex', '-1');
heading?.focus({ preventScroll: true });
window.scrollTo({ top: restorePosition ? stepScrollPositions.get(stepId) || 0 : 0, left: 0, behavior: 'instant' });
}
const apiRequest = fetchJson;
function renderLocalStatus() {
const statusEl = document.getElementById('wizard-local-status');
const stopButton = document.getElementById('wizard-local-stop');
const testButton = document.getElementById('wizard-local-test');
const resultEl = document.getElementById('wizard-local-test-result');
if (statusEl) {
statusEl.textContent = state.localStatusText || 'Status: Offline';
statusEl.dataset.tone = state.localStatusTone || 'muted';
}
if (stopButton) stopButton.disabled = !state.localRuntimeReady;
if (testButton) testButton.disabled = !state.localRuntimeReady;
if (resultEl) {
resultEl.hidden = !state.localTestResult;
resultEl.dataset.tone = state.localTestTone || 'muted';
resultEl.textContent = state.localTestResult || '';
}
}
function setLocalTestResult(text, tone = 'muted') {
state.localTestResult = text || '';
state.localTestTone = tone;
renderLocalStatus();
}
async function updateLocalStatus() {
if (!LOCAL_RUNTIME_CONTROLS) return;
try {
const data = await apiRequest('/api/local-model/status', { cache: 'no-store' });
const isReady = data.status === 'ready';
let text = 'Status: ' + ((data.status || 'offline').charAt(0).toUpperCase() + (data.status || 'offline').slice(1));
if (data.status === 'ready' && data.context_length) text += ` (ctx: ${data.context_length})`;
if (data.status === 'downloading' && data.download_progress) text += ` ${Math.round(data.download_progress * 100)}%`;
if (data.error) text += ` - ${data.error}`;
state.localRuntimeReady = isReady;
state.localStatusText = text;
state.localStatusTone = isReady ? 'ok' : (data.status === 'error' ? 'error' : 'muted');
renderLocalStatus();
} catch (error) {
state.localRuntimeReady = false;
state.localStatusText = `Status: Error - ${error.message}`;
state.localStatusTone = 'error';
renderLocalStatus();
}
}
function readLocalModelBody() {
return {
source: trim(state.localSource),
filename: trim(state.localFilename),
port: 8766,
n_gpu_layers: parseInt(state.localGpuLayers, 10),
n_ctx: parseInt(state.localContextLength, 10) || 16384,
chat_format: trim(state.localChatFormat),
};
}
function startLocalStatusPolling() {
if (!LOCAL_RUNTIME_CONTROLS || localStatusPollStarted) return;
localStatusPollStarted = true;
updateLocalStatus();
setInterval(updateLocalStatus, 3000);
}
function renderLocalControls() {
if (!LOCAL_RUNTIME_CONTROLS) return '';
return `
<div class="wizard-runtime-strip">
<button type="button" class="btn btn-ghost" id="wizard-local-start">Start local runtime</button>
<button type="button" class="btn btn-ghost" id="wizard-local-stop" disabled>Stop</button>
<button type="button" class="btn btn-ghost" id="wizard-local-test" disabled>Test tool calling</button>
<span id="wizard-local-status" class="wizard-runtime-status" role="status">Status: Offline</span>
</div>
<div id="wizard-local-test-result" class="wizard-test-result" role="status"></div>
`;
}
function summaryRows() {
const rows = [
['Connected access', hasModelSubscription() && !hasApiAccess()
? modelSources.filter((source) => state.agentsConnected.includes(source.credentialHarness))
.map((source) => source.label || source.id).join(', ') + ' subscription · no API key'
: profileLabel(activeProviderProfile())],
['Review mode', reviewLabel(state.reviewEnforcement)],
['Runtime mode', runtimeModeLabel(state.runtimeMode)],
...((hasApiAccess() || !hasModelSubscription()) ? [
['Total API budget', formatUsd(state.totalBudget)],
['Per-task API cap', formatUsd(state.perTaskCostUsd)],
] : [['Budget', 'Subscription quota · wait and auto-continue on reset']]),
...MODEL_SLOTS.map((slot) => [slot.label.replace(/ Model$/, ''), modelSummary(slot)]),
];
if (trim(state.openrouterKey)) rows.splice(1, 0, ['OpenRouter', 'configured']);
if (trim(state.openaiKey)) rows.splice(1, 0, ['OpenAI', 'configured']);
if (trim(state.cloudruKey)) rows.splice(1, 0, ['Cloud.ru', 'configured']);
if (trim(state.minimaxKey)) rows.splice(1, 0, ['MiniMax', 'configured']);
if (trim(state.deepseekKey)) rows.splice(1, 0, ['DeepSeek', 'configured']);
if (trim(state.zaiKey)) rows.splice(1, 0, ['Z.ai (GLM)', trim(state.zaiPlan) === 'coding' ? 'configured · coding plan' : 'configured']);
if (trim(state.anthropicKey)) rows.splice(1, 0, ['Anthropic', 'configured']);
if (hasLocalModel()) {
rows.splice(
1,
0,
['Local source', trim(state.localSource) + (trim(state.localFilename) ? ` / ${trim(state.localFilename)}` : '')],
['Local routing', localRoutingLabel(state.localRoutingMode)],
);
}
rows.push(['Agents', agentsSummaryValue()]);
for (const [key, label] of [['triad', 'Triad review'], ['scope', 'Scope review'],
['advisory', 'Advisory review'], ['deep_review', 'Deep self-review']]) {
const value = state.reviewerSlots?.[key];
if (value) rows.push([label, (Array.isArray(value) ? value : [value]).map(reviewerSummary).join(' · ')]);
}
if (trim(state.skillsRepoPath)) {
rows.push(['Skills repo', trim(state.skillsRepoPath)]);
}
return rows;
}
function modelSummary(slot) {
const values = trim(state[slot.stateKey]).split(',').map((value) => value.trim()).filter(Boolean);
const inherited = !values.length && slot.slot !== 'fallback';
if (inherited) values.push(trim(state.mainModel));
return values.map((value, index) => {
const { source, model } = parseModelSource(value);
const account = slot.slot === 'fallback' ? state.modelAccounts.fallback?.[index] : state.modelAccounts[slot.slot];
const context = slot.slot === 'fallback' ? state.modelContextWindows.fallback?.[index] : state.modelContextWindows[slot.slot];
const processing = slot.slot === 'fallback' ? state.modelProcessingPreferences?.fallback?.[index] : state.modelProcessingPreferences?.[slot.slot];
const assignment = source.startsWith('subscription:')
? `${source.slice(13)} · ${model} · ${account ? `Account: ${account}` : 'Auto rotation'}` : value;
return `${inherited ? 'Uses Main · ' : ''}${assignment} · Processing: ${processingIntentLabel(processing, state.processingPreference)}${Number(context) > 0 ? ` · ${Number(context).toLocaleString('en-US')} tokens, set by you` : ''}`;
}).join(' → ') || 'None';
}
function reviewerSummary(row) {
const actor = state.availableSubagents?.items?.find((item) => item.subagent_id === row.subagent_id);
const route = row.route || actor?.route;
const account = route?.profile_id || route?.credential_profile_id;
const effort = row.effort || actor?.effort;
return [row.enabled === false ? 'Disabled' : '', route?.target_id || row.subagent_id || 'Not configured',
account ? `Account: ${account}` : '', effort ? `Effort: ${effort}` : '',
`Processing: ${processingIntentLabel(row.subagent_id ? actor?.processing_preference : row.processing_preference, state.processingPreference)}`].filter(Boolean).join(' · ');
}
function agentsSummaryValue() {
// Read family labels from the same snapshot the Agents step displays.
const labels = familyLabels(state.agentsConnected, agentsStep?.snapshot, {
catalogKnown: Boolean(agentsStep?.catalogKnown),
});
const actorCount = (agentsStep?.availableSubagents?.items
|| state.availableSubagents?.items || []).length;
const actors = `${actorCount} Available subagent${actorCount === 1 ? '' : 's'}`;
if (!labels.length) return `${actors} · API/local access only`;
if (state.skipSubscriptionPresets) {
return `${actors} · ${labels.join(', ')} connected · automatic subscription preset skipped`;
}
return `${actors} · ${labels.join(', ')} connected`;
}
function shouldOfferPresetSkip() {
// Explicit recovery from failed assignments; healthy presets keep their normal path.
return (!state.recoveryPrepared || state.recoveryMain !== mainBinding()) && (state.skipSubscriptionPresets || Boolean(state.presetFailure)
|| (state.agentsConnected.length > 0 && Boolean(agentsStep?.previewError)));
}
function mainBinding() { return JSON.stringify([state.mainModel, state.modelAccounts.main || '', state.modelProcessingPreferences?.main || state.processingPreference || '']); }
function providerKeyField({ id, label, placeholder, value, note, inputType }) {
const type = inputType || 'password';
return `
<div class="field ui-field">
<div class="field-label-row">
<label for="${escapeHtml(id)}">${escapeHtml(label)}</label>
<button class="field-clear" data-clear="${escapeHtml(id)}" type="button" aria-label="Clear ${escapeHtml(label)}">Clear</button>
</div>
<input id="${escapeHtml(id)}" class="ui-control" type="${escapeHtml(type)}" aria-describedby="${escapeHtml(id)}-help" placeholder="${escapeHtml(placeholder)}" value="${escapeHtml(value)}">
<div id="${escapeHtml(id)}-help" class="field-note ui-field-help">${escapeHtml(note)}</div>
</div>
`;
}
function localInputField([id, stateKey, label, placeholder, note, className, type = 'text', min = '', step = '']) {
const clear = ['local-source', 'local-filename', 'local-chat-format'].includes(id)
? `<button class="field-clear" data-clear="${id}" type="button" aria-label="Clear ${label}">Clear</button>`
: '';
return `
<div class="${className} ui-field">
<div class="field-label-row"><label for="${id}">${label}</label>${clear}</div>
<input id="${id}" class="ui-control" type="${type}" ${note ? `aria-describedby="${id}-help"` : ''} ${min ? `min="${min}"` : ''} ${step ? `step="${step}"` : ''} placeholder="${placeholder}" value="${escapeHtml(state[stateKey])}">
${note ? `<div id="${id}-help" class="field-note ui-field-help">${note}</div>` : ''}
</div>
`;
}
function renderProvidersStep({ embedded = false } = {}) {
const selectedProfile = activeProviderProfile();
const localPreset = trim(state.localPreset);
const localSourceOpen = state.localSourceOpen || hasLocalModel();
const moreProvidersOpen = state.moreProvidersOpen || hasMoreProviderValue();
return `
${embedded ? '' : `<div class="step-header">
<div>
<h2 class="step-title">${escapeHtml(STEP_META.providers?.title || 'API access')}</h2>
<p class="step-copy">${escapeHtml(STEP_META.providers?.copy || '')}</p>
</div>
</div>
<div class="panel-card">
<h3>Keys first, routing second</h3>
<p>${escapeHtml(PROVIDER_PROFILES[selectedProfile]?.providerCopy || '')}</p>
</div>`}
<div class="field-grid">
${primaryProviderFields().map((field) => providerKeyField({
...field,
value: state[field.stateKey],
})).join('')}
</div>
<details class="wizard-collapse" data-collapse="more-providers" ${moreProvidersOpen ? 'open' : ''}>
<summary>
<span>More options</span>
<span class="selection-badge">${hasMoreProviderValue() ? 'Configured' : 'Optional'}</span>
</summary>
<div class="wizard-collapse-body">
<div class="field-grid">
${moreProviderFields().map((field) => providerKeyField({
...field,
value: state[field.stateKey],
})).join('')}
</div>
</div>
</details>
<details class="wizard-collapse" data-collapse="local-model" ${localSourceOpen ? 'open' : ''}>
<summary>
<span>Local model settings</span>
<span class="selection-badge">${hasLocalModel() ? 'Configured' : 'Optional'}</span>
</summary>
<div class="wizard-collapse-body">
<div class="field-grid">
<div class="field ui-field">
<div class="field-label-row">
<label for="local-preset">Preset</label>
<button class="field-clear" data-clear="local-preset" type="button" aria-label="Clear local preset">Clear</button>
</div>
<select id="local-preset" class="ui-control" aria-describedby="local-preset-help">
<option value="" ${localPreset === '' ? 'selected' : ''}>None</option>
${Object.entries(LOCAL_PRESETS).map(([id, preset]) => `<option value="${escapeHtml(id)}" ${localPreset === id ? 'selected' : ''}>${escapeHtml(preset.label)}</option>`).join('')}
<option value="custom" ${localPreset === 'custom' ? 'selected' : ''}>Custom source</option>
</select>
<div id="local-preset-help" class="field-note ui-field-help">Most people can ignore this. Open it only if you want local GGUF routing.</div>
</div>
<div class="field ui-field">
<div class="field-label-row"><label id="local-routing-label">Local routing</label></div>
<div class="selection-row" role="group" aria-labelledby="local-routing-label" aria-describedby="local-routing-help">
${LOCAL_ROUTING_MODES.map((mode) => `<button class="selection-pill ${state.localRoutingMode === mode.value ? 'active' : ''}" data-local-mode="${escapeHtml(mode.value)}" aria-pressed="${state.localRoutingMode === mode.value}" type="button">${escapeHtml(mode.buttonLabel || mode.label)}</button>`).join('')}
</div>
<div id="local-routing-help" class="field-note ui-field-help">Ignored unless a local model source is configured below.</div>
</div>
${LOCAL_FIELDS.map(localInputField).join('')}
</div>
${renderLocalControls()}
</div>
</details>
`;
}
function renderAgentsStep() {
// SKIPPABLE by construction: `validateCurrentStep` returns '' for this
// step, so Continue is never disabled and no field here can block
// finishing. An agent plan is an amplifier, never an admission gate.
return `
<div class="step-header">
<div>
<h2 class="step-title">${escapeHtml(STEP_META.agents.title)}</h2>
<p class="step-copy">${escapeHtml(STEP_META.agents.copy)}</p>
</div>
</div>
${agentsStepHtml()}
`;
}
function bindAgentsStep() {
if (!agentsStep) {
agentsStep = createAgentsStep({
isVisible: () => ['accounts', 'agents', 'models', 'budget'].includes(state.currentStep),
onChange: (connected) => {
state.agentsConnected = connected;
void refreshModelSources();
syncCurrentStepActionState();
},
previewPayload: () => ({ ...draftSettings(), ...(state.reviewerSlots
? { OUROBOROS_REVIEWER_SLOTS: JSON.stringify(state.reviewerSlots) } : {}) }),
providerProfiles: PROVIDER_PROFILES,
onSubagentsChange: (setting) => { state.availableSubagents = setting; adoptSubagentRoster({ OUROBOROS_SUBAGENTS: setting }); },
onSetupPreview: applySetupPreview,
onPreviewStatus: syncCurrentStepActionState,
onStatus: () => {
const quota = document.getElementById('wizard-subscription-quota');
if (quota && state.currentStep === 'budget') quota.innerHTML = subscriptionQuotaHtml();
},
});
}
agentsStep.setSkipPresets(state.skipSubscriptionPresets);
agentsStep.mount();
agentsStep.setProcessingPreference(state.processingPreference);
syncCurrentStepActionState();
}
function renderAccountsStep() {
return `
<div class="step-header"><div>
<h2 class="step-title">Accounts</h2>
<p class="step-copy">Start with Codex, or connect API access. You can add more accounts any time.</p>
</div></div>
${agentsStepHtml({ compact: true, showRoster: false })}
<div id="onboarding-access-note" class="wizard-inline-note" role="status" hidden></div>
<button id="onboarding-access-retry" class="btn btn-default" type="button" hidden>Retry model discovery</button>
<details class="wizard-collapse" data-collapse="api-access" ${state.apiAccessOpen || hasApiAccess() ? 'open' : ''}>
<summary><span>API keys and local models</span><span class="selection-badge">${hasApiAccess() ? 'Configured' : 'Optional'}</span></summary>
<div class="wizard-collapse-body">${renderProvidersStep({ embedded: true })}</div>
</details>
<div class="wizard-inline-note">Subscription limits still apply. This connection does not enable paid credits or change your provider's spending limits.</div>
`;
}
async function reviewAndStart() {
if (validateProvidersStep()) return;
state.error = '';
const originStep = state.currentStep;
const ready = await agentsStep.refreshSubagentsPreview({ force: true });
if (state.currentStep !== originStep) return;
if (!ready) {
state.error = agentsStep.previewError || 'Setup suggestions could not be prepared. Try again or choose your models manually.';
syncCurrentStepActionState(); return;
}
navigateStep('summary');
}
function subscriptionQuotaHtml() {
if (!state.agentsConnected.length) return '';
const snapshot = agentsStep?.snapshot;
const accountRead = agentsStep?.reads?.accounts || 'unread';
const quotaRead = agentsStep?.reads?.quota || 'unread';
const lines = accountRows(snapshot).filter((row) => row.enabled !== false).map((row) => {
const facts = accountRowFacts(row, snapshot, { accountsRead: accountRead, quotaRead });
return `<div class="subscription-quota-row"><strong>${escapeHtml(facts.name)}</strong>
<span>${escapeHtml(facts.quota.label)}</span></div>`;
}).join('');
return `<div class="panel-card"><h3>Subscription quota</h3>${lines}
<p class="field-note">Compatible accounts rotate automatically. If the pool is exhausted, Ouroboros waits and continues when quota resets. You can switch the waiting role yourself.</p>
</div>
`;
}
function renderCompatibleModelLoader() {
return `
<div class="panel-card" id="compatible-model-loader">
<h3>Load models from endpoint</h3>
<p class="field-note">Fetch the model list from your configured URL, then click a model to fill all empty slots.</p>
<div class="compatible-model-actions">
<button type="button" class="btn btn-secondary" id="load-compatible-models">Load models</button>
<span id="compatible-load-status" class="field-note compatible-load-status"></span>
</div>
<div id="compatible-model-list" class="compatible-model-list" hidden></div>
</div>
`;
}
function renderModelsStep() {
const profile = activeProviderProfile();
return `
<div class="step-header">
<div>
<h2 class="step-title">${escapeHtml(STEP_META.models.title)}</h2>
<p class="step-copy">${escapeHtml(STEP_META.models.copy)}</p>
</div>
</div>
${profile === 'openai-compatible' ? renderCompatibleModelLoader() : ''}
${modelRolesHost('onboarding-model-roles')}
<details class="wizard-collapse" data-collapse="subagents" ${state.subagentsOpen ? 'open' : ''}><summary>Available subagents</summary>
<div class="wizard-collapse-body">${availableSubagentsEditorHost('onboarding-available-subagents')}</div>
</details>
`;
}
function renderReviewModeStep() {
const runtimeMode = trim(state.runtimeMode) || 'advanced';
const runtimeModeCopy = HOST_MODE === 'desktop'
? 'Separate axis from review enforcement. This first-run choice becomes the boot baseline before Ouroboros starts; later elevation requires native launcher confirmation.'
: 'Separate axis from review enforcement. Web/Docker onboarding saves this through the owner endpoint; the selected mode becomes active after restart.';
return `
<div class="step-header">
<div>
<h2 class="step-title">${escapeHtml(STEP_META.review_mode.title)}</h2>
<p class="step-copy">${escapeHtml(STEP_META.review_mode.copy)}</p>
</div>
</div>
<div class="wizard-inline-note">${state.reviewerSlots ? 'Your reviewer assignments are ready below. You can change every reviewer, including deep self-review.' : agentsStep?.previewError ? 'Automatic reviewer assignments are unavailable. Configure them below, or use Main on the summary.' : 'Reviewer assignments will be prepared from your connected access.'}</div>
<details class="wizard-collapse" data-collapse="reviewers" ${state.reviewersOpen ? 'open' : ''}><summary>Reviewers</summary>
<div class="wizard-collapse-body">${renderReviewerSlotsSection()}</div>
</details>
<div class="wizard-choice-grid">
${REVIEW_MODES.map((mode) => `
<button type="button" class="wizard-choice ${escapeHtml(mode.className || mode.value)} ${state.reviewEnforcement === mode.value ? 'active' : ''}" data-review-mode="${escapeHtml(mode.value)}" aria-pressed="${state.reviewEnforcement === mode.value}">
<span class="tone">${escapeHtml(mode.tone)}</span>
<h3>${escapeHtml(mode.label)}</h3>
<p>${escapeHtml(mode.copy)}</p>
</button>
`).join('')}
</div>
<div class="panel-card runtime-mode-card">
<h3>Access level</h3>
<p class="field-note">${escapeHtml(runtimeModeCopy)}</p>
<div class="wizard-choice-grid ${RUNTIME_MODE_GRID_CLASS}">
${RUNTIME_MODES.map((mode) => `
<button type="button" class="wizard-choice ${escapeHtml(mode.className || mode.value)} ${runtimeMode === mode.value ? 'active' : ''}" data-runtime-mode="${escapeHtml(mode.value)}" aria-pressed="${runtimeMode === mode.value}">
<span class="tone">${escapeHtml(mode.tone)}</span>
<h3>${escapeHtml(mode.label)}</h3>
<p>${escapeHtml(mode.copy)}</p>
</button>
`).join('')}
</div>
<div class="field ui-field">
<div class="field-label-row">
<label for="skills-repo-path">External skills repo (optional)</label>
<button class="field-clear" data-clear="skills-repo-path" type="button" aria-label="Clear external skills repo">Clear</button>
</div>
<input id="skills-repo-path" class="ui-control" type="text" aria-describedby="skills-repo-path-help" placeholder="~/Ouroboros/skills or /absolute/path/to/skills" value="${escapeHtml(state.skillsRepoPath || '')}">
<div id="skills-repo-path-help" class="field-note ui-field-help">Optional. Extra discovery root on top of the in-data-plane <code>data/skills/{native,clawhub,external}/</code> tree. Leave empty if you do not maintain your own skills checkout — Ouroboros never clones/pulls this directory.</div>
</div>
</div>
`;
}
function renderBudgetStep() {
return `
<div class="step-header">
<div>
<h2 class="step-title">${escapeHtml(STEP_META.budget.title)}</h2>
<p class="step-copy">${escapeHtml(STEP_META.budget.copy)}</p>
</div>
</div>
<div id="wizard-subscription-quota">${subscriptionQuotaHtml()}</div>
<details class="wizard-collapse" data-collapse="api-budget" ${hasApiAccess() || !hasModelSubscription() || state.apiBudgetOpen ? 'open' : ''}>
<summary><span>API spending limits</span><span class="selection-badge">Optional with subscriptions</span></summary>
<div class="wizard-collapse-body grid two">
${BUDGET_FIELDS.map((field) => `
<div class="panel-card">
<h3>${escapeHtml(field.title)}</h3>
<div class="field ui-field">
<label for="${escapeHtml(field.inputId)}">${escapeHtml(field.label)}</label>
<input id="${escapeHtml(field.inputId)}" class="ui-control" type="number" aria-describedby="${escapeHtml(field.inputId)}-help" min="${escapeHtml(field.min || '0.01')}" step="${escapeHtml(field.step || 'any')}" value="${escapeHtml(state[field.stateKey])}">
<div id="${escapeHtml(field.inputId)}-help" class="field-note ui-field-help">${escapeHtml(field.note)}</div>
</div>
</div>
`).join('')}
</div></details>
`;
}
function summaryRowsHtml() {
return summaryRows().map(([label, value]) => `
<div class="summary-kv">
<strong>${escapeHtml(label)}</strong>
<span>${escapeHtml(value)}</span>
</div>
`).join('');
}
function refreshSummary() {
const summary = root.querySelector('.summary-card');
if (summary) summary.innerHTML = summaryRowsHtml();
syncCurrentStepActionState();
}
function renderSummaryStep() {
return `
<div class="step-header">
<div>
<h2 class="step-title">${escapeHtml(STEP_META.summary.title)}</h2>
<p class="step-copy">${escapeHtml(STEP_META.summary.copy)}</p>
</div>
</div>
<div class="summary-card">${summaryRowsHtml()}</div>
${state.recoveryPrepared ? `<div class="wizard-inline-note">Automatic subscription presets were skipped. ${state.recoveryMain === mainBinding() ? 'Reviewers were assigned to Main.' : 'Main changed; reviewers keep the assignments shown above. Use Main for reviewers again if you want to update them.'} Check the assignments, then Start Ouroboros to save this draft. Later changes in Settings are manual.</div>` : ''}
`;
}
function renderRestartRequiredScreen() {
return `
<div class="step-header">
<div>
<h2 class="step-title">Setup saved — restart to apply it</h2>
<p class="step-copy">Everything you entered is on disk. One choice needs a fresh start before it is live.</p>
</div>
</div>
<div class="panel-card">
<h3>Runtime mode takes effect at the next boot</h3>
<p>Ouroboros saved <code>${escapeHtml(state.completedRestartMode)}</code> as the runtime mode for the next boot, but this
process is still running the mode it started with. Restart Ouroboros to run in the mode you chose.
Opening the app now works — it simply runs in the previous mode until you do.</p>
<div class="wizard-runtime-strip">
<button type="button" class="btn btn-primary" id="open-app-btn">Open Ouroboros in the current mode</button>
</div>
</div>
`;
}
function renderStepContent() {
if (state.currentStep === 'accounts') return renderAccountsStep();
if (state.completedRestartMode) return renderRestartRequiredScreen();
if (state.currentStep === 'providers') return renderProvidersStep();
if (state.currentStep === 'agents') return renderAgentsStep();
if (state.currentStep === 'models') return renderModelsStep();
if (state.currentStep === 'review_mode') return renderReviewModeStep();
if (state.currentStep === 'budget') return renderBudgetStep();
return renderSummaryStep();
}
function stepCards() {
return STEP_ORDER.map((stepId, index) => {
const active = stepId === state.currentStep;
const done = STEP_ORDER.indexOf(state.currentStep) > index;
const meta = STEP_META[stepId];
return `
<div class="wizard-step ${active ? 'active' : ''} ${done ? 'done' : ''}">
<div class="wizard-step-index">Step ${index + 1}</div>
<p class="wizard-step-title">${escapeHtml(meta.title)}</p>
<p class="wizard-step-copy">${escapeHtml(meta.railCopy || '')}</p>
</div>
`;
}).join('');
}
function render() {
destroyReviewerSlots();
const meta = STEP_META[state.currentStep];
const index = STEP_ORDER.indexOf(state.currentStep);
// An UNKNOWN save (503 settings_save_timeout: the write may still be
// running in the server) makes "Check status" the primary action and
// the re-submit an explicit, secondary "Retry save" — never the default
// button beside it, which re-POSTed a second write over the first.
const saveUnknown = state.currentStep === 'summary' && state.saveUnknown;
const nextLabel = state.currentStep === 'summary'
? (state.saving ? 'Saving...' : (saveUnknown ? 'Retry save' : 'Start Ouroboros'))
: 'Continue';
root.innerHTML = `
<div class="wizard-shell">
<div class="wizard-header">
<div>
<h1 class="wizard-title">Ouroboros</h1>
<p class="wizard-subtitle">Your accounts, your models, one Ouroboros.</p>
</div>
<div class="wizard-badge">Step ${index + 1} of ${STEP_ORDER.length}</div>
</div>
<div class="wizard-steps">${stepCards()}</div>
<div class="wizard-content">
${renderStepContent()}
${state.completedRestartMode ? '' : `
<div class="wizard-footer">
<div class="footer-copy">${escapeHtml(meta.footer)}</div>
<div class="footer-actions">
<button class="btn btn-secondary" id="back-btn" type="button" ${index === 0 || state.saving || state.preparingRecovery ? 'disabled' : ''}>Back</button>
${state.currentStep === 'accounts' ? `<button class="btn btn-secondary" id="quick-start-btn" type="button" ${hasModelSubscription() ? '' : 'hidden'}>Review &amp; start</button>` : ''}
${state.currentStep === 'summary' ? `
<button class="btn btn-secondary" id="skip-presets-btn" type="button" ${shouldOfferPresetSkip() ? '' : 'hidden'} ${state.saving || state.preparingRecovery ? 'disabled' : ''}>Use Main for reviewers</button>
` : ''}
<button class="btn ${saveUnknown ? 'btn-secondary' : 'btn-primary'}" id="next-btn" type="button" ${nextButtonShouldBeDisabled() ? 'disabled' : ''}>${escapeHtml(nextLabel)}</button>
${saveUnknown ? `
<button class="btn btn-primary" id="check-save-btn" type="button" ${state.saving ? 'disabled' : ''}>Check status</button>
` : ''}
</div>
</div>
<div class="wizard-error" role="status">${escapeHtml(state.error)}</div>
`}
</div>
</div>
`;
bindEvents();
renderLocalStatus();
}
function bindClearButtons() {
const clearActions = Object.fromEntries(PROVIDER_FIELDS.map((field) => [
field.id,
() => { state[field.stateKey] = ''; },
]));
Object.assign(clearActions, {
'local-preset': () => {
state.localPreset = '';
state.localSource = '';
state.localFilename = '';
state.localRoutingMode = 'cloud';
state.localSourceOpen = false;
},
'local-source': () => {
state.localSource = '';
state.localPreset = detectLocalPresetSelection();
},
'local-filename': () => {
state.localFilename = '';
state.localPreset = detectLocalPresetSelection();
},
'local-chat-format': () => { state.localChatFormat = ''; },
'skills-repo-path': () => { state.skillsRepoPath = ''; },
});
root.querySelectorAll('[data-clear]').forEach((button) => {
button.addEventListener('click', () => {
const target = button.getAttribute('data-clear');
if (clearActions[target]) clearActions[target]();
state.error = '';
agentsStep?.invalidateGeneratedPreview();
render();
});
});
}
function bindProvidersStep() {
const localPreset = document.getElementById('local-preset');
const localSource = document.getElementById('local-source');
const localFilename = document.getElementById('local-filename');
const localContext = document.getElementById('local-context');
const localGpuLayers = document.getElementById('local-gpu-layers');
const localChatFormat = document.getElementById('local-chat-format');
function bindStateInput(input, key, after = null) {
if (!input) return;
input.addEventListener('input', () => {
state[key] = input.value;
if (after) after(input);
markStepEdited();
});
}
PROVIDER_FIELDS.forEach((field) => {
bindStateInput(document.getElementById(field.id), field.stateKey);
});
if (localPreset) localPreset.addEventListener('change', () => {
applyPresetSelection(localPreset.value);
state.error = '';
agentsStep?.invalidateGeneratedPreview();
render();
});
bindStateInput(localSource, 'localSource', () => {
state.localPreset = detectLocalPresetSelection();
if (localPreset) localPreset.value = state.localPreset || '';
state.localSourceOpen = true;
if (trim(state.localSource) && activeProviderProfile() === 'local' && trim(state.localRoutingMode) === 'cloud') {
state.localRoutingMode = 'all';
}
});
bindStateInput(localFilename, 'localFilename', () => {
state.localPreset = detectLocalPresetSelection();
if (localPreset) localPreset.value = state.localPreset || '';
});
bindStateInput(localContext, 'localContextLength');
bindStateInput(localGpuLayers, 'localGpuLayers');
bindStateInput(localChatFormat, 'localChatFormat');
bindChoices('data-local-mode', 'localRoutingMode');
if (LOCAL_RUNTIME_CONTROLS) {
startLocalStatusPolling();
document.getElementById('wizard-local-start')?.addEventListener('click', async () => {
const body = readLocalModelBody();
if (!body.source) {
state.error = 'Enter a local model source before starting the local runtime.';
render();
return;
}
setLocalTestResult('', 'muted');
try {
const resp = await fetch('/api/local-model/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await resp.json().catch(() => ({}));
if (resp.status === 412 && data.error === 'runtime_missing') {
setLocalTestResult(
'Local runtime (llama-cpp-python) is not installed.\n' +
'Go to Settings → Advanced → Local Model Runtime\n' +
'and click "Install Local Runtime".\n\n' +
'Manual: ' + (data.hint || 'pip install llama-cpp-python[server]'),
'error'
);
} else if (data.error) {
setLocalTestResult(`Start failed: ${data.error}`, 'error');
} else {
updateLocalStatus();
}
} catch (error) {
setLocalTestResult(`Start failed: ${error.message}`, 'error');
}
});
document.getElementById('wizard-local-stop')?.addEventListener('click', async () => {
try {
await apiRequest('/api/local-model/stop', { method: 'POST' });
setLocalTestResult('', 'muted');
updateLocalStatus();
} catch (error) {
setLocalTestResult(`Stop failed: ${error.message}`, 'error');
}
});
document.getElementById('wizard-local-test')?.addEventListener('click', async () => {
setLocalTestResult('Running tests...', 'muted');
try {
const result = await apiRequest('/api/local-model/test', { method: 'POST' });
const lines = [];
lines.push(`${result.chat_ok ? '✓' : '✗'} Basic chat${result.tokens_per_sec ? ` (${result.tokens_per_sec} tok/s)` : ''}`);
lines.push(`${result.tool_call_ok ? '✓' : '✗'} Tool calling`);
if (result.details && !result.success) lines.push(result.details);
setLocalTestResult(lines.join('\n'), result.success ? 'ok' : 'warn');
} catch (error) {
setLocalTestResult(`Test failed: ${error.message}`, 'error');
}
});
}
syncCurrentStepActionState();
}
function bindCompatibleModelLoader() {
const loadBtn = document.getElementById('load-compatible-models');
if (!loadBtn) return;
loadBtn.addEventListener('click', async () => {
const baseUrl = trim(state.compatibleBaseUrl).replace(/\/+$/, '');
const apiKey = trim(state.compatibleApiKey);
const statusEl = document.getElementById('compatible-load-status');
const listEl = document.getElementById('compatible-model-list');
if (!baseUrl) {
if (statusEl) statusEl.textContent = 'Go back and enter a base URL first.';
return;
}
if (statusEl) statusEl.textContent = 'Loading…';
loadBtn.disabled = true;
try {
let models;
if (HOST_MODE === 'web') {
const resp = await fetch('/api/openai-compatible/models', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ baseUrl, apiKey }),
cache: 'no-store',
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(data.error || `HTTP ${resp.status}`);
models = (data.models || []).map((m) => trim(m)).filter(Boolean).sort();
} else {
if (!window.pywebview?.api?.fetch_compatible_models) {
throw new Error('Desktop model-fetch bridge unavailable.');
}
const result = await window.pywebview.api.fetch_compatible_models({ baseUrl, apiKey });
if (result?.error) throw new Error(result.error);
models = (result?.models || []).map((m) => trim(m)).filter(Boolean).sort();
}
if (!models.length) throw new Error('No models returned by endpoint.');
if (statusEl) statusEl.textContent = `${models.length} model${models.length === 1 ? '' : 's'} found — click one to fill empty slots.`;
if (listEl) {
listEl.hidden = false;
listEl.innerHTML = models.map((m) =>
`<button type="button" class="selection-pill" data-apply-model="${escapeHtml(m)}">${escapeHtml(m)}</button>`
).join('');
}
} catch (err) {
const msg = String(err?.message || err || 'Unknown error');
if (statusEl) statusEl.textContent = `Failed: ${msg}`;
if (listEl) { listEl.hidden = true; listEl.innerHTML = ''; }
} finally {
loadBtn.disabled = false;
}
});
if (!root._compatModelListenerBound) {
root._compatModelListenerBound = true;
root.addEventListener('click', (event) => {
const pill = event.target.closest('[data-apply-model]');
if (!pill) return;
const modelId = `openai-compatible::${pill.dataset.applyModel}`;
if (!trim(state.mainModel)) state.mainModel = modelId;
if (!trim(state.lightModel)) state.lightModel = modelId;
if (!trim(state.fallbackModel)) state.fallbackModel = modelId;
state.modelsDirty = true;
agentsStep?.invalidateGeneratedPreview();
render();
});
}
}
function bindModelsStep() {
bindCompatibleModelLoader();
loadModelRoles();
modelRoles.mount();
agentsStep?.mount();
agentsStep?.setProcessingPreference(state.processingPreference);
syncCurrentStepActionState();
}
function bindChoices(attribute, stateKey) {
const buttons = root.querySelectorAll(`[${attribute}]`);
buttons.forEach((button) => button.addEventListener('click', () => {
state[stateKey] = button.getAttribute(attribute);
buttons.forEach((choice) => {
const selected = choice.getAttribute(attribute) === state[stateKey];
choice.classList.toggle('active', selected);
choice.setAttribute('aria-pressed', String(selected));
});
markStepEdited();
}));
}
function bindReviewModeStep() {
initReviewerSlots({ onChange: () => {
state.reviewerDraftDirty = true;
const value = collectReviewerSlots().OUROBOROS_REVIEWER_SLOTS;
if (value) state.reviewerSlots = JSON.parse(value);
markStepEdited();
} });
adoptSubagentRoster({ OUROBOROS_SUBAGENTS: state.availableSubagents });
// Keys typed on Accounts decide which providers these lanes may offer,
// so the list is derived from the CURRENT draft on every entry into
// this step rather than once at construction.
setReviewerSourceContext({ settings: draftSettings(), providerProfiles: PROVIDER_PROFILES });
setReviewerProcessingPreference(state.processingPreference, state.modelProcessingPreferences || {});
if (state.reviewerSlots) applyReviewerSlotsDraft(state.reviewerSlots);
bindChoices('data-review-mode', 'reviewEnforcement');
bindChoices('data-runtime-mode', 'runtimeMode');
const skillsInput = document.getElementById('skills-repo-path');
if (skillsInput) skillsInput.addEventListener('input', () => { state.skillsRepoPath = skillsInput.value; markStepEdited(); });
syncCurrentStepActionState();
}
function bindBudgetStep() {
BUDGET_FIELDS.forEach((field) => {
const input = document.getElementById(field.inputId);
if (input) input.addEventListener('input', () => { state[field.stateKey] = input.value; markStepEdited(); });
});
syncCurrentStepActionState();
}
// One atomic completion on every host (D-8); only shell notification differs.
// Separate settings/runtime-mode writes could leave a half-saved installation.
const ONBOARDING_COMPLETE_ENDPOINT = '/api/onboarding/complete';
function announceCompletion(result) {
const restartRequired = Boolean(result?.restart_required);
const runtimeMode = trim(result?.runtime_mode) || trim(state.runtimeMode) || 'advanced';
if (window.parent && window.parent !== window) {
// Target our own origin explicitly (paired with the receiver's
// origin check) instead of broadcasting to any embedding page.
const targetOrigin = window.location.origin === 'null'
? (window.parent?.location?.origin ?? '*')
: window.location.origin;
window.parent.postMessage({
type: 'ouroboros:onboarding-complete',
restart_required: restartRequired,
runtime_mode: runtimeMode,
}, targetOrigin);
return;
}
if (window.pywebview?.api?.onboarding_finished) {
// Desktop setup window: the launcher owns both this window and the
// managed server process, so it closes the window and recycles the
// server itself instead of asking the owner to restart the app.
window.pywebview.api.onboarding_finished({
ok: true,
restart_required: restartRequired,
runtime_mode: runtimeMode,
});
return;
}
// PLAIN BROWSER TAB. Nothing here owns the server process, so a receipt
// that says a restart is required cannot be discharged by navigating —
// and redirecting anyway drops the owner into an app running a runtime
// mode DIFFERENT from the one they just chose, with nothing on screen
// saying so. The other two shells each show this (the overlay's restart
// card, the launcher's own recycle); this one used to be the only place
// the flag was silently thrown away.
if (restartRequired) {
showBrowserRestartRequired(runtimeMode);
return;
}
window.location.replace('/');
}
function showBrowserRestartRequired(runtimeMode) {
state.saving = false;
state.completedRestartMode = trim(runtimeMode) || 'advanced';
render();
}
async function checkSaveStatus() {
// Completion answered 503 `settings_save_timeout`: its body kept running
// in the server past the shared writer bound, so whether the bytes
// landed is UNKNOWN (`saved: null`) and a retry would be a second write.
// Re-read the readiness probe the overlay itself gates on — 204 means a
// startup-ready provider is on disk, i.e. the transaction landed — and
// proceed exactly as a completion receipt would; otherwise stay open.
state.error = '';
render();
let status = 0;
try {
const response = await fetch('/api/onboarding', {
method: 'GET', headers: { Accept: 'application/json' },
});
status = response.status;
} catch (error) {
state.error = `Could not check the save status: ${String(error?.message || error)}. Try again in a moment.`;
render();
return;
}
if (status === 204) {
state.saveUnknown = false;
await agentsStep?.disposeForCompletion();
agentsStep = null;
// The receipt's `restart_required` never arrived: the boot-pinned
// mode is what the page loaded with, so a changed choice is treated
// as needing a restart — the honest side to err on.
announceCompletion({
runtime_mode: state.runtimeMode,
restart_required: trim(state.runtimeMode) !== trim(INITIAL_STATE.runtimeMode),
});
return;
}
state.error = 'Setup is not complete yet — the save may still be running in the '
+ 'server. Check again in a moment; if it never completes, finish setup again.';
render();
}
async function completeOnboardingAtomically(payload) {
// The atomic completion (D-8): server-side fresh-install proof, the
// structural provider gate, optional agent-preset compilation, a single
// persist, then supervisor start.
const response = await fetch(ONBOARDING_COMPLETE_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
// A body that will not parse is UNKNOWN, not empty. Substituting `{}`
// made a 200 carrying HTML (a proxy error page, a login redirect) look
// like a completion, and `{}` being truthy meant the caller announced
// success with `restart_required` silently gone.
let data = null;
let parsed = true;
try {
data = await response.json();
} catch (err) {
parsed = false;
}
if (!data || typeof data !== 'object') { data = {}; parsed = false; }
// ONE reader for both answers (typed refusal, success envelope); the branches are node-tested in onboarding_agents_step.js.
const answer = readCompletionAnswer({
status: response.status, ok: response.ok, parsed, data,
});
if (answer.failure) {
const error = new Error(answer.failure.message);
Object.assign(error, answer.failure);
throw error;
}
return answer.receipt;
}
async function saveWizardPayload(payload) {
const result = await completeOnboardingAtomically(payload);
await agentsStep?.disposeForCompletion();
agentsStep = null;
announceCompletion(result);
return 'ok';
}
async function prepareMainReviewers() {
if (state.saving || state.preparingRecovery) return;
const error = validateModelsStep();
if (error) { navigateStep('models'); state.error = error; return syncCurrentStepActionState(); }
Object.assign(state, { skipSubscriptionPresets: true, preparingRecovery: true, error: '' });
syncCurrentStepActionState();
const ready = await agentsStep?.setSkipPresets(true, { replaceReviewers: true });
if (disposed) return;
Object.assign(state, { preparingRecovery: false, recoveryPrepared: Boolean(ready) });
state.error = ready ? '' : agentsStep?.previewError || 'Reviewer assignments could not be prepared. Retry before saving.';
if (ready) state.recoveryMain = mainBinding();
else { // a failed recovery must not latch the wizard (no reload in the desktop setup window): keep Finish and the Use Main retry
state.skipSubscriptionPresets = false;
void agentsStep?.setSkipPresets(false);
}
render(); // Show the recovered assignments; only the next explicit Start saves.
}
async function saveWizard() {
if (state.saving || state.preparingRecovery) return;
const providersError = validateProvidersStep();
const modelsError = validateModelsStep();
const reviewError = validateReviewStep();
const budgetError = validateBudgetStep();
agentsStep?.noteSaveAttempt?.();
const subagentsError = agentsStep?.validateSubagents?.()?.[0] || '';
const previewError = agentsStep && !agentsStep.generatedPreviewReady
? (agentsStep.previewPending
? 'Available subagents are still updating from your latest setup choices. Try Finish again in a moment.'
: `Available subagents could not be refreshed from your latest setup choices${agentsStep.previewError ? `: ${agentsStep.previewError}` : '.'}`)
: '';
state.error = (state.skipSubscriptionPresets && !state.recoveryPrepared ? 'Use Main for reviewers to prepare the assignments before saving.' : '')
|| providersError || modelsError || reviewError || budgetError
|| subagentsError || previewError;
if (state.error) return syncCurrentStepActionState();
state.saving = true;
state.error = '';
render();
const payload = {
// Observations are not authority: the endpoint re-proves eligibility.
subscriptionsConnected: state.agentsConnected.length > 0,
skipSubscriptionPresets: state.skipSubscriptionPresets,
...onboardingSettingsDraft({ state, providerFields: PROVIDER_FIELDS, budgetFields: BUDGET_FIELDS, modelSlots: MODEL_SLOTS, trim }),
// Completion validates this visible draft and never replaces it.
OUROBOROS_SUBAGENTS: agentsStep?.availableSubagents
|| state.availableSubagents,
...(state.reviewerSlots ? { OUROBOROS_REVIEWER_SLOTS: JSON.stringify(state.reviewerSlots) } : {}),
};
try {
await saveWizardPayload(payload);
} catch (error) {
// The wizard STAYS OPEN with the real reason. A typed preset refusal
// wrote nothing, so the offered escape is honest: finish without the
// agent defaults and keep everything editable in Settings.
const notice = completionFailureNotice(error);
state.saving = false;
state.presetFailure = notice.canSkip ? { code: notice.code } : null;
state.saveUnknown = Boolean(notice.saveUnknown);
state.error = notice.text;
render();
}
}
function bindEvents() {
if (state.completedRestartMode) {
document.getElementById('open-app-btn')?.addEventListener('click', () => {
window.location.replace('/');
});
return;
}
bindClearButtons();
// Bind every disclosure independently; keeping only the first one
// loses later groups and the position of their draft on Back.
const collapseStateKeys = {
'api-access': 'apiAccessOpen', 'api-budget': 'apiBudgetOpen',
'more-providers': 'moreProvidersOpen', 'local-model': 'localSourceOpen',
subagents: 'subagentsOpen', reviewers: 'reviewersOpen',
};
root.querySelectorAll('[data-collapse]').forEach((details) => {
const key = collapseStateKeys[details.dataset.collapse];
if (key) details.addEventListener('toggle', () => { state[key] = details.open; });
});
document.getElementById('back-btn')?.addEventListener('click', previousStep);
document.getElementById('next-btn')?.addEventListener('click', () => {
if (state.currentStep === 'summary') saveWizard();
else nextStep();
});
document.getElementById('skip-presets-btn')?.addEventListener('click', () => {
void prepareMainReviewers();
});
document.getElementById('check-save-btn')?.addEventListener('click', () => {
checkSaveStatus();
});
document.getElementById('quick-start-btn')?.addEventListener('click', reviewAndStart);
document.getElementById('onboarding-access-retry')?.addEventListener('click', async () => {
await Promise.allSettled([agentsStep?.refreshStatus(), refreshModelSources(),
agentsStep?.refreshSubagentsPreview({ force: true })]);
if (!disposed) syncCurrentStepActionState();
});
if (state.currentStep === 'accounts') { bindProvidersStep(); bindAgentsStep(); }
if (state.currentStep === 'providers') bindProvidersStep();
if (state.currentStep === 'agents') bindAgentsStep();
if (state.currentStep === 'models') bindModelsStep();
if (state.currentStep === 'review_mode') bindReviewModeStep();
if (state.currentStep === 'budget') bindBudgetStep();
syncCurrentStepActionState();
}
applyModelDefaults(false);
window.addEventListener('pagehide', (event) => {
if (event.persisted) return;
disposed = true;
catalogGeneration += 1;
catalogRequest?.controller.abort();
modelRoles.destroy();
destroyReviewerSlots();
});
render();
})();