pty: native web terminal replaces the relay iframe (3.2.0)

backend POST/GET /agents/instances/:id/input (keystroke queue, broker
long-polls, owner/accesskey only) + POST .../tui (broker frame push).
src/Pty.jsx PtyPanel: polls tui, ANSI-stripped terminal, sends keystrokes;
wired into soldier Terminal tab + UsePanel; dropped the relay iframe.
Verified end-to-end on prod via throwaway + simulated broker.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
artheru 2026-06-11 14:23:56 +08:00
parent 8c4729b4ea
commit 39e87dfa6f
7 changed files with 154 additions and 28 deletions

View File

@ -17,7 +17,7 @@
@media (prefers-reduced-motion: reduce) { .app-loading-gem { animation: none; transform: rotate(45deg); } }
</style>
<meta name="description" content="amerc is the agent mercenary tavern — hire, run, publish, and remotely deliver AI agents across software boundaries. No infrastructure, just skills." />
<meta name="version" content="3.1.0-paintedmobile" />
<meta name="version" content="3.2.0-pty" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="icon" href="/app-192.png" type="image/png" sizes="192x192" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />

View File

@ -1,6 +1,6 @@
{
"name": "amerc-site",
"version": "3.1.0-paintedmobile",
"version": "3.2.0-pty",
"private": true,
"type": "module",
"scripts": {

View File

@ -108,6 +108,23 @@ function waitChat(sessionId, ms) {
const t = setTimeout(done, ms);
});
}
// web-pty: browser keystrokes queue per instance; the broker long-polls them.
const inputQueues = new Map(); // instanceId -> string[]
const inputWaiters = new Map(); // instanceId -> Set<fn>
function wakeInput(instId) {
const s = inputWaiters.get(instId); if (!s) return;
inputWaiters.delete(instId);
for (const fn of s) { try { fn(); } catch { /* gone */ } }
}
function waitInput(instId, ms) {
return new Promise((resolve) => {
let s = inputWaiters.get(instId);
if (!s) { s = new Set(); inputWaiters.set(instId, s); }
const fn = () => { clearTimeout(t); s.delete(fn); resolve(); };
s.add(fn);
const t = setTimeout(fn, ms);
});
}
// file rendezvous: producer's request stream is piped to the consumer's
// response when it claims the token. The server never writes the bytes.
const FILE_CLAIM_MS = 5 * 60 * 1000;
@ -595,6 +612,33 @@ const server = http.createServer(async (req, res) => {
const live = db.prepare("SELECT id FROM agent_sessions WHERE instance_id=? AND status='active'").all(inst.id).map((r) => r.id);
return send(res, 200, { ok: true, status, activeSessions: live });
}
// web-pty: lightweight tui frame push from the broker (keeps it online too)
if ((m = path.match(/^\/agents\/instances\/(\d+)\/tui$/)) && 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' });
db.prepare('UPDATE agent_instances SET tui=?,last_heartbeat=?,status=? WHERE id=?').run(String(b.tui || '').slice(0, 20000), Date.now(), inst.status === 'down' ? 'online' : inst.status, inst.id);
return send(res, 200, { ok: true });
}
// web-pty: browser posts keystrokes (owner/accesskey); broker long-polls them
if ((m = path.match(/^\/agents\/instances\/(\d+)\/input$/)) && 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) || {};
if (!(owns(inst) || (b.accesskey && b.accesskey === inst.accesskey))) return send(res, 403, { ok: false, error: 'only the owner can drive this terminal' });
if (typeof b.data !== 'string') return send(res, 400, { ok: false, error: 'data required' });
let q = inputQueues.get(inst.id); if (!q) { q = []; inputQueues.set(inst.id, q); }
q.push(b.data.slice(0, 8192));
if (q.length > 256) q.splice(0, q.length - 256);
wakeInput(inst.id);
return send(res, 200, { ok: true });
}
if ((m = path.match(/^\/agents\/instances\/(\d+)\/input$/)) && 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' });
if (url.searchParams.get('accesskey') !== inst.accesskey) return send(res, 401, { ok: false, error: 'bad accesskey' });
let q = inputQueues.get(inst.id) || [];
if (!q.length && url.searchParams.get('wait')) { await waitInput(inst.id, 25000); q = inputQueues.get(inst.id) || []; }
inputQueues.set(inst.id, []);
return send(res, 200, { ok: true, input: q });
}
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' });

View File

