diff --git a/apps/pwa/src/migrations/017_session_features.sql b/apps/pwa/src/migrations/017_session_features.sql
new file mode 100644
index 0000000..7948ad7
--- /dev/null
+++ b/apps/pwa/src/migrations/017_session_features.sql
@@ -0,0 +1,8 @@
+-- What the CLI on the other end can do, as a JSON array it declares when it
+-- registers. The page needs this to know whether to arm the arrow pad: an older
+-- mosh hands anything it is given to readline, so a keypress sent to one would
+-- be typed at the prompt of a live machine as text.
+--
+-- Declared rather than inferred from `version`, so shipping the next capability
+-- costs a string here instead of a release number the app has to know about.
+ALTER TABLE cli_sessions ADD COLUMN features TEXT;
diff --git a/apps/pwa/src/routes/sessions.mjs b/apps/pwa/src/routes/sessions.mjs
index c6e0bff..3983b3c 100644
--- a/apps/pwa/src/routes/sessions.mjs
+++ b/apps/pwa/src/routes/sessions.mjs
@@ -9,7 +9,7 @@
// GET /sessions human: connected instances
// GET /sessions/:id human: the mirror + a send box
// GET /sessions/:id/stream human: SSE (scrollback, then live)
-// POST /sessions/:id/commands human: queue a command
+// POST /sessions/:id/commands human: queue a command, or one key
import { Router } from "express";
import { get, all, run } from "../db.mjs";
import { id } from "../lib/crypto.mjs";
@@ -44,6 +44,30 @@ const LONG_POLL_MS = readLongPollMs();
// against a prompt that runs them one at a time.
const MAX_PASTED_LINES = 50;
+// Arrow keys pressed on the session page. The command queue carries opaque
+// strings, so a key rides it as a sentinel the CLI decodes — ESC leads it
+// because it is a byte nobody can put in the send box by typing, which is what
+// keeps a key from ever colliding with a real command. Kept in step with
+// `src/mirror.mjs` (the CLI half) by sessions-keys.test.mjs.
+const KEY_PREFIX = "\u001bmoshkey:";
+const KEY_NAMES = new Set(["up", "down", "left", "right", "enter"]);
+export const keyCommand = (name) => KEY_PREFIX + name;
+
+// Capabilities a CLI is allowed to claim when it registers. Anything else is
+// dropped, so a session row can never carry whatever a client felt like sending.
+const FEATURES = new Set(["keys"]);
+export function readFeatures(value) {
+ const list = Array.isArray(value) ? value : [];
+ return [...new Set(list.filter((f) => FEATURES.has(f)))];
+}
+// Keys are refused unless the CLI said it can press them. An older mosh, which
+// says nothing, hands whatever it is given to readline — sending it a key would
+// type the sentinel at the prompt of a live machine instead.
+export const supportsKeys = (session) => {
+ try { return JSON.parse(session.features || "[]").includes("keys"); }
+ catch { return false; }
+};
+
const isLive = (s) => s.status === "live" && Date.now() - Number(s.last_seen_at) < STALE_MS;
// A terminal dimension we're willing to render at. Anything outside this is a
@@ -118,11 +142,12 @@ sessionsRouter.post("/api/sessions", cliAuth, async (req, res) => {
cwd: req.body?.cwd ? String(req.body.cwd).slice(0, 200) : null,
cols: dim(req.body?.cols),
rows: dim(req.body?.rows),
+ features: JSON.stringify(readFeatures(req.body?.features)),
};
await run(
- `INSERT INTO cli_sessions (id,user_id,name,host,version,cwd,cols,rows,status,created_at,last_seen_at)
- VALUES (?,?,?,?,?,?,?,?,'live',?,?)`,
- [row.id, row.user_id, row.name, row.host, row.version, row.cwd, row.cols, row.rows, now, now]
+ `INSERT INTO cli_sessions (id,user_id,name,host,version,cwd,cols,rows,features,status,created_at,last_seen_at)
+ VALUES (?,?,?,?,?,?,?,?,?,'live',?,?)`,
+ [row.id, row.user_id, row.name, row.host, row.version, row.cwd, row.cols, row.rows, row.features, now, now]
);
res.json({ id: row.id, url: `/sessions/${row.id}` });
});
@@ -289,6 +314,18 @@ sessionsRouter.get("/sessions/:id", requireAuth, async (req, res) => {
if (!s) return res.status(404).type("html").send(page({ body: `No such session