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:
parent
8c4729b4ea
commit
39e87dfa6f
@ -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" />
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "amerc-site",
|
||||
"version": "3.1.0-paintedmobile",
|
||||
"version": "3.2.0-pty",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@ -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' });
|
||||
|
||||
@ -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
88
src/Pty.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 agent’s 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)'] },
|
||||
|
||||
@ -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; }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user