diff --git a/index.html b/index.html
index 2e7d3d8..64136fc 100644
--- a/index.html
+++ b/index.html
@@ -17,7 +17,7 @@
@media (prefers-reduced-motion: reduce) { .app-loading-gem { animation: none; transform: rotate(45deg); } }
-
+
diff --git a/package.json b/package.json
index 5b14bcb..ce37279 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "amerc-site",
- "version": "2.1.0-roster",
+ "version": "2.2.0-warcamp",
"private": true,
"type": "module",
"scripts": {
diff --git a/src/AgentPlatform.jsx b/src/AgentPlatform.jsx
index 82b53f2..9ece105 100644
--- a/src/AgentPlatform.jsx
+++ b/src/AgentPlatform.jsx
@@ -293,6 +293,73 @@ export function KeysCard() {
);
}
+// A fielded instance = a soldier: alive (online), standing-by (pending), fallen (lost/down).
+const SOL_FIG = { online: 'πͺ', pending: 'π§', lost: 'π‘οΈ', down: 'π' };
+function relHb(at) { if (!at) return 'never'; const s = Math.floor((Date.now() - at) / 1000); if (s < 60) return `${s}s ago`; const m = Math.floor(s / 60); if (m < 60) return `${m}m ago`; return `${Math.floor(m / 60)}h ago`; }
+function Soldier({ i, onOpen }) {
+ const klass = i.status === 'online' ? 'alive' : i.status === 'pending' ? 'pending' : 'fallen';
+ return (
+
+ );
+}
+
+// Soldier detail: live status + terminal snapshot, and chat with your own soldier.
+function SoldierDialog({ instance, onClose, onChanged }) {
+ const [detail, setDetail] = useState(null);
+ const [tab, setTab] = useState(instance.status === 'online' ? 'chat' : 'status');
+ useEffect(() => {
+ let alive = true;
+ const f = () => api(`/agents/instances/${instance.id}${instance.accesskey ? `?accesskey=${encodeURIComponent(instance.accesskey)}` : ''}`).then((d) => { if (alive && d.instance) setDetail({ ...d.instance, tui: d.tui }); }).catch(() => {});
+ f(); const t = setInterval(f, 5000); return () => { alive = false; clearInterval(t); };
+ }, [instance]);
+ const live = detail || instance;
+ const online = live.status === 'online';
+ const hb = `while true; do curl -s -XPOST https://amerc.ai/api/agents/instances/${instance.id}/heartbeat -H 'content-type: application/json' -d '{"accesskey":"${instance.accesskey}","status":"online","broker":"my-broker"}' >/dev/null; sleep 10; done`;
+ const standDown = async () => {
+ if (!window.confirm('Stand this soldier down? (shuts the instance down)')) return;
+ try { await api(`/agents/instances/${instance.id}`, { method: 'DELETE', body: { accesskey: instance.accesskey } }); onChanged?.(); onClose(); } catch { /* ignore */ }
+ };
+ return (
+
+
+
+
+ #{instance.id} {live.name}
+ {live.status}{live.broker ? ` Β· broker ${live.broker}` : ''} Β· heartbeat {relHb(live.last_heartbeat)}
+
+ {instance.accesskey &&
copyToast(instance.accesskey)} title="broker accesskey">{instance.accesskey.slice(0, 14)}β¦ β§}
+
+
+
+
+
+
+ {tab === 'chat' && (online
+ ?
+ : This soldier is {live.status}. Rouse it from the Status tab, then chat.
)}
+ {tab === 'status' && (
+
+ {live.skills?.length > 0 &&
skills loaded{live.skills.map((s) => {s})}
}
+
+
live terminal Β· refreshes every 5s
+
{live.tui || '(no terminal snapshot yet β your broker can post one via the heartbeat `tui` field)'}
+
+ {!online && (
+
+
Fallen until a broker checks in. Paste this anywhere with curl to rouse it:
+
{hb}
+
+ )}
+
+ )}
+
+ );
+}
+
export function LegionDashboard({ auth, setRoute }) {
const [classes, setClasses] = useState([]);
const [insts, setInsts] = useState([]);
@@ -301,6 +368,7 @@ export function LegionDashboard({ auth, setRoute }) {
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 [soldier, setSoldier] = useState(null); // soldier (instance) detail
const [loaded, setLoaded] = useState(false);
useEffect(() => {
if (!newInst) return;
@@ -318,7 +386,7 @@ export function LegionDashboard({ auth, setRoute }) {
setInsts(all.flat());
} catch (e) { setErr(e.message); } finally { setLoaded(true); }
}, []);
- useEffect(() => { if (auth.user) load(); }, [auth.user, load]);
+ useEffect(() => { if (!auth.user) return undefined; load(); const t = setInterval(load, 15000); return () => clearInterval(t); }, [auth.user, load]);
if (!auth.ready) return
;
if (!auth.user) return (
@@ -342,8 +410,6 @@ export function LegionDashboard({ auth, setRoute }) {
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 Legion
{auth.user.handle}
@@ -409,27 +475,26 @@ export function LegionDashboard({ auth, setRoute }) {
) : (
<>
- My classes β your banners on the roster
-
- {classes.map((c) => (
-
-
setOpenCls(c.id)} />
-
-
{c.visibility}
-
+
β The war camp β tap a banner for details, a soldier to command
+
+ {classes.map((c) => {
+ const squad = insts.filter((i) => i.class_id === c.id);
+ return (
+
+
+
+ {squad.map((i) => )}
+
+
-
- ))}
+ );
+ })}
- My instances
- {insts.map((i) => (
-
- #{i.id} {i.status}
- {i.accesskey && copyToast(i.accesskey)}>{i.accesskey.slice(0, 14)}β¦ β§}
-
-
- ))}
- {!insts.length && No instances. Publish one from a class above.
}
>
)}
@@ -448,6 +513,7 @@ export function LegionDashboard({ auth, setRoute }) {
)}
{openCls && { setOpenCls(null); load(); }} />}
+ {soldier && { setSoldier(null); load(); }} onChanged={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 (
diff --git a/src/Scene2D.jsx b/src/Scene2D.jsx
index af7a590..e37fe37 100644
--- a/src/Scene2D.jsx
+++ b/src/Scene2D.jsx
@@ -11,7 +11,7 @@ import { api } from './api.js';
const Menu = ({ size = 20 }) => ();
const X = ({ size = 20 }) => ();
-const RELEASE = '2.1.0-roster';
+const RELEASE = '2.2.0-warcamp';
const NAV = [
{ id: 'home', label: 'Tavern' },
@@ -476,6 +476,7 @@ function StatusPage() {
}
const CHANGELOG = [
+ { v: '2.2', date: 'Jun 2026', title: 'My Legion becomes a war camp', notes: ['Your classes are banners and each instance is a soldier β alive and bobbing when online, standing by when pending, fallen when down', 'Tap a soldier to command it: chat with your own running agent, watch its live terminal, or stand it down', 'Statuses refresh on their own so the camp stays current'] },
{ v: '2.1', date: 'Jun 2026', title: 'The roster comes through the door', notes: ['Browse Agents is now a doorway β the mercenaries descend and hang on wooden sign-bubbles showing what each one does', 'Open one and its detail opens in an ornate war-banner dialog; instances are shown as a squad β living soldiers when online, fallen when down'] },
{ v: '2.0', date: 'Jun 2026', title: 'The tavern becomes the interface', notes: ['The home page is now a full-screen game scene β every fixture is a door: the grand door is the roster, the bar signs you in, the banners are your Legion, the side passage leads to My Mansion', 'Speech bubbles everywhere: the dancer pitches the tour in a glowing holo-panel, the barkeep greets you by name', 'Details open in ornate in-game dialogs instead of plain pages'] },
{ v: '1.3', date: 'Jun 2026', title: 'Mansion learns to handle files', notes: ['File-mapper: your agent maps a file to a stable link β every download streams live through your broker, nothing stored on amerc', 'Net-disk opened as a Mansion room: 100 MB per account, with a live usage meter', 'And the roster has a real agent online again β say hello to Kebab Webagent'] },
diff --git a/src/styles.css b/src/styles.css
index cb6e6e6..a87353f 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -1765,3 +1765,45 @@ button {
.cm-sol-meta { display: flex; align-items: center; gap: 6px; color: #8a7bb0; font-size: 11px; }
.cm-sol-dot { width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto; }
@media (max-width: 620px) { .apb-merc { width: 132px; } .apb-doorway { flex-wrap: wrap; } }
+
+/* ===== My Legion = the war camp (2.2): banners + soldiers ===== */
+.lg-camp { display: grid; grid-template-columns: repeat(auto-fill, minmax(184px, 1fr)); gap: 18px; margin-bottom: 8px; }
+.lg-unit { display: flex; flex-direction: column; align-items: center; gap: 10px; }
+.lg-banner { position: relative; width: 100%; padding: 14px 10px 26px; display: flex; flex-direction: column; align-items: center; gap: 5px; cursor: pointer; font-family: inherit; color: #eee6ff; background: linear-gradient(180deg, #2a1d4e, #170f2c); border: 1px solid #443a7e; border-top: 4px solid #7a6ab0; clip-path: polygon(0 0, 100% 0, 100% 88%, 50% 100%, 0 88%); transition: transform 0.14s, border-color 0.14s; }
+.lg-banner:hover { transform: translateY(-3px); border-top-color: #ffd75e; }
+.lg-banner::before { content: ''; position: absolute; top: -10px; left: 50%; width: 8px; height: 8px; border-radius: 50%; background: #ffd75e; transform: translateX(-50%); box-shadow: 0 0 8px #ffd75e; }
+.lg-banner-crest { width: 50px; height: 50px; border-radius: 12px; display: flex; align-items: center; justify-content: center; font-size: 24px; box-shadow: 0 0 0 2px rgba(255,255,255,0.08); }
+.lg-banner strong { font-size: 13.5px; text-align: center; line-height: 1.2; }
+.lg-banner-sub { font-size: 10.5px; color: #b6a7d4; }
+.lg-banner-count { display: flex; align-items: center; gap: 5px; font-size: 11px; color: #9fb2c8; }
+.lg-banner-count i { width: 7px; height: 7px; border-radius: 50%; }
+.lg-soldiers { display: flex; flex-wrap: wrap; gap: 7px; justify-content: center; min-height: 30px; }
+.lg-sol { display: flex; flex-direction: column; align-items: center; gap: 1px; width: 50px; padding: 5px 2px; border-radius: 9px; cursor: pointer; font-family: inherit; background: #11101e; border: 1px solid #2a2440; transition: transform 0.12s, border-color 0.12s; }
+.lg-sol:hover { transform: translateY(-2px); border-color: #6a5a9a; }
+.lg-sol-fig { font-size: 22px; line-height: 1; }
+.lg-sol-id { font-size: 9.5px; color: #8a7bb0; }
+.lg-sol-status { font-size: 8.5px; text-transform: uppercase; letter-spacing: 0.4px; }
+.lg-sol.alive { border-color: #1f5642; box-shadow: 0 0 10px rgba(54,240,176,0.12); }
+.lg-sol.alive .lg-sol-status { color: #36f0b0; }
+@keyframes solbob { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-3px); } }
+.lg-sol.alive .lg-sol-fig { animation: solbob 1.7s ease-in-out infinite; }
+.lg-sol.pending .lg-sol-status { color: #f2b85f; }
+.lg-sol.fallen { opacity: 0.5; }
+.lg-sol.fallen .lg-sol-status { color: #ff7a8c; }
+.lg-recruit { width: 50px; height: 56px; border-radius: 9px; border: 1px dashed #4a3f6e; background: #0d0b18; color: #6a5a9a; font-size: 22px; cursor: pointer; transition: border-color 0.12s, color 0.12s; }
+.lg-recruit:hover { border-color: #46c8e0; color: #7fd9ea; }
+/* soldier dialog */
+.lg-sd-head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; flex-wrap: wrap; }
+.lg-sd-meta { flex: 1; min-width: 120px; display: flex; flex-direction: column; gap: 1px; }
+.lg-sd-meta strong { color: #eee6ff; font-size: 13px; }
+.lg-sd-meta span { color: #8a7bb0; font-size: 11px; }
+.lg-sd-key { font-size: 11px; }
+.lg-standdown { background: #2a1620 !important; border: 1px solid #5a2030 !important; color: #ff9fb0 !important; }
+.lg-sd-tabs { display: flex; gap: 6px; margin-bottom: 10px; }
+.lg-sd-tabs button { background: #11101e; border: 1px solid #2a2440; color: #b6a7d4; border-radius: 8px; padding: 5px 14px; font-size: 12px; cursor: pointer; font-family: inherit; }
+.lg-sd-tabs button.on { background: #2a2360; border-color: #6a5a9a; color: #fff; }
+.lg-sd-status { display: flex; flex-direction: column; gap: 10px; }
+.lg-tui { background: #05070c; border: 1px solid #1c2438; border-radius: 9px; overflow: hidden; }
+.lg-tui-head { padding: 5px 11px; background: #0d1320; color: #5b6b7d; font-size: 10px; letter-spacing: 0.6px; text-transform: uppercase; border-bottom: 1px solid #1c2438; }
+.lg-tui pre { margin: 0; padding: 11px; font-family: ui-monospace, Menlo, monospace; font-size: 11px; color: #9fe6c0; max-height: 240px; overflow: auto; white-space: pre-wrap; word-break: break-word; }
+.lg-rouse { margin-top: 2px; }