diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js
index ba1796e11..579315329 100644
--- a/packages/webui/src/public/bus.js
+++ b/packages/webui/src/public/bus.js
@@ -488,46 +488,65 @@
const FIRE_REASON='Fired from the Agents page.';
let fireAsk=null,fireBusy=false,fireReceipt=null;
const fireKey=(org,name)=>`${org}\u0000${name}`;
- function fireNote(){const org=hireOrg();const r=fireReceipt&&fireReceipt.org===org?fireReceipt:null;
+ // #1873 SP5: the system Agents page lists every organization's agents, so its controls carry their
+ // organization (data-agent-fire-org) and post to that organization's own bus, never to the one
+ // opened last. Its receipt box is `data-agents-receipt="*"` and shows a receipt from any of them.
+ // The agent cards keep the old shape: no attribute, the opened organization, canDecide.
+ function fireNote(scope=hireOrg()){const r=fireReceipt&&(scope==='*'?fireReceipt.system:fireReceipt.org===scope)?fireReceipt:null;
return r?`
${txt(r.text)}${r.code?` (${txt(r.code)})`:''}
`:'';}
- function paintFireNote(){for(const e of document.querySelectorAll('[data-agents-receipt]'))e.innerHTML=fireNote();}
- async function fireAgent(name){
- const org=hireOrg();
- if(fireBusy||!org||out()||!canDecide)return;
+ function paintFireNote(){for(const e of document.querySelectorAll('[data-agents-receipt]'))e.innerHTML=fireNote(e.dataset.agentsReceipt||hireOrg());}
+ const fireSel=(attr,name,org)=>`[${attr}="${CSS.escape(name)}"]${org?`[data-agent-fire-org="${CSS.escape(org)}"]`:':not([data-agent-fire-org])'}`;
+ async function fireAgent(name,named=null,orgName=named){
+ const org=named??hireOrg();
+ // A named organization was checked against its own config when the control was drawn (decidesIn).
+ if(fireBusy||!org||out()||(!named&&!canDecide)||isTester)return;
fireBusy=true;
for(const b of document.querySelectorAll('[data-agent-fire-yes],[data-agent-fire-no]'))b.disabled=true;
const epoch=orgEpoch;
- const {res,json}=await decidePost('/api/decisions/hire/start',{business:org,hire:{op:'fire',level:'org',name,reason:FIRE_REASON}});
- if(epoch!==orgEpoch){if(res?.type==='opaqueredirect')signOut();return;}
- fireBusy=false;fireAsk=null;
+ const {res,json}=await decidePost(`${decideBase({ org: named })}/decisions/hire/start`,{business:org,hire:{op:'fire',level:'org',name,reason:FIRE_REASON}});
+ // SP5 review F7: a named organization's Fire went to that organization, so its outcome still
+ // stands after the opened organization changes.
+ // After a switch, busy and confirm state belong to whatever Fire started since; leave them alone.
+ const stale=epoch!==orgEpoch;
+ if(stale&&!named){if(res?.type==='opaqueredirect')signOut();return;}
+ if(!stale){fireBusy=false;fireAsk=null;}
if(res?.type==='opaqueredirect'){signOut();return;}
+ const where=named?` in ${orgName}`:'';
if(res?.ok && json?.op==='resolved'){
for(const c of document.querySelectorAll(`[data-org="${CSS.escape(org)}"] [data-agent-card="${CSS.escape(name)}"]`))c.remove();
for(const r of document.querySelectorAll(`[data-agents-org="${CSS.escape(org)}"] tr[data-agent="${CSS.escape(name)}"]`))r.remove();
- fireReceipt={org,text:`Fired ${name}. Recorded decision ${json.id}.`};
+ for(const r of document.querySelectorAll(`[data-ws-agent="${CSS.escape(`${org}:${name}`)}"]`))r.remove();
+ fireReceipt={org,system:!!named,text:`Fired ${name}${where}. Recorded decision ${json.id}.`};
paintFireNote();announce(fireReceipt.text);
document.dispatchEvent(new CustomEvent('mosaic-org-changed',{detail:{org}}));
} else {
const r=decideRefusal(res,json,'outcome-unknown');
- fireReceipt={org,failed:true,text:`${name} was not fired: ${r.message}`,code:r.code};
+ fireReceipt={org,system:!!named,failed:true,text:`${name}${where} was not fired: ${r.message}`,code:r.code};
paintFireNote();
- for(const c of document.querySelectorAll(`[data-agent-fire-confirm="${CSS.escape(name)}"]`))c.outerHTML=fireButton(name);
+ for(const c of document.querySelectorAll(fireSel('data-agent-fire-confirm',name,named)))c.outerHTML=fireButton(name,named,orgName);
}
}
- const fireButton=name=>``;
- const fireConfirm=name=>`Fire ${txt(name)}? `;
- // What the agent card shows for one agent: nothing without the decide right or outside the page's own organization.
- function fireControl(org,name){
- if(!canDecide||isTester||org!==hireOrg())return '';
- return fireAsk===fireKey(org,name)?fireConfirm(name):fireButton(name);
+ const fireOrgAttr=org=>org?` data-agent-fire-org="${esc(org)}"`:'';
+ const fireFocus=(kind,name,org)=>esc(org?`${kind}:${org}:${name}`:`${kind}:${name}`);
+ // SP5 review F5: the words name the organization by its label (`orgName`); its id addresses the control.
+ const fireNameAttr=(org,orgName)=>org&&orgName&&orgName!==org?` data-agent-fire-name="${esc(orgName)}"`:'';
+ const fireButton=(name,org=null,orgName=org)=>``;
+ const fireConfirm=(name,org=null,orgName=org)=>`Fire ${txt(name)}? `;
+ // What the agent card shows for one agent: nothing without the decide right or outside the page's own
+ // organization. SP5: `allowed` given means the caller read that organization's own config (decidesIn);
+ // the control is then drawn for that organization, whichever one was opened last.
+ function fireControl(org,name,allowed,orgName=org){
+ const named=typeof allowed==='boolean';
+ if(isTester||(named?!allowed:(!canDecide||org!==hireOrg())))return '';
+ return fireAsk===fireKey(org,name)?fireConfirm(name,named?org:null,named?orgName:null):fireButton(name,named?org:null,named?orgName:null);
}
document.addEventListener('click',e=>{
const t=e.target.closest?.('[data-agent-fire],[data-agent-fire-yes],[data-agent-fire-no]');
if(!t)return;
- const org=hireOrg();
- if(t.hasAttribute('data-agent-fire')){const name=t.dataset.agentFire;fireAsk=fireKey(org,name);t.outerHTML=fireConfirm(name);document.querySelector(`[data-agent-fire-yes="${CSS.escape(name)}"]`)?.focus();}
- else if(t.hasAttribute('data-agent-fire-no')){const name=t.dataset.agentFireNo;fireAsk=null;t.closest('[data-agent-fire-confirm]').outerHTML=fireButton(name);announce('Cancelled. Nothing was fired.');}
- else void fireAgent(t.dataset.agentFireYes);
+ const named=t.dataset.agentFireOrg??null,org=named??hireOrg(),orgName=t.closest('[data-agent-fire-name]')?.dataset.agentFireName??named;
+ if(t.hasAttribute('data-agent-fire')){const name=t.dataset.agentFire;fireAsk=fireKey(org,name);t.outerHTML=fireConfirm(name,named,orgName);document.querySelector(fireSel('data-agent-fire-yes',name,named))?.focus();}
+ else if(t.hasAttribute('data-agent-fire-no')){const name=t.dataset.agentFireNo;fireAsk=null;t.closest('[data-agent-fire-confirm]').outerHTML=fireButton(name,named,orgName);document.querySelector(fireSel('data-agent-fire',name,named))?.focus();announce('Cancelled. Nothing was fired.');}
+ else void fireAgent(t.dataset.agentFireYes,named,orgName);
});
window.MosaicDecide=Object.freeze({fireControl,fireNote});
document.addEventListener('click',e=>{if(e.target.closest?.('[data-hire-load]'))hireSubmit(true);});
@@ -876,12 +895,15 @@
return `${title}
${why(err)}
${retry}
`;
}
}
- async function agentsView(get) {
+ // SP5: an organization's scoped page leads with its rows of the system Agents table (which owns the
+ // heading), then the levels grid under its own h2, then held roles and threads.
+ const scopedTop = async (q, org, note) => `${await window.MosaicWorkspace.agentsPage(q, { scope: org })}${await window.MosaicWorkspace.agentGrid(note, undefined, { under: true })}`;
+ async function agentsView(get, q = new URLSearchParams(), org = null) {
let claims;
try { claims = await get('agents'); } catch (err) {
if (!window.MosaicOrg || err.code === 'signed-out') throw err;
if (refusal(err)) forget();
- return `${await window.MosaicWorkspace.agentGrid()}
The selected organization's held roles could not be read. ${why(err)}
`).join('');
// Organization mode (sample 08): the levels grid carries the page heading; held roles follow.
- const top = window.MosaicOrg ? await window.MosaicWorkspace.agentGrid(readNote(get)) : head('Agents', readNote(get));
+ const top = !window.MosaicOrg ? head('Agents', readNote(get)) : org ? await scopedTop(q, org, readNote(get)) : await window.MosaicWorkspace.agentGrid(readNote(get));
return `${top}
Held roles ${claims.length}
The current holder of each claimed role. A role nobody holds does not appear. Working: the holder has messages it received and hasn't answered yet. Waiting: a decision the role raised is open in your inbox. Otherwise idle.
` : empty('No role is held right now.', 'A role shows here while a run holds its claim on the bus.')}
@@ -1677,6 +1699,13 @@
if (a === 'messages' && n === 1) return messagesView(get, q);
if (a === 'tasks' && n === 1) return tasksView(get);
if (a === 'tasks' && n === 2) return TASK.test(b) ? taskView(get, b) : notFound(`${txt(b)} is not a task reference.`);
+ // SP5: #/agents is every organization's agents; #/orgs//agents is that table for one, above its levels grid and held roles.
+ if (a === 'agents' && n === 1 && window.MosaicOrg) {
+ const scope = /^#\/orgs\/([^/?]+)/.exec(location.hash)?.[1];
+ let org = null; try { org = scope ? decodeURIComponent(scope) : null; } catch { return notFound(); }
+ if (!org) return configRead.then(() => window.MosaicWorkspace.agentsPage(q, { scope: null, fireIn: decidesIn }));
+ return agentsView(get, q, org);
+ }
if (a === 'agents' && n === 1) return agentsView(get);
if (a === 'queue' && n === 1) return queueView(get, q);
if (a === 'queue' && n === 2) return /^[1-9]\d{0,5}$/.test(b) ? queueRowView(get, b) : notFound(`${txt(b)} is not a queue row id.`);
@@ -1692,7 +1721,7 @@
const paused = () => $('pause')?.getAttribute('aria-pressed') === 'true';
// A file open in the editor is never refreshed from under its draft; Reload is the person's to press.
// The cross-organization pages (U3) poll at the workspace read's pollMs.
- const wsPage = path => !!window.MosaicOrg && (path === '/' || path === '' || path === '/dashboard' || path === '/projects' || path === '/inbox');
+ const wsPage = path => !!window.MosaicOrg && (path === '/' || path === '' || path === '/dashboard' || path === '/projects' || path === '/inbox' || path === '/agents');
function schedule() { clearTimeout(timer); if (!paused() && !out() && !routeOf().startsWith('/docs/')) timer = setTimeout(() => render(false), wsPage(routeOf().split('?')[0]) ? window.MosaicWorkspace?.pollMs || 10000 : 10000); }
// Keep focus, open and the copy status across a refresh of the same page.
// In the send form, the focused field and its caret too (its values come from `draft`).
@@ -2068,7 +2097,8 @@
// E2E6 P2-2: a write on the organization page that changes other sections (a pack apply) asks for a
// fresh read of the page; editors in progress are kept by holdLive.
// Ruling 2026-11-14: an apply, hire or fire redraws the Agents page in the same response cycle, not at the next poll.
- document.addEventListener('mosaic-org-changed', e => { if (window.MosaicOrg && e.detail?.org === window.MosaicOrg.id && ['/organization', '/agents'].includes(routeOf().split('?')[0])) render(false); });
+ // SP5: the system Agents page shows every organization, so a change in any of them redraws it.
+ document.addEventListener('mosaic-org-changed', e => { if (!window.MosaicOrg) return; const path = routeOf().split('?')[0]; if ((path === '/agents' && window.MosaicOrg.system()) || (e.detail?.org === window.MosaicOrg.id && ['/organization', '/agents'].includes(path))) render(false); });
// #1873 SP1: another organization opened in place (display.js, before this file's hashchange
// render). Nothing read, drafted or confirmed for the old one carries over; the new one's config
// is read before the next page draws. A write already sent finishes against the old organization,
diff --git a/packages/webui/src/public/display.js b/packages/webui/src/public/display.js
index 87d6125fb..40eeebc3c 100644
--- a/packages/webui/src/public/display.js
+++ b/packages/webui/src/public/display.js
@@ -22,8 +22,9 @@ window.MosaicLocalTime = value => Number.isFinite(Date.parse(value)) ? new Date(
// an organization page without the prefix gets the current organization's. #1873 SP2: an
// organization's own bare address (#/orgs/, no trailing slash) is that organization's page;
// #/orgs// stays the old dashboard address. SP3: `#/inbox` is a system route too (every
- // organization's items); `#/orgs//inbox` is that list, filtered.
- const system = hash => { const bare = tail(hash).split('?')[0]; return bare === '/dashboard' || (!orgOf(hash) && bare === '/inbox'); };
+ // organization's items); `#/orgs//inbox` is that list, filtered. SP5: `#/agents` is every
+ // organization's agents; `#/orgs//agents` is that table for one organization.
+ const system = hash => { const bare = tail(hash).split('?')[0]; return bare === '/dashboard' || (!orgOf(hash) && (bare === '/inbox' || bare === '/agents')); };
const normalize = (hash, seg = current) => {
const path = tail(hash), bare = path.split('?')[0], named = /^#\/orgs\/([^/?]+)$/.exec(hash.split('?')[0])?.[1];
if (named) return `#/orgs/${named}/organization${hash.slice(hash.split('?')[0].length)}`;
diff --git a/packages/webui/src/public/shell.css b/packages/webui/src/public/shell.css
index 2f36f5eaa..7b5cb57f9 100644
--- a/packages/webui/src/public/shell.css
+++ b/packages/webui/src/public/shell.css
@@ -692,6 +692,16 @@ html[data-organizations] .ws-fgroup{display:flex;gap:6px 8px;align-items:center;
html[data-organizations] .ws-ibx-filters .ws-fchips>.ws-label,html[data-organizations] .ws-fgroup>.ws-label{min-width:36px}
html[data-organizations] .ws-ichips{display:flex;flex-wrap:wrap;align-items:center;gap:4px 6px;margin-top:3px}
html[data-organizations] .ws-ichips .via{display:inline}
+/* #1873 SP5: the system Agents table. Open and Fire sit side by side at one size; a confirm wraps in its cell. */
+html[data-organizations] .ws-agt .ws-arole{font-size:12px;color:var(--muted)}
+html[data-organizations] .ws-agt td.ws-act{white-space:nowrap;text-align:right;width:1%}
+html[data-organizations] .ws-agt td.ws-act>*+*{margin-left:6px}
+html[data-organizations] .ws-agt td.ws-act .btn.small{min-height:0;padding:4px 10px;font:600 13px/1.4 var(--font);border-radius:var(--r)}
+html[data-organizations] .ws-agt .ws-fire-confirm{display:inline-flex;flex-wrap:wrap;justify-content:flex-end;align-items:center;gap:6px;white-space:normal}
+html[data-organizations] .ws-agt .t2.mono{overflow-wrap:anywhere}
+html[data-organizations] .ws-agt tr.on{background:color-mix(in srgb,var(--action) 10%,var(--surface))}
+html[data-organizations] .ws-agt tr.on td:first-child{box-shadow:inset 3px 0 0 var(--action)}
+@media (forced-colors:active){html[data-organizations] .ws-agt tr.on td:first-child{border-left:3px solid Highlight}}
html[data-organizations] .ws-ochip{background:color-mix(in srgb,var(--org,var(--muted)) 14%,var(--surface))}
html[data-organizations] .ws-ochip i{width:8px;height:8px;border-radius:999px;background:var(--org,var(--muted));flex:none}
html[data-organizations] .ws-msrc{align-items:center;gap:5px;font-size:12px;color:var(--muted);margin-bottom:2px}
diff --git a/packages/webui/src/public/workspace.js b/packages/webui/src/public/workspace.js
index aa2cc5c3d..f3fd6aeca 100644
--- a/packages/webui/src/public/workspace.js
+++ b/packages/webui/src/public/workspace.js
@@ -363,6 +363,17 @@
return hit ? { key: hit[0], copy: hit[1] } : { key: null, copy: null };
};
const LEVEL_CLS = { root: 'root', org: 'org', project: 'proj' };
+ // SP5: what an agent is, in words. A hired agent's definition is `org:hire-`. No read names
+ // the template it was hired from, so it reads as hired, and from which pack when a pack hired it;
+ // the definition id stays as secondary text.
+ const isHired = item => typeof item?.hireDecision === 'string' || /^org:hire-/.test(String(item?.definition ?? ''));
+ const packOf = item => (typeof item?.pack?.label === 'string' && item.pack.label ? item.pack.label : typeof item?.pack?.id === 'string' ? item.pack.id : '');
+ const ROLE_WORD = { 'chief-of-staff': 'Chief of staff', assistant: 'Assistant' };
+ const roleWords = item => {
+ const d = typeof item?.definition === 'string' ? item.definition : '';
+ if (isHired(item)) return packOf(item) ? `Hired from pack ${packOf(item)}` : 'Hired';
+ return ROLE_WORD[d] || d.replace(/^org:/, '') || 'not reported';
+ };
const projectName = (rows, id) => { const p = Array.isArray(rows) ? rows.find(r => r?.id === id) : null; return p?.name || id; };
let agentsPick = null; // the project chosen in "Available in a project", kept across repaints
// Repaints that section alone when the choice changes, one renderer per organization: a side-by-side
@@ -392,7 +403,8 @@
if (!set && inst === null) return `
${selOrg?.busHealthy === false ? `Agents not read: ${busWord(selOrg)}.` : 'Agents unavailable. The bus did not answer the instance read.'}
Agents are defined at three levels: root, organization and project. Each organization runs its own copy of a root agent, and a project's agents run only in that project. The copies are made when the organization's bus starts.${note ? ` ${note}` : ''}
Agents are defined at three levels: root, organization and project. Each organization runs its own copy of a root agent, and a project's agents run only in that project. The copies are made when the organization's bus starts.${note ? ` ${note}` : ''}
`;
if (!Array.isArray(list)) return `${head}
Organizations unavailable.
`;
const valid = list.filter(org => !org.invalid);
const admitted = valid.some(org => org.id === selected);
@@ -674,7 +687,7 @@
${secH('Needs you', '', 'Inbox')}
${needsList}${missLine(o, ['inbox'], 'needs')}
- ${secH('Active agents', 'sessions now', `Agents`)}
+ ${secH('Active agents', 'sessions now', 'Agents')}
${sessTable}${missLine(o, ['agents', 'claims'], 'sessions')}
@@ -879,6 +892,115 @@
`;
}
+ // ---- agents across organizations (#1873 SP5, prototype shot 14) ----
+ // One table of every organization's agents: the organization's own instances and the root and
+ // project copies its bus booted with, from each organization's instances read; what each one works
+ // on from the workspace read's role sessions. Filters by organization and state live in the hash.
+ // `opts.scope` names one organization (#/orgs//agents): the same table, that organization only.
+ // `opts.fireIn(org)` says from that organization's own config whether this Console decides there;
+ // Fire is drawn only on the system page and only there, and posts to the row's organization (bus.js).
+ const AGENT_STATE = [['working', 'Working'], ['waiting', 'Waiting on you'], ['idle', 'Idle'], ['running', 'Running'], ['not-running', 'Not running'], ['cant-launch', "Can't launch"]];
+ const AGENT_STATE_KEY = { working: 'working', 'waiting on you': 'waiting', idle: 'idle', running: 'running', 'not running': 'not-running', "can't launch": 'cant-launch' };
+ async function agentsPage(q, opts = {}) {
+ // SP5 review F11: a scope that is not an organization id is refused, never widened to the system list.
+ if (opts.scope != null && !(typeof opts.scope === 'string' && ORG_ID.test(opts.scope))) return '
Agents
That is not an organization address.
';
+ const scope = opts.scope ?? null;
+ const org = scope ? [scope] : listParam(q, 'org', id => ORG_ID.test(id));
+ const want = String(q?.get?.('state') || '');
+ const stateF = AGENT_STATE.some(([k]) => k === want) ? want : null;
+ let body;
+ try { [body] = await Promise.all([data.workspace({ sections: ['sessions', 'projects'] }), ensureThemes()]); took(body); }
+ catch (err) { return failedPage('ws-agents-all', 'data-ws-agents', 'Agents', err); }
+ const every = orgsOf(body);
+ const o = org ? { ...every, rows: every.rows.filter(r => org.includes(r.org)), unread: every.unread.filter(r => org.includes(r.org)) } : every;
+ const askable = (scope ? o.rows : every.rows).filter(r => r.state === 'ok');
+ // Each answering organization's instances, and (system page only) whether this Console decides there.
+ const reads = await Promise.all(askable.map(r => Promise.all([
+ data.instances(r.org).then(v => ({ inst: instancesOf(v), copies: copiesOf(v) }), err => ({ inst: null, copies: {}, err })),
+ !scope && typeof opts.fireIn === 'function' ? opts.fireIn(r.org).then(v => v === true, () => false) : false,
+ ]).then(([v, fire]) => ({ id: r.org, ...v, fire }))));
+ const sessions = arr(body.sessions).filter(x => x?.kind === 'role' && typeof x.org === 'string' && typeof x.role === 'string');
+ const projects = arr(body.projects).filter(x => typeof x?.org === 'string');
+ const projectRef = (id, p) => { const hit = projects.find(x => x.org === id && (x.id === p || x.key === p)); return hit ? { id: hit.id, name: hit.name || hit.key || hit.id } : { id: p, name: p }; };
+ const all = [];
+ for (const r of reads) {
+ if (!r.inst) continue;
+ const mine = sessions.filter(x => x.org === r.id);
+ const agents = mine.map(x => ({ role: x.role, holder_run: x.run, state: x.state }));
+ const session = name => mine.find(x => x.role === name) || null;
+ for (const [name, item] of Object.entries(r.inst)) {
+ const d = activity(name, item, agents);
+ // Never Fire the chief, the assistant or an instance the bus would refuse to end.
+ const keep = item?.definition === 'chief-of-staff' || name === 'chief' || item?.definition === 'assistant' || item?.endProtected === true;
+ all.push({ org: r.id, key: name, name, level: 'org', role: roleWords(item), id: isHired(item) ? item.definition : '', dot: d, s: session(name), fire: r.fire && !keep });
+ }
+ for (const [key, c] of Object.entries(r.copies || {})) {
+ if (!c || typeof c.name !== 'string') continue;
+ const d = copyDot(key, c, agents);
+ all.push({ org: r.id, key, name: c.name, level: c.level === 'project' ? 'project' : 'root', role: roleWords(c), id: copyBlocked(c) ? copyWhy(c) : '', dot: d, s: session(key), project: c.level === 'project' && c.project ? projectRef(r.id, c.project) : null, fire: false });
+ }
+ }
+ for (const a of all) a.state = AGENT_STATE_KEY[a.dot[2]] || 'not-running';
+ const counts = new Map();
+ for (const a of all) counts.set(a.org, (counts.get(a.org) || 0) + 1);
+ const inOrg = all.filter(a => !org || org.includes(a.org)).sort((x, y) => x.org.localeCompare(y.org) || x.name.localeCompare(y.name) || x.key.localeCompare(y.key));
+ const filteredRows = inOrg.filter(a => !stateF || a.state === stateF);
+ // SP5 review F1/F2: Open addresses one agent (`#/orgs//agents?agent=`). The scoped page puts
+ // that agent first and marks it, and draws no Open of its own, which would only link to itself.
+ const picked = scope ? String(q?.get?.('agent') || '') : '';
+ const hit = picked ? filteredRows.find(a => a.key === picked) || null : null;
+ const rows = hit ? [hit, ...filteredRows.filter(a => a !== hit)] : filteredRows;
+ const akey = a => `${a.org}:${a.key}`;
+ const orgChip = a => `${esc(o.name(a.org))}`;
+ const projOf = a => a.project || a.s?.project || null;
+ const projChip = a => (projOf(a)?.name ? `${esc(projOf(a).name)}` : '');
+ const work = a => (a.s?.task ? `${esc(a.s.task.title || a.s.task.ref)}${a.s.task.title && a.s.task.ref ? `${esc(a.s.task.ref)}` : ''}` : 'nothing reported');
+ const LEVEL_WORD = { root: 'root agent copy', project: 'project agent' };
+ const row = a => `
+
${esc(a.name)}${a.id ? `${esc(a.id)}` : ''}${orgChip(a)}${projChip(a)}${esc(a.role)}${a.s?.task ? `Working on ${esc(a.s.task.title || a.s.task.ref)}` : 'Working on nothing reported'}
`;
+ // An organization that did not answer is named first, with the reason its read gave; one that
+ // answered but whose instances read failed is named too, never counted as having no agents.
+ const why = r => `${stateWords(r).replace(/, read .*$/, '')}${r.reason ? `: ${esc(r.reason)}` : ''}${r.at ? ` (read ${esc(hm(r.at))})` : ''}`;
+ const failed = reads.filter(r => !r.inst && (!org || org.includes(r.id)));
+ const READ_ERR = { 403: 'refused', 0: 'signed out' };
+ const unread = (o.unread.length ? `
Not read: ${andList(o.unread.map(why))}.
` : '')
+ + (failed.length ? `
Agents not read in ${andList(failed.map(r => `${esc(o.name(r.id))} (${esc(READ_ERR[r.err?.status] || r.err?.code || 'no answer')})`))}.