diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js
index 40c6b6758..876dbad8a 100644
--- a/packages/webui/src/public/bus.js
+++ b/packages/webui/src/public/bus.js
@@ -671,17 +671,70 @@
await assistantPost('/api/name',{instance,name},instance,result=>{if(typeof result?.name!=='string')return unknownAssistant;delete nameDrafts[instance];const prior=last.get('names');if(prior)last.set('names',{...prior,rows:[...prior.rows.filter(r=>r.instance!==result.instance),{instance:result.instance,name:result.name,at:result.at}]});return `Seat name set to ${result.name}.`;});}
else if(e.target.dataset.assistantStop!==undefined)await assistantPost('/api/assistant-stop',{instance},instance,result=>Array.isArray(result?.stopped)?`Stopped ${plural(result.stopped.length,'run')}.`:unknownAssistant);
});
+ // Row 165 (#1662, Q171): role instances. Add one, or change one's definition, vars and launch
+ // membership, with the read's revision. A definition that declares credentials is named but not
+ // offered: binding credentials stays a hand edit. When a change needs a bus restart the receipt
+ // says so; the Console never restarts the bus. Drafts survive polling and refusals; nothing retries.
+ let instanceView=null, instanceDrafts={add:null,change:null}, instanceBusy=false, instanceReceipt='';
+ const RESTART_TEXT='Restart needed: the running bus keeps the roles it loaded at start. The Console does not restart it.';
+ const NO_RESTART_TEXT='No restart: the launcher reads the change at its next launch check.';
+ function instancesSection(view){
+ const title='
Role instances
';
+ if(!view)return `${title}
The role instances could not be read, so there is nothing to add or change. Read again to retry; they come from the bus host with your identity.
Add an instance or change one's definition, vars and launch membership. Holder, credentials and tracker stay hand edits, and so does an instance of a definition that declares credentials. An assistant changes in the Assistant section.
Instance
Definition
Launched
Vars
${rows}
${addForm}${changeForm}${bound.length?`
Definitions that declare credentials, a hand edit: ${bound.join(', ')}.
`:''}
${canSend?'Changes are checked and logged with your identity.':'Changes require signed-in send mode.'}
${txt(instanceReceipt)}
`;
+ }
+ const instanceFields=form=>Object.fromEntries([...form.querySelectorAll('[data-instance-field]')].map(el=>[el.dataset.instanceField,el.type==='checkbox'?el.checked:el.value]));
+ function instancePayload(op,f){
+ const payload={revision:instanceView.revision,instance:String(f.instance??'').trim(),op};
+ if(!payload.instance)throw Error('Name the instance.');
+ if(f.definition)payload.definition=f.definition;
+ if(op==='add'&&!payload.definition)throw Error('Choose a definition.');
+ if(String(f.vars??'').trim()!==''){let parsed;try{parsed=JSON.parse(f.vars);}catch{throw Error('Vars must be JSON.');}if(!parsed||typeof parsed!=='object'||Array.isArray(parsed))throw Error('Vars must be a JSON object.');payload.vars=parsed;}
+ if(op==='add'&&f.launch===true)payload.launch=true;
+ if(op==='change'&&(f.launch==='yes'||f.launch==='no'))payload.launch=f.launch==='yes';
+ if(op==='change'&&!['definition','vars','launch'].some(k=>Object.hasOwn(payload,k)))throw Error('Nothing changed.');
+ return payload;
+ }
+ const unknownInstance='Outcome unknown. Read the role instances again before trying. Draft kept.';
+ // The answer replaces the view the next render draws, so it must have the read's whole shape.
+ const instanceAnswer=r=>/^[a-f0-9]{64}$/.test(r?.revision??'')&&typeof r.restart==='boolean'&&[r.instances,r.definitions].every(o=>o&&typeof o==='object'&&!Array.isArray(o));
+ for(const kind of ['input','change'])document.addEventListener(kind,e=>{const form=e.target?.closest?.('.instance-form');if(form)instanceDrafts[form.dataset.op]=instanceFields(form);});
+ document.addEventListener('click',e=>{if(e.target.id!=='instances-reload')return;instanceDrafts={add:null,change:null};instanceView=null;instanceReceipt='';last.delete('instances');render(true);});
+ document.addEventListener('submit',async e=>{
+ const form=e.target?.closest?.('.instance-form');if(!form||e.target!==form)return;e.preventDefault();if(!canSend||out()||instanceBusy||!instanceView)return;
+ const op=form.dataset.op;instanceDrafts[op]=instanceFields(form);let payload;
+ try{payload=instancePayload(op,instanceDrafts[op]);}catch(err){instanceReceipt=err.message;render(false);return;}
+ instanceBusy=true;instanceReceipt='';render(false);
+ try{const response=await fetch('/api/instances',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});if(response.type==='opaqueredirect'){instanceReceipt='Sign in again. Draft kept.';signOut();}else{const result=await response.json();
+ if(response.ok&&instanceAnswer(result)){instanceDrafts[op]=null;instanceView=result;const prior=last.get('instances');if(prior)last.set('instances',{...prior,rows:[result]});instanceReceipt=`${op==='add'?'Added':'Changed'} ${payload.instance}. ${result.restart?RESTART_TEXT:NO_RESTART_TEXT}`;}
+ else instanceReceipt=response.ok||result?.sent==='unknown'?unknownInstance:`${result?.message||'Refused.'} Draft kept.`;}}
+ catch{instanceReceipt=unknownInstance;}finally{instanceBusy=false;if(!out())render(false);}
+ });
async function settingsView(get) {
const s = await get.api('settings'), n = s.notifier || {};
let usage, names = null;
try { [usage] = await get('usage'); } catch (err) { if (err.status === 403 || err.code === 'signed-out') throw err; }
try { names = await get('names'); } catch { /* the section says the name could not be read */ }
+ let instances = null;
+ try { [instances] = await get('instances'); } catch (err) { if (err.status === 403 || err.code === 'signed-out') throw err; }
let limitRows = [], limitsRefused = false;
try { limitRows = await get('limits'); } catch (err) { if (err.status === 403 || err.code === 'signed-out') throw err; limitsRefused = true; }
const notify = typeof n.binding === 'string'
? `
Binding
${txt(n.binding)}
The binding name only. Its channels, server and user ids stay in the binding file.
`
: empty('No notifier binding to show.', `${n.refused ? `${txt(n.refused)}: ` : ''}${txt(n.message || 'the notifier settings could not be read')}`);
- return `${head('Settings', `${readLine()} Agent limits and appearance.`)}${appearance()}${launchForm(s.launch)}${assistantSection(s.launch, names)}${tokenLimits(limitRows, limitsRefused)}${tokenUsage(usage)}
+ return `${head('Settings', `${readLine()} Agent limits and appearance.`)}${appearance()}${launchForm(s.launch)}${assistantSection(s.launch, names)}${instancesSection(instances)}${tokenLimits(limitRows, limitsRefused)}${tokenUsage(usage)}
Notifications
${notify}
About
Address
${txt(s.address) || 'unknown'}
Release
${s.release ? `${txt(s.release)}` : 'unknown: no RELEASE file was read'}