diff --git a/easel/bin/easel-desktop b/easel/bin/easel-desktop
index c5485bc924..e0e9354205 100755
--- a/easel/bin/easel-desktop
+++ b/easel/bin/easel-desktop
@@ -6,7 +6,7 @@ usage() {
cat <<'HELP'
Usage: aesel [directory] [--backend ac|claude|codex] [--model NAME]
[--piece FILE] [--resume THREAD_ID]
- [--no-autopublish]
+ [--no-autopublish] [--dev]
aesel --cwd DIRECTORY [options]
ac [directory] [options]
@@ -21,9 +21,11 @@ HELP
}
fail() { printf 'aesel: %s\n' "$*" >&2; exit 1; }
workspace=''
+dev=false
arguments=()
while (($#)); do
case "$1" in
+ --dev) dev=true; shift ;;
-h|--help) usage; exit 0 ;;
--cwd)
(($# >= 2)) && [[ -n "$2" ]] || fail '--cwd needs a directory'
@@ -50,8 +52,9 @@ while (($#)); do
done
[[ "$(uname -s)" == Darwin ]] || fail 'the standalone launcher currently requires macOS; use ac for the terminal interface'
app_path="${AESEL_DESKTOP_APP:-${EASEL_DESKTOP_APP:-$HOME/Applications/aesel.app}}"
+if $dev; then app_path="$HOME/Applications/Aesel Dev.app"; fi
# Existing installations keep working until the renamed app is installed.
-if [[ ! -d "$app_path" && -z "${AESEL_DESKTOP_APP:-}${EASEL_DESKTOP_APP:-}" && -d "$HOME/Applications/Easel.app" ]]; then
+if ! $dev && [[ ! -d "$app_path" && -z "${AESEL_DESKTOP_APP:-}${EASEL_DESKTOP_APP:-}" && -d "$HOME/Applications/Easel.app" ]]; then
app_path="$HOME/Applications/Easel.app"
fi
[[ -d "$app_path" ]] || fail "aesel.app is not installed at $app_path. Install the standalone app or run ac."
diff --git a/easel/desktop/build-status.cjs b/easel/desktop/build-status.cjs
new file mode 100644
index 0000000000..7646e5c809
--- /dev/null
+++ b/easel/desktop/build-status.cjs
@@ -0,0 +1,30 @@
+const fs=require('node:fs'),path=require('node:path');
+function devStatus(running,state){
+ const fresh=state?.checkedAt&&Date.now()-Date.parse(state.checkedAt)<180000;
+ let status=!state?.online||!fresh?'unknown':state.state;
+ if(state?.modified?.length)status='modified';
+ else if(state?.online&&fresh&&state.installed?.tree!==running.tree)status='ready';
+ else if(state?.online&&fresh&&state.target?.tree===running.tree)status='current';
+ return {channel:'dev',version:running.version,revision:running.revision,tree:running.tree,status,latest:state?.target?.revision,checkedAt:state?.checkedAt||null};
+}
+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;
+ const emit=()=>send('build-status',latest);
+ function poll(){
+ if(!devHome)return emit();
+ try{
+ const state=JSON.parse(fs.readFileSync(path.join(devHome,'status.json')));latest=devStatus(running,state);emit();
+ if(latest.status==='ready'&&requested!==state.installed.tree){
+ const target=fs.realpathSync(path.join(devHome,'current'));
+ const next=JSON.parse(fs.readFileSync(path.join(target,'build.json')));
+ if(next.tree!==state.installed.tree)return;
+ requested=next.tree;onDevReady(path.join(target,'easel'),{sameHost:next.host===running.host,sameUI:next.ui===running.ui});
+ }
+ }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);}};
+}
+module.exports={devStatus,createBuildStatus};
diff --git a/easel/desktop/credit-label.cjs b/easel/desktop/credit-label.cjs
index 9a0d375477..ad22d251d2 100644
--- a/easel/desktop/credit-label.cjs
+++ b/easel/desktop/credit-label.cjs
@@ -2,7 +2,7 @@
const {execFile} = require('node:child_process');
const {join} = require('node:path');
const {pathToFileURL} = require('node:url');
-function startCreditLabel({app, window, root, nativeRoot = app.isPackaged ? join(process.resourcesPath,'app.asar.unpacked') : __dirname}) {
+function startCreditLabel({app, window, root, nativeRoot = process.env.AESEL_DEV_ROOT ? join(process.env.AESEL_DEV_ROOT,'desktop') : app.isPackaged ? join(process.resourcesPath,'app.asar.unpacked') : __dirname}) {
let timer, disposed = false, busy = false, lastText = '', lastPayload, offer = null;
let sessionPromise;
const session = () => sessionPromise ||= import(pathToFileURL(join(root,'src/ac-session.mjs')).href).then(({ACSession})=>new ACSession());
diff --git a/easel/desktop/donkey.css b/easel/desktop/donkey.css
index c5940edb15..94fb9468e0 100644
--- a/easel/desktop/donkey.css
+++ b/easel/desktop/donkey.css
@@ -302,3 +302,5 @@ body:not(.preview-fullscreen) #artifact-shell{box-shadow:1px 1px 0 #282130,3px 3
#notebook-thinking-donkey{opacity:1;filter:none}
#prompt-feedback{width:calc(var(--donkey-size)*512/416 + 16px)}
+
+#provider-menu .build-status{font-size:12px;opacity:.8;overflow-wrap:anywhere}#provider-menu .build-status[data-status=ready],#provider-menu .build-status[data-status=modified]{color:var(--aesel-accent);opacity:1}
diff --git a/easel/desktop/main.cjs b/easel/desktop/main.cjs
index 8a376a4fcf..2daba52ba8 100644
--- a/easel/desktop/main.cjs
+++ b/easel/desktop/main.cjs
@@ -11,8 +11,9 @@ const {createUpdater} = require('./updater.cjs');
const {startFrameCapture} = require('./frame-capture.cjs');
const { tmpdir, homedir } = require('node:os');
-app.setName('aesel');
-app.setPath('userData', join(app.getPath('appData'), 'Easel'));
+const devHome=process.env.AESEL_DEV_HOME||'';
+app.setName(devHome?'Aesel Dev':'aesel');
+app.setPath('userData', join(app.getPath('appData'), devHome?'Aesel Dev':'Easel'));
let window, terminal, timer, quitting = false;
let lastVisibleState = null, keepPreviewOnStart = false;
let terminalSize = {cols:100,rows:32};
@@ -35,7 +36,7 @@ try { systemTextWatcher = watch(join(homedir(), '.local', 'share', 'slab', 'stat
readSystemTextSize();
const themeFollower = followSlabTheme(theme => { currentTheme = theme; if (window && !window.isDestroyed()) { window.setBackgroundColor(theme.background); send('theme',theme); } });
const startedAt = Date.now();
-const root = app.isPackaged ? join(process.resourcesPath, 'easel') : resolve(__dirname, '..');
+let root = process.env.AESEL_DEV_ROOT || (app.isPackaged ? join(process.resourcesPath, 'easel') : resolve(__dirname, '..'));
const qrEncoder = import(require('node:url').pathToFileURL(join(root, 'src/vendor/qr.mjs')).href);
const slabHome = join(tmpdir(), `easel-desktop-${process.pid}`);
const supplied = process.argv.slice(app.isPackaged ? 1 : 2);
@@ -125,8 +126,19 @@ function requestRestart(action = 'restart') {
writeFileSync(intentFile, JSON.stringify({action}), {mode:0o600});
terminal.kill('SIGUSR2');
}
-const canUpdateBinary = app.isPackaged && !process.mas && existsSync(join(process.resourcesPath,'app-update.yml'));
-const desktopUpdater = createUpdater({app, canUpdateBinary, requestRestart, prepareRelaunch: () => writeFileSync(continuationFile,JSON.stringify({cwd:workspace,at:Date.now()}),{mode:0o600}), notify: message => send('desktop-notice', message)});
+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;
+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');
+}});
+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());}
+ else void desktopUpdater.check();
+}
+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 start() {
@@ -143,7 +155,7 @@ function start() {
try {
// Native extraction can discard mode bits; repair only our own launcher.
if(process.platform!=='win32'&&!process.mas){
- const helper=join(app.isPackaged?join(process.resourcesPath,'app.asar.unpacked'):__dirname,'node_modules/node-pty/build/Release/spawn-helper');
+ const helper=join(devHome?join(root,'desktop'):app.isPackaged?join(process.resourcesPath,'app.asar.unpacked'):__dirname,'node_modules/node-pty/build/Release/spawn-helper');
if(existsSync(helper))chmodSync(helper,0o755);
}
terminal = process.mas
@@ -159,6 +171,7 @@ function start() {
pendingRestart = false; continueSession = true; keepPreviewOnStart = true;
timer?.close();
send('desktop-notice','Agent restarted. Your window and preview stay open.');
+ if(pendingDevRoot){root=pendingDevRoot;pendingDevRoot=null;buildStatus.adopt(root);if(pendingDevUI){pendingDevUI=false;window.loadFile(join(root,'desktop/index.html'));return;}}
start(); return;
}
if (request.action === 'update') {
@@ -221,7 +234,7 @@ app.whenReady().then(() => {
Menu.setApplicationMenu(Menu.buildFromTemplate([
{ label: 'Aesel', submenu: [
{role:'about'}, {type:'separator'}, {role:'close',accelerator:'CmdOrCtrl+W'},
- {label:'Check for Updates…', click:()=>desktopUpdater.check()},
+ {label:'Check for Updates…', click:checkBuildUpdates},
{label:'Restart Agent', click:()=>requestRestart('restart')},
{label:'Reload Interface', click:()=>window.webContents.reload()},
{label:'Restart App', click:()=>requestRestart('update')},
@@ -247,7 +260,7 @@ app.whenReady().then(() => {
] },
{ role: 'windowMenu' },
]));
- window = new BrowserWindow({ width: 760, height: 540, title: 'aesel', backgroundColor: '#463264',
+ window = new BrowserWindow({ width: 760, height: 540, title: devHome?'Aesel Dev':'aesel', backgroundColor: '#463264',
webPreferences: { preload: join(__dirname, 'preload.cjs'), contextIsolation: true, nodeIntegration: false, sandbox: true, webviewTag: true, plugins:true } });
const creditLabel = require('./credit-label.cjs').startCreditLabel({app, window, root});
require('./credit-checkout.cjs').startCreditCheckout({app,window,root,ipcMain,shell,refresh:creditLabel.refresh});
@@ -296,7 +309,7 @@ app.whenReady().then(() => {
screen.on('display-metrics-changed', sendDisplay);
window.on('enter-full-screen', fullscreenState);
window.on('leave-full-screen', () => { previewFullscreen = false; fullscreenState(); });
- const uiRoot = existsSync(join(root,'desktop-ui','index.html')) ? join(root,'desktop-ui') : __dirname;
+ const uiRoot = devHome ? join(root,'desktop') : existsSync(join(root,'desktop-ui','index.html')) ? join(root,'desktop-ui') : __dirname;
window.loadFile(join(uiRoot, 'index.html'));
if (canUpdateBinary) {
const firstCheck = setTimeout(() => desktopUpdater.check(), 30000); firstCheck.unref();
@@ -305,7 +318,7 @@ app.whenReady().then(() => {
if (terminal) { event.preventDefault(); if (!quitting) { quitting = true; terminal.kill('SIGTERM'); setTimeout(() => { if (terminal) { quitting = false; send('desktop-notice','The session is still saving or working. Retry closing when it is ready.'); } }, 6000).unref(); } }
});
});
-ipcMain.on('ready', event => { if (event.sender === window?.webContents) { send('theme',currentTheme); send('instance-label',address.label); if(systemTextSize)send('system-text-size',systemTextSize); sendDisplay(true); if(lastVisibleState)send('state',lastVisibleState); if(terminal)terminal.kill('SIGWINCH'); else start(); } });
+ipcMain.on('ready', event => { if (event.sender === window?.webContents) { send('theme',currentTheme); buildStatus.poll(); send('instance-label',address.label); if(systemTextSize)send('system-text-size',systemTextSize); sendDisplay(true); if(lastVisibleState)send('state',lastVisibleState); if(terminal)terminal.kill('SIGWINCH'); else start(); } });
// Only hide while the user is actually quitting. A stale or replayed closing
// phase must not make a healthy restarted window unreachable.
ipcMain.on('closing', event => { if(event.sender===window?.webContents && quitting) window.hide(); });
@@ -313,7 +326,7 @@ ipcMain.on('input', (event, data) => { if (event.sender === window?.webContents
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) { if(cols!==terminalSize.cols||rows!==terminalSize.rows){terminalSize={cols,rows};terminal?.resize(cols,rows);} }
});
-app.on('window-all-closed', () => { timer?.close(); stopFrameCapture(); themeFollower.close(); terminal?.kill(); app.quit(); });
+app.on('window-all-closed', () => { buildStatus.close();systemTextWatcher?.close(); timer?.close(); stopFrameCapture(); themeFollower.close(); terminal?.kill(); app.quit(); });
const isWindow = event => event.sender === window?.webContents;
ipcMain.on('copy-text', (event, text) => { if (isWindow(event) && typeof text === 'string' && text.length <= 1048576) clipboard.writeText(text); });
diff --git a/easel/desktop/native-title.cjs b/easel/desktop/native-title.cjs
index 2338aa5389..61e67043fa 100644
--- a/easel/desktop/native-title.cjs
+++ b/easel/desktop/native-title.cjs
@@ -11,7 +11,7 @@ function registerNativeTitle({ipcMain, window, app}) {
// Bound helper concurrency when titles change rapidly.
if (pending) await pending;
if (cache.has(key)) return cache.get(key);
- const nativeRoot = app.isPackaged ? join(process.resourcesPath,'app.asar.unpacked') : __dirname;
+ const nativeRoot = process.env.AESEL_DEV_ROOT ? join(process.env.AESEL_DEV_ROOT,'desktop') : app.isPackaged ? join(process.resourcesPath,'app.asar.unpacked') : __dirname;
pending = new Promise(resolve => execFile(join(nativeRoot,'native','credit-label'),[value.text,String(size),'--glyphs'],{encoding:'buffer',timeout:5000,maxBuffer:2*1024*1024},(error,data)=>{try{resolve(error?null:JSON.parse(data.toString('utf8')));}catch{resolve(null);}}));
const image = await pending;
pending = null;
diff --git a/easel/desktop/preload.cjs b/easel/desktop/preload.cjs
index e59d161a44..86d046b672 100644
--- a/easel/desktop/preload.cjs
+++ b/easel/desktop/preload.cjs
@@ -7,6 +7,8 @@ contextBridge.exposeInMainWorld('aesel', {
input: data => { if (typeof data === 'string') ipcRenderer.send('input', data); },
size: (cols, rows) => ipcRenderer.send('size', { cols, rows }),
titleGeometry: value => ipcRenderer.send('title-geometry',value),
+ onBuildStatus: fn => ipcRenderer.on('build-status',(_event,value)=>{if(value&&['dev','release','local'].includes(value.channel))fn(value);}),
+ checkBuildUpdates: () => ipcRenderer.send('check-build-updates'),
ready: () => ipcRenderer.send('ready'),
closing: () => ipcRenderer.send('closing'),
fullscreen: target => {
diff --git a/easel/desktop/renderer.js b/easel/desktop/renderer.js
index 207b7b5be3..948242cf93 100644
--- a/easel/desktop/renderer.js
+++ b/easel/desktop/renderer.js
@@ -614,7 +614,7 @@ boot().catch(error => { document.getElementById('terminal').textContent = `Could
const menu=document.createElement('dialog');menu.id='provider-menu';menu.setAttribute('aria-label','Settings');menu.hidden=true;
document.body.append(footer,menu);
window.installProxHover(footer,document.getElementById('version'));
- let provider=null,credits=null,generation=0,lastText='',menuKey='';
+ let provider=null,credits=null,buildInfo=null,generation=0,lastText='',menuKey='';
const balance=()=>{
if(!credits)return null;
if(Number.isFinite(credits.remaining)&&Number.isFinite(credits.purchased))return credits.remaining+credits.purchased;
@@ -630,12 +630,13 @@ boot().catch(error => { document.getElementById('terminal').textContent = `Could
button.addEventListener('click',action);menu.append(button);return button;
}
function rebuildMenu(){
- const nextKey=JSON.stringify([provider,credits]);if(nextKey===menuKey)return;menuKey=nextKey;
+ const nextKey=JSON.stringify([provider,credits,buildInfo]);if(nextKey===menuKey)return;menuKey=nextKey;
const focused=document.activeElement?.textContent;
const scroll=menu.scrollTop;const previewHost=menu.querySelector('.history-preview-host');const historyOpen=menu.querySelector('details')?.open??true;
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());}
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']]){
@@ -691,6 +692,7 @@ boot().catch(error => { document.getElementById('terminal').textContent = `Could
footer.append(document.getElementById('version'));
}
window.updateProviderFooter=value=>{if(!['ac','claude','codex'].includes(value?.backend))return;provider=value;window.updateNotebookBindings?.(value);render();};
+ window.aesel.onBuildStatus?.(value=>{buildInfo=value;render();});
window.aesel.onCredits(value=>{credits=value;render();});
footer.addEventListener('click',()=>{if(menu.hidden){menu.hidden=false;menu.showModal();window.aesel.input('\x1b[99;5~');footer.setAttribute('aria-expanded','true');menu.querySelector('button:not(:disabled)')?.focus();}else close();});
document.addEventListener('pointerdown',event=>{if(!footer.contains(event.target)&&!menu.contains(event.target))close();});
diff --git a/easel/desktop/test/build-status.test.cjs b/easel/desktop/test/build-status.test.cjs
new file mode 100644
index 0000000000..77b8e410fa
--- /dev/null
+++ b/easel/desktop/test/build-status.test.cjs
@@ -0,0 +1,6 @@
+const {test}=require('node:test');const assert=require('node:assert/strict');const {devStatus}=require('../build-status.cjs');
+const running={tree:'a',revision:'123456789',version:'0.7.3'};
+const state={online:true,state:'current',checkedAt:new Date().toISOString(),target:{tree:'a'},installed:{tree:'a'}};
+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');});
diff --git a/easel/desktop/updater.cjs b/easel/desktop/updater.cjs
index 769b796def..4ad8534120 100644
--- a/easel/desktop/updater.cjs
+++ b/easel/desktop/updater.cjs
@@ -1,21 +1,21 @@
// Binary updates are owned by the desktop host, never by the CLI tar updater.
-function createUpdater({app, notify, requestRestart, prepareRelaunch = () => {}, canUpdateBinary = app.isPackaged, load = () => require('electron-updater').autoUpdater}) {
+function createUpdater({app, notify, requestRestart, prepareRelaunch = () => {}, onStatus = () => {}, canUpdateBinary = app.isPackaged, load = () => require('electron-updater').autoUpdater}) {
let updater, downloaded = false, applying = false;
function configure() {
if (updater || !canUpdateBinary) return updater;
updater = load();
updater.autoDownload = true;
updater.autoInstallOnAppQuit = false; // Session checkpoint must succeed first.
- updater.on('error', error => notify(`Update failed: ${error.message}`));
- updater.on('update-not-available', () => notify('aesel is up to date.'));
- updater.on('update-available', info => notify(`Downloading aesel ${info.version}…`));
- updater.on('update-downloaded', () => { downloaded = true; if (!applying) requestRestart('update'); });
+ updater.on('error', error => { onStatus('unknown'); notify(`Update failed: ${error.message}`); });
+ updater.on('update-not-available', info => { onStatus('current',info); notify('aesel is up to date.'); });
+ updater.on('update-available', info => { onStatus('downloading',info); notify(`Downloading aesel ${info.version}…`); });
+ updater.on('update-downloaded', info => { onStatus('ready',info); downloaded = true; if (!applying) requestRestart('update'); });
return updater;
}
return {
async check() {
if (!canUpdateBinary) { notify('Reloading this development build…'); requestRestart('restart'); return; }
- try { await configure().checkForUpdates(); } catch (error) { notify(`Update failed: ${error.message}`); }
+ try { onStatus('checking'); await configure().checkForUpdates(); } catch (error) { onStatus('unknown'); notify(`Update failed: ${error.message}`); }
},
async afterCheckpoint(action) {
if (applying) return;
diff --git a/easel/dev/README.md b/easel/dev/README.md
new file mode 100644
index 0000000000..b6716a3eff
--- /dev/null
+++ b/easel/dev/README.md
@@ -0,0 +1,38 @@
+# Aesel Dev
+
+`Aesel Dev.app` and `aesel-dev` follow the committed `easel/` tree on monorepo
+`origin/main`. `aesel --dev` opens the same app. Ordinary `aesel` opens the signed
+release and follows its existing release feed. They use separate bundle IDs,
+application data, windows, and update channels. Account credentials remain with
+the existing AC/provider login stores.
+
+Both fleet machines check main once a minute. A change outside `easel/` does not
+create a dev build. New source is extracted to an immutable directory, native
+helpers are compiled, and `current` moves only after staging succeeds. Runtime
+changes checkpoint and restart the agent in its existing window. UI changes
+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.
+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.
+An offline machine catches up when it reconnects. “Lockstep” means the same
+committed source tree once both machines are online and have finished their
+current requests; it does not claim simultaneous swaps or copy uncommitted work.
+
+Install once on each Mac using matching Electron and production dependencies:
+
+```sh
+node easel/dev/install.mjs --repo ~/aesthetic-computer \
+ --base ~/Applications/aesel.app --dependencies /path/to/extracted/node_modules
+```
+
+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
+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/install.mjs b/easel/dev/install.mjs
new file mode 100644
index 0000000000..825870dacc
--- /dev/null
+++ b/easel/dev/install.mjs
@@ -0,0 +1,57 @@
+#!/usr/bin/env node
+import fs from 'node:fs';
+import path from 'node:path';
+import os from 'node:os';
+import {execFileSync} from 'node:child_process';
+import {fileURLToPath} from 'node:url';
+import {dependencyKey} from './sync.mjs';
+const option=(name,fallback)=>{const at=process.argv.indexOf(name);return at<0?fallback:process.argv[at+1];};
+const source=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..');
+const home=path.join(os.homedir(),'.local/share/aesel-dev');
+const repo=option('--repo',path.join(os.homedir(),'aesthetic-computer'));
+const base=option('--base',path.join(os.homedir(),'Applications/aesel.app'));
+const deps=option('--dependencies',path.join(source,'desktop/node_modules'));
+const app=path.join(os.homedir(),'Applications/Aesel Dev.app');
+const pkg=JSON.parse(fs.readFileSync(path.join(source,'desktop/package.json')));
+if(process.platform!=='darwin')throw Error('The fleet dev installer currently supports macOS.');
+if(!fs.existsSync(base)||!fs.existsSync(deps))throw Error('Provide an installed Electron app and its extracted production node_modules.');
+fs.mkdirSync(home,{recursive:true});fs.mkdirSync(path.join(home,'dependencies'),{recursive:true});
+// Copy production Electron dependencies, including the matching node-pty binary.
+execFileSync('/usr/bin/rsync',['-a',deps+'/',path.join(home,'dependencies/node_modules/')]);
+const dependencyTarget=path.join(home,'dependencies/node_modules');
+const helper=path.join(dependencyTarget,'node-pty/build/Release/spawn-helper');if(fs.existsSync(helper))fs.chmodSync(helper,0o755);
+const executable=execFileSync('/usr/libexec/PlistBuddy',['-c','Print:CFBundleExecutable',path.join(base,'Contents/Info.plist')],{encoding:'utf8'}).trim();
+const electron=path.join(base,'Contents/MacOS',executable);
+const actual=execFileSync(electron,['-p','process.versions.electron'],{encoding:'utf8',env:{...process.env,ELECTRON_RUN_AS_NODE:'1'}}).trim();
+if(actual!==pkg.devDependencies.electron)throw Error(`Electron ${pkg.devDependencies.electron} required; base has ${actual}.`);
+execFileSync(electron,['-e',`require(${JSON.stringify(path.join(dependencyTarget,'node-pty'))})`],{env:{...process.env,ELECTRON_RUN_AS_NODE:'1'}});
+fs.writeFileSync(path.join(home,'config.json'),JSON.stringify({repo,dependencyKey:dependencyKey(pkg)},null,2));
+fs.copyFileSync(path.join(source,'dev/sync.mjs'),path.join(home,'sync-bootstrap.mjs'));
+fs.writeFileSync(path.join(home,'sync-runner.mjs'),`import fs from 'node:fs';import {pathToFileURL} from 'node:url';const home=${JSON.stringify(home)};const current=home+'/current/easel/dev/sync.mjs';try{const module=await import(pathToFileURL(fs.existsSync(current)?current:home+'/sync-bootstrap.mjs'));console.log(JSON.stringify(module.sync({home})));}catch(error){console.error(error.message.split('\\n')[0]);process.exitCode=1;}\n`);
+execFileSync(process.execPath,[path.join(home,'sync-runner.mjs')],{stdio:'inherit',timeout:240000});
+const stage=app+`.stage-${process.pid}.app`;
+execFileSync('/bin/cp',['-cR',base,stage]);
+const resources=path.join(stage,'Contents/Resources');
+for(const name of ['app.asar','app.asar.unpacked','easel','app-update.yml'])fs.rmSync(path.join(resources,name),{recursive:true,force:true});
+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'})){
+ 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});
+if(fs.existsSync(app)){
+ const live=execFileSync('/bin/ps',['-axo','command='],{encoding:'utf8'}).split('\n').some(line=>line.startsWith(path.join(app,'Contents/MacOS')+'/'));
+ if(live)throw Error('Existing Dev app is running; staged shell kept. Quit Dev before replacing its Electron shell.');
+ fs.renameSync(app,path.join(home,`previous-shell-${Date.now()}.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 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});
+fs.writeFileSync(plist,`Labelcomputer.aesthetic.aesel-dev-syncProgramArguments${xml(fs.realpathSync(process.execPath))}${xml(path.join(home,'sync-runner.mjs'))}RunAtLoadStartInterval60ProcessTypeBackgroundNice10LowPriorityIOEnvironmentVariablesPATH/Users/jas/.local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbinStandardOutPath${xml(path.join(home,'sync.log'))}StandardErrorPath${xml(path.join(home,'sync-error.log'))}`);
+try{execFileSync('/bin/launchctl',['bootout',`gui/${process.getuid()}`,plist],{stdio:'ignore'});}catch{}
+execFileSync('/bin/launchctl',['bootstrap',`gui/${process.getuid()}`,plist]);
+console.log('Installed Aesel Dev. It follows committed origin/main; release Aesel is separate.');
diff --git a/easel/dev/sync.mjs b/easel/dev/sync.mjs
new file mode 100644
index 0000000000..6217ffc410
--- /dev/null
+++ b/easel/dev/sync.mjs
@@ -0,0 +1,77 @@
+#!/usr/bin/env node
+// The shared dev channel is the committed easel tree on origin/main.
+// Never checks out, resets, or edits a developer's working tree.
+import fs from 'node:fs';
+import path from 'node:path';
+import os from 'node:os';
+import crypto from 'node:crypto';
+import {execFileSync} from 'node:child_process';
+import {fileURLToPath} from 'node:url';
+export const digest = value => crypto.createHash('sha256').update(value).digest('hex');
+export function fingerprints(directory) {
+ const files = {};
+ function visit(relative = '') {
+ for (const entry of fs.readdirSync(path.join(directory, relative), {withFileTypes:true}).sort((a,b)=>a.name.localeCompare(b.name))) {
+ if (['node_modules','.git','dist','.build','.DS_Store'].includes(entry.name)) continue;
+ const name = path.join(relative,entry.name);
+ if (entry.isDirectory()) visit(name);
+ else if(entry.isSymbolicLink()) files[name]=digest('symlink:'+fs.readlinkSync(path.join(directory,name)));
+ else if (entry.isFile() && !['native/gamepad','desktop/native/credit-label'].includes(name)) files[name] = digest(fs.readFileSync(path.join(directory,name)));
+ }
+ }
+ visit(); return files;
+}
+export function modifiedFiles(directory, manifest) {
+ const actual=fingerprints(directory),expected=manifest.files;
+ 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})); }
+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')));
+ const stateFile=path.join(home,'status.json'),lock=path.join(home,'sync.lock');
+ fs.mkdirSync(home,{recursive:true});
+ try{fs.mkdirSync(lock);}catch{try{const pid=Number(fs.readFileSync(path.join(lock,'pid'),'utf8'));process.kill(pid,0);}catch(error){if(error.code==='ESRCH')fs.rmSync(lock,{recursive:true,force:true});}return {state:'busy'};}
+ fs.writeFileSync(path.join(lock,'pid'),String(process.pid));
+ let previous={};try{previous=JSON.parse(fs.readFileSync(stateFile));}catch{}
+ const git=(...args)=>execFileSync('/usr/bin/git',['-C',config.repo,...args],{encoding:'utf8',timeout:120000,stdio:['ignore','pipe','pipe'],env:{...process.env,GIT_TERMINAL_PROMPT:'0'}}).trim();
+ try {
+ if(fetch)git('fetch','origin','main','--quiet');
+ const revision=git('rev-parse','refs/remotes/origin/main'),tree=git('rev-parse',`${revision}:easel`);
+ const target=path.join(home,'versions',tree),current=path.join(home,'current');
+ let installed;try{installed=JSON.parse(fs.readFileSync(path.join(current,'build.json')));}catch{}
+ const changed=installed?modifiedFiles(path.join(current,'easel'),installed):[];
+ const state={channel:'dev',target:{revision,tree},checkedAt:new Date().toISOString(),online:true,installed:installed&&{revision:installed.revision,tree:installed.tree},modified:changed,state:changed.length?'modified':'checking'};
+ if(changed.length){writeJSON(stateFile,state);return state;}
+ if(installed?.tree!==tree){
+ state.state='syncing';writeJSON(stateFile,state);
+ if(!fs.existsSync(path.join(target,'build.json'))){
+ const stage=target+`.stage-${process.pid}`;fs.mkdirSync(stage,{recursive:true});
+ try {
+ execFileSync('/usr/bin/git',['-C',config.repo,'archive','--format=tar','-o',path.join(stage,'source.tar'),revision,'easel'],{timeout:120000});
+ execFileSync('/usr/bin/tar',['-xf',path.join(stage,'source.tar'),'-C',stage]);fs.unlinkSync(path.join(stage,'source.tar'));
+ const source=path.join(stage,'easel'),pkg=JSON.parse(fs.readFileSync(path.join(source,'desktop/package.json')));
+ if(dependencyKey(pkg)!==config.dependencyKey)throw Error('Dev dependencies changed; run install-dev again.');
+ fs.symlinkSync(path.join(home,'dependencies/node_modules'),path.join(source,'desktop/node_modules'));
+ build(source);
+ const files=fingerprints(source);
+ const hostFiles=Object.fromEntries(Object.entries(files).filter(([n])=>/^desktop\/.*\.cjs$/.test(n)||/^desktop\/native\//.test(n)||n==='desktop/package.json'||n==='native/gamepad.swift'));
+ const uiFiles=Object.fromEntries(Object.entries(files).filter(([n])=>n.startsWith('desktop/')&&!n.endsWith('.cjs')));
+ const manifest={ui:digest(JSON.stringify(uiFiles)),channel:'dev',revision,tree,version:pkg.version,builtAt:new Date().toISOString(),host:digest(JSON.stringify(hostFiles)),files};
+ writeJSON(path.join(stage,'build.json'),manifest);fs.renameSync(stage,target);
+ }catch(error){fs.rmSync(stage,{recursive:true,force:true});throw error;}
+ }
+ const manifest=JSON.parse(fs.readFileSync(path.join(target,'build.json')));
+ if(modifiedFiles(path.join(target,'easel'),manifest).length)throw Error('Staged dev build was modified.');
+ 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;
+ } 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;
+ } finally {fs.rmSync(lock,{recursive:true,force:true});}
+}
+if(process.argv[1]===fileURLToPath(import.meta.url)){
+ try{console.log(JSON.stringify(sync({fetch:!process.argv.includes('--no-fetch')})));}catch(error){console.error(error.message.split('\n')[0]);process.exitCode=1;}
+}
diff --git a/easel/test/dev-sync.test.mjs b/easel/test/dev-sync.test.mjs
new file mode 100644
index 0000000000..08509358d1
--- /dev/null
+++ b/easel/test/dev-sync.test.mjs
@@ -0,0 +1,16 @@
+import test from 'node:test';import assert from 'node:assert/strict';import fs from 'node:fs';import path from 'node:path';import os from 'node:os';import {execFileSync} from 'node:child_process';import {sync,dependencyKey} from '../dev/sync.mjs';
+function fixture(t){
+ const dir=fs.mkdtempSync(path.join(os.tmpdir(),'aesel-dev-test-')),repo=path.join(dir,'repo'),home=path.join(dir,'home');fs.mkdirSync(path.join(repo,'easel/desktop'),{recursive:true});fs.mkdirSync(home);
+ const git=(...args)=>execFileSync('git',['-C',repo,...args],{encoding:'utf8',stdio:['ignore','pipe','pipe']}).trim();git('init','-q');git('config','user.name','Fixture');git('config','user.email','fixture@example.invalid');git('config','commit.gpgsign','false');
+ const pkg={version:'0.7.3',dependencies:{},devDependencies:{electron:'39.2.7'}};fs.writeFileSync(path.join(repo,'easel/desktop/package.json'),JSON.stringify(pkg));fs.writeFileSync(path.join(repo,'easel/desktop/main.cjs'),'// main');
+ fs.writeFileSync(path.join(home,'config.json'),JSON.stringify({repo,dependencyKey:dependencyKey(pkg)}));
+ function commit(){git('add','.');git('commit','-qm','fixture');git('update-ref','refs/remotes/origin/main','HEAD');}
+ commit();t.after(()=>fs.rmSync(dir,{recursive:true,force:true}));return {home,repo,commit,git};
+}
+test('sync installs immutable builds atomically and ignores unrelated commits',t=>{
+ const f=fixture(t),args={home:f.home,fetch:false,build:()=>{}};const first=sync(args);const original=fs.realpathSync(path.join(f.home,'current'));
+ fs.writeFileSync(path.join(f.repo,'unrelated.txt'),'other project');f.commit();const unrelated=sync(args);assert.equal(unrelated.installed.tree,first.installed.tree);assert.equal(fs.realpathSync(path.join(f.home,'current')),original);
+ fs.writeFileSync(path.join(f.repo,'easel/desktop/main.cjs'),'// changed');f.commit();const next=sync(args);assert.notEqual(next.installed.tree,first.installed.tree);assert(fs.existsSync(original));assert.equal(fs.readFileSync(path.join(f.home,'current/easel/desktop/main.cjs'),'utf8'),'// changed');
+});
+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);});
--
2.51.2
From 1f93ee378d3117e0f06b6472e95f1aa529b5bd2d Mon Sep 17 00:00:00 2001
From: "prompt.ac/@jeffrey"
Date: Thu, 17 Sep 2026 19:33:15 -0700
Subject: [PATCH 2/2] Guard development shell replacement before touching
dependencies
---
easel/dev/install.mjs | 1 +
1 file changed, 1 insertion(+)
diff --git a/easel/dev/install.mjs b/easel/dev/install.mjs
index 825870dacc..a1495fef66 100644
--- a/easel/dev/install.mjs
+++ b/easel/dev/install.mjs
@@ -14,6 +14,7 @@ const deps=option('--dependencies',path.join(source,'desktop/node_modules'));
const app=path.join(os.homedir(),'Applications/Aesel Dev.app');
const pkg=JSON.parse(fs.readFileSync(path.join(source,'desktop/package.json')));
if(process.platform!=='darwin')throw Error('The fleet dev installer currently supports macOS.');
+if(fs.existsSync(app)&&execFileSync('/bin/ps',['-axo','command='],{encoding:'utf8'}).split('\n').some(line=>line.startsWith(path.join(app,'Contents/MacOS')+'/')))throw Error('Quit Aesel Dev before updating its Electron shell or dependencies.');
if(!fs.existsSync(base)||!fs.existsSync(deps))throw Error('Provide an installed Electron app and its extracted production node_modules.');
fs.mkdirSync(home,{recursive:true});fs.mkdirSync(path.join(home,'dependencies'),{recursive:true});
// Copy production Electron dependencies, including the matching node-pty binary.