diff --git a/easel/desktop/main.cjs b/easel/desktop/main.cjs index 2daba52ba8..341002852a 100644 --- a/easel/desktop/main.cjs +++ b/easel/desktop/main.cjs @@ -129,10 +129,11 @@ function requestRestart(action = 'restart') { const canUpdateBinary = !devHome && app.isPackaged && !process.mas && existsSync(join(process.resourcesPath,'app-update.yml')); const desktopUpdater = createUpdater({app, canUpdateBinary, onStatus:(status,info)=>buildStatus.release(status,info), requestRestart, prepareRelaunch: () => writeFileSync(continuationFile,JSON.stringify({cwd:workspace,at:Date.now()}),{mode:0o600}), notify: message => send('desktop-notice', message)}); -let pendingDevRoot=null,pendingDevUI=false; +let pendingDevRoot=null,pendingDevUI=false,pendingDevAction=null; +function applyPendingDev(){if(!pendingDevAction||!lastVisibleState||pendingRestart)return;const action=pendingDevAction;pendingDevAction=null;requestRestart(action);} const buildStatus=require('./build-status.cjs').createBuildStatus({app,devHome,root,send,channel:canUpdateBinary||process.mas?'release':'local',onDevReady:(next,compatibility)=>{ pendingDevRoot=next;pendingDevUI=!compatibility.sameUI; - requestRestart(compatibility.sameHost?'restart':'update'); + pendingDevAction=compatibility.sameHost?'restart':'update';applyPendingDev(); }}); function checkBuildUpdates(){ if(devHome){const child=spawn(process.execPath,[join(devHome,'sync-runner.mjs')],{env:{...process.env,ELECTRON_RUN_AS_NODE:'1'},stdio:'ignore'});child.on('error',()=>buildStatus.poll());child.on('exit',()=>buildStatus.poll());} @@ -140,7 +141,8 @@ function checkBuildUpdates(){ } ipcMain.on('check-build-updates',event=>{if(event.sender===window?.webContents)checkBuildUpdates();}); -function send(channel, data) { if (window && !window.isDestroyed()) window.webContents.send(channel, data); } +function send(channel, data) { if(channel==='build-status'){const item=Menu.getApplicationMenu()?.getMenuItemById('aesel-build-status');if(item)item.label=[data.channel==='dev'?'Dev':data.channel==='release'?'Release':'Local',data.version,data.revision?.slice(0,8),({current:'Up to date',ready:'Update ready',modified:'Local changes',unknown:'Unable to verify',checking:'Checking…',syncing:'Syncing…',downloading:'Downloading…'})[data.status]||'Unable to verify'].filter(Boolean).join(' · ');} +if (window && !window.isDestroyed()) window.webContents.send(channel, data); } function start() { if (terminal) return; const args = [join(root, 'src/tui.mjs'), '--cwd', workspace]; @@ -211,7 +213,7 @@ function start() { catch (error) { currentPaperPath=currentPreviewPath=currentPreviewMime=currentPreviewName='';previewData = {previewError:error.message}; } } lastVisibleState = {...JSON.parse(visible), qr, ...previewData}; - send('state', lastVisibleState); + send('state', lastVisibleState);applyPendingDev(); } } } catch {} @@ -232,7 +234,8 @@ function openNewWindow(){ app.whenReady().then(() => { if (!primaryInstance) return; Menu.setApplicationMenu(Menu.buildFromTemplate([ - { label: 'Aesel', submenu: [ + { label: devHome?'Aesel Dev':'Aesel', submenu: [ + {id:'aesel-build-status',label:devHome?'Dev · checking…':'Release · checking…',enabled:false}, {role:'about'}, {type:'separator'}, {role:'close',accelerator:'CmdOrCtrl+W'}, {label:'Check for Updates…', click:checkBuildUpdates}, {label:'Restart Agent', click:()=>requestRestart('restart')}, diff --git a/easel/desktop/test/build-status.test.cjs b/easel/desktop/test/build-status.test.cjs index 77b8e410fa..25c81ef02a 100644 --- a/easel/desktop/test/build-status.test.cjs +++ b/easel/desktop/test/build-status.test.cjs @@ -4,3 +4,9 @@ const state={online:true,state:'current',checkedAt:new Date().toISOString(),targ test('dev compares source trees rather than app semver',()=>{assert.equal(devStatus(running,state).status,'current');assert.equal(devStatus(running,{...state,target:{tree:'b'},installed:{tree:'b'}}).status,'ready');}); test('offline and stale checks never claim up to date',()=>{assert.equal(devStatus(running,{...state,online:false}).status,'unknown');assert.equal(devStatus(running,{...state,checkedAt:'2000-01-01'}).status,'unknown');}); test('local edits outrank a staged update',()=>{assert.equal(devStatus(running,{...state,modified:['src/tui.mjs'],installed:{tree:'b'}}).status,'modified');}); +test('automatic application waits until the session is initialized',()=>{ + const fs=require('node:fs'),vm=require('node:vm');const source=fs.readFileSync(require.resolve('../main.cjs'),'utf8'); + const start=source.indexOf('function applyPendingDev()'),end=source.indexOf('\nconst buildStatus=',start); + const calls=[],context={pendingDevAction:'restart',lastVisibleState:null,pendingRestart:false,requestRestart:action=>calls.push(action)}; + vm.createContext(context);vm.runInContext(source.slice(start,end),context);context.applyPendingDev();assert.deepEqual(calls,[]);assert.equal(context.pendingDevAction,'restart');context.lastVisibleState={status:'working'};context.applyPendingDev();assert.deepEqual(calls,['restart']);context.applyPendingDev();assert.equal(calls.length,1); +}); diff --git a/easel/dev/install.mjs b/easel/dev/install.mjs index a1495fef66..afccb7396a 100644 --- a/easel/dev/install.mjs +++ b/easel/dev/install.mjs @@ -37,7 +37,7 @@ for(const name of ['app.asar','app.asar.unpacked','easel','app-update.yml'])fs.r fs.mkdirSync(path.join(resources,'app'),{recursive:true}); fs.writeFileSync(path.join(resources,'app/package.json'),JSON.stringify({name:'aesel-dev',version:pkg.version,main:'main.cjs'})); fs.writeFileSync(path.join(resources,'app/main.cjs'),`const fs=require('node:fs'),path=require('node:path');const home=path.join(require('node:os').homedir(),'.local/share/aesel-dev');const root=path.join(fs.realpathSync(path.join(home,'current')),'easel');process.env.AESEL_DEV_HOME=home;process.env.AESEL_DEV_ROOT=root;require(path.join(root,'desktop/main.cjs'));\n`); -for(const [key,value] of Object.entries({CFBundleIdentifier:'computer.aesthetic.aesel.dev',CFBundleName:'Aesel Dev',CFBundleDisplayName:'Aesel Dev'})){ +for(const [key,value] of Object.entries({CFBundleIdentifier:'computer.aesthetic.aesel.dev',CFBundleDisplayName:'Aesel Dev'})){ try{execFileSync('/usr/libexec/PlistBuddy',['-c',`Set :${key} ${value}`,path.join(stage,'Contents/Info.plist')],{stdio:'pipe'});}catch{execFileSync('/usr/libexec/PlistBuddy',['-c',`Add :${key} string ${value}`,path.join(stage,'Contents/Info.plist')]);} } execFileSync('/usr/bin/codesign',['--force','--deep','--sign','-','--preserve-metadata=entitlements,requirements,flags',stage],{stdio:'inherit',timeout:120000}); -- 2.51.2 From b80f2cacd06e81bed035b830be65e0fcef38422e Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Thu, 17 Sep 2026 19:41:06 -0700 Subject: [PATCH 2/7] Bound development snapshot retention while preserving live and edited builds --- easel/dev/README.md | 2 +- easel/dev/sync.mjs | 13 ++++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/easel/dev/README.md b/easel/dev/README.md index b6716a3eff..904e7ee886 100644 --- a/easel/dev/README.md +++ b/easel/dev/README.md @@ -32,7 +32,7 @@ node easel/dev/install.mjs --repo ~/aesthetic-computer \ The installer creates `~/Applications/Aesel Dev.app`, `~/.local/bin/aesel-dev`, and the launch agent `computer.aesthetic.aesel-dev-sync`. State, logs, immutable source snapshots, and the atomic `current` link live in -`~/.local/share/aesel-dev/`. The native Electron shell is not replaced by source +`~/.local/share/aesel-dev/`. The last three snapshots, any live PTY snapshot, and locally edited snapshots are retained. The native Electron shell is not replaced by source sync. If Electron or npm dependency requirements change, syncing stops visibly until this installer is rerun with matching dependencies. No signing credentials are copied; the development shell is signed locally with an ad-hoc identity. diff --git a/easel/dev/sync.mjs b/easel/dev/sync.mjs index 6217ffc410..6860448924 100644 --- a/easel/dev/sync.mjs +++ b/easel/dev/sync.mjs @@ -26,6 +26,17 @@ export function modifiedFiles(directory, manifest) { return [...new Set([...Object.keys(actual),...Object.keys(expected)])].filter(name=>actual[name]!==expected[name]); } export function dependencyKey(pkg) { return digest(JSON.stringify({dependencies:pkg.dependencies,electron:pkg.devDependencies?.electron})); } +export function pruneBuilds(home, retain=3) { + // Keep recent builds, live PTY roots, and any locally edited snapshot. + let processes;try{processes=execFileSync('/bin/ps',['-axo','command='],{encoding:'utf8',maxBuffer:4*1024*1024});}catch{return;} + const current=fs.realpathSync(path.join(home,'current')); + const versions=path.join(home,'versions'); + const builds=fs.readdirSync(versions).filter(name=>/^[a-f0-9]{40}$/.test(name)).map(name=>path.join(versions,name)).filter(dir=>fs.existsSync(path.join(dir,'build.json'))).sort((a,b)=>fs.statSync(b).mtimeMs-fs.statSync(a).mtimeMs); + for(const dir of builds.slice(retain)){ + if(dir===current||processes.includes(dir))continue; + try{const manifest=JSON.parse(fs.readFileSync(path.join(dir,'build.json')));if(!modifiedFiles(path.join(dir,'easel'),manifest).length)fs.rmSync(dir,{recursive:true});}catch{} + } +} function writeJSON(file,value){const temp=file+'.tmp';fs.writeFileSync(temp,JSON.stringify(value,null,2)+'\n');fs.renameSync(temp,file);} export function sync({home=path.join(os.homedir(),'.local/share/aesel-dev'),fetch=true,build=source=>execFileSync('/bin/bash',[path.join(source,'desktop/scripts/build-credit-label.sh')],{timeout:120000,stdio:['ignore','pipe','pipe']})}={}) { const config=JSON.parse(fs.readFileSync(path.join(home,'config.json'))); @@ -66,7 +77,7 @@ export function sync({home=path.join(os.homedir(),'.local/share/aesel-dev'),fetc const next=path.join(home,`current-${process.pid}`);fs.symlinkSync(target,next);fs.renameSync(next,current); installed=manifest; } - state.installed={revision:installed.revision,tree:installed.tree};state.state='current';writeJSON(stateFile,state);return state; + state.installed={revision:installed.revision,tree:installed.tree};state.state='current';writeJSON(stateFile,state);pruneBuilds(home);return state; } catch(error) { const state={...previous,channel:'dev',state:'error',online:false,attemptedAt:new Date().toISOString(),error:String(error.message).split('\n')[0].slice(0,180)}; writeJSON(stateFile,state);throw error; -- 2.51.2 From f2b0139058c64382d3b81b41c6a617666a7d4166 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Thu, 17 Sep 2026 19:43:10 -0700 Subject: [PATCH 3/7] Wait for explicit agent readiness before applying automatic dev updates --- easel/desktop/main.cjs | 7 ++++--- easel/desktop/test/build-status.test.cjs | 4 ++-- easel/src/tui.mjs | 3 +++ easel/test/dev-sync.test.mjs | 6 ++++++ 4 files changed, 15 insertions(+), 5 deletions(-) diff --git a/easel/desktop/main.cjs b/easel/desktop/main.cjs index 341002852a..b078f18aa5 100644 --- a/easel/desktop/main.cjs +++ b/easel/desktop/main.cjs @@ -129,8 +129,8 @@ function requestRestart(action = 'restart') { const canUpdateBinary = !devHome && app.isPackaged && !process.mas && existsSync(join(process.resourcesPath,'app-update.yml')); const desktopUpdater = createUpdater({app, canUpdateBinary, onStatus:(status,info)=>buildStatus.release(status,info), requestRestart, prepareRelaunch: () => writeFileSync(continuationFile,JSON.stringify({cwd:workspace,at:Date.now()}),{mode:0o600}), notify: message => send('desktop-notice', message)}); -let pendingDevRoot=null,pendingDevUI=false,pendingDevAction=null; -function applyPendingDev(){if(!pendingDevAction||!lastVisibleState||pendingRestart)return;const action=pendingDevAction;pendingDevAction=null;requestRestart(action);} +let pendingDevRoot=null,pendingDevUI=false,pendingDevAction=null,agentReady=false,agentReadyBuffer=''; +function applyPendingDev(){if(!pendingDevAction||!agentReady||!lastVisibleState||pendingRestart)return;const action=pendingDevAction;pendingDevAction=null;requestRestart(action);} const buildStatus=require('./build-status.cjs').createBuildStatus({app,devHome,root,send,channel:canUpdateBinary||process.mas?'release':'local',onDevReady:(next,compatibility)=>{ pendingDevRoot=next;pendingDevUI=!compatibility.sameUI; pendingDevAction=compatibility.sameHost?'restart':'update';applyPendingDev(); @@ -163,7 +163,8 @@ function start() { terminal = process.mas ? require('./mas-terminal.cjs').createMasTerminal(args, { ...terminalSize, cwd: workspace, env }) : pty.spawn(process.execPath, args, { name: 'xterm-256color', ...terminalSize, cwd: workspace, env }); - terminal.onData(data => send('output', data)); + agentReady=false;agentReadyBuffer=''; + terminal.onData(data => {send('output', data);if(!agentReady){agentReadyBuffer=(agentReadyBuffer+data).slice(-256);if(agentReadyBuffer.includes('\x1b]777;easel-agent-ready\x07')){agentReady=true;applyPendingDev();}}}); terminal.onExit(({ exitCode }) => { terminal = null; if (exitCode === 75 && existsSync(controlFile)) { diff --git a/easel/desktop/test/build-status.test.cjs b/easel/desktop/test/build-status.test.cjs index 25c81ef02a..4b70c75a0d 100644 --- a/easel/desktop/test/build-status.test.cjs +++ b/easel/desktop/test/build-status.test.cjs @@ -7,6 +7,6 @@ test('local edits outrank a staged update',()=>{assert.equal(devStatus(running,{ test('automatic application waits until the session is initialized',()=>{ const fs=require('node:fs'),vm=require('node:vm');const source=fs.readFileSync(require.resolve('../main.cjs'),'utf8'); const start=source.indexOf('function applyPendingDev()'),end=source.indexOf('\nconst buildStatus=',start); - const calls=[],context={pendingDevAction:'restart',lastVisibleState:null,pendingRestart:false,requestRestart:action=>calls.push(action)}; - vm.createContext(context);vm.runInContext(source.slice(start,end),context);context.applyPendingDev();assert.deepEqual(calls,[]);assert.equal(context.pendingDevAction,'restart');context.lastVisibleState={status:'working'};context.applyPendingDev();assert.deepEqual(calls,['restart']);context.applyPendingDev();assert.equal(calls.length,1); + const calls=[],context={pendingDevAction:'restart',agentReady:false,lastVisibleState:null,pendingRestart:false,requestRestart:action=>calls.push(action)}; + vm.createContext(context);vm.runInContext(source.slice(start,end),context);context.applyPendingDev();assert.deepEqual(calls,[]);assert.equal(context.pendingDevAction,'restart');context.lastVisibleState={status:'working'};context.applyPendingDev();assert.deepEqual(calls,[]);context.agentReady=true;context.applyPendingDev();assert.deepEqual(calls,['restart']);context.applyPendingDev();assert.equal(calls.length,1); }); diff --git a/easel/src/tui.mjs b/easel/src/tui.mjs index c08b5b7ac8..b818ab7902 100755 --- a/easel/src/tui.mjs +++ b/easel/src/tui.mjs @@ -2224,3 +2224,6 @@ try { addEntry("error", errorText(error)); redraw(); } + +// The desktop may now request a checkpoint safely, including after a failed connection. +if(process.env.EASEL_DESKTOP)process.stdout.write('\x1b]777;easel-agent-ready\x07'); diff --git a/easel/test/dev-sync.test.mjs b/easel/test/dev-sync.test.mjs index 08509358d1..480d5cadd0 100644 --- a/easel/test/dev-sync.test.mjs +++ b/easel/test/dev-sync.test.mjs @@ -14,3 +14,9 @@ test('sync installs immutable builds atomically and ignores unrelated commits',t }); test('local modifications are visible and never overwritten',t=>{const f=fixture(t),args={home:f.home,fetch:false,build:()=>{}};sync(args);const file=path.join(f.home,'current/easel/desktop/main.cjs');fs.writeFileSync(file,'local work');fs.writeFileSync(path.join(f.repo,'easel/desktop/main.cjs'),'remote work');f.commit();const status=sync(args);assert.equal(status.state,'modified');assert.equal(fs.readFileSync(file,'utf8'),'local work');}); test('failed staging keeps current build and marks freshness unknown',t=>{const f=fixture(t),args={home:f.home,fetch:false,build:()=>{}};sync(args);const original=fs.realpathSync(path.join(f.home,'current'));fs.writeFileSync(path.join(f.repo,'easel/desktop/main.cjs'),'remote');f.commit();assert.throws(()=>sync({...args,build(){throw Error('build failed');}}),/build failed/);assert.equal(fs.realpathSync(path.join(f.home,'current')),original);assert.equal(JSON.parse(fs.readFileSync(path.join(f.home,'status.json'))).online,false);}); +test('retention removes only old unmodified snapshots',t=>{ + const f=fixture(t),args={home:f.home,fetch:false,build:()=>{}};sync(args);const edited=fs.realpathSync(path.join(f.home,'current')); + const next=i=>{fs.writeFileSync(path.join(f.repo,'easel/desktop/main.cjs'),'// revision '+i);f.commit();sync(args);return fs.realpathSync(path.join(f.home,'current'));}; + const old=next(1);fs.writeFileSync(path.join(edited,'easel/desktop/main.cjs'),'local edit');for(let i=2;i<=5;i++)next(i); + assert(fs.existsSync(edited));assert(!fs.existsSync(old));assert.equal(fs.readdirSync(path.join(f.home,'versions')).length,4); +}); -- 2.51.2 From a00e4f58ee66257e8cbadbfcf4c9bb7e75a7402c Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Thu, 17 Sep 2026 19:44:38 -0700 Subject: [PATCH 4/7] Pin running desktop source during dev snapshot cleanup --- easel/desktop/build-status.cjs | 5 +++-- easel/desktop/main.cjs | 2 +- easel/dev/sync.mjs | 5 ++++- easel/test/dev-sync.test.mjs | 4 ++-- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/easel/desktop/build-status.cjs b/easel/desktop/build-status.cjs index 7646e5c809..5aabe35104 100644 --- a/easel/desktop/build-status.cjs +++ b/easel/desktop/build-status.cjs @@ -10,7 +10,8 @@ function devStatus(running,state){ function createBuildStatus({app,devHome,root,send,onDevReady,channel=app.isPackaged?'release':'local'}){ let running=devHome?JSON.parse(fs.readFileSync(path.join(root,'../build.json'))):null; let latest={channel,version:app.getVersion(),status:'unknown'}; - let requested='',timer; + let requested='',timer,pin=''; + if(devHome){const dir=path.join(devHome,'running');fs.mkdirSync(dir,{recursive:true});pin=path.join(dir,process.pid+'.json');fs.writeFileSync(pin,JSON.stringify({pid:process.pid,tree:running.tree}));process.once('exit',()=>{try{fs.unlinkSync(pin);}catch{}});} const emit=()=>send('build-status',latest); function poll(){ if(!devHome)return emit(); @@ -25,6 +26,6 @@ function createBuildStatus({app,devHome,root,send,onDevReady,channel=app.isPacka }catch{latest={...latest,status:'unknown'};emit();} } if(devHome){timer=setInterval(poll,3000);timer.unref();} - return {poll,emit,release(status,info={}){if(!devHome){latest={...latest,status,latest:info.version||latest.latest,checkedAt:new Date().toISOString()};emit();}},adopt(nextRoot){running=JSON.parse(fs.readFileSync(path.join(nextRoot,'../build.json')));requested='';poll();},close(){clearInterval(timer);}}; + return {poll,emit,release(status,info={}){if(!devHome){latest={...latest,status,latest:info.version||latest.latest,checkedAt:new Date().toISOString()};emit();}},adopt(nextRoot){running=JSON.parse(fs.readFileSync(path.join(nextRoot,'../build.json')));requested='';poll();},close(){clearInterval(timer);if(pin)try{fs.unlinkSync(pin);}catch{}}}; } module.exports={devStatus,createBuildStatus}; diff --git a/easel/desktop/main.cjs b/easel/desktop/main.cjs index b078f18aa5..e3916884ce 100644 --- a/easel/desktop/main.cjs +++ b/easel/desktop/main.cjs @@ -164,7 +164,7 @@ function start() { ? require('./mas-terminal.cjs').createMasTerminal(args, { ...terminalSize, cwd: workspace, env }) : pty.spawn(process.execPath, args, { name: 'xterm-256color', ...terminalSize, cwd: workspace, env }); agentReady=false;agentReadyBuffer=''; - terminal.onData(data => {send('output', data);if(!agentReady){agentReadyBuffer=(agentReadyBuffer+data).slice(-256);if(agentReadyBuffer.includes('\x1b]777;easel-agent-ready\x07')){agentReady=true;applyPendingDev();}}}); + terminal.onData(data => {send('output', data);if(!agentReady){agentReadyBuffer+=data;if(agentReadyBuffer.includes('\x1b]777;easel-agent-ready\x07')){agentReady=true;applyPendingDev();}agentReadyBuffer=agentReadyBuffer.slice(-64);}}); terminal.onExit(({ exitCode }) => { terminal = null; if (exitCode === 75 && existsSync(controlFile)) { diff --git a/easel/dev/sync.mjs b/easel/dev/sync.mjs index 6860448924..af375f224a 100644 --- a/easel/dev/sync.mjs +++ b/easel/dev/sync.mjs @@ -30,10 +30,13 @@ export function pruneBuilds(home, retain=3) { // Keep recent builds, live PTY roots, and any locally edited snapshot. let processes;try{processes=execFileSync('/bin/ps',['-axo','command='],{encoding:'utf8',maxBuffer:4*1024*1024});}catch{return;} const current=fs.realpathSync(path.join(home,'current')); + const pinned=new Set(); + const pins=path.join(home,'running'); + if(fs.existsSync(pins))for(const name of fs.readdirSync(pins)){if(!/^\d+\.json$/.test(name))continue;const file=path.join(pins,name);try{const pin=JSON.parse(fs.readFileSync(file));if(!Number.isInteger(pin.pid)||pin.pid<=0||!(/^[a-f0-9]{40}$/.test(pin.tree)))continue;process.kill(pin.pid,0);pinned.add(pin.tree);}catch(error){if(error.code==='ESRCH')fs.rmSync(file,{force:true});}} const versions=path.join(home,'versions'); const builds=fs.readdirSync(versions).filter(name=>/^[a-f0-9]{40}$/.test(name)).map(name=>path.join(versions,name)).filter(dir=>fs.existsSync(path.join(dir,'build.json'))).sort((a,b)=>fs.statSync(b).mtimeMs-fs.statSync(a).mtimeMs); for(const dir of builds.slice(retain)){ - if(dir===current||processes.includes(dir))continue; + if(dir===current||pinned.has(path.basename(dir))||processes.includes(dir))continue; try{const manifest=JSON.parse(fs.readFileSync(path.join(dir,'build.json')));if(!modifiedFiles(path.join(dir,'easel'),manifest).length)fs.rmSync(dir,{recursive:true});}catch{} } } diff --git a/easel/test/dev-sync.test.mjs b/easel/test/dev-sync.test.mjs index 480d5cadd0..6e3b236c72 100644 --- a/easel/test/dev-sync.test.mjs +++ b/easel/test/dev-sync.test.mjs @@ -17,6 +17,6 @@ test('failed staging keeps current build and marks freshness unknown',t=>{const test('retention removes only old unmodified snapshots',t=>{ const f=fixture(t),args={home:f.home,fetch:false,build:()=>{}};sync(args);const edited=fs.realpathSync(path.join(f.home,'current')); const next=i=>{fs.writeFileSync(path.join(f.repo,'easel/desktop/main.cjs'),'// revision '+i);f.commit();sync(args);return fs.realpathSync(path.join(f.home,'current'));}; - const old=next(1);fs.writeFileSync(path.join(edited,'easel/desktop/main.cjs'),'local edit');for(let i=2;i<=5;i++)next(i); - assert(fs.existsSync(edited));assert(!fs.existsSync(old));assert.equal(fs.readdirSync(path.join(f.home,'versions')).length,4); + const old=next(1);fs.writeFileSync(path.join(edited,'easel/desktop/main.cjs'),'local edit');const unpinned=next(2);fs.mkdirSync(path.join(f.home,'running'));fs.writeFileSync(path.join(f.home,'running',process.pid+'.json'),JSON.stringify({pid:process.pid,tree:path.basename(old)}));for(let i=3;i<=6;i++)next(i); + assert(fs.existsSync(edited));assert(fs.existsSync(old));assert(!fs.existsSync(unpinned));assert.equal(fs.readdirSync(path.join(f.home,'versions')).length,5); }); -- 2.51.2 From 6040e2c203efeaf6ff714201cc5f6c06295caf23 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Thu, 17 Sep 2026 19:46:05 -0700 Subject: [PATCH 5/7] Identify dev builds by content so equivalent fleet copies match --- easel/desktop/main.cjs | 2 +- easel/desktop/renderer.js | 2 +- easel/dev/README.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/easel/desktop/main.cjs b/easel/desktop/main.cjs index e3916884ce..9bc1ec0c45 100644 --- a/easel/desktop/main.cjs +++ b/easel/desktop/main.cjs @@ -141,7 +141,7 @@ function checkBuildUpdates(){ } ipcMain.on('check-build-updates',event=>{if(event.sender===window?.webContents)checkBuildUpdates();}); -function send(channel, data) { if(channel==='build-status'){const item=Menu.getApplicationMenu()?.getMenuItemById('aesel-build-status');if(item)item.label=[data.channel==='dev'?'Dev':data.channel==='release'?'Release':'Local',data.version,data.revision?.slice(0,8),({current:'Up to date',ready:'Update ready',modified:'Local changes',unknown:'Unable to verify',checking:'Checking…',syncing:'Syncing…',downloading:'Downloading…'})[data.status]||'Unable to verify'].filter(Boolean).join(' · ');} +function send(channel, data) { if(channel==='build-status'){const item=Menu.getApplicationMenu()?.getMenuItemById('aesel-build-status');if(item)item.label=[data.channel==='dev'?'Dev':data.channel==='release'?'Release':'Local',data.version,(data.tree||data.revision)?.slice(0,8),({current:'Up to date',ready:'Update ready',modified:'Local changes',unknown:'Unable to verify',checking:'Checking…',syncing:'Syncing…',downloading:'Downloading…'})[data.status]||'Unable to verify'].filter(Boolean).join(' · ');} if (window && !window.isDestroyed()) window.webContents.send(channel, data); } function start() { if (terminal) return; diff --git a/easel/desktop/renderer.js b/easel/desktop/renderer.js index 948242cf93..f16cf82673 100644 --- a/easel/desktop/renderer.js +++ b/easel/desktop/renderer.js @@ -636,7 +636,7 @@ boot().catch(error => { document.getElementById('terminal').textContent = `Could menu.replaceChildren();const n=balance(); const header=document.createElement('header');const title=document.createElement('h2');title.textContent='Settings'; const dismiss=document.createElement('button');dismiss.type='button';dismiss.textContent='×';dismiss.setAttribute('aria-label','Close settings');dismiss.addEventListener('click',close);header.append(title,dismiss);menu.append(header); - if(buildInfo){const status=document.createElement('p');status.className='build-status';status.dataset.status=buildInfo.status;const channel=buildInfo.channel==='dev'?'Dev':buildInfo.channel==='release'?'Release':'Local';const state={current:'Up to date',ready:'Update ready · saving session',modified:'Local changes · sync paused',checking:'Checking…',syncing:'Downloading dev build…',downloading:'Downloading update…',unknown:'Unable to verify'}[buildInfo.status]||'Unable to verify';status.textContent=[channel,buildInfo.version,buildInfo.revision?.slice(0,8),state].filter(Boolean).join(' · ');menu.append(status);item('Check for updates',()=>window.aesel.checkBuildUpdates());} + if(buildInfo){const status=document.createElement('p');status.className='build-status';status.dataset.status=buildInfo.status;status.title=buildInfo.revision?`Source commit ${buildInfo.revision}`:'';const channel=buildInfo.channel==='dev'?'Dev':buildInfo.channel==='release'?'Release':'Local';const state={current:'Up to date',ready:'Update ready · saving session',modified:'Local changes · sync paused',checking:'Checking…',syncing:'Downloading dev build…',downloading:'Downloading update…',unknown:'Unable to verify'}[buildInfo.status]||'Unable to verify';status.textContent=[channel,buildInfo.version,(buildInfo.tree||buildInfo.revision)?.slice(0,8),state].filter(Boolean).join(' · ');menu.append(status);item('Check for updates',()=>window.aesel.checkBuildUpdates());} const current=document.createElement('p');current.textContent=[provider?.backend==='ac'?'AC':provider?.backend==='codex'?'Codex':'Claude',provider?.model].filter(Boolean).join(' · ');menu.append(current); for(const [index,id,name] of [[0,'ac','AC'],[1,'claude','Claude'],[2,'codex','Codex']]){ diff --git a/easel/dev/README.md b/easel/dev/README.md index 904e7ee886..56704257c8 100644 --- a/easel/dev/README.md +++ b/easel/dev/README.md @@ -14,7 +14,7 @@ also reload the interface. Host changes checkpoint and relaunch the Dev app. Active requests finish before the checkpoint. Project files are never synced between machines, and working Git checkouts are never reset. -Settings shows Dev/Release/Local, app version, dev revision, and update state. +Settings shows Dev/Release/Local, app version, content-based dev build ID, and update state. Hover the settings status for its Git commit. Identical Aesel source keeps the same build ID even when other projects change. Piece versions such as v3 are separate. Offline or old checks say they cannot verify freshness. Local edits inside an installed dev snapshot pause syncing and show “Local changes”; move them into a source checkout before republishing. -- 2.51.2 From 6f8a39a07b1c66cc8ce771f2b54ac350175a4e0d Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Thu, 17 Sep 2026 19:45:55 -0700 Subject: [PATCH 6/7] Prioritize active Chrome sheets in Captutor modal inspection --- slab/captutor/lib/modal-police.mjs | 7 +++++-- slab/captutor/test/modal-police.test.mjs | 10 ++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/slab/captutor/lib/modal-police.mjs b/slab/captutor/lib/modal-police.mjs index b68fa58de7..0056defeaf 100644 --- a/slab/captutor/lib/modal-police.mjs +++ b/slab/captutor/lib/modal-police.mjs @@ -32,7 +32,10 @@ function walk(e,depth){ return {texts,buttons,modal}; } let windowIndex=0; -for(const w of chrome.windows()){const start=hits.length;walk(w,0);for(const h of hits.slice(start))h.windowIndex=windowIndex;windowIndex++;} +for(const w of chrome.windows()){const start=hits.length;let sheets=[];try{sheets=w.sheets();}catch{} + // Chrome can retain off-screen AX group copies of expired consent dialogs. + // A real attached sheet is the active blocking surface; inspect it first. + for(const root of sheets.length?sheets:[w])walk(root,0);for(const h of hits.slice(start))h.windowIndex=windowIndex;windowIndex++;} // Chrome may expose the same sheet both below its window and as a window. const uniqueHits=[];const seen=new Set(),physical=new Set(); for(const h of hits){let position=null;try{position=h.element.position();}catch{} @@ -59,7 +62,7 @@ async function native(action,expected) { const tail=action ? `const h=uniqueHits.filter(h=>h.kind===${JSON.stringify(action)});if(h.length!==1||!h[0].element)throw Error('Modal changed or action ambiguous');if(JSON.stringify([h[0].kind,h[0].title,[...h[0].buttons].sort()])!==${JSON.stringify(JSON.stringify(expected?[expected.kind,expected.title,[...expected.buttons].sort()]:null))})throw Error('Modal fingerprint changed');h[0].element.click();JSON.stringify(true);` : `JSON.stringify(uniqueHits.map(({kind,title,buttons})=>({kind,title,buttons})));`; - const execute=()=>run('/usr/bin/osascript',['-l','JavaScript','-e',CHROME_MODAL_SCRIPT+tail],{timeout:10000,maxBuffer:128*1024}); + const execute=()=>run('/usr/bin/osascript',['-l','JavaScript','-e',CHROME_MODAL_SCRIPT+tail],{timeout:30000,maxBuffer:128*1024}); // Never replay a click. Only retry a read-only scan with an empty response. if(!action)return readNativeModalScan(execute); return JSON.parse((await execute()).stdout); diff --git a/slab/captutor/test/modal-police.test.mjs b/slab/captutor/test/modal-police.test.mjs index d6a92f5dea..1f54a14d76 100644 --- a/slab/captutor/test/modal-police.test.mjs +++ b/slab/captutor/test/modal-police.test.mjs @@ -72,3 +72,13 @@ test('invalid native scan is never interpreted as clean',async()=>{ await assert.rejects(readNativeModalScan(async()=>({stdout:'{}'})),/Invalid native/); await assert.rejects(readNativeModalScan(async()=>({stdout:'[null]'})),/Invalid native/); }); + +test('active Chrome sheet takes precedence over abandoned dialog group copies',()=>{ + const e=(role,name,kids=[])=>({role:()=>role,subrole:()=>'',name:()=>name,description:()=>name,value:()=>'',uiElements:()=>kids,position:()=>[20,30]}); + const sheet=e('AXSheet','Allow remote debugging?',[e('AXButton','Cancel'),e('AXButton','Allow')]); + const ghost=e('AXGroup','Allow remote debugging?',[e('AXButton','Allow')]); + const w=e('AXWindow','Chrome',[ghost,sheet]);w.sheets=()=>[sheet]; + w.uiElements=()=>assert.fail('Do not traverse stale groups behind the active sheet'); + const result=runInNewContext(CHROME_MODAL_SCRIPT+';JSON.stringify(uniqueHits.map(({kind})=>kind))',{Application:()=>({processes:{byName:()=>({windows:()=>[w]})}})}); + assert.deepEqual(JSON.parse(result),['remote-debugging']); +}); -- 2.51.2 From 17379256edd5f255515b5391fe3876b5a8dcb2a3 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Thu, 17 Sep 2026 19:48:46 -0700 Subject: [PATCH 7/7] Route fleet launchers explicitly to release or synchronized dev --- easel/dev/install.mjs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/easel/dev/install.mjs b/easel/dev/install.mjs index afccb7396a..92a36c0c5e 100644 --- a/easel/dev/install.mjs +++ b/easel/dev/install.mjs @@ -48,7 +48,13 @@ if(fs.existsSync(app)){ } fs.renameSync(stage,app); const bin=path.join(os.homedir(),'.local/bin');fs.mkdirSync(bin,{recursive:true}); -fs.writeFileSync(path.join(bin,'aesel-dev'),`#!/bin/bash\nexec "$HOME/.local/share/aesel-dev/current/easel/bin/easel-desktop" --dev "$@"\n`,{mode:0o755}); +const previousLaunchers=path.join(home,'previous-launchers');fs.mkdirSync(previousLaunchers,{recursive:true}); +for(const name of ['aesel','easel','aesel-dev']){ + const target=path.join(bin,name),backup=path.join(previousLaunchers,name); + if(fs.existsSync(target)&&!fs.existsSync(backup))fs.copyFileSync(target,backup); + const temp=target+'.dev-install'; + fs.writeFileSync(temp,`#!/bin/bash\nexec "$HOME/.local/share/aesel-dev/current/easel/bin/easel-desktop" ${name==='aesel-dev'?'--dev ':''}"$@"\n`,{mode:0o755});fs.chmodSync(temp,0o755);fs.renameSync(temp,target); +} const xml=s=>s.replaceAll('&','&').replaceAll('<','<').replaceAll('>','>'); const plist=path.join(os.homedir(),'Library/LaunchAgents/computer.aesthetic.aesel-dev-sync.plist'); fs.mkdirSync(path.dirname(plist),{recursive:true});