diff --git a/index.html b/index.html
index bd69078..c842d2a 100644
--- a/index.html
+++ b/index.html
@@ -7,7 +7,7 @@
name="description"
content="amerc is an agent mercenary layer for embedding, bringing, and hosting agents across vertical software boundaries."
/>
-
+
diff --git a/package.json b/package.json
index 3f2ef0d..b74ecc8 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "amerc-site",
- "version": "0.20.0-suite",
+ "version": "0.21.1-pool",
"private": true,
"type": "module",
"scripts": {
diff --git a/server/amerc-api.mjs b/server/amerc-api.mjs
index d8501f2..8662443 100644
--- a/server/amerc-api.mjs
+++ b/server/amerc-api.mjs
@@ -43,8 +43,40 @@ db.exec(`
CREATE TABLE IF NOT EXISTS boards (
id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, data TEXT DEFAULT '', links TEXT DEFAULT '[]',
updated_by TEXT DEFAULT '', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL);
+
+ -- agent platform: classes -> instances -> sessions
+ CREATE TABLE IF NOT EXISTS agent_classes (
+ id INTEGER PRIMARY KEY AUTOINCREMENT, slug TEXT UNIQUE NOT NULL, name TEXT NOT NULL, owner TEXT NOT NULL,
+ tags TEXT DEFAULT '', description TEXT DEFAULT '', kind TEXT DEFAULT 'codex', command TEXT DEFAULT '',
+ visibility TEXT NOT NULL DEFAULT 'public', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL);
+ CREATE TABLE IF NOT EXISTS agent_instances (
+ id INTEGER PRIMARY KEY AUTOINCREMENT, class_id INTEGER NOT NULL, owner TEXT NOT NULL, name TEXT DEFAULT '',
+ accesskey TEXT NOT NULL, visibility TEXT NOT NULL DEFAULT 'public', kind TEXT DEFAULT 'persistent',
+ status TEXT NOT NULL DEFAULT 'pending', broker TEXT DEFAULT '', tui TEXT DEFAULT '',
+ last_heartbeat INTEGER, created_at INTEGER NOT NULL);
+ CREATE TABLE IF NOT EXISTS agent_sessions (
+ id INTEGER PRIMARY KEY AUTOINCREMENT, instance_id INTEGER NOT NULL, class_id INTEGER, party TEXT DEFAULT '',
+ connector TEXT DEFAULT 'ws', status TEXT NOT NULL DEFAULT 'active', started_at INTEGER NOT NULL, ended_at INTEGER);
+ CREATE TABLE IF NOT EXISTS class_subscriptions (
+ id INTEGER PRIMARY KEY AUTOINCREMENT, class_id INTEGER NOT NULL, party TEXT NOT NULL, token TEXT NOT NULL,
+ created_at INTEGER NOT NULL);
`);
+// instance lifecycle thresholds
+const INSTANCE_LOST_MS = 30 * 1000; // no heartbeat -> lost
+const INSTANCE_DOWN_MS = 5 * 60 * 1000; // lost too long -> auto shut down
+function sweepInstances() {
+ const now = Date.now();
+ for (const inst of db.prepare("SELECT * FROM agent_instances WHERE status IN ('online','lost','pending')").all()) {
+ const age = now - (inst.last_heartbeat || inst.created_at);
+ if (age > INSTANCE_DOWN_MS) db.prepare("UPDATE agent_instances SET status='down' WHERE id=?").run(inst.id);
+ else if (age > INSTANCE_LOST_MS && inst.status === 'online') db.prepare("UPDATE agent_instances SET status='lost' WHERE id=?").run(inst.id);
+ }
+ // close sessions whose instance is down
+ db.prepare("UPDATE agent_sessions SET status='closed', ended_at=? WHERE status='active' AND instance_id IN (SELECT id FROM agent_instances WHERE status='down')").run(now);
+}
+setInterval(sweepInstances, 10 * 1000).unref?.();
+
// ---- crypto -----------------------------------------------------------------
function hashPassword(pw) {
const salt = crypto.randomBytes(16);
@@ -146,6 +178,13 @@ const server = http.createServer(async (req, res) => {
return send(res, 200, { ok: true, user: publicUser(u) }, { 'Set-Cookie': sessionCookie(signToken({ uid: u.id })) });
}
if (path === '/auth/logout' && method === 'POST') return send(res, 200, { ok: true }, { 'Set-Cookie': clearCookie });
+ // SSO bridge for nginx auth_request (Gitea reverse-proxy auth). Always 200; sets user header when logged in.
+ if (path === '/auth/verify' && method === 'GET') {
+ const idn = identify(req);
+ const h = {};
+ if (idn?.kind === 'user') { h['X-Amerc-User'] = idn.user.handle; h['X-Amerc-Email'] = idn.user.email; }
+ return send(res, 200, { ok: true, user: idn?.kind === 'user' ? publicUser(idn.user) : null }, h);
+ }
if (path === '/auth/me' && method === 'GET') { const id = identify(req); return send(res, 200, { ok: true, user: id?.kind === 'user' ? publicUser(id.user) : null, agent: id?.kind === 'agent' ? id.name : null }); }
if (path === '/auth/password' && method === 'POST') {
const id = identify(req); if (id?.kind !== 'user') return send(res, 401, { ok: false, error: 'login required' });
@@ -262,6 +301,131 @@ const server = http.createServer(async (req, res) => {
db.prepare('UPDATE boards SET name=?,data=?,links=?,updated_by=?,updated_at=? WHERE id=?').run(b.name ?? bd.name, b.data ?? bd.data, b.links != null ? JSON.stringify(b.links) : bd.links, who(id), Date.now(), bd.id); return send(res, 200, { ok: true }); }
if ((m = path.match(/^\/boards\/(\d+)$/)) && method === 'DELETE') { if (!id) return needAuth(); db.prepare('DELETE FROM boards WHERE id=?').run(Number(m[1])); return send(res, 200, { ok: true }); }
+ // ---------- AGENT PLATFORM: classes -> instances -> sessions ----------
+ const isAdmin = id?.kind === 'user' && id.user.role === 'admin';
+ const owns = (row) => id && (row.owner === who(id) || isAdmin);
+ const liveStatus = (inst) => {
+ if (inst.status === 'down') return 'down';
+ const age = Date.now() - (inst.last_heartbeat || inst.created_at);
+ if (age > INSTANCE_DOWN_MS) return 'down';
+ if (age > INSTANCE_LOST_MS && inst.status !== 'pending') return 'lost';
+ return inst.status;
+ };
+ const instView = (inst, key = false) => ({ id: inst.id, class_id: inst.class_id, owner: inst.owner, name: inst.name, visibility: inst.visibility, kind: inst.kind, status: liveStatus(inst), broker: inst.broker, last_heartbeat: inst.last_heartbeat, created_at: inst.created_at, ...(key ? { accesskey: inst.accesskey } : {}) });
+ const slugify = (s) => String(s || 'agent').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40) || 'agent';
+
+ // public stats for the tavern frontpage
+ if (path === '/agents/stats' && method === 'GET') {
+ const classes = db.prepare("SELECT COUNT(*) n FROM agent_classes WHERE visibility='public'").get().n;
+ const insts = db.prepare('SELECT * FROM agent_instances').all();
+ const online = insts.filter((i) => liveStatus(i) === 'online').length;
+ const sessions = db.prepare("SELECT COUNT(*) n FROM agent_sessions WHERE status='active'").get().n;
+ const owners = db.prepare('SELECT COUNT(DISTINCT owner) n FROM agent_classes').get().n;
+ const tagRows = db.prepare("SELECT tags FROM agent_classes WHERE visibility='public'").all();
+ const tags = {}; tagRows.forEach((r) => (r.tags || '').split(',').map((t) => t.trim()).filter(Boolean).forEach((t) => (tags[t] = (tags[t] || 0) + 1)));
+ return send(res, 200, { ok: true, classes, instances: insts.length, online, sessions, owners, tags });
+ }
+
+ // browse classes (public + own private)
+ if (path === '/agents/classes' && method === 'GET') {
+ const tag = url.searchParams.get('tag'); const q = (url.searchParams.get('q') || '').toLowerCase(); const mine = url.searchParams.get('mine');
+ let rows = db.prepare('SELECT * FROM agent_classes ORDER BY updated_at DESC').all();
+ const me = id ? who(id) : null;
+ rows = rows.filter((c) => c.visibility === 'public' || (me && c.owner === me) || isAdmin);
+ if (mine && me) rows = rows.filter((c) => c.owner === me);
+ if (tag) rows = rows.filter((c) => (c.tags || '').split(',').map((t) => t.trim()).includes(tag));
+ if (q) rows = rows.filter((c) => (c.name + ' ' + c.tags + ' ' + c.description).toLowerCase().includes(q));
+ const out = rows.map((c) => {
+ const insts = db.prepare('SELECT * FROM agent_instances WHERE class_id=?').all(c.id);
+ return { ...c, tags: (c.tags || '').split(',').map((t) => t.trim()).filter(Boolean), instanceCount: insts.length, onlineCount: insts.filter((i) => liveStatus(i) === 'online').length };
+ });
+ return send(res, 200, { ok: true, classes: out });
+ }
+ if (path === '/agents/classes' && method === 'POST') {
+ if (!id) return needAuth();
+ const b = await readBody(req) || {}; const now = Date.now();
+ 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);
+ return send(res, 200, { ok: true, id: Number(info.lastInsertRowid), slug });
+ }
+ if ((m = path.match(/^\/agents\/classes\/(\d+)$/)) && method === 'GET') {
+ const c = db.prepare('SELECT * FROM agent_classes WHERE id=?').get(Number(m[1])); if (!c) return send(res, 404, { ok: false, error: 'not found' });
+ const me = id ? who(id) : null;
+ if (c.visibility !== 'public' && !(me && c.owner === me) && !isAdmin) return send(res, 403, { ok: false, error: 'private class' });
+ let insts = db.prepare('SELECT * FROM agent_instances WHERE class_id=?').all(c.id);
+ insts = insts.filter((i) => i.visibility === 'public' || owns(i)).map((i) => instView(i, owns(i)));
+ return send(res, 200, { ok: true, class: { ...c, tags: (c.tags || '').split(',').map((t) => t.trim()).filter(Boolean) }, instances: insts });
+ }
+ if ((m = path.match(/^\/agents\/classes\/(\d+)$/)) && (method === 'PATCH' || method === 'DELETE')) {
+ const c = db.prepare('SELECT * FROM agent_classes WHERE id=?').get(Number(m[1])); if (!c) return send(res, 404, { ok: false, error: 'not found' });
+ 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);
+ return send(res, 200, { ok: true });
+ }
+ // subscription token for a class
+ if ((m = path.match(/^\/agents\/classes\/(\d+)\/subscribe$/)) && method === 'POST') {
+ if (!id) return needAuth(); const c = db.prepare('SELECT * FROM agent_classes WHERE id=?').get(Number(m[1])); if (!c) return send(res, 404, { ok: false, error: 'not found' });
+ const me = who(id);
+ let sub = db.prepare('SELECT * FROM class_subscriptions WHERE class_id=? AND party=?').get(c.id, me);
+ if (!sub) { const tok = 'sub_' + crypto.randomBytes(18).toString('base64url'); db.prepare('INSERT INTO class_subscriptions(class_id,party,token,created_at) VALUES(?,?,?,?)').run(c.id, me, tok, Date.now()); sub = { token: tok }; }
+ return send(res, 200, { ok: true, token: sub.token, classSlug: c.slug });
+ }
+
+ // instances
+ if (path === '/agents/instances' && method === 'GET') {
+ const classId = url.searchParams.get('classId'); const me = id ? who(id) : null;
+ let rows = classId ? db.prepare('SELECT * FROM agent_instances WHERE class_id=?').all(Number(classId)) : db.prepare('SELECT * FROM agent_instances').all();
+ rows = rows.filter((i) => i.visibility === 'public' || (me && i.owner === me) || isAdmin);
+ return send(res, 200, { ok: true, instances: rows.map((i) => instView(i, owns(i))) });
+ }
+ if (path === '/agents/instances' && method === 'POST') {
+ if (!id) return needAuth(); const b = await readBody(req) || {};
+ const c = db.prepare('SELECT * FROM agent_classes WHERE id=?').get(Number(b.classId)); if (!c) return send(res, 404, { ok: false, error: 'class not found' });
+ const accesskey = 'ik_' + crypto.randomBytes(20).toString('base64url');
+ const info = db.prepare('INSERT INTO agent_instances(class_id,owner,name,accesskey,visibility,kind,status,created_at,last_heartbeat) VALUES(?,?,?,?,?,?,?,?,?)')
+ .run(c.id, who(id), b.name || `${c.name} instance`, accesskey, b.visibility === 'private' ? 'private' : 'public', b.kind || 'persistent', 'pending', Date.now(), Date.now());
+ return send(res, 200, { ok: true, id: Number(info.lastInsertRowid), accesskey, classSlug: c.slug, relayWs: `wss://amerc.ai/webagent/ws?role=broker&token=${accesskey}` });
+ }
+ if ((m = path.match(/^\/agents\/instances\/(\d+)$/)) && method === 'GET') {
+ const inst = db.prepare('SELECT * FROM agent_instances WHERE id=?').get(Number(m[1])); if (!inst) return send(res, 404, { ok: false, error: 'not found' });
+ const akey = url.searchParams.get('accesskey'); const authed = owns(inst) || (akey && akey === inst.accesskey);
+ if (inst.visibility !== 'public' && !authed) return send(res, 403, { ok: false, error: 'private instance' });
+ return send(res, 200, { ok: true, instance: instView(inst, authed), tui: authed ? inst.tui : undefined });
+ }
+ // heartbeat (broker, by accesskey — no session needed)
+ if ((m = path.match(/^\/agents\/instances\/(\d+)\/heartbeat$/)) && method === 'POST') {
+ const b = await readBody(req, MAX_UPLOAD) || {}; const inst = db.prepare('SELECT * FROM agent_instances WHERE id=?').get(Number(m[1])); if (!inst) return send(res, 404, { ok: false, error: 'not found' });
+ if (b.accesskey !== inst.accesskey) return send(res, 401, { ok: false, error: 'bad accesskey' });
+ const status = b.status === 'down' ? 'down' : 'online';
+ db.prepare('UPDATE agent_instances SET status=?,last_heartbeat=?,broker=?,tui=? WHERE id=?').run(status, Date.now(), b.broker || inst.broker, b.tui != null ? String(b.tui).slice(0, 20000) : inst.tui, inst.id);
+ return send(res, 200, { ok: true, status });
+ }
+ if ((m = path.match(/^\/agents\/instances\/(\d+)$/)) && method === 'DELETE') {
+ const inst = db.prepare('SELECT * FROM agent_instances WHERE id=?').get(Number(m[1])); if (!inst) return send(res, 404, { ok: false, error: 'not found' });
+ const b = await readBody(req) || {}; if (!owns(inst) && b.accesskey !== inst.accesskey) return send(res, 403, { ok: false, error: 'not authorized' });
+ db.prepare("UPDATE agent_instances SET status='down' WHERE id=?").run(inst.id);
+ db.prepare("UPDATE agent_sessions SET status='closed', ended_at=? WHERE instance_id=? AND status='active'").run(Date.now(), inst.id);
+ return send(res, 200, { ok: true });
+ }
+ // start a session against an instance
+ if ((m = path.match(/^\/agents\/instances\/(\d+)\/sessions$/)) && method === 'POST') {
+ const inst = db.prepare('SELECT * FROM agent_instances WHERE id=?').get(Number(m[1])); if (!inst) return send(res, 404, { ok: false, error: 'not found' });
+ const b = await readBody(req) || {}; const authed = owns(inst) || (b.accesskey && b.accesskey === inst.accesskey) || (inst.visibility === 'public' && id);
+ if (!authed) return send(res, 403, { ok: false, error: 'not authorized — need login (public) or accesskey (private)' });
+ const info = db.prepare('INSERT INTO agent_sessions(instance_id,class_id,party,connector,status,started_at) VALUES(?,?,?,?,?,?)')
+ .run(inst.id, inst.class_id, id ? who(id) : 'anon', b.connector || 'ws', 'active', Date.now());
+ return send(res, 200, { ok: true, sessionId: Number(info.lastInsertRowid), relayToken: inst.accesskey, relayWs: `wss://amerc.ai/webagent/ws?role=iframe&token=${inst.accesskey}`, chatboxJs: `https://webagent.amerc.ai/client.js?token=${inst.accesskey}` });
+ }
+ if ((m = path.match(/^\/agents\/sessions\/(\d+)\/close$/)) && method === 'POST') {
+ db.prepare("UPDATE agent_sessions SET status='closed', ended_at=? WHERE id=?").run(Date.now(), Number(m[1])); return send(res, 200, { ok: true });
+ }
+
return send(res, 404, { ok: false, error: 'not found' });
} catch (err) {
return send(res, 500, { ok: false, error: 'server error', detail: String(err && err.message || err) });
diff --git a/server/platform-tests.mjs b/server/platform-tests.mjs
new file mode 100644
index 0000000..00b165b
--- /dev/null
+++ b/server/platform-tests.mjs
@@ -0,0 +1,116 @@
+// amerc platform test suite. Spins up the API on a temp DB and exercises every surface.
+// Run: node --experimental-sqlite server/platform-tests.mjs
+import { spawn } from 'node:child_process';
+import { mkdtempSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+
+const PORT = 5310, BASE = `http://127.0.0.1:${PORT}/api`;
+const dir = mkdtempSync(join(tmpdir(), 'amerc-test-'));
+const srv = spawn(process.execPath, ['--experimental-sqlite', new URL('./amerc-api.mjs', import.meta.url).pathname], {
+ env: { ...process.env, PORT: String(PORT), AMERC_DB: join(dir, 't.db'), AMERC_FILES: join(dir, 'disk'), AMERC_SECRET: 'test-secret' },
+ stdio: 'ignore',
+});
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+let pass = 0, fail = 0; const fails = [];
+function ok(cond, name) { if (cond) { pass++; } else { fail++; fails.push(name); console.log(' ✗ ' + name); } }
+function eq(a, b, name) { ok(JSON.stringify(a) === JSON.stringify(b), `${name} (got ${JSON.stringify(a)}, want ${JSON.stringify(b)})`); }
+
+let cookies = {};
+function setCookie(res) { const sc = res.headers.get('set-cookie'); if (sc) { const [kv] = sc.split(';'); const [k, v] = kv.split('='); cookies[k] = v; } }
+const cookieHdr = () => Object.entries(cookies).map(([k, v]) => `${k}=${v}`).join('; ');
+async function req(path, { method = 'GET', body, cookie = true, bearer, raw } = {}) {
+ const headers = {};
+ if (body) headers['Content-Type'] = 'application/json';
+ if (cookie && cookieHdr()) headers.Cookie = cookieHdr();
+ if (bearer) headers.Authorization = `Bearer ${bearer}`;
+ const res = await fetch(BASE + path, { method, headers, body: body ? JSON.stringify(body) : undefined });
+ setCookie(res);
+ if (raw) return res;
+ let j = {}; try { j = await res.json(); } catch {}
+ return { status: res.status, ...j };
+}
+
+async function main() {
+ // wait for server
+ for (let i = 0; i < 30; i++) { try { const r = await fetch(BASE + '/health'); if (r.ok) break; } catch {} await sleep(150); }
+
+ console.log('\n== auth + SSO ==');
+ let r = await req('/health'); ok(r.ok && r.version === 2, 'health v2');
+ r = await req('/auth/signup', { method: 'POST', body: { email: 'Admin@x.io', password: 'secret123', handle: 'admin' } });
+ ok(r.ok && r.user.role === 'admin', 'first signup -> admin'); eq(r.user.email, 'admin@x.io', 'email lowercased');
+ r = await req('/auth/me'); ok(r.user && r.user.handle === 'admin', 'me via cookie');
+ r = await req('/auth/verify', { raw: true }); ok(r.status === 200 && r.headers.get('x-amerc-user') === 'admin', 'SSO verify header');
+ r = await req('/auth/signup', { method: 'POST', body: { email: 'm@x.io', password: 'short' } }); ok(r.status === 400, 'reject short password');
+ const adminCookies = { ...cookies };
+ // member
+ cookies = {}; r = await req('/auth/signup', { method: 'POST', body: { email: 'mercA@x.io', password: 'hunter22', handle: 'mercA' } });
+ ok(r.user.role === 'member', 'second signup -> member');
+ r = await req('/admin/users'); ok(r.status === 403, 'member blocked from admin');
+ r = await req('/auth/password', { method: 'POST', body: { currentPassword: 'WRONG', newPassword: 'newpass1' } }); ok(r.status === 401, 'pw change wrong current');
+ r = await req('/auth/password', { method: 'POST', body: { currentPassword: 'hunter22', newPassword: 'newpass1' } }); ok(r.ok, 'pw change ok');
+ r = await req('/auth/login', { method: 'POST', body: { email: 'mercA@x.io', password: 'newpass1' } }); ok(r.ok, 'login with new pw');
+ const memberCookies = { ...cookies };
+
+ console.log('== admin + agent keys ==');
+ cookies = adminCookies;
+ r = await req('/admin/users'); ok(r.ok && r.users.length === 2, 'admin lists users');
+ r = await req('/admin/keys', { method: 'POST', body: { name: 'fleet' } }); const agentKey = r.key; ok(r.ok && agentKey?.startsWith('amk_'), 'mint agent key');
+ cookies = {}; r = await req('/auth/me', { bearer: agentKey }); ok(r.agent === 'fleet', 'agent identity via Bearer');
+
+ console.log('== docs / netdisk / boards (agent + user) ==');
+ cookies = {}; r = await req('/docs', { method: 'POST', bearer: agentKey, body: { title: 'Agent Doc', body: '# hi' } }); const docId = r.id; ok(r.ok, 'agent writes doc');
+ cookies = adminCookies; r = await req(`/docs/${docId}`); ok(r.doc.updated_by === 'agent:fleet', 'doc authored by agent');
+ r = await req('/files', { method: 'POST', body: { name: 'n.md', kind: 'text', text: 'hello', mime: 'text/markdown' } }); const fileId = r.id; ok(r.ok, 'create text file');
+ r = await req(`/files/${fileId}/raw`, { raw: true }); ok((await r.text()) === 'hello', 'download text file');
+ r = await req('/boards', { method: 'POST', body: { name: 'B', data: '{}', links: [{ fileId }] } }); ok(r.ok, 'create board');
+ cookies = {}; r = await req('/docs'); ok(r.status === 401, 'docs need auth');
+
+ console.log('== agent platform: classes ==');
+ cookies = adminCookies;
+ r = await req('/agents/classes', { method: 'POST', body: { name: 'Backend Coder', tags: ['backend', 'model'], description: 'api', visibility: 'public' } });
+ const classId = r.id; ok(r.ok && r.slug === 'backend-coder', 'publish class + slug');
+ await req('/agents/classes', { method: 'POST', body: { name: 'Frontend Pixie', tags: ['frontend'] } });
+ cookies = memberCookies; await req('/agents/classes', { method: 'POST', body: { name: 'Secret Bot', tags: ['x'], visibility: 'private' } });
+ cookies = {}; r = await req('/agents/classes'); ok(r.classes.length === 2, 'browse: only public classes anon');
+ r = await req('/agents/classes?tag=backend'); eq(r.classes.map((c) => c.name), ['Backend Coder'], 'filter by tag');
+ cookies = memberCookies; r = await req('/agents/classes?mine=1'); ok(r.classes.length === 1 && r.classes[0].name === 'Secret Bot', 'mine shows own private');
+ cookies = adminCookies; r = await req('/agents/classes'); ok(r.classes.length === 3, 'admin sees private too');
+ r = await req(`/agents/classes/${classId}/subscribe`, { method: 'POST' }); ok(r.token?.startsWith('sub_'), 'subscription token');
+
+ console.log('== agent platform: instances + lifecycle ==');
+ cookies = adminCookies;
+ r = await req('/agents/instances', { method: 'POST', body: { classId, visibility: 'public' } });
+ const instId = r.id, accesskey = r.accesskey; ok(r.ok && accesskey?.startsWith('ik_'), 'create instance + accesskey');
+ ok(r.relayWs.includes(accesskey), 'instance returns relay WS');
+ r = await req(`/agents/instances/${instId}`); eq(r.instance.status, 'pending', 'instance starts pending');
+ r = await req(`/agents/instances/${instId}/heartbeat`, { method: 'POST', cookie: false, body: { accesskey, broker: 'kebab', tui: 'booting' } }); eq(r.status, 'online', 'heartbeat -> online');
+ r = await req(`/agents/instances/${instId}`); eq(r.instance.status, 'online', 'instance online');
+ r = await req(`/agents/instances/${instId}/heartbeat`, { method: 'POST', cookie: false, body: { accesskey: 'WRONG' } }); ok(r.status === 401, 'bad accesskey rejected');
+ // stats reflect it
+ r = await req('/agents/stats'); ok(r.classes === 2 && r.instances === 1 && r.online === 1, 'stats classes/instances/online');
+ // session
+ r = await req(`/agents/instances/${instId}/sessions`, { method: 'POST', body: { connector: 'chatbox' } });
+ ok(r.ok && r.relayToken === accesskey && r.chatboxJs.includes(accesskey), 'start session -> relay token + chatbox js');
+ r = await req('/agents/stats'); ok(r.sessions === 1, 'stats sessions');
+ // private instance needs accesskey for a non-owner
+ cookies = adminCookies; r = await req('/agents/instances', { method: 'POST', body: { classId, visibility: 'private' } });
+ const privId = r.id, privKey = r.accesskey;
+ cookies = memberCookies; r = await req(`/agents/instances/${privId}`); ok(r.status === 403, 'private instance hidden from non-owner');
+ r = await req(`/agents/instances/${privId}?accesskey=${privKey}`); ok(r.ok, 'private instance visible with accesskey');
+ // shutdown
+ cookies = adminCookies; r = await req(`/agents/instances/${instId}`, { method: 'DELETE', body: { accesskey } }); ok(r.ok, 'shutdown instance');
+ r = await req(`/agents/instances/${instId}`); eq(r.instance.status, 'down', 'instance down after shutdown');
+
+ console.log('== webagent broker login ==');
+ cookies = {};
+ r = await req('/webagent/login', { method: 'POST', body: { username: 'admin@x.io', password: 'secret123' } }); ok(r.ok && r.token?.startsWith('wa_'), 'webagent login (amerc user)');
+ r = await req('/webagent/login', { method: 'POST', body: { username: 'admin@x.io', password: 'nope' } }); ok(r.status === 401, 'webagent login bad pw');
+
+ console.log(`\n${fail ? '❌' : '✅'} ${pass} passed, ${fail} failed`);
+ if (fail) console.log('FAILED: ' + fails.join(' | '));
+ srv.kill(); rmSync(dir, { recursive: true, force: true });
+ process.exit(fail ? 1 : 0);
+}
+main().catch((e) => { console.error(e); srv.kill(); process.exit(1); });
diff --git a/src/AgentPlatform.jsx b/src/AgentPlatform.jsx
new file mode 100644
index 0000000..8f91b32
--- /dev/null
+++ b/src/AgentPlatform.jsx
@@ -0,0 +1,233 @@
+import React, { useCallback, useEffect, useState } from 'react';
+import { api } from './api.js';
+
+const STATUS_COLOR = { online: '#36f0b0', pending: '#f2b85f', lost: '#ff9f5a', down: '#ff7a8c' };
+
+// ---- Browse Agents: all public classes, filter by tag --------------------
+export function AgentBrowse({ auth, setRoute }) {
+ const [classes, setClasses] = useState([]);
+ const [tags, setTags] = useState({});
+ const [tag, setTag] = useState('');
+ const [q, setQ] = useState('');
+ const [open, setOpen] = useState(null);
+ const [err, setErr] = useState('');
+
+ const load = useCallback(async () => {
+ setErr('');
+ try {
+ const [c, s] = await Promise.all([api(`/agents/classes${tag ? `?tag=${encodeURIComponent(tag)}` : ''}`), api('/agents/stats')]);
+ setClasses((c.classes || []).filter((x) => !q || (x.name + x.tags.join() + x.description).toLowerCase().includes(q.toLowerCase())));
+ setTags(s.tags || {});
+ } catch (e) { setErr(e.message); }
+ }, [tag, q]);
+ useEffect(() => { load(); }, [load]);
+
+ return (
+
+
+
Browse Agents
+ setQ(e.target.value)} />
+
+
+
+ {Object.entries(tags).sort((a, b) => b[1] - a[1]).map(([t, n]) => (
+
+ ))}
+
+ {err &&
{err}
}
+
+ {classes.map((c) => (
+
+ ))}
+ {!classes.length &&
No agent classes yet. Open My Booth to publish one.
}
+
+ {open &&
{ setOpen(null); load(); }} />}
+
+ );
+}
+
+function ClassModal({ id, auth, onClose }) {
+ const [data, setData] = useState(null);
+ const [sub, setSub] = useState(null);
+ const [use, setUse] = useState(null); // instance to use
+ const [err, setErr] = useState('');
+ const load = useCallback(() => api(`/agents/classes/${id}`).then(setData).catch((e) => setErr(e.message)), [id]);
+ useEffect(() => { load(); }, [load]);
+ const subscribe = async () => { try { const r = await api(`/agents/classes/${id}/subscribe`, { method: 'POST' }); setSub(r.token); } catch (e) { setErr(e.message); } };
+ if (!data) return null;
+ const c = data.class;
+ return (
+
+
e.stopPropagation()}>
+
+
{c.name}
{c.kind} by {c.owner}
+
+
+
{c.description}
+
{c.tags.map((t) => {t})}
+ {err &&
{err}
}
+
+ {auth.user ? : Log in to subscribe.}
+ {sub && navigator.clipboard?.writeText(sub)}>{sub} ⧉}
+
+
Instances
+
+ {data.instances.map((i) => (
+
+
+ #{i.id} {i.name}
+ {i.status}{i.broker ? ` · ${i.broker}` : ''} · {i.visibility}
+
+
+ ))}
+ {!data.instances.length &&
No instances published. The owner can publish one in My Booth.
}
+
+ {use &&
setUse(null)} />}
+
+
+ );
+}
+
+// ---- Use an instance: chatbox + web-pty ----------------------------------
+export function UsePanel({ instance, onClose }) {
+ const [mode, setMode] = useState('chatbox');
+ const [sess, setSess] = useState(null);
+ const [accesskey, setAccesskey] = useState(instance.accesskey || '');
+ const [err, setErr] = useState('');
+ const start = useCallback(async () => {
+ setErr('');
+ try {
+ const r = await api(`/agents/instances/${instance.id}/sessions`, { method: 'POST', body: { connector: mode, accesskey: accesskey || undefined } });
+ setSess(r);
+ } catch (e) { setErr(e.message); }
+ }, [instance.id, mode, accesskey]);
+ useEffect(() => { if (instance.accesskey) start(); }, []); // owner: auto-start
+
+ return (
+
+
+
+
+
+
+
+
+ {!sess && (
+
+ {!instance.accesskey && instance.visibility !== 'public' && (
+
setAccesskey(e.target.value)} />
+ )}
+
+ {err &&
{err}
}
+
+ )}
+ {sess && (
+ <>
+
+ {mode === 'chatbox' && (
+
+ Embed this agent on any site
+ {``}
+
+ )}
+ >
+ )}
+
+ );
+}
+
+// ---- My Booth: publish classes + instances, manage ------------------------
+export function BoothDashboard({ 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 [newInst, setNewInst] = useState(null);
+
+ const load = useCallback(async () => {
+ setErr('');
+ try {
+ const c = await api('/agents/classes?mine=1');
+ setClasses(c.classes || []);
+ const all = await Promise.all((c.classes || []).map((x) => api(`/agents/instances?classId=${x.id}`).then((r) => r.instances).catch(() => [])));
+ setInsts(all.flat());
+ } catch (e) { setErr(e.message); }
+ }, []);
+ useEffect(() => { if (auth.user) load(); }, [auth.user, load]);
+
+ if (!auth.ready) return ;
+ if (!auth.user) return (
+ My Booth
Log in to publish and run agents.
+ );
+
+ const publishClass = async (e) => {
+ e.preventDefault();
+ try { await api('/agents/classes', { method: 'POST', body: { ...form, tags: form.tags.split(',').map((t) => t.trim()).filter(Boolean) } }); setForm({ ...form, name: '', tags: '', description: '' }); load(); }
+ catch (e2) { setErr(e2.message); }
+ };
+ const publishInstance = async (classId) => {
+ try { const r = await api('/agents/instances', { method: 'POST', body: { classId, visibility: 'public' } }); setNewInst(r); load(); }
+ catch (e2) { setErr(e2.message); }
+ };
+ const shutdown = async (i) => { try { await api(`/agents/instances/${i.id}`, { method: 'DELETE', body: { accesskey: i.accesskey } }); load(); } catch (e2) { setErr(e2.message); } };
+
+ return (
+
+
My Booth
{auth.user.handle}
+ {err &&
{err}
}
+
+
+
+
My classes
+ {classes.map((c) => (
+
+
{c.name}{c.visibility}{c.onlineCount}/{c.instanceCount} online
+
+
{c.tags.map((t) => {t})}
+
+ ))}
+ {!classes.length &&
No classes yet — publish one on the left.
}
+
My instances
+ {insts.map((i) => (
+
+ #{i.id} {i.status}
+ {i.accesskey && navigator.clipboard?.writeText(i.accesskey)}>{i.accesskey.slice(0, 14)}… ⧉}
+
+
+ ))}
+ {!insts.length &&
No instances. Publish one from a class above.
}
+
+
+ {newInst && (
+
setNewInst(null)}>
+
e.stopPropagation()}>
+
Instance #{newInst.id} created
+
Run your broker against this instance, then it shows online. The accesskey is the relay token.
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/src/Scene2D.jsx b/src/Scene2D.jsx
index 687b907..09b1317 100644
--- a/src/Scene2D.jsx
+++ b/src/Scene2D.jsx
@@ -1,21 +1,18 @@
-import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import React, { useCallback, useEffect, useState } from 'react';
import { Menu, X } from 'lucide-react';
import { useAuth, AuthPanel, AdminConsole } from './Auth.jsx';
+import { AgentBrowse, BoothDashboard } from './AgentPlatform.jsx';
+import TopDownTavern from './TopDownTavern.jsx';
+import { api } from './api.js';
-const RELEASE = '0.20.0-suite';
+const RELEASE = '0.21.0-pool';
-// ---------------------------------------------------------------------------
-// Routing
-// ---------------------------------------------------------------------------
const NAV = [
- { id: 'home', label: 'Solution' },
- { id: 'agents', label: 'Staff' },
- { id: 'contracts', label: 'Pricing' },
- { id: 'signin', label: 'Login' },
+ { id: 'home', label: 'Tavern' },
+ { id: 'agents', label: 'Browse Agents' },
{ id: 'booth', label: 'My Booth' },
];
-
-const VALID_ROUTES = ['home', 'agents', 'booth', 'contracts', 'admin', 'signin'];
+const VALID_ROUTES = ['home', 'agents', 'booth', 'admin', 'signin'];
function useHashRoute() {
const normalize = () => {
@@ -27,100 +24,16 @@ function useHashRoute() {
const onHash = () => setRouteState(normalize());
window.addEventListener('hashchange', onHash);
window.addEventListener('popstate', onHash);
- return () => {
- window.removeEventListener('hashchange', onHash);
- window.removeEventListener('popstate', onHash);
- };
+ return () => { window.removeEventListener('hashchange', onHash); window.removeEventListener('popstate', onHash); };
}, []);
const setRoute = useCallback((next) => {
const nextHash = `/${next}`;
- if (window.location.hash !== `#${nextHash}`) {
- window.history.pushState(null, '', `${window.location.pathname}${window.location.search}#${nextHash}`);
- }
+ if (window.location.hash !== `#${nextHash}`) window.history.pushState(null, '', `${window.location.pathname}${window.location.search}#${nextHash}`);
setRouteState(next);
}, []);
return [route, setRoute];
}
-// ---------------------------------------------------------------------------
-// Route panel copy (carried over from the 3D scene so nothing regresses)
-// ---------------------------------------------------------------------------
-function panelData(route) {
- const panels = {
- home: {
- title: 'Browse Agents',
- subtitle: 'Walk the tavern floor and inspect the mercenary roster.',
- rows: ['Vertical site embeds a command booth', 'Customer backend mints a scoped session', 'Agent acts through supplied skills in tinybox'],
- stats: ['12,458 sessions', '7,326 deliveries', '+23.7% usage'],
- actions: ['Inspect Roster', 'Publish Agent', 'Remote Use', 'Port Mapping', 'Run Directly'],
- tint: '#27d8ff',
- },
- agents: {
- title: 'Mercenary Staff',
- subtitle: 'Specialists with scoped tools, runtime limits, and visible feedback.',
- rows: ['Iron Tusk / site ops / 18 coin per hour', 'Velvet Hex / user consent / 14 coin per hour', 'Socket Wraith / tinybox bridge / 16 coin per hour'],
- stats: ['6 active ranks', 'SLA visible', 'skills scoped'],
- actions: ['Filter by Skill', 'Inspect Runtime', 'Hire Specialist', 'Watch Feedback', 'Swap Agent'],
- tint: '#27d8ff',
- },
- booth: {
- title: 'My Booth',
- subtitle: 'Mount, message, inspect, and carry personal agents into websites.',
- rows: ['Iron Tusk / running / cart.update / 00:13', 'Socket Wraith / waiting / claim.lookup / 02:41', 'Nightforge / paused / terminal.chat / 18:09'],
- stats: ['3 mounted', '4 callbacks', 'healthy'],
- actions: ['Mount Agent', 'Open TUI', 'Send Message', 'Review Logs', 'Detach Booth'],
- tint: '#36f0b0',
- },
- contracts: {
- title: 'Contract Pricing',
- subtitle: 'Three paths: embedded agent, bring-your-own agent, or hosted agent.',
- rows: ['Site hires agent / API key to session', 'User brings agent / connect amerc', 'Hosted mercenary / TUI operations'],
- stats: ['session', 'skills', 'runtime'],
- actions: ['Create API Key', 'Bind Skills', 'Set Budget', 'Publish Terms', 'Start Session'],
- tint: '#f2b85f',
- },
- admin: {
- title: 'Quartermaster',
- subtitle: 'Tenant, key, session, skill, and risk controls for operators.',
- rows: ['northstar-retail / embedded / green', 'claims-lab / byoa / amber', 'sandbox / hosted / red'],
- stats: ['tenants', 'keys', 'risk queue'],
- actions: ['Audit Tenants', 'Rotate Keys', 'Trace Sessions', 'Block Skill', 'Review Risk'],
- tint: '#f2b85f',
- },
- signin: {
- title: 'Open Booth',
- subtitle: 'Sign in to the mercenary control surface.',
- rows: ['Guild handle / operator@vertical-site.ai', 'Access sigil / ************', `Release / ${RELEASE}`],
- stats: ['preview', 'amerc.ai', 'frontend'],
- actions: ['Choose Account', 'Link Agent', 'Grant Scope', 'Enter Booth', 'Return to Site'],
- tint: '#9d73ff',
- },
- };
- return panels[route] || panels.home;
-}
-
-// Characters that live on the tavern floor. Clicking one routes you.
-const CHARACTERS = [
- {
- id: 'orc', sprite: '/scene2d/orc.png', route: 'agents',
- name: 'Grommash', role: 'Bartender', say: 'Pick a mercenary, friend.',
- left: '72%', h: 460, z: 6, flip: false, bob: 3.4,
- },
- {
- id: 'elf', sprite: '/scene2d/elf.png', route: 'home',
- name: 'Aelis', role: 'Guide', say: 'Welcome to amerc. This way.',
- left: '46%', h: 420, z: 5, flip: false, bob: 4.2,
- },
- {
- id: 'dwarf', sprite: '/scene2d/dwarf.png', route: 'booth',
- name: 'Durn', role: 'Patron', say: 'My booth runs three agents.',
- left: '20%', h: 360, z: 4, flip: true, bob: 3.0,
- },
-];
-
-// ---------------------------------------------------------------------------
-// Top bar
-// ---------------------------------------------------------------------------
function PixelTopbar({ route, setRoute, auth }) {
const [open, setOpen] = useState(false);
const go = (id) => { setRoute(id); setOpen(false); };
@@ -128,181 +41,89 @@ function PixelTopbar({ route, setRoute, auth }) {
const isAdmin = auth?.user?.role === 'admin';
const nav = (
<>
- {NAV.filter((item) => !(item.id === 'signin' && loggedIn)).map((item) => (
-
+ {NAV.map((item) => (
+
))}
- {isAdmin && (
-
- )}
- {loggedIn && (
-
- )}
+ Docs
+ PM
+ {isAdmin && }
+ {loggedIn
+ ?
+ : }
>
);
return (
-
+
{open && {nav}
}
);
}
-// ---------------------------------------------------------------------------
-// Scene
-// ---------------------------------------------------------------------------
-function TavernFloor({ route, setRoute, auth }) {
- const stageRef = useRef(null);
- const [parallax, setParallax] = useState({ x: 0, y: 0 });
- const [active, setActive] = useState(null); // hovered/selected character id
-
- const onMove = useCallback((e) => {
- const el = stageRef.current;
- if (!el) return;
- const r = el.getBoundingClientRect();
- const px = (e.clientX - r.left) / r.width - 0.5;
- const py = (e.clientY - r.top) / r.height - 0.5;
- setParallax({ x: px, y: py });
- }, []);
-
- const embers = useMemo(
- () => Array.from({ length: 26 }, (_, i) => ({
- id: i,
- left: `${(i * 37) % 100}%`,
- delay: `${(i % 13) * 0.9}s`,
- dur: `${7 + (i % 6)}s`,
- size: 2 + (i % 3),
- })),
- [],
- );
-
+function StatsStrip() {
+ const [s, setS] = useState(null);
+ useEffect(() => { api('/agents/stats').then(setS).catch(() => {}); }, []);
+ const items = [
+ ['classes', 'agent classes'], ['instances', 'instances'], ['online', 'online now'],
+ ['sessions', 'live sessions'], ['owners', 'publishers'],
+ ];
return (
- setParallax({ x: 0, y: 0 })}>
- {/* parallax tavern backdrop */}
-
-
-
- {/* floating embers */}
-
- {embers.map((e) => (
-
- ))}
-
-
- {/* characters on the floor */}
-
- {CHARACTERS.map((c) => (
-
- ))}
-
-
- {/* warm light + vignette */}
-
-
- {/* foreground holo board with route content */}
-
+
+ {items.map(([k, label]) => (
+
{s ? s[k] : '—'}{label}
+ ))}
);
}
-function RoutePanel({ route, setRoute, auth }) {
- const data = panelData(route);
- const wide = route === 'admin' && auth.user?.role === 'admin';
+function Home({ setRoute }) {
return (
-
+
+
+
+
+
Hire agents like mercenaries.
+
Publish an agent class, run instances, serve users in live sessions — as a chatbox or a web terminal.
+
+
+
+
+
+
+
+
);
}
-function RouteBody({ data, route, setRoute }) {
- return (
- <>
-
- {data.rows.map((r, i) => (
- - {r}
- ))}
-
-
- {data.stats.map((s, i) => (
- {s}
- ))}
-
-
- {data.actions.map((a, i) => (
-
- ))}
-
- >
- );
+function Page({ children }) { return
{children}
; }
+
+function Scene({ route, setRoute, auth }) {
+ if (route === 'home') return
;
+ if (route === 'agents') return
;
+ if (route === 'booth') return
;
+ if (route === 'signin') return
+
+
setRoute('booth')} />
+ ;
+ if (route === 'admin') return
;
+ return
;
}
export default function Scene2DApp() {
const [route, setRoute] = useHashRoute();
const auth = useAuth();
return (
-
+
amerc agent mercenary tavern
-
+
);
}
diff --git a/src/TopDownTavern.jsx b/src/TopDownTavern.jsx
new file mode 100644
index 0000000..df1befa
--- /dev/null
+++ b/src/TopDownTavern.jsx
@@ -0,0 +1,146 @@
+import React, { useEffect, useRef, useState } from 'react';
+
+// 3/4 top-down RPG tavern, drawn procedurally on a canvas at a fixed virtual size.
+// Pixel-chunky: rendered at TILE px then scaled up with image-rendering:pixelated.
+const COLS = 20, ROWS = 13, TILE = 16; // virtual room = 320x208, scaled up
+const W = COLS * TILE, H = ROWS * TILE;
+
+// tile map legend: . floor # wall = bar T table o stool b barrel r rug f fireplace s shelf c crate p plant w window
+const MAP = [
+ '####################',
+ '#wwww##ffff##wwww##s#',
+ '#..................s#',
+ '#..bb....rr....cc...#',
+ '#..bb..rrrrrr..cc...#',
+ '#......rrrrrr.......#',
+ '#..T...rrrrrr...T...#',
+ '#.ooo..........ooo..#',
+ '#..................##',
+ '#===================#',
+ '#...=...=...=...=...##',
+ '#..................##',
+ '####################',
+];
+
+const PAL = {
+ floorA: '#6b4a2b', floorB: '#5e4126', grain: '#4f361f',
+ wall: '#3a2a3f', wallTop: '#52405a', mortar: '#241a28',
+ rug: '#7a1f2b', rugEdge: '#d8a843', rugMid: '#9c2a38',
+ bar: '#7a4a24', barTop: '#a06a36', barEdge: '#3c220f',
+ wood: '#8a5a2e', woodHi: '#a87a44', woodLo: '#4a2e16',
+ barrel: '#6b4524', barrelBand: '#caa54a', barrelTop: '#a8732f',
+ stone: '#4a4452', stoneHi: '#6a6276',
+ fire: '#ff9f38', fireHot: '#ffd66b', glass: '#2aa9d8',
+};
+
+function px(ctx, x, y, w, h, c) { ctx.fillStyle = c; ctx.fillRect(x, y, w, h); }
+
+function drawFloor(ctx, x, y) {
+ const a = ((x / TILE) + (y / TILE)) % 2 === 0;
+ px(ctx, x, y, TILE, TILE, a ? PAL.floorA : PAL.floorB);
+ ctx.fillStyle = PAL.grain;
+ ctx.fillRect(x, y + 5, TILE, 1); ctx.fillRect(x, y + 11, TILE, 1);
+ ctx.fillRect(x + (a ? 4 : 11), y, 1, TILE);
+}
+function drawWall(ctx, x, y) {
+ px(ctx, x, y, TILE, TILE, PAL.wall);
+ px(ctx, x, y, TILE, 4, PAL.wallTop); // 3/4 top cap
+ ctx.fillStyle = PAL.mortar;
+ ctx.fillRect(x, y + 9, TILE, 1);
+ ctx.fillRect(x + ((y / TILE) % 2 ? 8 : 0), y + 4, 1, 5);
+ ctx.fillRect(x + ((y / TILE) % 2 ? 0 : 8), y + 9, 1, 7);
+}
+function drawRug(ctx, x, y, edge, midOnly) {
+ px(ctx, x, y, TILE, TILE, PAL.rug);
+ if (midOnly) { px(ctx, x + 3, y + 3, TILE - 6, TILE - 6, PAL.rugMid); }
+ ctx.fillStyle = PAL.rugEdge;
+ if (edge.t) ctx.fillRect(x, y, TILE, 2);
+ if (edge.b) ctx.fillRect(x, y + TILE - 2, TILE, 2);
+ if (edge.l) ctx.fillRect(x, y, 2, TILE);
+ if (edge.r) ctx.fillRect(x + TILE - 2, y, 2, TILE);
+}
+
+function drawTile(ctx, ch, x, y, neigh) {
+ switch (ch) {
+ case '#': drawWall(ctx, x, y); break;
+ case 'w': drawWall(ctx, x, y); px(ctx, x + 3, y + 5, TILE - 6, TILE - 8, '#0a1830'); px(ctx, x + 4, y + 6, TILE - 8, TILE - 10, PAL.glass); px(ctx, x + 7, y + 6, 1, TILE - 10, '#0a1830'); break;
+ case 's': drawWall(ctx, x, y); px(ctx, x + 2, y + 4, TILE - 4, 2, PAL.wood); px(ctx, x + 2, y + 8, TILE - 4, 2, PAL.wood); px(ctx, x + 2, y + 12, TILE - 4, 2, PAL.wood); px(ctx, x + 4, y + 1, 2, 3, PAL.woodHi); px(ctx, x + 9, y + 5, 2, 3, PAL.fire); break;
+ case 'f': drawWall(ctx, x, y); px(ctx, x + 2, y + 6, TILE - 4, TILE - 6, '#1a1014'); break;
+ default: drawFloor(ctx, x, y);
+ if (ch === 'r') {
+ const e = { t: neigh.t !== 'r', b: neigh.b !== 'r', l: neigh.l !== 'r', r: neigh.r !== 'r' };
+ drawRug(ctx, x, y, e, true);
+ }
+ if (ch === '=') { // bar counter
+ px(ctx, x, y + 3, TILE, TILE - 3, PAL.bar); px(ctx, x, y, TILE, 4, PAL.barTop); px(ctx, x, y + 3, TILE, 1, PAL.barEdge); px(ctx, x, y + TILE - 1, TILE, 1, PAL.barEdge);
+ }
+ if (ch === 'T') { px(ctx, x + 1, y + 2, TILE - 2, TILE - 4, PAL.woodLo); px(ctx, x + 1, y + 1, TILE - 2, 4, PAL.wood); px(ctx, x + 3, y + 2, TILE - 6, 1, PAL.woodHi); }
+ if (ch === 'o') { px(ctx, x + 5, y + 6, 6, 6, PAL.woodLo); px(ctx, x + 5, y + 5, 6, 2, PAL.wood); }
+ if (ch === 'b') { px(ctx, x + 3, y + 2, TILE - 6, TILE - 3, PAL.barrel); px(ctx, x + 3, y + 1, TILE - 6, 3, PAL.barrelTop); px(ctx, x + 3, y + 6, TILE - 6, 1, PAL.barrelBand); px(ctx, x + 3, y + 11, TILE - 6, 1, PAL.barrelBand); }
+ if (ch === 'c') { px(ctx, x + 2, y + 3, TILE - 4, TILE - 4, PAL.wood); px(ctx, x + 2, y + 2, TILE - 4, 2, PAL.woodHi); ctx.fillStyle = PAL.woodLo; ctx.fillRect(x + 2, y + 8, TILE - 4, 1); ctx.fillRect(x + 8, y + 3, 1, TILE - 5); }
+ }
+}
+
+export default function TopDownTavern({ stage = {}, onHotspot }) {
+ const canvasRef = useRef(null);
+ const [t, setT] = useState(0);
+ useEffect(() => {
+ const ctx = canvasRef.current.getContext('2d');
+ ctx.imageSmoothingEnabled = false;
+ let raf, frame = 0;
+ const at = (c, r) => (MAP[r] && MAP[r][c]) || '.';
+ const render = () => {
+ frame++;
+ // base map
+ for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) {
+ const ch = at(c, r);
+ drawTile(ctx, ch, c * TILE, r * TILE, { t: at(c, r - 1), b: at(c, r + 1), l: at(c - 1, r), r: at(c + 1, r) });
+ }
+ // fireplace fire (animated) under the 'f' wall tiles row1 cols6-9
+ const flick = 0.6 + 0.4 * Math.sin(frame * 0.2);
+ for (let c = 6; c <= 9; c++) {
+ const x = c * TILE, y = 1 * TILE;
+ px(ctx, x + 3, y + 8 - flick * 3, TILE - 6, 6 + flick * 3, PAL.fire);
+ px(ctx, x + 5, y + 10 - flick * 2, TILE - 10, 4, PAL.fireHot);
+ }
+ // wall lantern glow (animated) near windows
+ ctx.globalAlpha = 0.10 + 0.05 * Math.sin(frame * 0.15);
+ [[3, 2], [16, 2], [10, 1]].forEach(([c, r]) => { ctx.fillStyle = PAL.fire; ctx.beginPath(); ctx.arc(c * TILE + 8, r * TILE + 10, 14, 0, 7); ctx.fill(); });
+ ctx.globalAlpha = 1;
+ raf = requestAnimationFrame(render);
+ if (frame % 30 === 0) setT(frame);
+ };
+ render();
+ return () => cancelAnimationFrame(raf);
+ }, []);
+
+ return (
+
+
+
+ {/* characters (sprites as NPCs over the floor) */}
+ onHotspot?.('agents')} />
+ onHotspot?.('agents')} />
+ onHotspot?.('booth')} />
+ {/* hotspots */}
+
+
+
+
+ );
+}
+
+function Npc({ sprite, x, y, h, flip, label, say, onClick }) {
+ const [hover, setHover] = useState(false);
+ return (
+
+ );
+}
diff --git a/src/styles.css b/src/styles.css
index 4398dd2..070fd5d 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -791,12 +791,23 @@ button {
.cs-user button { padding: 6px 12px; border-radius: 7px; background: #1b2536; color: #dce6f5; cursor: pointer; font-size: 12px; }
.cs-main { flex: 1; min-height: 0; overflow: hidden; display: flex; }
-.cs-login-wrap { margin: auto; }
-.cs-login { display: flex; flex-direction: column; gap: 12px; width: 320px; padding: 28px; background: #0e1422; border: 1px solid #1f2a3d; border-radius: 14px; box-shadow: 0 20px 60px rgba(0,0,0,0.5); }
-.cs-login .cs-brand { justify-content: center; margin-bottom: 6px; }
+/* RPG building-block login frame — matches the amerc tavern look across all sites */
+.cs-login-wrap { margin: auto; padding: 20px; }
+.cs-login { display: flex; flex-direction: column; gap: 12px; width: 330px; padding: 26px 24px; color: #fff0d7;
+ background: linear-gradient(180deg, #2a1608, #160a04); border: 3px solid #000; border-radius: 4px;
+ box-shadow: inset 0 0 0 2px #6b3a1c, inset 0 0 26px rgba(0,0,0,0.7), 6px 6px 0 #000, 0 24px 60px rgba(0,0,0,0.6);
+ font-family: "Press Start 2P", ui-monospace, monospace; }
+.cs-login .cs-brand { justify-content: center; margin-bottom: 8px; color: #f7bc5d; }
+.cs-login .cs-gem { background: #27d8ff; }
.cs-tabs { display: flex; gap: 6px; }
-.cs-tabs button { flex: 1; padding: 8px; border-radius: 8px; background: #18213200; background: #182132; color: #aab8d0; cursor: pointer; font-size: 13px; }
-.cs-tabs button.on { background: var(--accent); color: #061018; font-weight: 600; }
+.cs-tabs button { flex: 1; padding: 9px; font-family: inherit; font-size: 9px; letter-spacing: 1px; color: #fff0d7;
+ background: #3a2110; border: 2px solid #000; box-shadow: inset -2px -2px 0 #1c0f06, inset 2px 2px 0 #6b3a1c; cursor: pointer; }
+.cs-tabs button.on { background: var(--accent); color: #061018; font-weight: 600; box-shadow: inset -2px -2px 0 rgba(0,0,0,0.3), inset 2px 2px 0 rgba(255,255,255,0.3); }
+.cs-login input { font-family: inherit !important; font-size: 10px; padding: 11px; color: #fff; background: #0c0814; border: 2px solid #000; border-radius: 0; box-shadow: inset 1px 1px 0 #251a3a; }
+.cs-login .cs-btn, .cs-login .cs-primary { font-family: inherit; font-size: 10px; border-radius: 0; border: 2px solid #000; letter-spacing: 1px;
+ box-shadow: inset -2px -2px 0 rgba(0,0,0,0.35), inset 2px 2px 0 rgba(255,255,255,0.18), 2px 2px 0 #000; padding: 11px; }
+.cs-login .cs-hint { font-size: 8px; line-height: 1.7; text-align: center; }
+.cs-login .cs-err { font-size: 9px; }
.cs-login input, .cs-field input, .nd-folder-input, .nd-bar select, .wb-name, .docs-side-top input, .docs-title-input, .docs-folder-input, .pm-nav + * input { font-family: inherit; }
.cs-login input { padding: 11px; border-radius: 8px; background: #0a0f1a; border: 1px solid #233044; color: #eaf2ff; font-size: 14px; }
.cs-login input:focus, textarea:focus, .nd-text:focus { outline: 2px solid var(--accent); border-color: transparent; }
@@ -905,3 +916,96 @@ button {
.nd-grid { flex-direction: column; } .nd-list { max-width: none; }
.cs-appnav { display: none; }
}
+
+/* ===================================================================== */
+/* 0.21 — top-down RPG tavern + agent platform */
+/* ===================================================================== */
+.td-app { background: #0a0710; }
+.td-home { position: relative; flex: 1; display: flex; align-items: center; justify-content: center; padding: 18px; overflow: hidden; }
+.td-stage { position: relative; aspect-ratio: 320 / 208; width: min(96vw, calc((100vh - 150px) * 1.538)); box-shadow: 0 16px 50px rgba(0,0,0,0.6), inset 0 0 0 4px #1a0f08; }
+.td-room { position: absolute; inset: 0; }
+.td-canvas { width: 100%; height: 100%; display: block; image-rendering: pixelated; }
+.td-overlay { position: absolute; inset: 0; }
+
+.td-npc { position: absolute; transform: translateX(-50%); bottom: auto; background: none; cursor: pointer; padding: 0; filter: drop-shadow(2px 3px 0 rgba(0,0,0,0.5)); }
+.td-npc img { height: 100%; image-rendering: pixelated; display: block; animation: td-bob 3.6s ease-in-out infinite; }
+.td-npc:hover { filter: drop-shadow(0 0 8px rgba(255,200,120,0.7)) drop-shadow(2px 3px 0 rgba(0,0,0,0.5)); z-index: 5; }
+@keyframes td-bob { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-4%)} }
+.td-npc-shadow { position: absolute; left: 50%; bottom: -4px; width: 60%; height: 8%; transform: translateX(-50%); background: radial-gradient(ellipse, rgba(0,0,0,0.5), transparent 70%); }
+.td-say { position: absolute; bottom: 102%; left: 50%; transform: translateX(-50%); width: max-content; max-width: 180px; padding: 6px 8px; font-size: 8px; line-height: 1.6; color: #1c1206; background: var(--cream); border: 2px solid #000; box-shadow: 2px 2px 0 #000; text-align: center; z-index: 6; }
+.td-say strong { color: #7a3d0c; } .td-say em { display: block; font-style: normal; color: #3a2a14; margin-top: 2px; }
+
+.td-hot { position: absolute; transform: translate(-50%, -50%); font-family: "Press Start 2P", monospace; cursor: pointer; z-index: 4; }
+.td-hot-board { display: flex; flex-direction: column; gap: 4px; align-items: center; padding: 8px 14px; color: #cdeaff; background: rgba(8,20,36,0.82); border: 2px solid #27d8ff; box-shadow: 0 0 18px rgba(39,216,255,0.4), 3px 3px 0 #000; font-size: 11px; }
+.td-hot-board .td-hot-tag { font-size: 6px; letter-spacing: 2px; color: #27d8ff; }
+.td-hot-board:hover { background: rgba(12,30,52,0.92); }
+.td-hot-door { padding: 8px 14px; font-size: 10px; color: #36f0b0; background: rgba(8,24,18,0.82); border: 2px solid #36f0b0; box-shadow: 0 0 16px rgba(54,240,176,0.35), 3px 3px 0 #000; }
+.td-hot-door:hover { background: rgba(12,34,26,0.92); }
+
+.td-hero { position: absolute; left: 3%; top: 8%; width: min(42%, 360px); z-index: 4; font-family: "Press Start 2P", monospace; }
+.td-hero h1 { font-size: clamp(13px, 2.1vw, 22px); line-height: 1.5; color: #fff; text-shadow: 2px 2px 0 #000, 0 0 18px rgba(0,0,0,0.8); margin: 0 0 10px; }
+.td-hero p { font-family: Inter, sans-serif; font-size: clamp(10px, 1.3vw, 13px); line-height: 1.7; color: #e7d4b6; text-shadow: 1px 1px 2px #000; margin: 0 0 12px; }
+.td-hero-cta { display: flex; gap: 8px; flex-wrap: wrap; }
+.td-stats { position: absolute; left: 50%; bottom: 3%; transform: translateX(-50%); display: flex; gap: 6px; z-index: 4; }
+.td-stat { display: flex; flex-direction: column; align-items: center; padding: 6px 10px; background: rgba(8,12,22,0.8); border: 2px solid #000; box-shadow: 2px 2px 0 #000; font-family: "Press Start 2P", monospace; }
+.td-stat b { font-size: 14px; color: #27d8ff; } .td-stat span { font-size: 6px; color: #9fb0d0; margin-top: 3px; letter-spacing: 1px; }
+
+.td-page { flex: 1; overflow: auto; background: #0a0710 radial-gradient(circle at 50% -10%, #1a0f22, #0a0710 60%); }
+.td-signin { padding: 40px 16px; } .td-app .td-page .px-board { color: var(--cream); }
+
+/* agent platform */
+.ap { max-width: 1080px; margin: 0 auto; padding: 24px 20px 60px; color: #e7d4b6; font-family: Inter, sans-serif; }
+.ap-head { display: flex; align-items: center; gap: 14px; margin-bottom: 14px; }
+.ap-head h2 { font-family: "Press Start 2P", monospace; font-size: 18px; color: #fff; margin: 0; }
+.ap-by { color: #9a86b8; font-size: 13px; }
+.ap-search { margin-left: auto; padding: 9px 12px; border-radius: 8px; background: #140d1e; border: 1px solid #2e2440; color: #eaf2ff; font-family: inherit; }
+.ap-tags { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 18px; }
+.ap-tag { padding: 6px 10px; border-radius: 16px; background: #181226; border: 1px solid #2e2440; color: #c9b8e6; cursor: pointer; font-size: 12px; }
+.ap-tag.on { background: #27d8ff; color: #061018; border-color: transparent; font-weight: 600; }
+.ap-tag em { opacity: 0.6; font-style: normal; }
+.ap-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 14px; }
+.ap-card { text-align: left; padding: 16px; border-radius: 12px; background: #130d20; border: 1px solid #271c3a; cursor: pointer; display: flex; flex-direction: column; gap: 8px; color: inherit; }
+.ap-card:hover { border-color: #27d8ff; transform: translateY(-2px); transition: 0.12s; }
+.ap-card-top { display: flex; align-items: center; gap: 8px; } .ap-card-top strong { font-size: 15px; color: #fff; }
+.ap-kind { font-size: 10px; padding: 2px 7px; border-radius: 5px; background: #271c3a; color: #c9b8e6; }
+.ap-desc { font-size: 13px; color: #b9a6cf; margin: 0; line-height: 1.6; }
+.ap-card-tags { display: flex; gap: 5px; flex-wrap: wrap; }
+.ap-card-tags span { font-size: 10px; padding: 2px 7px; border-radius: 10px; background: #1d1530; color: #9d8fc0; }
+.ap-card-foot { display: flex; justify-content: space-between; font-size: 11px; color: #8a7aa8; margin-top: auto; }
+.ap-online { display: flex; align-items: center; gap: 5px; } .ap-online i { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
+.ap-hint { color: #8a7aa8; font-size: 12px; line-height: 1.6; }
+.ap-h4 { font-family: "Press Start 2P", monospace; font-size: 10px; color: #27d8ff; margin: 16px 0 8px; }
+
+.ap-modal { position: fixed; inset: 0; background: rgba(4,6,14,0.75); display: flex; align-items: center; justify-content: center; z-index: 200; padding: 16px; }
+.ap-modal-box { width: min(640px, 96vw); max-height: 88vh; overflow: auto; background: #120c1e; border: 2px solid #2e2440; border-radius: 14px; padding: 18px; }
+.ap-modal-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 10px; margin-bottom: 8px; }
+.ap-modal-head h3 { margin: 0; color: #fff; } .ap-modal-head button { background: #271c3a; color: #ddd; border-radius: 6px; padding: 4px 10px; cursor: pointer; }
+.ap-sub { display: flex; align-items: center; gap: 10px; margin: 12px 0; flex-wrap: wrap; }
+.ap-token { font-family: ui-monospace, monospace; font-size: 12px; padding: 6px 9px; background: #0a0f1a; border: 1px solid #2e2440; border-radius: 7px; color: #36f0b0; cursor: pointer; word-break: break-all; }
+.ap-inst-list { display: flex; flex-direction: column; gap: 8px; }
+.ap-inst { display: flex; align-items: center; gap: 10px; padding: 9px 12px; background: #0e0a18; border: 1px solid #241a36; border-radius: 9px; font-size: 13px; }
+.ap-inst-status { width: 10px; height: 10px; border-radius: 50%; flex: none; }
+.ap-inst-name { font-weight: 600; color: #eee; } .ap-inst-meta { color: #8a7aa8; font-size: 11px; margin-left: auto; margin-right: 8px; }
+.ap-use { margin-top: 14px; border: 1px solid #2e2440; border-radius: 10px; overflow: hidden; }
+.ap-use-head { display: flex; justify-content: space-between; align-items: center; background: #0e0a18; padding: 6px 8px; }
+.ap-use-tabs button { padding: 6px 10px; border-radius: 6px; background: #1d1530; color: #c9b8e6; cursor: pointer; font-size: 12px; margin-right: 4px; }
+.ap-use-tabs button.on { background: #27d8ff; color: #061018; }
+.ap-use-x { background: none; color: #8a7aa8; cursor: pointer; font-size: 12px; }
+.ap-use-start { padding: 14px; display: flex; gap: 8px; flex-wrap: wrap; }
+.ap-use-start input { padding: 8px; border-radius: 7px; background: #0a0f1a; border: 1px solid #2e2440; color: #eaf2ff; flex: 1; }
+.ap-iframe { width: 100%; height: 440px; border: 0; background: #fff; display: block; }
+.ap-embed { padding: 10px; background: #0a0f1a; font-size: 12px; } .ap-embed code { color: #36f0b0; font-family: ui-monospace, monospace; word-break: break-all; }
+.ap-gate { text-align: center; padding-top: 40px; }
+.ap-booth-grid { display: grid; grid-template-columns: 320px 1fr; gap: 20px; align-items: start; }
+.ap-publish { display: flex; flex-direction: column; gap: 8px; padding: 16px; background: #130d20; border: 1px solid #271c3a; border-radius: 12px; }
+.ap-publish input, .ap-publish textarea, .ap-publish select { padding: 9px; border-radius: 7px; background: #0a0f1a; border: 1px solid #2e2440; color: #eaf2ff; font-family: inherit; font-size: 13px; }
+.ap-publish textarea { min-height: 60px; resize: vertical; } .ap-row { display: flex; gap: 8px; } .ap-row > * { flex: 1; }
+.ap-mine-class, .ap-mine-inst { padding: 10px 12px; background: #0e0a18; border: 1px solid #241a36; border-radius: 9px; margin-bottom: 8px; }
+.ap-mine-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; font-size: 13px; } .ap-mine-row strong { color: #fff; } .ap-mine-row span { color: #8a7aa8; font-size: 12px; }
+.ap-mine-inst { display: flex; align-items: center; gap: 10px; font-size: 13px; } .ap-mine-inst em { font-style: normal; color: #c9b8e6; }
+.ap-field { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: #9a86b8; margin: 8px 0; }
+@media (max-width: 760px) {
+ .td-hero { position: static; width: auto; padding: 12px; }
+ .td-stage { width: 96vw; } .td-stats { position: static; transform: none; flex-wrap: wrap; justify-content: center; margin-top: 10px; }
+ .td-home { flex-direction: column; } .ap-booth-grid { grid-template-columns: 1fr; }
+}