From 876e89196ef1f905a281c7396aca200b4c943e76 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Tue, 15 Sep 2026 17:09:32 -0700 Subject: [PATCH] Remember Easel provider settings and finish desktop quit behavior --- easel/desktop/donkey.css | 4 +++ easel/desktop/main.cjs | 6 ++-- easel/desktop/preload.cjs | 1 + easel/desktop/renderer.js | 6 ++-- easel/docs/duputo-api-review.md | 44 ++++++++++++++++++++++++ easel/src/launch-chooser.mjs | 2 +- easel/src/provider-preferences.mjs | 19 ++++++++++ easel/src/render.mjs | 26 ++++++++++++-- easel/src/tui.mjs | 21 ++++++++--- easel/test/provider-preferences.test.mjs | 3 ++ 10 files changed, 119 insertions(+), 13 deletions(-) create mode 100644 easel/docs/duputo-api-review.md create mode 100644 easel/src/provider-preferences.mjs create mode 100644 easel/test/provider-preferences.test.mjs diff --git a/easel/desktop/donkey.css b/easel/desktop/donkey.css index 3988781b75..e47ea89db5 100644 --- a/easel/desktop/donkey.css +++ b/easel/desktop/donkey.css @@ -5,3 +5,7 @@ #desktop-notice{max-width:calc(100% - var(--aesel-size) - 40px)} body[data-phase="startup"] #terminal{padding-right:10px} + +/* One wooden shelf continues underneath the terminal and Aesel's feet. */ +body:not([data-phase="startup"])::before{content:"";position:fixed;left:0;right:0;bottom:0;height:var(--easel-shelf-height,84px);background:repeating-linear-gradient(177deg,#33231b 0 5px,#3a271c 6px 7px,#33231b 8px 14px,#432d1e 15px 16px);pointer-events:none} +#terminal{position:relative} diff --git a/easel/desktop/main.cjs b/easel/desktop/main.cjs index 16249be662..b0e9e5cbfb 100644 --- a/easel/desktop/main.cjs +++ b/easel/desktop/main.cjs @@ -76,8 +76,8 @@ const desktopUpdater = createUpdater({app, canUpdateBinary, requestRestart, prep function send(channel, data) { if (window && !window.isDestroyed()) window.webContents.send(channel, data); } function start() { if (terminal) return; - const args = [join(root, 'src/tui.mjs'), '--cwd', workspace, '--backend', option('--backend') || 'ac']; - for (const name of ['--piece', '--resume', '--model']) if (option(name)) args.push(name, option(name)); + const args = [join(root, 'src/tui.mjs'), '--cwd', workspace]; + for (const name of ['--piece', '--resume', '--model', '--backend', '--effort']) if (option(name)) args.push(name, option(name)); if (continueSession) args.push('--continue-session'); if (supplied.includes('--no-autopublish')) args.push('--no-autopublish'); const env = { ...process.env, ELECTRON_RUN_AS_NODE: '1', TERM: 'xterm-256color', COLORTERM: 'truecolor', SLAB_HOME: slabHome, EASEL_DESKTOP: '1', EASEL_THEME:'slab', EASEL_MOUSE:'0', EASEL_DESKTOP_SESSION:sessionFile, EASEL_DESKTOP_CONTROL:controlFile, EASEL_DESKTOP_INTENT:intentFile, EASEL_PREVIEW_EVENTS:previewEventsFile }; @@ -209,6 +209,7 @@ app.whenReady().then(() => { }); }); ipcMain.on('ready', event => { if (event.sender === window?.webContents) { send('theme',currentTheme); sendDisplay(true); start(); } }); +ipcMain.on('closing', event => { if(event.sender===window?.webContents) window.hide(); }); ipcMain.on('input', (event, data) => { if (event.sender === window?.webContents && typeof data === 'string' && data.length < 1_048_576) terminal?.write(data); }); ipcMain.on('size', (event, { cols, rows } = {}) => { if (event.sender === window?.webContents && Number.isInteger(cols) && Number.isInteger(rows) && cols >= 32 && cols <= 500 && rows >= 10 && rows <= 300) { terminalSize={cols,rows}; terminal?.resize(cols, rows); } @@ -258,6 +259,7 @@ ipcMain.on('open-piece', (event, value) => { ipcMain.on('fullscreen', (event, value) => { if (isWindow(event)) toggleFullscreen(typeof value === 'string' ? value : value?.target); }); app.on('web-contents-created', (_event, contents) => { contents.on('before-input-event', (event, input) => { + if(input.type==='keyDown' && input.control && !input.meta && !input.alt && input.key.toLowerCase()==='c') { event.preventDefault(); terminal?.write('\x03'); return; } if (input.type === 'keyDown' && (input.meta || input.control) && !input.alt && ['+','=','-','0'].includes(input.key)) { event.preventDefault(); send('text-size', input.key === '0' ? 'reset' : input.key === '-' ? 'smaller' : 'larger'); return; } diff --git a/easel/desktop/preload.cjs b/easel/desktop/preload.cjs index 0ce50e4eb0..3d83ea4a38 100644 --- a/easel/desktop/preload.cjs +++ b/easel/desktop/preload.cjs @@ -3,6 +3,7 @@ contextBridge.exposeInMainWorld('easel', { input: data => { if (typeof data === 'string') ipcRenderer.send('input', data); }, size: (cols, rows) => ipcRenderer.send('size', { cols, rows }), ready: () => ipcRenderer.send('ready'), + closing: () => ipcRenderer.send('closing'), fullscreen: target => { if (target === 'app' || target === 'preview') ipcRenderer.send('fullscreen', target); }, diff --git a/easel/desktop/renderer.js b/easel/desktop/renderer.js index 1940f9d3df..27ccddd8df 100644 --- a/easel/desktop/renderer.js +++ b/easel/desktop/renderer.js @@ -50,9 +50,10 @@ window.easel.onDisplay(display => { if (!window.currentPreviewMedium || window.currentPreviewMedium === 'piece') window.setPreviewDimensions(...pieceDimensions); } }); +function sizeShelf(){const screen=document.querySelector('.xterm-screen')?.getBoundingClientRect();if(screen)document.documentElement.style.setProperty('--easel-shelf-height',`${innerHeight-screen.top-screen.height+(screen.height/terminal.rows)*4}px`);} function resize() { if (resizeFrame || fullscreenState.preview) return; - resizeFrame = requestAnimationFrame(() => { resizeFrame = 0; sizePreviewBox(); if (fullscreenState.preview) return; fit.fit(); window.easel.size(terminal.cols, terminal.rows); }); + resizeFrame = requestAnimationFrame(() => { resizeFrame = 0; sizePreviewBox(); if (fullscreenState.preview) return; fit.fit(); sizeShelf(); window.easel.size(terminal.cols, terminal.rows); }); } window.easel.onNotice(message => { const notice = document.getElementById('desktop-notice'); @@ -82,7 +83,7 @@ let draggingSelection = false, pendingOutput = '', pastedInput = false; let clickOrigin = null; let lastHoverCell = '', hoverFrame = 0, pendingHover = null; terminal.parser.registerOscHandler(777, data => { - if(data.startsWith('easel-phase:')) { document.body.dataset.phase=data.slice(12); resize(); return true; } + if(data.startsWith('easel-phase:')) { document.body.dataset.phase=data.slice(12); if(data.slice(12)==='closing') window.easel.closing(); else resize(); return true; } if (!data.startsWith('easel-pointer:')) return false; const action=data.slice('easel-pointer:'.length); terminalElement.dataset.pointer = (['about','profile'].includes(action)||action.startsWith('settings:')||action.startsWith('choice:')) ? 'link' : ''; @@ -289,6 +290,7 @@ window.easel.onState(state => { preview.addEventListener('did-finish-load', () => { artifact.classList.remove('refresh'); requestAnimationFrame(() => artifact.classList.add('refresh')); }); sizePreviewBox(); fit.fit(); +sizeShelf(); window.easel.size(terminal.cols, terminal.rows); window.easel.ready(); terminal.focus(); diff --git a/easel/docs/duputo-api-review.md b/easel/docs/duputo-api-review.md new file mode 100644 index 0000000000..da0dfe515f --- /dev/null +++ b/easel/docs/duputo-api-review.md @@ -0,0 +1,44 @@ +# Duputo API review + +Observed September 15, 2026, read-only. The monitored turn ran 17:01:29–17:02:45 PDT. Repeated checks during the review found no later transcript activity. No input, approvals or messages were sent to the session; its piece and settings were not changed. + +The main problem was unavailable tools, not an agent refusing to consult AC's APIs. Duputo tried the right tools first, and the host returned rejections. Improving prompts alone would leave that failure intact. + +## Observed sequence + +| Order | Calls and result | +| --- | --- | +| 1 | Discovered AC tools and read the starter piece. | +| 2 | Requested `ac_api` for `shape`, `circle` and `line` in parallel. All three returned `isError` with “user rejected MCP tool call.” | +| 3 | Listed the installed app resources and developer directory, then requested `ac_examples(circle)`. That call returned the same rejection. | +| 4 | Searched the installed source for a repository location, listed the home directory, located drawing functions in `graph.mjs`, and read the 36-line `line` implementation. | +| 5 | Applied one patch, ran `node --check`, and requested `ac_preview` plus `ac_frame`. Syntax passed; both preview calls returned the same rejection. | +| 6 | Finished with an explicit statement that visual verification remained incomplete. | + +Counts: nine orchestration calls, eight shell commands, six rejected AC MCP calls, one patch. The eight shell commands comprise the starter read, four repository-location calls, two targeted runtime-source reads and one syntax check. The six fallback shell calls between the first rejection and editing took about 31 seconds. There was no repeated identical API lookup or repeated edit/test loop in this turn. + +The rejected API answers were already present in the installed `context/api.json`: polygon `shape`, `circle` signature and `line` overloads. The source hunt therefore duplicated information Easel shipped. That does not establish that every desired drawing operation was fully documented. + +The agent did not claim visual verification passed. It attempted both diagnostics and pixel capture, then disclosed their rejection. No frame evidence reached the model, so this review cannot establish that the resulting animation looked correct or ran without runtime errors. A syntax check only established parse validity. + +## Host and context findings + +**Confirmed from code:** `easel/src/tui.mjs` handles only `item/commandExecution/requestApproval` and `item/fileChange/requestApproval`. Other server requests receive JSON-RPC `-32601`. The installed app has the same limitation. `AppServer` requests user-reviewed approvals with `approvalPolicy: on-request`. + +**Confirmed from the installed CLI schema:** the MCP elicitation route is `mcpServer/elicitation/request`. It is not `item/mcpToolCall/requestApproval`. Parameters require `serverName`, `threadId` and a mode-specific payload. Modes include `form`, `openai/form`, `openaiForm`, `url`, and `openai/userVerification`. Form mode includes `message` and `requestedSchema`. Its response requires `action: accept | decline | cancel`, with optional `content` and `_meta`; the command-approval `{decision: accept}` response is not this protocol. + +The schema was generated locally with `codex app-server generate-json-schema --experimental`, into a temporary directory. Installed binary strings also contain `codex_approval_kind`, `mcp_tool_call`, persistence choices and the exact rejection text. + +**Inference:** the unhandled elicitation route likely produced the six apparent user rejections. The rollout does not retain the actual server-to-client approval requests, so it does not prove that causal chain or that the user personally declined anything. Capture sanitized method/mode/request-ID diagnostics to confirm it. Do not describe an integration rejection as a human decision. + +**A second concrete gap:** `ac_examples` searches the workspace's top-level pieces outside the monorepo. In a standalone Easel workspace it does not automatically search bundled AC reference examples. `ac_outline` and `ac_symbol` also intentionally stay within the workspace. The guidance to use them instead of reading runtime libraries is not a complete fallback for an installed app. + +**Example quality:** the generated `line` examples include comments containing the word “line”, rather than three useful line calls. The builder's textual symbol matching can produce irrelevant examples. The actual `line` signature was sufficient here, but cleaner call-site selection would reduce further digging. + +## Three improvements + +1. **Handle MCP elicitation explicitly.** Queue parallel requests rather than replacing a single pending approval. Show the server/tool, requested action and schema-defined options; return the user's selected content using the proper `action` response. Keep URL and device-verification flows distinct. Do not automatically accept arbitrary forms, silently persist trust or bypass a declined call. Record unsupported-method errors separately from user decisions. A protocol fixture can validate request queuing, decline/cancel, mode handling and exact response shape without inference or paid tools. +2. **Make the bundled API genuinely self-contained.** Provide curated runnable examples and precise overloads in the map; make `ac_examples` fall back to a packaged reference index. Explain the scope of outline/symbol tools. When an MCP channel is unavailable, point to the exact bundled map path rather than making the agent discover a developer checkout. No broader filesystem permission is needed to read information already packaged with the app. +3. **Keep verification status explicit.** Distinguish syntax checked, diagnostics received and actual frame inspected for the current revision. Surface tool rejection/unsupported bridge status prominently so the agent does not spend a turn searching for an API-access problem. The existing instruction that missing feedback is not success is good; preserve it. Do not require repeated calls after an explicit denial. + +No new session activity appeared in the final transcript check. This is a critique of one observed turn, not a general performance benchmark or proof that the proposed protocol fix resolves every rejection. diff --git a/easel/src/launch-chooser.mjs b/easel/src/launch-chooser.mjs index 1d5a3a1b7d..89e6955a59 100644 --- a/easel/src/launch-chooser.mjs +++ b/easel/src/launch-chooser.mjs @@ -47,7 +47,7 @@ export async function chooseLaunch({threads,input=process.stdin,output=process.s function draw(){output.write(diff.update(renderChooser(state,threads,output.columns||80,output.rows||24),output.columns||80));} input.setRawMode(true);input.resume();output.write('\x1b[?25l');output.on('resize',draw);draw(); return new Promise(resolve=>{ - const finish=value=>{clearTimeout(escapeTimer);output.off('resize',draw);input.off('data',onData);input.setRawMode(Boolean(priorRaw));input.pause();output.write('\x1b[2J\x1b[H');resolve(value);}; + const finish=value=>{clearTimeout(escapeTimer);output.off('resize',draw);input.off('data',onData);input.setRawMode(Boolean(priorRaw));input.pause();if(value || !process.env.EASEL_DESKTOP)output.write('\x1b[2J\x1b[H');resolve(value);}; function onData(chunk){clearTimeout(escapeTimer);buffer+=chunk.toString(); while(buffer){let key=buffer[0];if(key==='\x1b'){const sequence=buffer.match(/^\x1b\[[0-?]*[ -/]*[@-~]/);if(!sequence){if(buffer.length>1 && buffer[1]!=='['){buffer=buffer.slice(1);continue;}escapeTimer=setTimeout(()=>{buffer='';},40);return;}key=sequence[0];}buffer=buffer.slice(key.length); if(key==='\x03'||key==='\x04')return finish(null); diff --git a/easel/src/provider-preferences.mjs b/easel/src/provider-preferences.mjs new file mode 100644 index 0000000000..bfd89af90d --- /dev/null +++ b/easel/src/provider-preferences.mjs @@ -0,0 +1,19 @@ +import {readFile,mkdir,writeFile,rename} from 'node:fs/promises'; +import {dirname,join} from 'node:path'; +import {homedir} from 'node:os'; +import {randomUUID} from 'node:crypto'; +export const preferencesPath=join(homedir(),'.config','easel','provider.json'); +export function providerPreferences(value){ + if(!value||!['ac','claude','codex'].includes(value.backend))return null; + if(typeof value.model!=='string'||value.model.length>200||/[\x00-\x1f]/.test(value.model))return null; + if(!['','none','minimal','low','medium','high','xhigh','max'].includes(value.effort||''))return null; + return {backend:value.backend,model:value.model,effort:value.effort||''}; +} +export async function readProviderPreferences(file=preferencesPath){try{return providerPreferences(JSON.parse(await readFile(file,'utf8')));}catch{return null;}} +export async function saveProviderPreferences(value,file=preferencesPath){const settings=providerPreferences(value);if(!settings)throw new Error('Invalid provider preferences');await mkdir(dirname(file),{recursive:true,mode:0o700});const temp=file+'.'+randomUUID()+'.tmp';await writeFile(temp,JSON.stringify(settings)+'\n',{mode:0o600});await rename(temp,file);} +export function chooseProviderPreferences({restored,explicit={},saved,fallback='claude'}){ + if(restored)return providerPreferences(restored); + const backend=explicit.backend||saved?.backend||fallback; + const matching=saved?.backend===backend?saved:null; + return {backend,model:explicit.model??matching?.model,effort:explicit.effort??matching?.effort??''}; +} diff --git a/easel/src/render.mjs b/easel/src/render.mjs index a9b5f0e9ae..4968440f3e 100644 --- a/easel/src/render.mjs +++ b/easel/src/render.mjs @@ -114,6 +114,25 @@ export const easelInk = { reset: color.reset + color.ground, }; +// Static, low-contrast grain. SGR styling and selected control backgrounds survive. +export function woodgrain(line,row=0) { + let x=0,explicit=false,last='',out=''; + for(const token of line.match(/\x1b\[[0-?]*[ -/]*[@-~]|[^\x1b]/gu)||[]) { + if(token.startsWith('\x1b')) { + if(token.endsWith('m')) { const codes=token.slice(2,-1).split(';').map(Number);if(codes[0]===0||codes[0]===49)explicit=false;if(codes[0]===48||(codes[0]>=40&&codes[0]<=47))explicit=true;last=''; } + out+=token;continue; + } + if(!explicit) { + const tone=(Math.floor(x/5)+row*3+Math.floor(x/17))%9; + const rgb=tone===0?[67,45,30]:tone<3?[58,39,28]:[51,35,27]; + const escape=truecolor?'\x1b[48;2;'+rgb.join(';')+'m':'\x1b[48;5;'+(tone===0?237:tone<3?236:235)+'m'; + if(last!==escape){out+=escape;last=escape;} + } + out+=token;x+=charWidth(token); + } + return out; +} + export function cleanText(value) { return String(value ?? "") .replace(ESCAPE, "") @@ -439,10 +458,11 @@ export function renderFrame(state, columns = 80, rows = 24, useColor = true) { `${title}${account ? ` ${account}` : ""}${piece ? ` ${piece}` : ""}`, room, ); + const titleInk=text=>!useColor?text:Array.from(text).map((ch,i)=>paint(true, (state.hover==='about'?'block ':'')+['highlight','handle','status','soft','prompt'][i%5],ch)).join(''); const left = leftPlain === title || !account - ? paint(useColor, state.hover === "about" ? "block bold" : "bold text", leftPlain) - : `${paint(useColor, state.hover === "about" ? "block bold" : "bold text", title)} ` + + ? titleInk(leftPlain) + : `${titleInk(title)} ` + `${account.startsWith("@") ? coloredHandle(account,state.handleColors,useColor,state.hover === "profile") : paint(useColor,"muted",account)}` + `${piece ? ` ${paint(useColor, "soft", piece)}` : ""}`; const gap = " ".repeat( @@ -548,7 +568,7 @@ export function renderFrame(state, columns = 80, rows = 24, useColor = true) { const lines = [...body, rule, header, controlLine, prompt, help]; return lines .slice(0, height) - .map((line) => `${ground}${fit(line, width)}${reset}`) + .map((line,index) => {const fitted=fit(line,width);return `${ground}${useColor && index>=height-4?woodgrain(fitted,index-(height-4)):fitted}${reset}`;}) .join("\n"); } diff --git a/easel/src/tui.mjs b/easel/src/tui.mjs index f16f486ee8..2027057d8b 100755 --- a/easel/src/tui.mjs +++ b/easel/src/tui.mjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +import {readProviderPreferences,saveProviderPreferences,chooseProviderPreferences} from './provider-preferences.mjs'; import {captureFrame} from "./preview-frame.mjs"; import {API_WORKFLOW} from "./api-context.mjs"; @@ -102,9 +103,14 @@ const initialPrompt = desktopRestored ? "" : option("--prompt"); const initialPiece = desktopRestored?.live.file || option("--piece"); // Which engine bridge drives the conversation, and on which model. The bridge // can be swapped mid-session with /backend, so neither is a constant. -let backend = backendFor(desktopRestored?.backend || option("--backend") || process.env.EASEL_BACKEND || DEFAULT_BACKEND); -let model = desktopRestored?.model ?? (option("--model") || backend.defaultModel); -let effort = desktopRestored?.effort || option("--effort") || ""; +let savedProvider=await readProviderPreferences(); +if(!savedProvider) { try { const previous=await readDesktopSession(localSessionPath,cwd);if(previous)savedProvider={backend:previous.backend,model:previous.model,effort:previous.effort||''}; }catch{} } +const providerChoice=chooseProviderPreferences({restored:desktopRestored, saved:savedProvider, + explicit:{backend:option('--backend')||process.env.EASEL_BACKEND||undefined,model:option('--model')||undefined,effort:option('--effort')||undefined},fallback:process.env.EASEL_DESKTOP?'ac':DEFAULT_BACKEND}); +let backend=backendFor(providerChoice.backend); +let model=providerChoice.model??backend.defaultModel; +let effort=providerChoice.effort; +async function rememberProvider(){try{await saveProviderPreferences({backend:backend.id,model,effort});}catch(error){addEntry('error',`Could not remember provider: ${error.message}`);}} let handoff = desktopRestored?.handoff || ""; let archivedConversation = desktopRestored?.archivedConversation || []; let mouseEnabled = process.env.EASEL_MOUSE === "0" ? false : (desktopRestored?.options?.mouseEnabled ?? true); @@ -617,9 +623,9 @@ async function finish(code = 0) { }); } process.stdin.pause(); - await transcriptPending.catch(() => {}); await desktopSave.catch(() => {}); await writeDesktopSession(localSessionPath, captureDesktop()); + if(desktopSessionPath) process.stdout.write('\x1b]777;easel-phase:closing\x07'); } catch (error) { addEntry("error", `Cannot quit safely: desktop state could not be saved (${error.message}).`); finishing = false; @@ -639,7 +645,7 @@ async function finish(code = 0) { draftBroadcast.close(); process.stdin.setRawMode(false); process.stdin.pause(); - process.stdout.write(MOUSE_OFF + "\x1b[?2004l\x1b[?25h\x1b[?1049l"); + if(!desktopSessionPath) process.stdout.write(MOUSE_OFF + "\x1b[?2004l\x1b[?25h\x1b[?1049l"); process.exitCode = code; // The last save has to land. Quitting a second after an edit would otherwise // drop it — auto-publish coalesces, and the timer it was waiting on dies with @@ -657,6 +663,8 @@ async function finish(code = 0) { // The blank goes last: an untouched piece is deleted, and deleting it before // a flush would publish an empty file or nothing at all. // Keep the saved thread’s piece, including an untouched blank, resumable. + // All durable saves/uploads above have settled; sockets must not delay desktop exit. + if(desktopSessionPath) process.exit(code); } function errorText(error) { @@ -1179,6 +1187,8 @@ async function restartEngine(note, nextBackend = backend, nextModel = model, nex previous.close(); slabSession.connected(engine.threadId); state.model = connection?.model || model; + await rememberProvider(); + saveDesktopIdle(); addEntry("notice", `${note} · ${engineLabel()} · current piece and recent conversation carried over`); state.status = "ready"; } catch (error) { @@ -2008,6 +2018,7 @@ try { slabSession.connected(connection?.thread?.id || engine.threadId); state.status = "ready"; state.model = connection?.model || model; + await rememberProvider(); if (desktopRestored) { addEntry("notice", `Desktop thread restored · ${engineLabel()}`); } else if (resumeThreadId && !restoreThread(connection.thread)) { diff --git a/easel/test/provider-preferences.test.mjs b/easel/test/provider-preferences.test.mjs new file mode 100644 index 0000000000..78e9577caf --- /dev/null +++ b/easel/test/provider-preferences.test.mjs @@ -0,0 +1,3 @@ +import test from 'node:test';import assert from 'node:assert/strict';import {mkdtemp,rm,stat,writeFile} from 'node:fs/promises';import {join} from 'node:path';import {tmpdir} from 'node:os'; +import {readProviderPreferences,saveProviderPreferences,chooseProviderPreferences} from '../src/provider-preferences.mjs'; +test('provider/model/effort survive reopen; overrides cannot reuse another provider model',async t=>{const root=await mkdtemp(join(tmpdir(),'easel-provider-'));t.after(()=>rm(root,{recursive:true,force:true}));const file=join(root,'provider.json');const choice={backend:'codex',model:'gpt-5.4',effort:'high'};await saveProviderPreferences(choice,file);const saved=await readProviderPreferences(file);assert.deepEqual(chooseProviderPreferences({saved}),choice);assert.deepEqual(chooseProviderPreferences({saved,explicit:{backend:'claude'}}),{backend:'claude',model:undefined,effort:''});assert.deepEqual(chooseProviderPreferences({saved,restored:{backend:'ac',model:'fast',effort:''}}),{backend:'ac',model:'fast',effort:''});assert.equal((await stat(file)).mode&0o777,0o600);await writeFile(file,'broken');assert.equal(await readProviderPreferences(file),null);}); -- 2.51.2