legion: agent cards reused + class runtime properties (1.2.0)

- AgentCard extracted; Browse and My Legion render the identical card;
  Legion cards open the same ClassModal (instances + Use + chat)
- class properties: managed (amerc-managed, loads amerc skills),
  terminal_mode new|shared per session, skills_reload for shared
  terminals — publish form, API create/patch, badges in the modal
- kebab coordination: broker spec delivered via TerminalMan 155; kebab
  already heartbeats instance 2 with the skills array (status lost ->
  stabilizing); monitor armed for online

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
artheru 2026-06-11 02:58:51 +08:00
parent 2941f14583
commit b4b5df91f0
5 changed files with 79 additions and 32 deletions

View File

@ -1,6 +1,6 @@
{
"name": "amerc-site",
"version": "1.1.0-chat",
"version": "1.2.0-legioncards",
"private": true,
"type": "module",
"scripts": {

View File

@ -76,6 +76,12 @@ db.exec(`
try { db.exec("ALTER TABLE api_keys ADD COLUMN owner TEXT DEFAULT ''"); } catch { /* column exists */ }
try { db.exec("ALTER TABLE agent_instances ADD COLUMN skills TEXT DEFAULT ''"); } catch { /* column exists */ }
// class runtime properties: amerc-managed (loads amerc skills), terminal mode
// per session ('new' | 'shared'), and whether a shared terminal reloads the
// client's skills on each new session.
try { db.exec("ALTER TABLE agent_classes ADD COLUMN managed INTEGER DEFAULT 0"); } catch { /* column exists */ }
try { db.exec("ALTER TABLE agent_classes ADD COLUMN terminal_mode TEXT DEFAULT 'new'"); } catch { /* column exists */ }
try { db.exec("ALTER TABLE agent_classes ADD COLUMN skills_reload INTEGER DEFAULT 1"); } catch { /* column exists */ }
// ---- live chat plumbing (in-memory; survives nothing, stores nothing) -------
// waiters: long-poll watchers per session, woken when a message lands.
@ -464,8 +470,9 @@ const server = http.createServer(async (req, res) => {
const tags = Array.isArray(b.tags) ? b.tags.join(',') : (b.tags || '');
let slug = slugify(b.slug || b.name); let n = 1;
while (db.prepare('SELECT id FROM agent_classes WHERE slug=?').get(slug)) slug = `${slugify(b.name)}-${++n}`;
const info = db.prepare('INSERT INTO agent_classes(slug,name,owner,tags,description,kind,command,visibility,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)')
.run(slug, b.name || 'Agent', who(id), tags, b.description || '', b.kind || 'codex', b.command || '', b.visibility === 'private' ? 'private' : 'public', now, now);
const info = db.prepare('INSERT INTO agent_classes(slug,name,owner,tags,description,kind,command,visibility,managed,terminal_mode,skills_reload,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)')
.run(slug, b.name || 'Agent', who(id), tags, b.description || '', b.kind || 'codex', b.command || '', b.visibility === 'private' ? 'private' : 'public',
b.managed ? 1 : 0, b.terminalMode === 'shared' ? 'shared' : 'new', b.skillsReload === false ? 0 : 1, now, now);
return send(res, 200, { ok: true, id: Number(info.lastInsertRowid), slug });
}
if ((m = path.match(/^\/agents\/classes\/(\d+)$/)) && method === 'GET') {
@ -481,8 +488,10 @@ const server = http.createServer(async (req, res) => {
if (!owns(c)) return send(res, 403, { ok: false, error: 'not owner' });
if (method === 'DELETE') { db.prepare('DELETE FROM agent_classes WHERE id=?').run(c.id); db.prepare('DELETE FROM agent_instances WHERE class_id=?').run(c.id); return send(res, 200, { ok: true }); }
const b = await readBody(req) || {}; const tags = Array.isArray(b.tags) ? b.tags.join(',') : (b.tags ?? c.tags);
db.prepare('UPDATE agent_classes SET name=?,tags=?,description=?,kind=?,command=?,visibility=?,updated_at=? WHERE id=?')
.run(b.name ?? c.name, tags, b.description ?? c.description, b.kind ?? c.kind, b.command ?? c.command, b.visibility ?? c.visibility, Date.now(), c.id);
db.prepare('UPDATE agent_classes SET name=?,tags=?,description=?,kind=?,command=?,visibility=?,managed=?,terminal_mode=?,skills_reload=?,updated_at=? WHERE id=?')
.run(b.name ?? c.name, tags, b.description ?? c.description, b.kind ?? c.kind, b.command ?? c.command, b.visibility ?? c.visibility,
b.managed != null ? (b.managed ? 1 : 0) : c.managed, b.terminalMode != null ? (b.terminalMode === 'shared' ? 'shared' : 'new') : c.terminal_mode,
b.skillsReload != null ? (b.skillsReload ? 1 : 0) : c.skills_reload, Date.now(), c.id);
return send(res, 200, { ok: true });
}
// subscription token for a class

View File

@ -8,6 +8,24 @@ const TAG_EMOJI = { backend: '🛠️', model: '🧠', frontend: '🎨', ui: '
const classEmoji = (c) => { for (const t of (c.tags || [])) if (TAG_EMOJI[t]) return TAG_EMOJI[t]; return '🗡️'; };
function avatarStyle(name) { let h = 0; for (const ch of String(name)) h = (h * 31 + ch.charCodeAt(0)) >>> 0; const a = h % 360, b = (a + 70 + h % 90) % 360; return { background: `linear-gradient(135deg, hsl(${a} 68% 46%), hsl(${b} 64% 32%))` }; }
// One agent card, used identically in Browse Agents and My Legion.
export function AgentCard({ c, onClick }) {
return (
<button className="ap-card" onClick={onClick}>
<div className="ap-card-head2">
<span className="ap-avatar" style={avatarStyle(c.name)}>{classEmoji(c)}</span>
<div className="ap-card-titles"><strong>{c.name}</strong><span className="ap-kind">{c.kind}{c.managed ? ' · amerc-managed' : ''}</span></div>
</div>
<p className="ap-desc">{c.description || 'No description.'}</p>
<div className="ap-card-tags">{(c.tags || []).map((t) => <span key={t}>{t}</span>)}</div>
<div className="ap-card-foot">
<span>by {c.owner}</span>
<span className="ap-online"><i className={c.onlineCount ? 'pulse' : ''} style={{ background: c.onlineCount ? STATUS_COLOR.online : '#555' }} />{c.onlineCount}/{c.instanceCount} online</span>
</div>
</button>
);
}
// ---- Browse Agents: all public classes, filter by tag --------------------
export function AgentBrowse({ auth, setRoute }) {
const [classes, setClasses] = useState([]);
@ -82,20 +100,7 @@ export function AgentBrowse({ auth, setRoute }) {
<div className="ap-skel-foot"><i /><i /></div>
</div>
))}
{loaded && classes.map((c) => (
<button key={c.id} className="ap-card" onClick={() => openClass(c)}>
<div className="ap-card-head2">
<span className="ap-avatar" style={avatarStyle(c.name)}>{classEmoji(c)}</span>
<div className="ap-card-titles"><strong>{c.name}</strong><span className="ap-kind">{c.kind}</span></div>
</div>
<p className="ap-desc">{c.description || 'No description.'}</p>
<div className="ap-card-tags">{c.tags.map((t) => <span key={t}>{t}</span>)}</div>
<div className="ap-card-foot">
<span>by {c.owner}</span>
<span className="ap-online"><i className={c.onlineCount ? 'pulse' : ''} style={{ background: c.onlineCount ? STATUS_COLOR.online : '#555' }} />{c.onlineCount}/{c.instanceCount} online</span>
</div>
</button>
))}
{loaded && classes.map((c) => <AgentCard key={c.id} c={c} onClick={() => openClass(c)} />)}
{loaded && classes.length > 0 && (
<button className="ap-card ap-card-add" onClick={() => setRoute('legion')}>
<span className="ap-add-plus" aria-hidden="true"></span>
@ -152,6 +157,11 @@ function ClassModal({ id, auth, onClose }) {
</div>
<p className="ap-desc">{c.description}</p>
<div className="ap-card-tags">{c.tags.map((t) => <span key={t}>{t}</span>)}</div>
<div className="ap-props">
{!!c.managed && <span className="ap-prop managed">🛡 amerc-managed · amerc skills loaded</span>}
<span className="ap-prop">{c.terminal_mode === 'shared' ? '🖥 one shared terminal across sessions' : '🖥 fresh terminal per session'}</span>
{c.terminal_mode === 'shared' && <span className="ap-prop">{c.skills_reload ? '↻ client skills reload each session' : '⏸ client skills persist between sessions'}</span>}
</div>
{err && <p className="px-auth-err">{err}</p>}
<div className="ap-sub">
{auth.user ? <button className="px-action" onClick={subscribe}>Get subscription token</button> : <span className="ap-hint">Log in to subscribe.</span>}
@ -270,9 +280,10 @@ export function LegionDashboard({ auth, setRoute }) {
const [classes, setClasses] = useState([]);
const [insts, setInsts] = useState([]);
const [err, setErr] = useState('');
const [form, setForm] = useState({ name: '', tags: '', description: '', kind: 'codex', command: 'codex --yolo', visibility: 'public' });
const [form, setForm] = useState({ name: '', tags: '', description: '', kind: 'codex', command: 'codex --yolo', visibility: 'public', managed: false, terminalMode: 'new', skillsReload: true });
const [newInst, setNewInst] = useState(null);
const [logOpen, setLogOpen] = useState(null); // chatlog session to reopen
const [openCls, setOpenCls] = useState(null); // class modal (same as Browse)
const [loaded, setLoaded] = useState(false);
useEffect(() => {
if (!newInst) return;
@ -344,6 +355,18 @@ export function LegionDashboard({ auth, setRoute }) {
<input placeholder="codex --yolo" value={form.command} onChange={(e) => setForm({ ...form, command: e.target.value })} />
</label>
<p className="ap-hint">Kind is your agent's runtime; the launch command is how your broker starts it.</p>
<div className="ap-pub-props">
<label className="ap-check"><input type="checkbox" checked={form.managed} onChange={(e) => setForm({ ...form, managed: e.target.checked })} /> amerc-managed load amerc skills into the agent</label>
<label className="ap-pub-field"><span>Terminal per session</span>
<select value={form.terminalMode} onChange={(e) => setForm({ ...form, terminalMode: e.target.value })}>
<option value="new">new terminal for each session</option>
<option value="shared">same terminal, shared across sessions</option>
</select>
</label>
{form.terminalMode === 'shared' && (
<label className="ap-check"><input type="checkbox" checked={form.skillsReload} onChange={(e) => setForm({ ...form, skillsReload: e.target.checked })} /> reload the client's skills on each new session</label>
)}
</div>
<button className="px-action px-auth-submit" type="submit">Publish class</button>
</form>
<div className="ap-mine">
@ -369,17 +392,18 @@ export function LegionDashboard({ auth, setRoute }) {
</div>
) : (
<>
<h4 className="ap-h4">My classes</h4>
{classes.map((c) => (
<div key={c.id} className="ap-mine-class">
<div className="ap-mine-row">
<span className="ap-avatar ap-avatar-sm" style={avatarStyle(c.name)}>{classEmoji(c)}</span>
<strong>{c.name}</strong><span className="ap-kind">{c.visibility}</span>
<span className="ap-online"><i className={c.onlineCount ? 'pulse' : ''} style={{ background: c.onlineCount ? STATUS_COLOR.online : '#555' }} />{c.onlineCount}/{c.instanceCount}</span>
<button className="px-action" onClick={() => publishInstance(c.id)}>+ Publish instance</button></div>
<div className="ap-card-tags">{c.tags.map((t) => <span key={t}>{t}</span>)}</div>
</div>
))}
<h4 className="ap-h4">My classes your banners on the roster</h4>
<div className="ap-mine-cards">
{classes.map((c) => (
<div key={c.id} className="ap-mine-cardwrap">
<AgentCard c={c} onClick={() => setOpenCls(c.id)} />
<div className="ap-mine-actions">
<span className="ap-kind">{c.visibility}</span>
<button className="px-action" onClick={() => publishInstance(c.id)}>+ Field an instance</button>
</div>
</div>
))}
</div>
<h4 className="ap-h4">My instances</h4>
{insts.map((i) => (
<div key={i.id} className="ap-mine-inst">
@ -406,6 +430,7 @@ export function LegionDashboard({ auth, setRoute }) {
</div>
</div>
)}
{openCls && <ClassModal id={openCls} auth={auth} onClose={() => { setOpenCls(null); load(); }} />}
{newInst && (() => {
const hb = `while true; do curl -s -XPOST https://amerc.ai/api/agents/instances/${newInst.id}/heartbeat -H 'content-type: application/json' -d '{"accesskey":"${newInst.accesskey}","status":"online","broker":"my-broker"}' >/dev/null; sleep 10; done`;
return (

View File

@ -10,7 +10,7 @@ import { api } from './api.js';
const Menu = ({ size = 20 }) => (<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><line x1="3" y1="6" x2="21" y2="6" /><line x1="3" y1="12" x2="21" y2="12" /><line x1="3" y1="18" x2="21" y2="18" /></svg>);
const X = ({ size = 20 }) => (<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>);
const RELEASE = '1.1.0-chat';
const RELEASE = '1.2.0-legioncards';
const NAV = [
{ id: 'home', label: 'Tavern' },
@ -477,6 +477,7 @@ function StatusPage() {
}
const CHANGELOG = [
{ v: '1.2', date: 'Jun 2026', title: 'Legion fields agent cards', notes: ['Your classes in My Legion are now the same agent cards as Browse Agents — open one to see and use its instances right there', 'New class properties at publish time: amerc-managed (loads amerc skills), terminal-per-session vs shared terminal, and skills-reload on shared terminals'] },
{ v: '1.1', date: 'Jun 2026', title: 'Chat, rebuilt native', notes: ['Agent chat now runs on amerc itself — no external relay, so it works whenever the site is up', 'Send files and images both ways: relayed live with a progress bar, never stored on the server', 'The chat shows which skills the agent has loaded', 'Every session keeps its chatlog — reopen any conversation from My Legion'] },
{ v: '1.0', date: 'Jun 2026', title: 'Legion & My Mansion', notes: ['My Booth is now your Legion — your war-band of agent classes and the instances you field', 'The Mansion became My Mansion, your estate of live services'] },
{ v: '0.99', date: 'Jun 2026', title: 'Clearer publish form', notes: ['The Booth publish form now labels every field, so the pre-filled Kind and Launch command are no longer ambiguous'] },

View File

@ -1628,3 +1628,15 @@ button {
.ch-log-last { color: #8595ab; font-size: 11.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ch-log-meta { display: flex; flex-direction: column; gap: 2px; align-items: flex-end; color: #5b6b7d; font-size: 10.5px; flex: 0 0 auto; }
.ap-modal-chat { width: min(660px, 94vw); padding: 10px; }
/* class runtime properties + Legion card reuse (1.2) */
.ap-props { display: flex; flex-wrap: wrap; gap: 6px; margin: 10px 0 4px; }
.ap-prop { background: #131a29; border: 1px solid #243049; color: #9fb2c8; border-radius: 999px; padding: 3px 11px; font-size: 11px; }
.ap-prop.managed { background: rgba(54, 240, 176, 0.07); border-color: #1f5642; color: #5fe0b0; }
.ap-pub-props { display: flex; flex-direction: column; gap: 9px; margin: 4px 0 2px; padding: 10px 12px; background: #0d1320; border: 1px solid #20283c; border-radius: 10px; }
.ap-check { display: flex; align-items: center; gap: 8px; color: #b6c5da; font-size: 12px; cursor: pointer; }
.ap-check input { accent-color: #46c8e0; width: 14px; height: 14px; }
.ap-mine-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; margin-bottom: 14px; }
.ap-mine-cardwrap { display: flex; flex-direction: column; gap: 7px; }
.ap-mine-cardwrap .ap-card { width: 100%; }
.ap-mine-actions { display: flex; align-items: center; justify-content: space-between; gap: 8px; }