From 39e87dfa6fcfacff66bbc7734daea363ff51b110 Mon Sep 17 00:00:00 2001 From: artheru Date: Thu, 11 Jun 2026 14:23:56 +0800 Subject: [PATCH] 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 --- index.html | 2 +- package.json | 2 +- server/amerc-api.mjs | 44 ++++++++++++++++++++++ src/AgentPlatform.jsx | 32 ++++------------ src/Pty.jsx | 88 +++++++++++++++++++++++++++++++++++++++++++ src/Scene2D.jsx | 3 +- src/styles.css | 11 ++++++ 7 files changed, 154 insertions(+), 28 deletions(-) create mode 100644 src/Pty.jsx diff --git a/index.html b/index.html index 224daed..aa9600a 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 573cc73..9a970ec 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "amerc-site", - "version": "3.1.0-paintedmobile", + "version": "3.2.0-pty", "private": true, "type": "module", "scripts": { diff --git a/server/amerc-api.mjs b/server/amerc-api.mjs index 8ae3ad8..db43dcb 100644 --- a/server/amerc-api.mjs +++ b/server/amerc-api.mjs @@ -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 +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' }); diff --git a/src/AgentPlatform.jsx b/src/AgentPlatform.jsx index 9ece105..e615601 100644 --- a/src/AgentPlatform.jsx +++ b/src/AgentPlatform.jsx @@ -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 (
- - + +
@@ -234,18 +225,7 @@ export function UsePanel({ instance, onClose }) {
)} {mode === 'chatbox' && keyOk && } - {mode === 'webpty' && keyOk && !sess && ( -
- - {err &&

{err}

} -
- )} - {mode === 'webpty' && sess && ( - <> -