From e72aa69a7b743de8d54cba915d0a5452891f05a3 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Thu, 3 Sep 2026 14:52:04 -0400 Subject: [PATCH] =?UTF-8?q?claude-in=20v1:=20handle-scoped=20agent=20prese?= =?UTF-8?q?nce=20=E2=80=94=20link=20an=20agent,=20light=20the=20mark=20on?= =?UTF-8?q?=20AC=20surfaces?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Presence relay (session-server/agent-presence.mjs) mirrors oskiewar's role=agent counting, platform-wide and scoped to the user's handle. bios lights a small linked-agent glyph bottom-right while an agent is attached; toolchain/ac-agent.mjs is the claude-in CLI. Claude-Session: https://claude.ai/code/session_01DfdByEQCKzXHsRsXPwPLPA --- session-server/agent-presence.mjs | 137 ++++++++++++++++++ session-server/agent-presence.test.mjs | 100 +++++++++++++ session-server/session.mjs | 5 + system/public/aesthetic.computer/bios.mjs | 4 + .../aesthetic.computer/lib/agent-presence.mjs | 92 ++++++++++++ toolchain/ac-agent.mjs | 63 ++++++++ 6 files changed, 401 insertions(+) create mode 100644 session-server/agent-presence.mjs create mode 100644 session-server/agent-presence.test.mjs create mode 100644 system/public/aesthetic.computer/lib/agent-presence.mjs create mode 100644 toolchain/ac-agent.mjs diff --git a/session-server/agent-presence.mjs b/session-server/agent-presence.mjs new file mode 100644 index 0000000000..5ad2b12e0d --- /dev/null +++ b/session-server/agent-presence.mjs @@ -0,0 +1,137 @@ +// Agent Presence, 26.09.03 +// Tiny public relay: an AC surface announces itself under its handle's room, +// an agent attaches to the same room, and both sides hear who is in it. This +// lights the little agent mark on the aesthetic.computer interface — the same +// awareness oskiewar's telemetry antenna gives its debug bug, platform-wide. +// Presence only: no state rides through here, so the room cost stays a set of +// sockets and a label each. + +const ROOM_NAME = /^[a-z0-9_-]{1,32}$/; +const LABEL = /^[a-z0-9 _-]{1,24}$/i; +const MAX_AGENTS = 4; +const MAX_SURFACES = 16; +const MAX_ROOMS = 256; +const ROOM_TTL_MS = 10 * 60 * 1000; +const MAX_MESSAGE_BYTES = 512; + +export function canonicalRoom(value) { + const raw = String(value || "").toLowerCase(); + const name = raw.startsWith("@") ? raw.slice(1) : raw; + return ROOM_NAME.test(name) ? name : null; +} + +function send(ws, type, content) { + if (ws?.readyState !== 1) return false; + try { ws.send(JSON.stringify({ type, content })); return true; } + catch { return false; } +} + +export class AgentPresenceManager { + constructor({ now = () => Date.now() } = {}) { + this.rooms = new Map(); + this.now = now; + } + + accepts(url) { + try { return new URL(url, "http://session").pathname === "/agent-presence"; } + catch { return false; } + } + + handleConnection(ws, req) { + if (!this.accepts(req?.url)) return false; + // This route returns before the ordinary game-socket heartbeat setup. + ws.isAlive = true; + ws.on("pong", () => { ws.isAlive = true; }); + const url = new URL(req.url, "http://session"); + const roomName = canonicalRoom(url.searchParams.get("room")); + if (!roomName) { + send(ws, "agent-presence:error", { message: "Invalid room" }); + ws.close?.(4400, "Invalid room"); + return true; + } + const role = url.searchParams.get("role") === "agent" ? "agent" : "surface"; + const rawLabel = url.searchParams.get("label") || role; + const label = LABEL.test(rawLabel) ? rawLabel : role; + + this.prune(); + let room = this.rooms.get(roomName); + if (!room) { + if (this.rooms.size >= MAX_ROOMS) { + send(ws, "agent-presence:error", { message: "Presence capacity reached" }); + ws.close?.(4429, "Capacity reached"); + return true; + } + room = { name: roomName, agents: new Map(), surfaces: new Map(), + updatedAt: this.now(), helloAt: 0 }; + this.rooms.set(roomName, room); + } + + const members = role === "agent" ? room.agents : room.surfaces; + const cap = role === "agent" ? MAX_AGENTS : MAX_SURFACES; + if (members.size >= cap) { + send(ws, "agent-presence:error", { message: `Room full for ${role}s` }); + ws.close?.(4429, "Capacity reached"); + return true; + } + + members.set(ws, { label }); + room.updatedAt = this.now(); + send(ws, "agent-presence:status", this.status(room)); + this.broadcast(room); + + ws.on("message", (data) => this.hello(room, ws, data)); + const remove = () => { + members.delete(ws); + room.updatedAt = this.now(); + this.broadcast(room); + }; + ws.on("close", remove); + ws.on("error", remove); + return true; + } + + // An agent may rename itself once connected; nothing else rides through. + hello(room, ws, data) { + if (Buffer.byteLength(data) > MAX_MESSAGE_BYTES) return; + let message; + try { message = JSON.parse(data.toString()); } catch { return; } + if (message.type !== "agent-presence:hello") return; + const entry = room.agents.get(ws) || room.surfaces.get(ws); + if (!entry) return; + const label = message.content?.label; + if (typeof label !== "string" || !LABEL.test(label)) return; + const now = this.now(); + if (room.helloAt && now - room.helloAt < 1000) return; + room.helloAt = now; + entry.label = label; + this.broadcast(room); + } + + status(room) { + return { + room: room.name, + agents: [...room.agents.values()].map((entry) => entry.label), + surfaces: room.surfaces.size, + updatedAt: room.updatedAt, + }; + } + + broadcast(room) { + const status = this.status(room); + for (const ws of [...room.agents.keys(), ...room.surfaces.keys()]) + send(ws, "agent-presence:status", status); + } + + prune() { + const oldest = this.now() - ROOM_TTL_MS; + for (const [name, room] of this.rooms) { + if (room.agents.size === 0 && room.surfaces.size === 0 && + room.updatedAt < oldest) + this.rooms.delete(name); + } + } +} + +export const AGENT_PRESENCE_LIMITS = Object.freeze({ + MAX_AGENTS, MAX_SURFACES, MAX_ROOMS, ROOM_TTL_MS, MAX_MESSAGE_BYTES, +}); diff --git a/session-server/agent-presence.test.mjs b/session-server/agent-presence.test.mjs new file mode 100644 index 0000000000..278937426c --- /dev/null +++ b/session-server/agent-presence.test.mjs @@ -0,0 +1,100 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; +import { + AgentPresenceManager, + canonicalRoom, + AGENT_PRESENCE_LIMITS, +} from "./agent-presence.mjs"; + +class FakeSocket extends EventEmitter { + constructor() { super(); this.readyState = 1; this.sent = []; this.closed = null; } + send(value) { this.sent.push(JSON.parse(value)); } + close(code, reason) { this.readyState = 3; this.closed = { code, reason }; this.emit("close"); } +} + +const req = (query) => ({ url: `/agent-presence?${query}` }); +const last = (ws) => ws.sent[ws.sent.length - 1]; + +test("room names accept handles with or without the @", () => { + assert.equal(canonicalRoom("@jeffrey"), "jeffrey"); + assert.equal(canonicalRoom("JEFFREY"), "jeffrey"); + assert.equal(canonicalRoom("laer-klokken_2"), "laer-klokken_2"); + assert.equal(canonicalRoom(""), null); + assert.equal(canonicalRoom("no spaces"), null); + assert.equal(canonicalRoom("x".repeat(33)), null); +}); + +test("wrong path is not claimed; bad room is rejected", () => { + const manager = new AgentPresenceManager(); + assert.equal(manager.handleConnection(new FakeSocket(), { url: "/other" }), false); + const ws = new FakeSocket(); + assert.equal(manager.handleConnection(ws, req("room=bad room")), true); + assert.equal(ws.closed.code, 4400); +}); + +test("surface hears the agent arrive and leave", () => { + const manager = new AgentPresenceManager(); + const surface = new FakeSocket(); + manager.handleConnection(surface, req("room=@jeffrey&role=surface")); + assert.deepEqual(last(surface).content.agents, []); + assert.equal(last(surface).content.surfaces, 1); + + const agent = new FakeSocket(); + manager.handleConnection(agent, req("room=jeffrey&role=agent&label=claude")); + assert.deepEqual(last(surface).content.agents, ["claude"]); + assert.deepEqual(last(agent).content.agents, ["claude"]); + + agent.close(); + assert.deepEqual(last(surface).content.agents, []); +}); + +test("agent can rename itself with hello, rate-limited", () => { + let time = 1000; + const manager = new AgentPresenceManager({ now: () => time }); + const surface = new FakeSocket(); + const agent = new FakeSocket(); + manager.handleConnection(surface, req("room=jeffrey&role=surface")); + manager.handleConnection(agent, req("room=jeffrey&role=agent")); + assert.deepEqual(last(surface).content.agents, ["agent"]); + + agent.emit("message", JSON.stringify({ + type: "agent-presence:hello", content: { label: "claude" } })); + assert.deepEqual(last(surface).content.agents, ["claude"]); + + agent.emit("message", JSON.stringify({ + type: "agent-presence:hello", content: { label: "too-soon" } })); + assert.deepEqual(last(surface).content.agents, ["claude"]); + + time += 2000; + agent.emit("message", JSON.stringify({ + type: "agent-presence:hello", content: { label: "fable" } })); + assert.deepEqual(last(surface).content.agents, ["fable"]); +}); + +test("agent capacity is enforced apart from surfaces", () => { + const manager = new AgentPresenceManager(); + for (let i = 0; i < AGENT_PRESENCE_LIMITS.MAX_AGENTS; i += 1) { + const ws = new FakeSocket(); + manager.handleConnection(ws, req("room=jeffrey&role=agent")); + assert.equal(ws.closed, null); + } + const extra = new FakeSocket(); + manager.handleConnection(extra, req("room=jeffrey&role=agent")); + assert.equal(extra.closed.code, 4429); + const surface = new FakeSocket(); + manager.handleConnection(surface, req("room=jeffrey&role=surface")); + assert.equal(surface.closed, null); +}); + +test("empty rooms prune after the TTL", () => { + let time = 1000; + const manager = new AgentPresenceManager({ now: () => time }); + const ws = new FakeSocket(); + manager.handleConnection(ws, req("room=jeffrey&role=surface")); + ws.close(); + assert.equal(manager.rooms.size, 1); + time += AGENT_PRESENCE_LIMITS.ROOM_TTL_MS + 1; + manager.prune(); + assert.equal(manager.rooms.size, 0); +}); diff --git a/session-server/session.mjs b/session-server/session.mjs index 5d44c397e7..8efbc8ef09 100644 --- a/session-server/session.mjs +++ b/session-server/session.mjs @@ -156,6 +156,7 @@ chatManager.setPresenceResolver(getHandlesOnPiece); const duelManager = new DuelManager(); const fightManager = new FightManager(); const oskiewarLiveManager = new OskiewarLiveManager(); +const agentPresenceManager = new AgentPresenceManager(); // 🌐 World Managers — Q3-style server-authoritative multiplayer, one // instance per 3D world. Wire protocol is shared; only the event prefix // differs (`arena:*`, `land:*`). Routing below is generic over this map — @@ -170,6 +171,7 @@ import { ChatManager } from "./chat-manager.mjs"; // Multi-instance chat support import { DuelManager } from "./duel-manager.mjs"; // Server-authoritative duel game. import { FightManager } from "./fight-manager.mjs"; // Fight presence + seat queue. import { OskiewarLiveManager } from "./oskiewar-live-manager.mjs"; +import { AgentPresenceManager } from "./agent-presence.mjs"; // Handle-scoped agent awareness. import { WorldManager, ARENA_CFG, ARENA_SPAWNS } from "./world-manager.mjs"; // Server-authoritative 3D worlds. import { LAND_CFG, LAND_SPAWNS } from "../system/public/aesthetic.computer/lib/land-world.mjs"; @@ -1753,6 +1755,9 @@ wss.on("connection", async (ws, req) => { // Public, pronounceable-ID-scoped oskiewar phone spectator rooms. if (oskiewarLiveManager.handleConnection(ws, req)) return; + + // Handle-scoped agent presence — lights the agent mark on AC surfaces. + if (agentPresenceManager.handleConnection(ws, req)) return; // Route status dashboard WebSocket connections separately if (req.url === '/status-stream') { diff --git a/system/public/aesthetic.computer/bios.mjs b/system/public/aesthetic.computer/bios.mjs index 3c815826c8..99ae42f8e1 100644 --- a/system/public/aesthetic.computer/bios.mjs +++ b/system/public/aesthetic.computer/bios.mjs @@ -6579,6 +6579,10 @@ async function boot(parsed, bpm = 60, resolution, debug) { if (type === "handle") { HANDLE = content; // Set the global HANDLE const to the user handle. + // Join the handle's presence room so a linked agent lights the mark. + import("./lib/agent-presence.mjs") + .then((mod) => mod.startAgentPresence({ room: HANDLE })) + .catch(() => {}); return; } diff --git a/system/public/aesthetic.computer/lib/agent-presence.mjs b/system/public/aesthetic.computer/lib/agent-presence.mjs new file mode 100644 index 0000000000..368beb4b37 --- /dev/null +++ b/system/public/aesthetic.computer/lib/agent-presence.mjs @@ -0,0 +1,92 @@ +// Agent Presence (client), 26.09.03 +// Connects this AC surface to the presence relay under the user's handle and +// shows a small agent mark in the bottom-right corner while an agent is +// linked in — the platform-wide cousin of oskiewar's telemetry antenna. + +const RELAY_HOST = "session-server.aesthetic.computer"; +const RETRY_MS = 30 * 1000; + +let socket = null; +let currentRoom = null; +let mark = null; +let retryTimer = null; + +// A 12x12 linked-agent glyph: antenna up top ("linked"), head below ("agent"). +function drawMark(canvas, agents) { + const ctx = canvas.getContext("2d"); + ctx.clearRect(0, 0, 12, 12); + ctx.fillStyle = "rgb(70, 200, 90)"; // acgreen antenna + ctx.fillRect(5, 0, 2, 3); + ctx.fillRect(2, 1, 2, 1); + ctx.fillRect(8, 1, 2, 1); + ctx.fillStyle = "rgb(180, 72, 135)"; // acpink head + ctx.fillRect(3, 4, 6, 6); + ctx.fillStyle = "rgb(255, 255, 255)"; // eyes + ctx.fillRect(4, 6, 1, 2); + ctx.fillRect(7, 6, 1, 2); + canvas.title = `linked: ${agents.join(", ")}`; +} + +function ensureMark() { + if (mark) return mark; + mark = document.createElement("canvas"); + mark.width = 12; + mark.height = 12; + mark.style.cssText = + "position:fixed;right:8px;bottom:8px;width:24px;height:24px;" + + "image-rendering:pixelated;z-index:1000;display:none;opacity:0.9;"; + mark.dataset.acAgentMark = "true"; + document.body.appendChild(mark); + return mark; +} + +function show(agents) { + const canvas = ensureMark(); + if (agents.length > 0) { + drawMark(canvas, agents); + canvas.style.display = "block"; + } else { + canvas.style.display = "none"; + } +} + +function connect(room) { + const dev = location.hostname === "localhost"; + const url = dev + ? `ws://localhost:8889/agent-presence?room=${room}&role=surface` + : `wss://${RELAY_HOST}/agent-presence?room=${room}&role=surface`; + try { + socket = new WebSocket(url); + } catch { + return; // No relay reachable; the mark just never lights. + } + socket.onmessage = (event) => { + let message; + try { message = JSON.parse(event.data); } catch { return; } + if (message.type === "agent-presence:status") { + show(message.content?.agents || []); + } + }; + const retry = () => { + socket = null; + show([]); + if (currentRoom !== room) return; // Superseded by a newer start. + clearTimeout(retryTimer); + retryTimer = setTimeout(() => { + if (currentRoom === room && !socket) connect(room); + }, RETRY_MS); + }; + socket.onclose = retry; + socket.onerror = () => socket?.close(); +} + +// Idempotent: bios calls this whenever the handle lands or changes. +export function startAgentPresence({ room }) { + const name = String(room || "").toLowerCase().replace(/^@/, ""); + if (!/^[a-z0-9_-]{1,32}$/.test(name)) return; + if (name === currentRoom && socket) return; + currentRoom = name; + clearTimeout(retryTimer); + if (socket) { try { socket.close(); } catch { /* replaced */ } socket = null; } + connect(name); +} diff --git a/toolchain/ac-agent.mjs b/toolchain/ac-agent.mjs new file mode 100644 index 0000000000..0130cbdfa5 --- /dev/null +++ b/toolchain/ac-agent.mjs @@ -0,0 +1,63 @@ +#!/usr/bin/env node +// AC Agent, 26.09.03 +// "Claude in" to aesthetic.computer: attach to a handle's presence room so +// every AC surface signed in as that handle shows the linked-agent mark in +// its bottom-right corner. Stays attached until killed. +// +// node toolchain/ac-agent.mjs @jeffrey +// node toolchain/ac-agent.mjs @jeffrey --label fable +// AC_PRESENCE_RELAY=ws://localhost:8889 node toolchain/ac-agent.mjs @jeffrey + +const RELAY = + process.env.AC_PRESENCE_RELAY || "wss://session-server.aesthetic.computer"; +const RETRY_MS = 5000; + +const args = process.argv.slice(2); +const target = args.find((value) => !value.startsWith("--")) || ""; +const labelIndex = args.indexOf("--label"); +const label = labelIndex !== -1 ? args[labelIndex + 1] : "claude"; +const room = target.toLowerCase().replace(/^@/, ""); + +if (!/^[a-z0-9_-]{1,32}$/.test(room)) { + console.error("usage: ac-agent.mjs <@handle> [--label name]"); + process.exit(1); +} + +let attempts = 0; + +function connect() { + const url = `${RELAY}/agent-presence?room=${room}&role=agent&label=${ + encodeURIComponent(label)}`; + const socket = new WebSocket(url); + + socket.onopen = () => { + attempts = 0; + console.log(`🛰️ linked into @${room} as "${label}"`); + }; + + socket.onmessage = (event) => { + let message; + try { message = JSON.parse(event.data); } catch { return; } + if (message.type === "agent-presence:status") { + const { agents, surfaces } = message.content; + console.log( + `[@${room}] surfaces=${surfaces} agents=${agents.join(", ") || "none"}`, + ); + } else if (message.type === "agent-presence:error") { + console.error(`❌ ${message.content.message}`); + } + }; + + socket.onclose = () => { + attempts += 1; + if (attempts > 60) { + console.error("❌ relay unreachable, giving up"); + process.exit(1); + } + console.log(`… reconnecting (${attempts})`); + setTimeout(connect, RETRY_MS); + }; + socket.onerror = () => socket.close(); +} + +connect(); -- 2.51.2