blob: 0804d9b087db1025dc05514f9e63e3f8b4567e18 [file]
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sessions ยท amem</title>
<!-- the reader's theme and contrast, set before the first paint; storage is a convenience -->
<script>try { const s = localStorage, r = document.documentElement; if (s.getItem('amem-theme')) r.dataset.theme = s.getItem('amem-theme'); if (s.getItem('amem-contrast')) r.dataset.contrast = s.getItem('amem-contrast'); } catch (_) { /* private window */ }</script>
<link rel="stylesheet" href="tokens.css">
<style>
.launch { display: grid; gap: var(--am-space-8); grid-template-columns: minmax(0, 1fr); align-items: start; }
.launch-form fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
.launch-foot { margin-top: var(--am-space-4); }
.more { justify-self: start; }
.auth-ready { display: flex; gap: var(--am-space-1); align-items: center; }
.auth-ready .am-state { width: 16px; height: 16px; }
.sub-field { grid-auto-flow: column; align-items: center; gap: var(--am-space-2); }
#unpaired .am-alert-act { display: grid; }
@media (min-width: 1100px) {
.launch { grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr); }
}
</style>
</head>
<body class="am-body">
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false"><defs>
<symbol id="i-mark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7.5 3.5H3.5v17h4M16.5 3.5h4v17h-4"/><path d="M8 8.5h8M8 12h8M8 15.5h5"/></symbol>
<symbol id="i-running" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="6.2"/><circle cx="8" cy="8" r="3" fill="currentColor" stroke="none"/></symbol>
<symbol id="i-connecting" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="6.2" stroke-dasharray="2.7 2.2"/><circle cx="8" cy="8" r="1.5" fill="currentColor" stroke="none"/></symbol>
<symbol id="i-done" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="6.2"/><path d="M5.3 8.2l1.9 1.9 3.6-3.9"/></symbol>
<symbol id="i-ok" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M2.8 8.6l3.3 3.2L13.2 4.6"/></symbol>
<symbol id="i-fail" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><circle cx="8" cy="8" r="6.2"/><path d="M5.8 5.8l4.4 4.4M10.2 5.8l-4.4 4.4"/></symbol>
<symbol id="i-warn" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2.2l6.3 11.2H1.7Z"/><path d="M8 6.6v3M8 11.6v.05"/></symbol>
<symbol id="i-absent" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><circle cx="8" cy="8" r="6.2" stroke-dasharray="0.1 2.6"/></symbol>
<symbol id="i-refused" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><circle cx="8" cy="8" r="6.2"/><path d="M3.7 12.3l8.6-8.6"/></symbol>
<symbol id="i-ceiling" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 2.8h11"/><path d="M8 13.6V5.8M5.2 8.5L8 5.7l2.8 2.8"/></symbol>
<symbol id="i-admit" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 13.8V2.4h7.2v11.4"/><path d="M1.6 8.1h6.6M6 5.9l2.2 2.2L6 10.3"/></symbol>
<symbol id="i-handoff" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 9.6h3.2l1 1.7h3.6l1-1.7H14v3a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1Z"/><path d="M8 2v5.6M5.7 5.4L8 7.7l2.3-2.3"/></symbol>
<symbol id="i-lease" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="6.2"/><path d="M8 4.6V8l2.4 1.5"/></symbol>
<symbol id="i-queued" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M3 4.6h10M3 8h10M3 11.4h6"/></symbol>
<symbol id="i-invalid" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><circle cx="8" cy="8" r="6.2"/><path d="M5 8h6"/></symbol>
<symbol id="i-superseded" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2.4 8h8.4M8 5l3 3-3 3"/><path d="M13.6 3.2v9.6"/></symbol>
<symbol id="i-demoted" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2.2v8.2M4.9 7.4L8 10.5l3.1-3.1"/><path d="M3 13.6h10"/></symbol>
<symbol id="i-erased" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><rect x="2.4" y="2.4" width="11.2" height="11.2" rx="1.6" stroke-dasharray="2.2 2.2"/></symbol>
<symbol id="i-copy" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"><rect x="5.6" y="5.6" width="8" height="8" rx="1.4"/><path d="M10.4 5.6V3.8a1.3 1.3 0 0 0-1.3-1.3H3.8a1.3 1.3 0 0 0-1.3 1.3v5.3a1.3 1.3 0 0 0 1.3 1.3h1.8"/></symbol>
<symbol id="i-terminal" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="1.8" y="2.6" width="12.4" height="10.8" rx="1.6"/><path d="M4.6 6.2l2 1.8-2 1.8M8.4 10h3"/></symbol>
<symbol id="i-search" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><circle cx="7" cy="7" r="4.4"/><path d="M10.3 10.3l3.5 3.5"/></symbol>
<symbol id="i-entry" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2.8" y="1.8" width="10.4" height="12.4" rx="1.4"/><path d="M5.4 5.4h5.2M5.4 8h5.2M5.4 10.6h3"/></symbol>
<symbol id="i-start" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"><path d="M4.6 2.9v10.2L12.8 8Z"/></symbol>
<symbol id="i-label-0" viewBox="0 0 16 16" stroke="currentColor" stroke-width="1.2"><rect x="1.6" y="9.6" width="3" height="4.6" rx=".5" fill="currentColor"/><rect x="6.5" y="6.4" width="3" height="7.8" rx=".5" fill="none"/><rect x="11.4" y="2.6" width="3" height="11.6" rx=".5" fill="none"/></symbol>
<symbol id="i-label-1" viewBox="0 0 16 16" stroke="currentColor" stroke-width="1.2"><rect x="1.6" y="9.6" width="3" height="4.6" rx=".5" fill="currentColor"/><rect x="6.5" y="6.4" width="3" height="7.8" rx=".5" fill="currentColor"/><rect x="11.4" y="2.6" width="3" height="11.6" rx=".5" fill="none"/></symbol>
<symbol id="i-label-2" viewBox="0 0 16 16" stroke="currentColor" stroke-width="1.2"><rect x="1.6" y="9.6" width="3" height="4.6" rx=".5" fill="currentColor"/><rect x="6.5" y="6.4" width="3" height="7.8" rx=".5" fill="currentColor"/><rect x="11.4" y="2.6" width="3" height="11.6" rx=".5" fill="currentColor"/></symbol>
<symbol id="i-trust-0" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4"><circle cx="8" cy="8" r="5.6"/></symbol>
<symbol id="i-trust-1" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4"><circle cx="8" cy="8" r="5.6"/><path d="M8 2.4a5.6 5.6 0 0 0 0 11.2Z" fill="currentColor" stroke="none"/></symbol>
<symbol id="i-trust-2" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4"><circle cx="8" cy="8" r="5.6" fill="currentColor"/></symbol>
<symbol id="i-pair" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="4.8" cy="8" r="2.8"/><path d="M7.6 8h6.6M11.2 8v2.4M13.6 8v1.8"/></symbol>
<symbol id="i-busy" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M8 1.8a6.2 6.2 0 1 0 6.2 6.2"/></symbol>
</defs></svg>
<a class="am-skip" href="#main">Skip to the page</a>
<header class="am-top">
<a class="am-mark" href="index.html"><svg class="am-glyph" viewBox="0 0 24 24" aria-hidden="true"><use href="#i-mark"/></svg>amem</a>
<nav class="am-nav" aria-label="Pages">
<a href="index.html" aria-current="page">Sessions</a>
<a href="board.html">Board</a>
<a href="reader.html">Reader</a>
</nav>
<div class="am-top-end" id="pairing"></div>
</header>
<main class="am-main" id="main">
<div class="am-head">
<h1 class="am-title">Sessions</h1>
<p class="am-lead">A session is a coding harness in a box with no network. Its model calls cross one metered line, and what it learns lands on the record.</p>
</div>
<div id="unpaired" class="am-alert am-alert--await" role="status" hidden>
<svg class="am-glyph" aria-hidden="true"><use href="#i-pair"/></svg>
<p><b>Not paired.</b> <span id="unpaired-why"></span></p>
<div class="am-alert-act">
<div class="am-cmd"><code><span class="p">$ </span>amem</code><button class="am-btn am-btn--secondary" type="button" data-copy><svg class="am-glyph" aria-hidden="true"><use href="#i-copy"/></svg><span>Copy command</span></button></div>
</div>
</div>
<section class="am-section" aria-labelledby="list-h">
<div class="am-section-head">
<h2 class="am-section-title" id="list-h">Running and recent</h2>
<span class="am-hint">Newest first. A session keeps running when its page is closed.</span>
</div>
<div id="sessions" aria-busy="true"></div>
<button class="am-btn am-btn--quiet more" type="button" id="more" hidden></button>
</section>
<div class="launch">
<section class="am-panel launch-form" aria-labelledby="start-h">
<h2 class="am-section-title" id="start-h">Start a session</h2>
<form id="new" novalidate>
<fieldset id="fields" class="am-launch">
<div class="am-field c3"><label for="profile">Profile</label>
<select class="am-ctrl" id="profile" name="profile"><option value="">None, set it up here</option></select>
<span class="am-hint" id="profile-hint">From <span class="am-mono">profiles.toml</span>, and every harness by its own name. Choosing one fills the form; what you change is a flag on top.</span></div>
<div class="am-field c3"><label for="harness">Harness</label>
<select class="am-ctrl" id="harness" name="harness"><option value="">Choose a harness</option></select>
<span class="am-hint auth-ready" id="auth-ready"></span></div>
<div class="am-field c3"><span class="am-legend" id="auth-l">Auth</span>
<div class="am-seg" role="radiogroup" aria-labelledby="auth-l" id="auth"></div>
<span class="am-hint">The glyph on each says what this host has for the harness.</span></div>
<div class="am-field c3"><label for="provider">Destination</label>
<select class="am-ctrl" id="provider" name="provider"></select>
<span class="am-hint" id="provider-hint"></span></div>
<div class="am-field c3"><label for="model">Model</label>
<input class="am-ctrl is-mono" id="model" name="model" list="models" placeholder="the harness's default" autocomplete="off" spellcheck="false">
<datalist id="models"></datalist>
<span class="am-hint" id="models-hint"></span></div>
<div class="am-field c3"><label for="backend">Backend</label>
<select class="am-ctrl is-mono" id="backend" name="backend"></select>
<span class="am-hint" id="backend-hint">Where the harness runs; <span class="am-mono">auto</span> picks for each harness.</span></div>
<div class="am-field"><label for="prompt">Prompt</label>
<textarea class="am-ctrl" id="prompt" name="prompt" placeholder="Load T0 and summarise it"></textarea>
<span class="am-hint" id="prompt-hint">Leave it empty for an interactive session.</span></div>
<div class="am-launch-foot">
<label class="am-check"><input type="checkbox" id="work" name="work"> Work the next task from the ledger</label>
<span class="am-field sub-field"><span class="am-legend" id="sub-l">Subagents</span>
<span class="am-seg" role="radiogroup" aria-labelledby="sub-l" id="subagents"></span>
<span class="am-hint" id="sub-hint"></span></span>
</div>
</fieldset>
<div class="am-launch launch-foot">
<div class="am-cmd">
<code><span class="p">$ </span><span id="cmd"></span></code>
<button class="am-btn am-btn--secondary" type="button" data-copy><svg class="am-glyph" aria-hidden="true"><use href="#i-copy"/></svg><span>Copy command</span></button>
</div>
<div class="am-launch-foot">
<button class="am-btn am-btn--primary" type="submit" id="start" disabled><svg class="am-glyph" aria-hidden="true"><use href="#i-start"/></svg><span>Start session</span></button>
<span class="am-hint" id="form-hint" aria-live="polite">Opens the session's page. It keeps running if you close it.</span>
</div>
</div>
</form>
</section>
<section class="am-section" aria-labelledby="host-h">
<div class="am-section-head">
<h2 class="am-section-title" id="host-h">This host</h2>
<button class="am-btn am-btn--secondary" type="button" id="online" disabled>Ask the gateway</button>
</div>
<p class="am-hint" id="host-hint">What <span class="am-mono">amem doctor</span> finds each harness has, for each way to sign in. It reads files and the environment; the gateway is asked only when you ask.</p>
<ul class="am-ready" id="ready" aria-label="Harnesses, and what each has to launch"></ul>
</section>
</div>
<footer class="am-foot">
<div class="am-prefs" role="group" aria-label="Show these pages in">
<div class="am-field"><span class="am-legend" id="lg-theme">Theme</span>
<div class="am-seg" role="radiogroup" aria-labelledby="lg-theme">
<label><input type="radio" name="am-theme" value="system" checked><span>System</span></label>
<label><input type="radio" name="am-theme" value="light"><span>Light</span></label>
<label><input type="radio" name="am-theme" value="dark"><span>Dark</span></label>
</div></div>
<div class="am-field"><span class="am-legend" id="lg-contrast">Contrast</span>
<div class="am-seg" role="radiogroup" aria-labelledby="lg-contrast">
<label><input type="radio" name="am-contrast" value="system" checked><span>System</span></label>
<label><input type="radio" name="am-contrast" value="normal"><span>Normal</span></label>
<label><input type="radio" name="am-contrast" value="more"><span>More</span></label>
</div></div>
</div>
<span><span class="am-mono">amem doctor</span>, in a terminal, says all of what this page summarises.</span>
</footer>
</main>
<script src="amem.js"></script>
<script>
// ---- small DOM helpers: what the API says is only ever set as text ----
const $ = (id) => document.getElementById(id);
const NS = 'http://www.w3.org/2000/svg';
function el(tag, cls, ...kids) {
const e = document.createElement(tag);
if (cls) e.className = cls;
for (const k of kids) if (k !== null && k !== undefined && k !== false) e.append(k);
return e;
}
function glyph(name, label) {
const s = document.createElementNS(NS, 'svg');
s.setAttribute('class', 'am-glyph');
if (label) { s.setAttribute('role', 'img'); s.setAttribute('aria-label', label); } else s.setAttribute('aria-hidden', 'true');
const u = document.createElementNS(NS, 'use');
u.setAttribute('href', `#i-${name}`);
s.append(u);
return s;
}
const state = (tone, name, label) => el('span', `am-state am-state--${tone}`, glyph(name, label));
function chip(tone, name, word, count) {
const c = el('span', `am-chip am-chip--${tone}`, glyph(name));
if (count !== undefined) c.append(el('span', 'am-chip-count', String(count)));
c.append(el('span', 'am-chip-word', word));
c.title = count !== undefined ? `${count} ${word}` : word;
return c;
}
const mono = (t) => el('span', 'am-mono', t);
const clock = (iso) => `${String(iso || '').slice(11, 19)}Z`;
function since(iso) {
const m = Math.max(0, Math.round((Date.now() - Date.parse(iso)) / 60000));
return m < 1 ? 'under a minute' : m < 120 ? `${m} min` : `${Math.round(m / 60)} h`;
}
// ---- theme and contrast: system unless the reader says otherwise, kept per browser ----
for (const name of ['theme', 'contrast']) {
const now = document.documentElement.dataset[name] || 'system';
for (const r of document.querySelectorAll(`input[name="am-${name}"]`)) {
r.checked = r.value === now;
r.addEventListener('change', () => {
if (r.value === 'system') delete document.documentElement.dataset[name];
else document.documentElement.dataset[name] = r.value;
try { if (r.value === 'system') localStorage.removeItem(`amem-${name}`); else localStorage.setItem(`amem-${name}`, r.value); } catch (_) { /* private window */ }
});
}
}
// ---- copy: the button says what happened, in the same word, then goes back ----
for (const b of document.querySelectorAll('[data-copy]')) {
b.addEventListener('click', async () => {
const text = b.closest('.am-cmd').querySelector('code').textContent.replace(/^\$\s*/, '');
const label = b.querySelector('span');
try { await navigator.clipboard.writeText(text); label.textContent = 'Copied'; } catch (_) { label.textContent = 'Select it to copy'; }
setTimeout(() => { label.textContent = 'Copy command'; }, 1600);
});
}
// what a page says when it has no key; a hosted copy also says where amem has to run
let unreach = '';
const unpairedWhy = () => { if (unreach) return unreach; const w = amem.unpaired(); return amem.hosted && !/own machine/.test(w) ? `${w} This copy of the pages runs in your browser and talks to amem on your own machine: run amem there, and it opens this page paired.` : w; };
// ---- the companion: the extension beside this page, when the server has it ----
let companion = null; // the server's word: {connected, version}
function companionWord() {
if (!companion) return [];
const c = el('span', 'am-id', companion.connected ? `companion ${companion.version || ''}`.trim() : 'no companion');
c.title = companion.connected
? 'The amem companion extension is connected: a session whose fetch grant says browser = true fetches with this browser\'s logins.'
: 'No companion extension is connected. Install crates/web/companion unpacked to let a session fetch with this browser\'s logins.';
return [' ยท ', c];
}
async function refreshCompanion() {
const r = await amem.fetch('/api/companion').catch(() => null);
if (!r || !r.ok) return;
const now = await r.json();
const changed = !companion || companion.connected !== now.connected || companion.version !== now.version;
companion = now;
if (changed && paired) showPairing();
}
// ---- pairing ----
let paired = false;
function showPairing() {
const end = $('pairing');
if (paired) {
let host = amem.server() || location.origin;
try { host = new URL(host).host; } catch (_) { /* as it is */ }
const id = el('span', 'am-id', host);
end.replaceChildren('Paired with ', id, ...companionWord());
$('unpaired').hidden = true;
return;
}
end.replaceChildren(state('await', 'pair'), 'Not paired');
$('unpaired-why').textContent = unpairedWhy();
$('unpaired').hidden = false;
$('fields').disabled = true;
$('start').disabled = true;
$('online').disabled = true;
$('form-hint').textContent = 'Pair this browser first, as the note above says.';
$('auth-ready').textContent = '';
$('sessions').removeAttribute('aria-busy');
$('sessions').replaceChildren(el('div', 'am-empty', el('b', null, 'Sessions show here once this browser is paired'),
el('p', null, 'Nothing on this page reaches amem until then.')));
$('ready').replaceChildren();
}
// ---- the form ----
let options = null;
let profiles = {};
let doctor = null;
let base = null; // what the chosen profile fills in, so a change from it is an override
let subTouched = false;
const ALIAS = { antigravity: 'agy' };
const MODES = [['subscription', 'Subscription'], ['key', 'Key'], ['gateway', 'Gateway']];
const MODE_NAME = Object.fromEntries(MODES);
function fill(select, values, keep, first) {
const before = keep ? select.value : '';
select.replaceChildren();
if (first) select.append(new Option(first, ''));
for (const v of values) select.append(new Option(v, v));
if (before && values.includes(before)) select.value = before;
}
function seg(box, name, values, labels) {
box.replaceChildren(...values.map((v) => {
const input = el('input');
input.type = 'radio'; input.name = name; input.value = v;
return el('label', null, input, el('span', null, labels[v] || v));
}));
}
const radio = (name) => (document.querySelector(`input[name="${name}"]:checked`) || {}).value || '';
function setRadio(name, value) { for (const r of document.querySelectorAll(`input[name="${name}"]`)) r.checked = r.value === value; }
async function load() {
await amem.ready;
paired = amem.paired();
if (paired) {
const r = await amem.fetch('/api/options').catch(() => null);
if (!r || !r.ok) {
paired = false;
// not reaching the server, or being refused by it, is not the same as not being paired
let where = amem.server() || location.origin;
try { where = new URL(where).host; } catch (_) { /* as it is */ }
if (!r) unreach = `amem is not answering at ${where}. Is it running? Run amem, and it opens this page paired.`;
else if (r.status === 403) unreach = `amem at ${where} refuses this page: start it with amem --hosted ${location.origin}/ to let this copy in.`;
} else options = await r.json();
}
showPairing();
if (!paired) return;
$('online').disabled = false;
fill($('harness'), options.harnesses, false, 'Choose a harness');
if (options.defaults.harness) $('harness').value = options.defaults.harness;
seg($('auth'), 'auth', options.auth, MODE_NAME);
setRadio('auth', options.defaults.auth);
seg($('subagents'), 'subagents', options.subagents, { harness: 'Harness', board: 'Board' });
setRadio('subagents', 'harness');
fill($('backend'), options.backends, false);
$('backend').value = options.defaults.backend || options.backends[0];
describe();
onAuth();
loadProfiles();
loadDoctor(false);
refresh();
setInterval(refresh, 3000);
refreshCompanion();
setInterval(refreshCompanion, 5000);
}
async function loadProfiles() {
const r = await amem.fetch('/api/profiles').catch(() => null);
if (!r || !r.ok) { $('profile-hint').textContent = 'No profiles: the launcher did not list them.'; return; }
profiles = await r.json();
fill($('profile'), Object.keys(profiles).sort(), true, 'None, set it up here');
}
function onProfile() {
// a profile's name is typed, so it is mono; the choice of none is not
$('profile').classList.toggle('is-mono', !!$('profile').value);
const p = profiles[$('profile').value];
if (!p) { base = null; preview(); return; }
base = {
harness: p.harness || '',
auth: p.auth || options.defaults.auth,
provider: p.provider || '',
model: p.model || '',
backend: p.backend || options.defaults.backend,
subagents: p.subagents || '',
};
$('harness').value = options.harnesses.includes(base.harness) ? base.harness : '';
setRadio('auth', base.auth);
$('model').value = base.model;
$('backend').value = base.backend;
subTouched = !!base.subagents;
setRadio('subagents', base.subagents || ($('work').checked ? 'board' : 'harness'));
onAuth(base.provider);
// a profile that names no destination gets the one the auth mode picks, as the launcher does
if (!base.provider && !$('provider').disabled) { base.provider = $('provider').value; preview(); }
}
function onAuth(provider) {
const auth = radio('auth');
const dests = options.destinations[auth] || [];
const p = $('provider');
if (auth === 'subscription') {
fill(p, [], false, "The harness's own login");
p.disabled = true;
} else {
fill(p, dests, true, auth === 'gateway' ? null : "The harness's own provider");
if (auth === 'gateway' && dests.length && !p.value) p.value = dests[0];
p.disabled = false;
}
if (typeof provider === 'string' && dests.includes(provider)) p.value = provider;
destinationHint();
readiness();
loadModels();
preview();
}
// the ceiling grants.toml gives a destination, as the doctor read it
const LABELS = ['public', 'internal', 'restricted'];
function ceilings() {
const g = doctor && (doctor.checks || []).find((c) => c.area === 'record' && c.subject === 'grants');
const m = g && /ceilings (.*)$/.exec(g.detail || '');
const out = {};
if (m) for (const pair of m[1].split(/,\s*/)) { const [d, l] = pair.split(/\s+/); if (d && l) out[d] = l; }
return out;
}
function destinationHint() {
const h = $('provider-hint');
if (radio('auth') === 'subscription') { h.textContent = 'A subscription login goes where the harness sends it.'; return; }
const d = $('provider').value;
if (!d) { h.textContent = 'The provider the harness is made for.'; return; }
const c = ceilings()[d];
if (!LABELS.includes(c)) { h.replaceChildren(el('span', 'am-mono', d), ': its ceiling is in ', mono('grants.toml'), '.'); return; }
const fact = el('span', 'am-fact', glyph(`label-${LABELS.indexOf(c)}`), c);
fact.style.fontSize = 'inherit';
h.replaceChildren('Ceiling ', fact, ' in ', mono('grants.toml'), '. The session reads nothing above it.');
}
let modelsSeq = 0;
async function loadModels() {
const seq = ++modelsSeq;
const auth = radio('auth'), provider = $('provider').value;
const list = $('models'), hint = $('models-hint');
list.replaceChildren();
if (auth === 'subscription') { hint.textContent = "A login is the harness's own: leave the model to it, or name one."; return; }
hint.replaceChildren(el('span', 'am-busy', glyph('busy'), `Asking ${provider || 'the provider'} for its models`));
try {
const r = await amem.fetch(`/api/models?auth=${encodeURIComponent(auth)}&provider=${encodeURIComponent(provider)}`);
if (seq !== modelsSeq) return;
if (!r.ok) { hint.textContent = `No model list: ${(await r.text()).slice(0, 160)}. Type a name, or leave it to the harness.`; return; }
const v = await r.json();
for (const m of v.models || []) list.append(new Option(m, m));
const n = (v.models || []).length;
hint.textContent = n ? `${n} model${n === 1 ? '' : 's'} at ${v.destination || provider}; pick one or type a name` : (v.note || 'The destination lists no models; type a name.');
if (n === 1 && !$('model').value && !base) $('model').value = v.models[0];
preview();
} catch (e) { if (seq === modelsSeq) hint.textContent = `No model list: ${e.message}`; }
}
// ---- readiness, in the doctor's words: ready, or what is missing ----
function verdict(r) {
if (!r) return { tone: 'ink', glyph: 'absent', words: ['not checked'] };
if (r.ok) return { tone: 'ok', glyph: 'ok', words: ['ready'] };
const m = r.missing || '';
if (m === 'n/a') return { tone: 'ink', glyph: 'absent', words: ['not offered'] };
if (/ not installed$/.test(m)) return { tone: 'ink', glyph: 'absent', words: ['not installed'] };
if (m === 'gateway not answering') return { tone: 'fail', glyph: 'fail', words: ['gateway not answering'] };
if (m === 'gateway not asked') return { tone: 'ink', glyph: 'absent', words: ['gateway not asked'] };
if (m === 'login') return { tone: 'ink', glyph: 'absent', words: ['no login'] };
return { tone: 'ink', glyph: 'absent', words: ['no ', mono(m)] };
}
const readyOf = (h) => (doctor && doctor.ready ? doctor.ready[h] || doctor.ready[ALIAS[h]] : null);
const say = (v) => v.words.map((w) => (typeof w === 'string' ? w : w.cloneNode(true)));
function readiness() {
const h = $('harness').value;
const modes = readyOf(h);
// each auth segment carries the chosen harness's verdict, in its glyph and its name
for (const label of $('auth').querySelectorAll('label')) {
const input = label.querySelector('input');
const span = label.querySelector('span');
for (const old of span.querySelectorAll('.am-state')) old.remove();
input.removeAttribute('aria-label');
if (!h || !modes) continue;
const v = verdict(modes[input.value]);
span.prepend(state(v.tone, v.glyph));
input.setAttribute('aria-label', `${MODE_NAME[input.value] || input.value}, ${v.words.map((w) => (typeof w === 'string' ? w : w.textContent)).join('')}`);
}
const out = $('auth-ready');
const auth = radio('auth');
if (!h) out.textContent = 'Choose one to see what this host has for it.';
else if (!modes) out.textContent = doctor ? `amem doctor says nothing of ${h}.` : 'Asking amem doctor.';
else {
const v = verdict(modes[auth]);
out.replaceChildren(state(v.tone, v.glyph), el('span', null, `With ${(MODE_NAME[auth] || auth).toLowerCase()}: `, ...say(v)));
}
for (const row of $('ready').children) row.classList.toggle('is-open', row.dataset.harness === h);
startState();
}
async function loadDoctor(online) {
const b = $('online');
if (online) { b.setAttribute('aria-busy', 'true'); b.replaceChildren(el('span', 'am-busy', glyph('busy'), 'Asking the gateway')); }
else if (!doctor) $('ready').replaceChildren(el('li', 'am-ready-row', el('span', 'am-busy', glyph('busy'), 'Asking amem doctor')));
try {
const r = await amem.fetch(online ? '/api/doctor?online=1' : '/api/doctor');
if (!r.ok) throw new Error((await r.text()).slice(0, 200) || r.statusText);
doctor = await r.json();
renderReady();
if (online) $('host-hint').textContent = `Asked the gateway at ${clock(new Date().toISOString())}.`;
} catch (e) {
$('ready').replaceChildren(el('li', 'am-ready-row', el('span', 'am-err', glyph('fail'), `amem doctor did not answer: ${e.message}. Run amem doctor in a terminal.`)));
} finally {
b.removeAttribute('aria-busy');
b.replaceChildren('Ask the gateway');
}
destinationHint();
readiness();
}
function renderReady() {
$('ready').replaceChildren(...options.harnesses.map((h) => {
const modes = readyOf(h) || {};
const box = el('span', 'am-ready-modes');
const missing = MODES.map(([m]) => (modes[m] && modes[m].missing) || '');
if (missing.every((m) => / not installed$/.test(m))) {
box.append(el('span', 'am-ready-mode', state('ink', 'absent'), 'not installed'));
} else {
for (const [m, name] of MODES) {
const v = verdict(modes[m]);
box.append(el('span', 'am-ready-mode', state(v.tone, v.glyph), el('b', null, name), ' ', ...say(v)));
}
}
const row = el('li', 'am-ready-row', el('span', 'am-ready-h', h), box);
row.dataset.harness = h;
return row;
}));
}
// ---- what is sent, and the command it is ----
const FIELDS = ['harness', 'auth', 'provider', 'model', 'backend', 'subagents'];
function body() {
const work = $('work').checked;
const v = {
harness: $('harness').value, auth: radio('auth'), provider: $('provider').disabled ? '' : $('provider').value,
model: $('model').value.trim(), backend: $('backend').value, subagents: radio('subagents'),
};
const b = { work };
const prompt = $('prompt').value.trim();
if (!work && prompt) b.prompt = prompt;
if (base) {
// with a profile, only what differs from it is sent, and said as a flag
b.profile = $('profile').value;
const from = { ...base, subagents: base.subagents || (work ? 'board' : 'harness') };
for (const k of FIELDS) if (v[k] && v[k] !== from[k]) b[k] = v[k];
} else {
for (const k of FIELDS) if (v[k]) b[k] = v[k];
}
return b;
}
const quote = (s) => (/^[A-Za-z0-9_./:=@%+-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`);
function preview() {
if (!options) return;
const b = body();
const words = [];
const flags = [];
const flag = (f, value) => flags.push([f, quote(value)]);
if (b.profile) {
words.push('amem', ...(b.work ? ['work'] : []), b.profile);
for (const k of FIELDS) if (b[k]) flag(`--${k}`, b[k]);
} else {
words.push('amem', b.work ? 'work' : 'launch');
flag('--harness', b.harness || '<harness>');
flag('--auth', b.auth);
if (b.provider) flag('--provider', b.provider);
if (b.model) flag('--model', b.model);
if (b.backend && b.backend !== options.defaults.backend) flag('--backend', b.backend);
if (b.subagents && b.subagents !== (b.work ? 'board' : 'harness')) flag('--subagents', b.subagents);
}
if (b.prompt) flag('--prompt', b.prompt);
const out = [words.join(' ')];
for (const [f, v] of flags) out.push(' ', el('span', 'f', f), ` ${v}`);
$('cmd').replaceChildren(...out);
$('prompt').disabled = b.work;
$('prompt-hint').textContent = b.work ? 'Not used while working the next task: the task is the prompt.' : 'Leave it empty for an interactive session.';
startState();
}
function startState() {
if (!paired || !options) return;
const start = $('start');
if (start.getAttribute('aria-busy') === 'true') return;
const can = !!($('profile').value || $('harness').value);
start.disabled = !can;
const hint = $('form-hint');
if (hint.classList.contains('am-err')) return;
if (!can) { hint.textContent = 'Choose a profile or a harness first.'; return; }
const h = $('harness').value;
const modes = readyOf(h);
const r = modes && modes[radio('auth')];
if (r && !r.ok && r.missing !== 'gateway not asked') {
hint.replaceChildren(`amem doctor finds ${h} has `, ...say(verdict(r)), ' here, so the session may stop at once.');
} else hint.textContent = "Opens the session's page. It keeps running if you close it.";
}
function formError(text) {
const hint = $('form-hint');
hint.className = 'am-err';
hint.replaceChildren(glyph('fail'), text);
}
function clearError() {
const hint = $('form-hint');
if (hint.classList.contains('am-err')) { hint.className = 'am-hint'; startState(); }
}
$('profile').addEventListener('change', () => { clearError(); onProfile(); });
$('harness').addEventListener('change', () => { readiness(); preview(); });
$('auth').addEventListener('change', () => onAuth());
$('provider').addEventListener('change', () => { destinationHint(); loadModels(); preview(); });
// what the chosen backend and subagent setting mean, in the server's words
function describe() {
const d = (options && options.descriptions) || {};
const b = $('backend').value;
if (d.backends && d.backends[b]) $('backend-hint').textContent = d.backends[b];
const s = radio('subagents');
if (d.subagents && d.subagents[s]) $('sub-hint').textContent = d.subagents[s];
}
$('backend').addEventListener('change', describe);
$('subagents').addEventListener('change', () => { subTouched = true; describe(); });
$('work').addEventListener('change', () => { if (!subTouched) setRadio('subagents', $('work').checked ? 'board' : 'harness'); describe(); });
$('new').addEventListener('input', () => { clearError(); preview(); });
$('new').addEventListener('change', preview);
$('online').addEventListener('click', () => loadDoctor(true));
$('new').addEventListener('submit', async (e) => {
e.preventDefault();
const start = $('start');
if (start.disabled) return;
start.setAttribute('aria-busy', 'true');
start.disabled = true;
start.querySelector('span').textContent = 'Starting';
try {
const r = await amem.fetch('/api/sessions', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body()) });
if (!r.ok) throw new Error((await r.text()) || r.statusText);
const s = await r.json();
location.href = `session.html?id=${encodeURIComponent(s.id)}`;
} catch (err) {
start.removeAttribute('aria-busy');
start.querySelector('span').textContent = 'Start session';
start.disabled = false;
formError(`The session did not start: ${err.message}`);
}
});
// ---- the session list ----
let showAll = false;
const SHOWN = 6;
function row(s) {
const a = el('a', 'am-srow');
a.href = `session.html?id=${encodeURIComponent(s.id)}`;
const l = s.launch || {};
const chips = el('span', 'am-srow-chips');
let gutter, words;
if (s.alive) { gutter = state('live', 'running', 'running'); words = `running for ${since(s.started)}`; }
else if (s.exit === 0) { gutter = state('ok', 'done', 'exited 0'); words = 'exited 0'; }
else {
const said = s.exit === null || s.exit === undefined ? 'stopped' : `exited ${s.exit}`;
gutter = state('fail', 'fail', said);
words = said;
chips.append(chip('fail', 'fail', said));
}
const where = l.provider || l.auth || (l.profile ? `profile ${l.profile}` : '');
const time = el('span', 'am-time', clock(s.started));
time.title = s.started;
a.append(gutter, el('span', 'am-srow-title', s.title), chips,
el('span', 'am-srow-meta', where ? `${where}, ` : '', `${words}, started `, time));
return a;
}
async function refresh() {
const r = await amem.fetch('/api/sessions').catch(() => null);
const box = $('sessions');
box.removeAttribute('aria-busy');
if (!r) return;
if (r.status === 401) { paired = false; showPairing(); return; }
if (!r.ok) return;
const list = (await r.json()).reverse();
if (!list.length) {
box.replaceChildren(el('div', 'am-empty', el('b', null, 'No sessions yet'),
el('p', null, 'A session is a harness in the box. Start one with the form below, or run ', el('code', null, 'amem launch'), ' in a terminal; it appears here either way.')));
$('more').hidden = true;
return;
}
const shown = showAll ? list : list.slice(0, SHOWN);
box.replaceChildren(el('div', 'am-list', ...shown.map(row)));
const more = $('more');
more.hidden = list.length <= SHOWN;
more.textContent = showAll ? 'Show the newest only' : `Show ${list.length - SHOWN} more`;
}
$('more').addEventListener('click', () => { showAll = !showAll; refresh(); });
load();
</script>
</body>
</html>