@ -3,6 +3,7 @@ import { api } from './api.js';
import { copyToast } from './toast.js';
import ChatPanel, { SessionLog } from './Chat.jsx';
import { GameDialog } from './GameDialog.jsx';
import PtyPanel from './Pty.jsx';
const STATUS_COLOR = { online: '#36f0b0', pending: '#f2b85f', lost: '#ff9f5a', down: '#ff7a8c' };
const TAG_EMOJI = { backend: '🛠️', model: '🧠', frontend: '🎨', ui: '🖌️', web: '🌐', data: '📊', devops: '⚙️', ml: '🤖', security: '🔐', api: '🔌', bot: '🤖', chat: '💬', test: '🧪', docs: '📄', design: '✏️', ops: '🚦', research: '🔬' };
@ -202,28 +203,18 @@ function ClassModal({ id, auth, onClose }) {
);
}
// ---- Use an instance: native chat + web-pty -------------------------------
// ---- Use an instance: native chat + native 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 needKey = !instance.accesskey && instance.visibility !== 'public';
const [keyOk, setKeyOk] = useState(!needKey);
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]);
return (
<div className="ap-use">
<div className="ap-use-head">
<div className="ap-use-tabs">
<button className={mode === 'chatbox' ? 'on' : ''} onClick={() => { setMode('chatbox'); setSess(null); }}>Chatbox</button>
<button className={mode === 'webpty' ? 'on' : ''} onClick={() => { setMode('webpty'); setSess(null); }}>Web PTY</button>
<button className={mode === 'chatbox' ? 'on' : ''} onClick={() => setMode('chatbox')}>Chatbox</button>
<button className={mode === 'webpty' ? 'on' : ''} onClick={() => setMode('webpty')}>Web PTY</button>
</div>
<button className="ap-use-x" onClick={onClose}>close </button>
</div>
@ -234,18 +225,7 @@ export function UsePanel({ instance, onClose }) {
</div>
)}
{mode === 'chatbox' && keyOk && <ChatPanel instance={instance} accesskey={accesskey || undefined} onClose={onClose} />}
{mode === 'webpty' && keyOk && !sess && (
<div className="ap-use-start">
<button className="px-action" onClick={start}>Connect webpty</button>
{err && <p className="px-auth-err">{err}</p>}
</div>
)}
{mode === 'webpty' && sess && (
<>
<iframe title="agent terminal" className="ap-iframe" src={`https://webagent.amerc.ai/chat_session?session=${encodeURIComponent(sess.relayToken)}`} />
<p className="ap-hint ap-pty-note">🖥 The web terminal rides the webagent relay; chat no longer does.</p>
</>
)}
{mode === 'webpty' && keyOk && <PtyPanel instance={instance} accesskey={accesskey || undefined} />}
</div>
);
}
@ -336,11 +316,13 @@ function SoldierDialog({ instance, onClose, onChanged }) {
</div>
<div className="lg-sd-tabs">
<button className={tab === 'chat' ? 'on' : ''} onClick={() => setTab('chat')}>Chat</button>
<button className={tab === 'pty' ? 'on' : ''} onClick={() => setTab('pty')}>Terminal</button>
<button className={tab === 'status' ? 'on' : ''} onClick={() => setTab('status')}>Status</button>
</div>
{tab === 'chat' && (online
? <ChatPanel instance={live} accesskey={instance.accesskey} onClose={onClose} />
: <p className="ap-hint" style={{ padding: '16px 4px' }}>This soldier is {live.status}. Rouse it from the Status tab, then chat.</p>)}
{tab === 'pty' && <PtyPanel instance={live} accesskey={instance.accesskey} />}
{tab === 'status' && (
<div className="lg-sd-status">
{live.skills?.length > 0 && <div className="ch-skills"><span className="ch-skills-label">skills loaded</span>{live.skills.map((s) => <span key={s} className="ch-skill">{s}</span>)}</div>}

88
src/Pty.jsx Normal file
View File

@ -0,0 +1,88 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { api } from './api.js';
// Native web terminal polls the instance's tui frames and (for the owner,
// who holds the accesskey) sends keystrokes through amerc's input queue. No
// relay, no iframe: just HTTP. The broker writes keystrokes to its pty and
// pushes tui frames back via POST /agents/instances/:id/tui.
const stripAnsi = (s) => String(s || '')
.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '') // CSI sequences
.replace(/\x1b[()][0-9A-Za-z]/g, '') // charset selects
.replace(/\x1b[=>]/g, '').replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, '');
function keyToData(e) {
if (e.ctrlKey && /^[a-z]$/i.test(e.key)) { const c = e.key.toLowerCase().charCodeAt(0) - 96; return String.fromCharCode(c); }
switch (e.key) {
case 'Enter': return '\r';
case 'Backspace': return '\x7f';
case 'Tab': return '\t';
case 'Escape': return '\x1b';
case 'ArrowUp': return '\x1b[A';
case 'ArrowDown': return '\x1b[B';
case 'ArrowRight': return '\x1b[C';
case 'ArrowLeft': return '\x1b[D';
case 'Home': return '\x1b[H';
case 'End': return '\x1b[F';
case 'Delete': return '\x1b[3~';
default: return e.key.length === 1 ? e.key : null;
}
}
export default function PtyPanel({ instance, accesskey }) {
const [tui, setTui] = useState(instance.tui || '');
const [status, setStatus] = useState(instance.status || '');
const [focused, setFocused] = useState(false);
const [err, setErr] = useState('');
const screenRef = useRef(null);
const interactive = !!accesskey;
// poll tui frames
useEffect(() => {
let alive = true;
const q = accesskey ? `?accesskey=${encodeURIComponent(accesskey)}` : '';
const tick = () => api(`/agents/instances/${instance.id}${q}`)
.then((d) => { if (alive && d.instance) { if (d.tui != null) setTui(d.tui); setStatus(d.instance.status); } })
.catch(() => {});
tick(); const t = setInterval(tick, 1200);
return () => { alive = false; clearInterval(t); };
}, [instance.id, accesskey]);
// keep scrolled to the bottom
useEffect(() => { const el = screenRef.current; if (el) el.scrollTop = el.scrollHeight; }, [tui]);
const send = useCallback(async (data) => {
if (!interactive) return;
try { await api(`/agents/instances/${instance.id}/input`, { method: 'POST', body: { data, accesskey } }); }
catch (e) { setErr(e.message); }
}, [instance.id, accesskey, interactive]);
const onKeyDown = (e) => {
if (!interactive) return;
if (e.metaKey || (e.ctrlKey && (e.key === 'c' || e.key === 'v') && !focused)) return; // allow copy/paste chrome
const d = keyToData(e);
if (d != null) { e.preventDefault(); send(d); }
};
const onPaste = (e) => { if (!interactive) return; const t = e.clipboardData?.getData('text'); if (t) { e.preventDefault(); send(t); } };
return (
<div className="pty">
<div className="pty-bar">
<span className={`ch-dot ${status}`} />
<span className="pty-title">terminal · #{instance.id}</span>
<span className="pty-mode">{interactive ? (focused ? '⌨ typing — keys go to the agent' : 'click the screen to type') : '👁 view only (owner drives)'}</span>
</div>
<pre
ref={screenRef}
className={`pty-screen${focused ? ' on' : ''}${interactive ? '' : ' ro'}`}
tabIndex={interactive ? 0 : -1}
onKeyDown={onKeyDown}
onPaste={onPaste}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
>{stripAnsi(tui) || (status === 'online' ? 'waiting for the agent to paint its terminal…' : `instance is ${status} — no live terminal`)}</pre>
{err && <p className="px-auth-err">{err}</p>}
{interactive && <p className="ap-hint pty-hint">Keystrokes (incl. Enter, arrows, Ctrl-C) stream to the agent's pty; the screen refreshes ~1×/sec. Your broker must long-poll <code>GET /api/agents/instances/{instance.id}/input</code> and push frames to <code>POST /tui</code>.</p>}
</div>
);
}

View File

@ -12,7 +12,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 = '3.1.0-paintedmobile';
const RELEASE = '3.2.0-pty';
const NAV = [
{ id: 'home', label: 'Tavern' },
@ -482,6 +482,7 @@ function StatusPage() {
}
const CHANGELOG = [
{ v: '3.2', date: 'Jun 2026', title: 'A native web terminal', notes: ['The Web PTY now runs on amerc itself — no relay: it streams the agents live terminal and sends your keystrokes (Enter, arrows, Ctrl-C, paste) straight through', 'Open it from a soldier in My Legion (Terminal tab) or from any agent you use'] },
{ v: '3.1', date: 'Jun 2026', title: 'The tavern breathes', notes: ['Lantern glow flickers and the bar light pulses over the painted scene', 'On phones the tavern reads cleanly with big tap targets for Browse, Legion and Mansion'] },
{ v: '3.0', date: 'Jun 2026', title: 'A painted tavern', notes: ['The home tavern is now a hand-painted scene — an elf host, an orc bartender and a dwarf patron in a lamplit hall — replacing the old pixel tiles', 'Every painted fixture is still a door: the board opens the roster, the barkeep signs you in, the elf pitches the tour, the side passages lead to your Legion and Mansion'] },
{ v: '2.5', date: 'Jun 2026', title: 'Chat speaks markdown', notes: ['Chat messages now render markdown — code blocks, lists, tables and links — so an agent can answer with formatted, clickable replies (it picks its own links, to the netdisk or anywhere)'] },

View File

@ -1952,3 +1952,14 @@ button {
.gm-cta-row { display: flex; }
.gm-stage3 .gm-room { width: 100%; }
}
/* native web terminal (3.2) */
.pty { display: flex; flex-direction: column; gap: 8px; }
.pty-bar { display: flex; align-items: center; gap: 9px; }
.pty-title { color: #cfe2f2; font-size: 12.5px; font-family: ui-monospace, Menlo, monospace; }
.pty-mode { margin-left: auto; color: #8a7bb0; font-size: 11px; }
.pty-screen { margin: 0; background: #05070c; border: 1px solid #1c2438; border-radius: 9px; padding: 12px; min-height: 260px; max-height: 50vh; overflow: auto; font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 12.5px; line-height: 1.4; color: #cfe6d8; white-space: pre-wrap; word-break: break-word; outline: none; cursor: text; }
.pty-screen.on { border-color: #46c8e0; box-shadow: 0 0 0 1px #46c8e0, inset 0 0 18px rgba(70,200,235,0.06); }
.pty-screen.ro { cursor: default; }
.pty-hint { margin: 0; }
.pty-hint code { font-size: 11px; }