--- /dev/null
+{
+ "name": "crimata-agent",
+ "version": "1.0.0",
+ "scripts": {
+ "build": "tsc",
+ "start": "node dist/index.js",
+ "dev": "ts-node src/index.ts"
+ },
+ "dependencies": {
+ "@anthropic-ai/sdk": "^0.39.0",
+ "express": "^4.18.0",
+ "ws": "^8.18.0"
+ },
+ "devDependencies": {
+ "@types/express": "^4.17.0",
+ "@types/node": "^20.0.0",
+ "@types/ws": "^8.5.0",
+ "typescript": "^5.0.0"
+ }
+}
--- /dev/null
+import Anthropic from '@anthropic-ai/sdk'
+import WebSocket from 'ws'
+import { Canvas } from './canvas'
+import { toolDefinitions } from './tools'
+import { BusEvent, CanvasElement, InstalledApp, WSMessage } from './types'
+
+const SERVICE_PORTS: Record<string, number> = {
+ auth: 7700,
+ dock: 7701,
+ agent: 7702,
+}
+
+async function fetchApps(): Promise<InstalledApp[]> {
+ try {
+ const res = await fetch('http://localhost:7701/apps')
+ return await res.json() as InstalledApp[]
+ } catch {
+ return []
+ }
+}
+
+function appPort(appId: string, apps: InstalledApp[]): number {
+ if (SERVICE_PORTS[appId]) return SERVICE_PORTS[appId]
+ return apps.find(a => a.id === appId)?.port ?? 3000
+}
+
+function buildSystemPrompt(apps: InstalledApp[]): string {
+ const appList = apps.map(a => {
+ const components = a.components?.length ? a.components.join(', ') : 'none'
+ const apiList = a.api?.map(e => `${e.method} ${e.endpoint} — ${e.description}`).join('; ') ?? ''
+ return ` - ${a.id} (${a.name}, port ${a.port}): components=[${components}] api=[${apiList}]`
+ }).join('\n')
+
+ return `You are the Crimata desktop agent — an agentic window manager for a personal Linux instance.
+You control a spatial canvas UI by calling tools. Think of yourself as both the OS shell and a helpful assistant.
+
+Installed apps:
+${appList || ' (none)'}
+
+Component naming: "app.component", e.g. "contacts.list", "contacts.card"
+App icons use type "app_icon", windows use type "window".
+
+Layout rules:
+- Place app icons spread naturally across the canvas (not all in one corner)
+- Open windows adjacent to their app icon
+- Keep the layout clean and uncluttered
+
+Behaviour:
+- On session.start: render all installed app icons on the canvas, then open each app's defaultComponent
+- On app_icon.clicked: render the app's default window next to the icon
+- On cursor.input: interpret naturally — open apps, answer questions, rearrange canvas, call APIs
+- On button.clicked or api events: decide whether to respond or stay silent
+- Only respond when you have something useful to do — silence is fine`
+}
+
+export async function handleEvent(
+ event: BusEvent,
+ canvas: Canvas,
+ ws: WebSocket | null,
+ apiKey: string,
+): Promise<void> {
+ const apps = await fetchApps()
+ const model = process.env.CLAUDE_MODEL || 'claude-haiku-4-5-20251001'
+
+ const client = new Anthropic({ apiKey })
+
+ const canvasJson = JSON.stringify(canvas.getState(), null, 2)
+ const userMsg = `Canvas:\n${canvasJson}\n\nEvent: ${JSON.stringify(event)}`
+
+ const messages: Anthropic.MessageParam[] = [
+ { role: 'user', content: userMsg },
+ ]
+
+ // Agentic loop — keep going until Claude stops calling tools
+ while (true) {
+ const response = await client.messages.create({
+ model,
+ max_tokens: 2048,
+ system: buildSystemPrompt(apps),
+ tools: toolDefinitions,
+ messages,
+ })
+
+ const toolUses = response.content.filter(b => b.type === 'tool_use')
+ if (toolUses.length === 0) break
+
+ const toolResults: Anthropic.ToolResultBlockParam[] = []
+
+ for (const block of response.content) {
+ if (block.type !== 'tool_use') continue
+
+ const input = block.input as Record<string, unknown>
+ let result: unknown = { ok: true }
+
+ switch (block.name) {
+ case 'render': {
+ const el = input as unknown as CanvasElement
+ canvas.render(el)
+ send(ws, { op: 'render', element: el })
+ break
+ }
+
+ case 'remove': {
+ const id = input.id as string
+ canvas.remove(id)
+ send(ws, { op: 'remove', id })
+ break
+ }
+
+ case 'call_api': {
+ try {
+ const port = appPort(input.app as string, apps)
+ const url = `http://localhost:${port}${input.endpoint as string}`
+ const res = await fetch(url, {
+ method: input.method as string,
+ headers: { 'Content-Type': 'application/json' },
+ body: input.body ? JSON.stringify(input.body) : undefined,
+ })
+ result = await res.json()
+ } catch (e: unknown) {
+ result = { error: String(e) }
+ }
+ break
+ }
+
+ case 'show_cursor_response': {
+ send(ws, {
+ op: 'cursor_response',
+ text: input.text as string | undefined,
+ pills: input.pills as string[] | undefined,
+ position: input.position as { x: number; y: number },
+ })
+ break
+ }
+ }
+
+ toolResults.push({
+ type: 'tool_result',
+ tool_use_id: block.id,
+ content: JSON.stringify(result),
+ })
+ }
+
+ messages.push({ role: 'assistant', content: response.content })
+ messages.push({ role: 'user', content: toolResults })
+
+ if (response.stop_reason !== 'tool_use') break
+ }
+}
+
+function send(ws: WebSocket | null, msg: WSMessage): void {
+ if (ws && ws.readyState === WebSocket.OPEN) {
+ ws.send(JSON.stringify(msg))
+ }
+}
--- /dev/null
+import { CanvasElement } from './types'
+
+export class Canvas {
+ private elements = new Map<string, CanvasElement>()
+
+ render(element: CanvasElement): void {
+ this.elements.set(element.id, element)
+ }
+
+ remove(id: string): void {
+ this.elements.delete(id)
+ }
+
+ update(id: string, patch: Partial<CanvasElement>): void {
+ const el = this.elements.get(id)
+ if (el) this.elements.set(id, { ...el, ...patch })
+ }
+
+ getState(): CanvasElement[] {
+ return Array.from(this.elements.values())
+ }
+
+ clear(): void {
+ this.elements.clear()
+ }
+}
--- /dev/null
+import express from 'express'
+import { WebSocketServer, WebSocket } from 'ws'
+import { createServer } from 'http'
+import { Canvas } from './canvas'
+import { handleEvent } from './agent'
+import { BusEvent } from './types'
+
+const PORT = 7702
+const API_KEY = process.env.ANTHROPIC_API_KEY ?? ''
+
+const app = express()
+const server = createServer(app)
+const wss = new WebSocketServer({ server, path: '/ws' })
+const canvas = new Canvas()
+
+let activeWs: WebSocket | null = null
+let sessionStarted = false
+
+app.use(express.json())
+
+app.get('/health', (_req, res) => res.json({ status: 'ok' }))
+
+// POST /events — server-side services emit events to the agent
+app.post('/events', async (req, res) => {
+ const event: BusEvent = req.body
+ res.json({ ok: true })
+ handleEvent(event, canvas, activeWs, API_KEY).catch(console.error)
+})
+
+wss.on('connection', (ws) => {
+ activeWs = ws
+ console.log('browser connected')
+
+ // Send current canvas state immediately
+ ws.send(JSON.stringify({ op: 'canvas', elements: canvas.getState() }))
+
+ // Fire session.start on first browser connection to initialise canvas
+ if (!sessionStarted) {
+ sessionStarted = true
+ handleEvent({ type: 'session.start' }, canvas, ws, API_KEY).catch(console.error)
+ }
+
+ ws.on('message', async (raw) => {
+ try {
+ const event: BusEvent = JSON.parse(raw.toString())
+ await handleEvent(event, canvas, ws, API_KEY)
+ } catch (e) {
+ console.error('ws message error:', e)
+ }
+ })
+
+ ws.on('close', () => {
+ if (activeWs === ws) activeWs = null
+ console.log('browser disconnected')
+ })
+})
+
+server.listen(PORT, () => {
+ console.log(`crimata-agent listening on :${PORT}`)
+})
--- /dev/null
+import Anthropic from '@anthropic-ai/sdk'
+
+export const toolDefinitions: Anthropic.Tool[] = [
+ {
+ name: 'render',
+ description: 'Place or update a component on the canvas. Use stable IDs so re-renders update in place.',
+ input_schema: {
+ type: 'object' as const,
+ properties: {
+ id: { type: 'string', description: 'Unique stable ID, e.g. "contacts-list", "mail-inbox"' },
+ type: { type: 'string', enum: ['app_icon', 'window'] },
+ component: { type: 'string', description: 'App component, e.g. "contacts.list", "contacts.card"' },
+ props: { type: 'object', description: 'Data to pass to the component' },
+ x: { type: 'number', description: 'Canvas X position in pixels' },
+ y: { type: 'number', description: 'Canvas Y position in pixels' },
+ running: { type: 'boolean', description: 'Whether the app is running (for app_icon type)' },
+ },
+ required: ['id', 'type', 'component', 'x', 'y'],
+ },
+ },
+ {
+ name: 'remove',
+ description: 'Remove a component from the canvas.',
+ input_schema: {
+ type: 'object' as const,
+ properties: {
+ id: { type: 'string' },
+ },
+ required: ['id'],
+ },
+ },
+ {
+ name: 'call_api',
+ description: 'Call a local app or service API on this instance. Returns the JSON response.',
+ input_schema: {
+ type: 'object' as const,
+ properties: {
+ app: { type: 'string', description: 'App ID or service: "contacts", "auth", "dock"' },
+ endpoint: { type: 'string', description: 'Path, e.g. "/contacts" or "/contacts/123"' },
+ method: { type: 'string', enum: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] },
+ body: { type: 'object', description: 'Request body for POST/PUT/PATCH' },
+ },
+ required: ['app', 'endpoint', 'method'],
+ },
+ },
+ {
+ name: 'show_cursor_response',
+ description: 'Show a text response or action pills near the cursor. Use for replying to user input.',
+ input_schema: {
+ type: 'object' as const,
+ properties: {
+ text: { type: 'string', description: 'Text to display' },
+ pills: { type: 'array', items: { type: 'string' }, description: 'Action buttons to show' },
+ position: {
+ type: 'object',
+ properties: {
+ x: { type: 'number' },
+ y: { type: 'number' },
+ },
+ required: ['x', 'y'],
+ },
+ },
+ required: ['position'],
+ },
+ },
+]
--- /dev/null
+export type CanvasElement = {
+ id: string
+ type: 'app_icon' | 'window' | 'cursor_response'
+ component: string // e.g. "contacts", "contacts.list", "generic.form"
+ props?: Record<string, unknown>
+ x: number
+ y: number
+ running?: boolean
+}
+
+export type BusEvent = {
+ type: string
+ data?: Record<string, unknown>
+ position?: { x: number; y: number }
+ text?: string
+}
+
+export type InstalledApp = {
+ id: string
+ name: string
+ icon: string
+ port: number
+ running: boolean
+ defaultComponent: string
+ components: string[]
+ api: ApiEndpoint[]
+}
+
+export type ApiEndpoint = {
+ name: string
+ description: string
+ method: string
+ endpoint: string
+ params?: Record<string, string>
+ body?: Record<string, string>
+}
+
+export type WSMessage =
+ | { op: 'render'; element: CanvasElement }
+ | { op: 'remove'; id: string }
+ | { op: 'canvas'; elements: CanvasElement[] }
+ | { op: 'cursor_response'; text?: string; pills?: string[]; position: { x: number; y: number } }
--- /dev/null
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "module": "commonjs",
+ "lib": ["ES2020"],
+ "outDir": "./dist",
+ "rootDir": "./src",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "resolveJsonModule": true
+ },
+ "include": ["src/**/*"]
+}
{
"id": "contacts",
"name": "Contacts",
+ "icon": "👤",
"port": 3001,
+ "defaultComponent": "contacts.list",
+ "components": ["contacts.list", "contacts.card"],
"db": true,
- "migrate": "npm run migrate"
+ "migrate": "npm run migrate",
+ "api": [
+ {
+ "name": "list_contacts",
+ "description": "Get all contacts ordered by name",
+ "method": "GET",
+ "endpoint": "/contacts"
+ },
+ {
+ "name": "get_contact",
+ "description": "Get a single contact by ID",
+ "method": "GET",
+ "endpoint": "/contacts/:id"
+ },
+ {
+ "name": "create_contact",
+ "description": "Create a new contact (name and domain required)",
+ "method": "POST",
+ "endpoint": "/contacts",
+ "body": {
+ "name": "string",
+ "domain": "string",
+ "avatar_url": "string?",
+ "notes": "string?"
+ }
+ },
+ {
+ "name": "update_contact",
+ "description": "Partially update a contact by ID",
+ "method": "PATCH",
+ "endpoint": "/contacts/:id",
+ "body": {
+ "name": "string?",
+ "domain": "string?",
+ "avatar_url": "string?",
+ "notes": "string?"
+ }
+ },
+ {
+ "name": "delete_contact",
+ "description": "Delete a contact by ID",
+ "method": "DELETE",
+ "endpoint": "/contacts/:id"
+ }
+ ]
}
#include "apps.h"
#define MANIFEST_GLOB "/usr/lib/crimata-*/crimata.json"
-#define MAX_FILE_SIZE 4096
+#define MAX_FILE_SIZE 16384
-/* Pull a string value out of a flat JSON object (same approach as auth) */
+/* Pull a string value out of a flat JSON object */
static int json_str(const char *json, const char *key, char *out, size_t out_len)
{
char search[128];
return 1;
}
+/* Extract a raw JSON array or object value (string-aware bracket matching) */
+static int json_raw_value(const char *json, const char *key, char *out, size_t out_len)
+{
+ char search[128];
+ snprintf(search, sizeof(search), "\"%s\"", key);
+ const char *pos = strstr(json, search);
+ if (!pos) return 0;
+
+ pos += strlen(search);
+ while (*pos == ' ' || *pos == ':') pos++;
+ if (*pos != '[' && *pos != '{') return 0;
+
+ char open = *pos;
+ char close = (open == '[') ? ']' : '}';
+ int depth = 0;
+ int in_str = 0;
+ size_t i = 0;
+
+ while (*pos && i < out_len - 1) {
+ char c = *pos;
+ out[i++] = c;
+
+ if (in_str) {
+ if (c == '\\' && *(pos + 1)) {
+ /* escaped char — copy next byte too */
+ pos++;
+ if (i < out_len - 1) out[i++] = *pos;
+ } else if (c == '"') {
+ in_str = 0;
+ }
+ } else {
+ if (c == '"') in_str = 1;
+ else if (c == open) depth++;
+ else if (c == close) {
+ depth--;
+ if (depth == 0) { pos++; break; }
+ }
+ }
+ pos++;
+ }
+
+ out[i] = '\0';
+ return (depth == 0 && i > 0) ? 1 : 0;
+}
+
static int parse_manifest(const char *path, app_t *app)
{
FILE *f = fopen(path, "r");
if (!json_str(buf, "id", app->id, sizeof(app->id))) return 0;
if (!json_str(buf, "name", app->name, sizeof(app->name))) return 0;
- json_str(buf, "icon", app->icon, sizeof(app->icon));
+
+ json_str(buf, "icon", app->icon, sizeof(app->icon));
+ json_str(buf, "defaultComponent", app->default_component, sizeof(app->default_component));
json_int(buf, "port", &app->port);
- app->running = 0;
+ json_raw_value(buf, "components", app->components_json, sizeof(app->components_json));
+ json_raw_value(buf, "api", app->api_json, sizeof(app->api_json));
+ app->running = 0;
return 1;
}
#define APPS_H
#define MAX_APPS 32
-#define MAX_STR 128
+#define MAX_STR 256
typedef struct {
char id[MAX_STR];
char name[MAX_STR];
char icon[MAX_STR];
int port;
- int running; /* populated at request time via systemd check */
+ int running; /* populated at request time via systemd check */
+ char default_component[MAX_STR];
+ char components_json[2048]; /* raw JSON array, e.g. ["contacts.list","contacts.card"] */
+ char api_json[8192]; /* raw JSON array of ApiEndpoint objects */
} app_t;
/* Scan /usr/lib/crimata-*/crimata.json — returns app count */
#include "systemd.h"
#define PORT 7701
-#define BUF_SIZE 16384
+#define BUF_SIZE 65536
/* ── JSON response helper ─────────────────────────────────────────────────── */
pos += snprintf(buf + pos, sizeof(buf) - pos, "[");
for (int i = 0; i < count; i++) {
+ const char *components = apps[i].components_json[0] ? apps[i].components_json : "[]";
+ const char *api = apps[i].api_json[0] ? apps[i].api_json : "[]";
+
pos += snprintf(buf + pos, sizeof(buf) - pos,
- "%s{\"id\":\"%s\",\"name\":\"%s\",\"icon\":\"%s\","
- "\"port\":%d,\"running\":%s}",
+ "%s{"
+ "\"id\":\"%s\","
+ "\"name\":\"%s\","
+ "\"icon\":\"%s\","
+ "\"port\":%d,"
+ "\"running\":%s,"
+ "\"defaultComponent\":\"%s\","
+ "\"components\":%s,"
+ "\"api\":%s"
+ "}",
i > 0 ? "," : "",
- apps[i].id, apps[i].name, apps[i].icon,
- apps[i].port, apps[i].running ? "true" : "false");
+ apps[i].id,
+ apps[i].name,
+ apps[i].icon,
+ apps[i].port,
+ apps[i].running ? "true" : "false",
+ apps[i].default_component,
+ components,
+ api);
}
pos += snprintf(buf + pos, sizeof(buf) - pos, "]");
static enum MHD_Result handle_action(struct MHD_Connection *conn,
const char *app_id, int start)
{
- /* Verify app exists */
app_t apps[MAX_APPS];
int count = apps_scan(apps);
int found = 0;
char app_id[MAX_STR];
if (strcmp(method, "POST") == 0) {
- if (sscanf(url, "/apps/%127[^/]/start", app_id) == 1)
+ if (sscanf(url, "/apps/%255[^/]/start", app_id) == 1)
return handle_action(conn, app_id, 1);
- if (sscanf(url, "/apps/%127[^/]/stop", app_id) == 1)
+ if (sscanf(url, "/apps/%255[^/]/stop", app_id) == 1)
return handle_action(conn, app_id, 0);
}
--- /dev/null
+<div class="contact-card">
+ <div class="cc-avatar" id="cc-avatar">?</div>
+ <h2 id="cc-name">—</h2>
+ <div class="cc-fields" id="cc-fields"></div>
+</div>
+
+<style>
+.contact-card { padding: 28px 24px; font-family: -apple-system, sans-serif; text-align: center; width: 280px; }
+.cc-avatar { width: 64px; height: 64px; background: #e8e8e8; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 26px; font-weight: 600; color: #666; margin: 0 auto 14px; }
+.contact-card h2 { font-size: 18px; font-weight: 600; color: #1a1a1a; margin-bottom: 14px; }
+.cc-fields { text-align: left; display: flex; flex-direction: column; gap: 8px; }
+.cc-field { font-size: 13px; color: #888; }
+.cc-field span { font-weight: 500; color: #333; }
+.cc-empty { color: #aaa; font-size: 13px; }
+</style>
+
+<script>
+(function () {
+ const props = window.__componentProps || {}
+ const id = props.id || props.contactId
+ if (!id) {
+ document.getElementById('cc-name').textContent = 'No contact selected'
+ return
+ }
+ fetch(`/apps/contacts/${id}`)
+ .then(r => r.ok ? r.json() : Promise.reject())
+ .then(c => {
+ document.getElementById('cc-avatar').textContent = (c.name || '?')[0].toUpperCase()
+ document.getElementById('cc-name').textContent = c.name || '—'
+ const fields = document.getElementById('cc-fields')
+ fields.innerHTML = [
+ c.domain ? `<div class="cc-field">Domain: <span>${c.domain}</span></div>` : '',
+ c.notes ? `<div class="cc-field">Notes: <span>${c.notes}</span></div>` : '',
+ ].join('')
+ if (!fields.innerHTML) fields.innerHTML = '<div class="cc-empty">No details.</div>'
+ })
+ .catch(() => {
+ document.getElementById('cc-name').textContent = 'Contact not found'
+ })
+})()
+</script>
--- /dev/null
+<div class="contacts-list">
+ <div class="cl-header">
+ <h2>Contacts</h2>
+ <button data-event="new_contact">+ New</button>
+ </div>
+ <div class="cl-items" id="cl-items">
+ <div class="cl-empty">Loading…</div>
+ </div>
+</div>
+
+<style>
+.contacts-list { padding: 20px 24px; font-family: -apple-system, sans-serif; width: 360px; }
+.cl-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
+.cl-header h2 { font-size: 17px; font-weight: 600; color: #1a1a1a; }
+.cl-header button { background: #1a1a1a; color: white; border: none; border-radius: 8px; padding: 6px 14px; font-size: 12px; font-weight: 500; cursor: pointer; }
+.cl-header button:hover { background: #333; }
+.contact-row { display: flex; align-items: center; gap: 12px; padding: 9px 10px; border-radius: 10px; cursor: pointer; transition: background 0.1s; }
+.contact-row:hover { background: #f5f5f5; }
+.contact-avatar { width: 34px; height: 34px; background: #e8e8e8; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 600; color: #666; flex-shrink: 0; }
+.contact-name { font-size: 14px; font-weight: 500; color: #1a1a1a; }
+.contact-domain { font-size: 12px; color: #999; }
+.cl-empty { color: #aaa; font-size: 13px; padding: 12px 0; }
+</style>
+
+<script>
+(function () {
+ const list = document.getElementById('cl-items')
+ fetch('/apps/contacts/')
+ .then(r => r.json())
+ .then(contacts => {
+ if (!contacts.length) {
+ list.innerHTML = '<div class="cl-empty">No contacts yet.</div>'
+ return
+ }
+ list.innerHTML = contacts.map(c => `
+ <div class="contact-row" data-id="${c.id}">
+ <div class="contact-avatar">${(c.name || '?')[0].toUpperCase()}</div>
+ <div>
+ <div class="contact-name">${c.name || '—'}</div>
+ <div class="contact-domain">${c.domain || ''}</div>
+ </div>
+ </div>
+ `).join('')
+ })
+ .catch(() => { list.innerHTML = '<div class="cl-empty">Could not load contacts.</div>' })
+})()
+</script>
<title>Crimata</title>
<link rel="stylesheet" href="styles/canvas.css" />
<link rel="stylesheet" href="styles/window.css" />
- <link rel="stylesheet" href="styles/dock.css" />
<link rel="stylesheet" href="styles/cursor.css" />
<link rel="stylesheet" href="styles/login.css" />
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
</head>
-<body x-data="os" @keydown.window="handleKeydown">
+<body x-data="os" @keydown.window="handleKeydown" @mousemove.window="trackCursor">
<!-- Login overlay (shown until authenticated) -->
<div id="login-overlay" x-show="!authed" x-cloak>
</div>
</div>
- <!-- Infinite canvas -->
- <div id="canvas"
- @mousedown="startPan"
- @mousemove="pan"
- @mouseup="endPan"
- @mouseleave="endPan"
- :style="`transform: translate(${offset.x}px, ${offset.y}px)`"
- >
- <!-- Windows render here -->
- <template x-for="win in windows" :key="win.id">
- <div class="window"
- :style="`left:${win.x}px; top:${win.y}px; width:${win.w}px; height:${win.h}px; z-index:${win.z}`"
- @mousedown.stop="focusWindow(win)"
- >
- <div class="window-titlebar"
- @mousedown.stop="startDrag($event, win)"
- >
- <span class="window-title" x-text="win.title"></span>
- <button class="window-close" @click.stop="closeWindow(win)">✕</button>
- </div>
- <div class="window-content">
- <iframe :src="win.url" frameborder="0"></iframe>
- </div>
- </div>
- </template>
- </div>
+ <!-- Infinite canvas — elements placed by the agent -->
+ <div id="canvas"></div>
+
+ <!-- Cursor dot -->
+ <div id="cursor-dot" x-show="authed" x-cloak></div>
- <!-- Cursor input overlay -->
- <div id="cursor-input" x-show="inputMode" @click.stop>
+ <!-- Input bubble (Cmd+Space) — follows cursor -->
+ <div id="cursor-bubble"
+ x-show="inputMode"
+ x-cloak
+ :style="`left:${cursorX}px; top:${cursorY}px`"
+ >
<input
- id="cursor-input-field"
+ id="cursor-input"
type="text"
x-model="cursorQuery"
@keydown.enter="submitQuery"
@keydown.escape="exitInputMode"
- placeholder="Type a command..."
+ placeholder="Ask anything…"
x-ref="cursorInput"
/>
</div>
- <!-- Dock -->
- <div id="dock">
- <template x-for="app in installedApps" :key="app.id">
- <div class="dock-icon" @click="openApp(app)" :title="app.name">
- <span x-text="app.icon"></span>
- <span class="dock-dot" x-show="app.running"></span>
- </div>
- </template>
- <div id="dock-user" x-show="currentUser" x-text="currentUser"></div>
- </div>
-
<script src="js/os.js"></script>
</body>
</html>
+/* ── Canvas / WebSocket layer ───────────────────────────────────────────────
+ Manages the agent WebSocket connection and renders canvas elements.
+ This layer is pure vanilla JS — Alpine handles auth + cursor input.
+ ─────────────────────────────────────────────────────────────────────────── */
+
+let ws = null
+let canvasDiv = null
+const elMap = {} // id → DOM element
+
+function connectWS() {
+ const proto = location.protocol === 'https:' ? 'wss' : 'ws'
+ ws = new WebSocket(`${proto}://${location.host}/ws`)
+
+ ws.onopen = () => console.log('agent connected')
+
+ ws.onmessage = (e) => {
+ const msg = JSON.parse(e.data)
+ switch (msg.op) {
+ case 'render': renderEl(msg.element); break
+ case 'remove': removeEl(msg.id); break
+ case 'canvas': msg.elements.forEach(renderEl); break
+ case 'cursor_response': showCursorResponse(msg); break
+ }
+ }
+
+ ws.onclose = () => {
+ console.log('agent disconnected — reconnecting in 2s')
+ setTimeout(connectWS, 2000)
+ }
+}
+
+function sendEvent(event) {
+ if (ws && ws.readyState === WebSocket.OPEN)
+ ws.send(JSON.stringify(event))
+}
+
+/* ── Element lifecycle ────────────────────────────────────────────────────── */
+
+function removeEl(id) {
+ const div = elMap[id]
+ if (div) { div.remove(); delete elMap[id] }
+}
+
+function renderEl(el) {
+ removeEl(el.id)
+
+ const div = document.createElement('div')
+ div.dataset.elId = el.id
+ div.classList.add('canvas-element', el.type.replace(/_/g, '-'))
+ div.style.left = el.x + 'px'
+ div.style.top = el.y + 'px'
+
+ if (el.type === 'app_icon') {
+ buildAppIcon(div, el)
+ } else if (el.type === 'window') {
+ buildWindow(div, el)
+ }
+
+ canvasDiv.appendChild(div)
+ elMap[el.id] = div
+ makeDraggable(div, el)
+}
+
+/* ── App icon ─────────────────────────────────────────────────────────────── */
+
+function buildAppIcon(div, el) {
+ const icon = el.props?.icon || '📦'
+ const label = el.props?.label || el.props?.name || el.component
+ const dot = el.running ? '<div class="icon-dot"></div>' : ''
+
+ div.innerHTML = `
+ <div class="icon-glyph">${icon}</div>
+ <div class="icon-label">${label}</div>
+ ${dot}
+ `
+
+ div.addEventListener('click', () => {
+ sendEvent({
+ type: 'app_icon.clicked',
+ data: { id: el.id, component: el.component },
+ position: { x: el.x, y: el.y },
+ })
+ })
+}
+
+/* ── Window (content card) ────────────────────────────────────────────────── */
+
+function buildWindow(div, el) {
+ const content = document.createElement('div')
+ content.className = 'window-content'
+ content.innerHTML = '<div class="loading">Loading…</div>'
+ div.appendChild(content)
+
+ // Apply explicit size if provided
+ if (el.props?.width) div.style.width = el.props.width + 'px'
+ if (el.props?.height) div.style.height = el.props.height + 'px'
+
+ const parts = el.component.split('.')
+ const appId = parts[0]
+ const compName = parts[1] || parts[0]
+ const url = `/components/${appId}/${compName}.html`
+
+ fetch(url)
+ .then(r => r.ok ? r.text() : Promise.reject(r.status))
+ .then(html => {
+ content.innerHTML = html
+ // Make props available to component scripts via window scope
+ window.__componentProps = el.props || {}
+ // Re-execute inline scripts (innerHTML doesn't run scripts)
+ content.querySelectorAll('script').forEach(old => {
+ const s = document.createElement('script')
+ s.textContent = old.textContent
+ old.replaceWith(s)
+ })
+ })
+ .catch(() => {
+ content.innerHTML = `<div class="loading">${el.component}</div>`
+ })
+
+ // Forward data-event button clicks to agent
+ div.addEventListener('click', (e) => {
+ const btn = e.target.closest('button[data-event]')
+ if (btn) {
+ sendEvent({
+ type: 'button.clicked',
+ data: { window: el.id, event: btn.dataset.event, value: btn.dataset.value },
+ })
+ }
+ })
+}
+
+/* ── Cursor response ──────────────────────────────────────────────────────── */
+
+function showCursorResponse(msg) {
+ removeEl('cursor_response')
+
+ const div = document.createElement('div')
+ div.dataset.elId = 'cursor_response'
+ div.classList.add('canvas-element', 'cursor-response')
+ div.style.left = msg.position.x + 'px'
+ div.style.top = msg.position.y + 'px'
+
+ let html = ''
+ if (msg.text) html += `<p>${msg.text}</p>`
+ if (msg.pills?.length) {
+ html += '<div class="response-pills">'
+ msg.pills.forEach(p => { html += `<button data-pill="${p}">${p}</button>` })
+ html += '</div>'
+ }
+ div.innerHTML = html
+
+ div.querySelectorAll('[data-pill]').forEach(btn => {
+ btn.addEventListener('click', () => {
+ sendEvent({ type: 'pill.clicked', data: { text: btn.dataset.pill } })
+ removeEl('cursor_response')
+ })
+ })
+
+ // Dismiss on outside click
+ const dismiss = (e) => {
+ if (!div.contains(e.target)) {
+ removeEl('cursor_response')
+ document.removeEventListener('click', dismiss)
+ }
+ }
+ setTimeout(() => document.addEventListener('click', dismiss), 50)
+
+ canvasDiv.appendChild(div)
+ elMap['cursor_response'] = div
+}
+
+/* ── Drag ─────────────────────────────────────────────────────────────────── */
+
+function makeDraggable(div, el) {
+ let startX, startY, startL, startT
+
+ const handle = div // drag from anywhere on element
+
+ handle.addEventListener('mousedown', (e) => {
+ // Don't initiate drag from interactive elements inside windows
+ if (el.type === 'window' && (
+ e.target.tagName === 'BUTTON' ||
+ e.target.tagName === 'INPUT' ||
+ e.target.tagName === 'A' ||
+ e.target.tagName === 'SELECT'
+ )) return
+
+ e.preventDefault()
+ startX = e.clientX
+ startY = e.clientY
+ startL = parseInt(div.style.left) || 0
+ startT = parseInt(div.style.top) || 0
+
+ const onMove = (e) => {
+ div.style.left = (startL + e.clientX - startX) + 'px'
+ div.style.top = (startT + e.clientY - startY) + 'px'
+ }
+ const onUp = () => {
+ window.removeEventListener('mousemove', onMove)
+ window.removeEventListener('mouseup', onUp)
+ }
+ window.addEventListener('mousemove', onMove)
+ window.addEventListener('mouseup', onUp)
+ })
+}
+
+/* ── Alpine component (auth + cursor input) ─────────────────────────────────
+ Keeps Alpine scope minimal — just auth state and cursor input.
+ ─────────────────────────────────────────────────────────────────────────── */
+
+const cursorDot = document.getElementById('cursor-dot')
+
document.addEventListener('alpine:init', () => {
Alpine.data('os', () => ({
- // ── Auth state ────────────────────────────────────────────────────────
+ // Auth
authed: false,
- currentUser: '',
loginUsername: '',
loginPassword: '',
loginError: '',
+ // Cursor input
+ inputMode: false,
+ cursorQuery: '',
+ cursorX: 0,
+ cursorY: 0,
+
+ trackCursor(e) {
+ this.cursorX = e.clientX
+ this.cursorY = e.clientY
+ if (cursorDot) {
+ cursorDot.style.left = e.clientX + 'px'
+ cursorDot.style.top = e.clientY + 'px'
+ }
+ },
+
+ async init() {
+ canvasDiv = document.getElementById('canvas')
+ await this.checkAuth()
+ if (this.authed) connectWS()
+ },
+
+ async checkAuth() {
+ const token = sessionStorage.getItem('auth_token')
+ if (!token) return
+ try {
+ const res = await fetch('/auth/me', {
+ headers: { Authorization: `Bearer ${token}` },
+ })
+ if (res.ok) {
+ this.authed = true
+ } else {
+ sessionStorage.removeItem('auth_token')
+ }
+ } catch (_) { /* auth service down */ }
+ },
+
async submitLogin() {
this.loginError = ''
try {
- const res = await fetch('/auth', {
+ const res = await fetch('/auth/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
return
}
sessionStorage.setItem('auth_token', data.token)
- this.currentUser = data.username
this.loginPassword = ''
- this.authed = true
- await this.loadApps()
- const bio = this.installedApps.find(a => a.id === 'bio')
- if (bio) this.openApp(bio)
- } catch (e) {
- this.loginError = 'Auth service unreachable'
- }
- },
-
- async checkAuth() {
- const token = sessionStorage.getItem('auth_token')
- if (!token) return
-
- try {
- const res = await fetch('/me', {
- headers: { 'Authorization': `Bearer ${token}` },
- })
- if (res.ok) {
- const data = await res.json()
- this.currentUser = data.username
- this.authed = true
- } else {
- sessionStorage.removeItem('auth_token')
- }
+ this.authed = true
+ connectWS()
} catch (_) {
- /* auth service down — leave authed=false */
- }
- },
-
- // ── Canvas pan state ──────────────────────────────────────────────────
- offset: { x: 0, y: 0 },
- isPanning: false,
- panStart: { x: 0, y: 0 },
-
- startPan(e) {
- if (e.target !== document.getElementById('canvas') &&
- !e.target.closest('#canvas') ||
- e.target.closest('.window')) return
- this.isPanning = true
- this.panStart = { x: e.clientX - this.offset.x, y: e.clientY - this.offset.y }
- document.getElementById('canvas').classList.add('panning')
- },
-
- pan(e) {
- if (!this.isPanning) return
- this.offset = { x: e.clientX - this.panStart.x, y: e.clientY - this.panStart.y }
- },
-
- endPan() {
- this.isPanning = false
- document.getElementById('canvas').classList.remove('panning')
- },
-
- // ── Windows ───────────────────────────────────────────────────────────
- windows: [],
- zCounter: 100,
-
- openApp(app) {
- // Bring to front if already open
- const existing = this.windows.find(w => w.appId === app.id)
- if (existing) { this.focusWindow(existing); return }
-
- this.windows.push({
- id: Date.now(),
- appId: app.id,
- title: app.name,
- url: app.url,
- x: 120 + Math.random() * 200,
- y: 80 + Math.random() * 100,
- w: 900,
- h: 600,
- z: ++this.zCounter,
- })
- },
-
- focusWindow(win) {
- win.z = ++this.zCounter
- },
-
- closeWindow(win) {
- this.windows = this.windows.filter(w => w.id !== win.id)
- },
-
- // ── Window drag ───────────────────────────────────────────────────────
- dragging: null,
- dragStart: { x: 0, y: 0 },
-
- startDrag(e, win) {
- this.focusWindow(win)
- this.dragging = win
- this.dragStart = { x: e.clientX - win.x, y: e.clientY - win.y }
-
- const onMove = (e) => {
- if (!this.dragging) return
- this.dragging.x = e.clientX - this.dragStart.x
- this.dragging.y = e.clientY - this.dragStart.y
- }
- const onUp = () => {
- this.dragging = null
- window.removeEventListener('mousemove', onMove)
- window.removeEventListener('mouseup', onUp)
+ this.loginError = 'Auth service unreachable'
}
- window.addEventListener('mousemove', onMove)
- window.addEventListener('mouseup', onUp)
},
- // ── Cursor input mode ─────────────────────────────────────────────────
- inputMode: false,
- cursorQuery: '',
-
handleKeydown(e) {
- // ⌘+Space or Ctrl+Space toggles input mode
if ((e.metaKey || e.ctrlKey) && e.code === 'Space') {
e.preventDefault()
this.inputMode ? this.exitInputMode() : this.enterInputMode()
- return
}
},
},
submitQuery() {
- const query = this.cursorQuery.trim().toLowerCase()
-
- // Check if it matches an installed app name
- const app = this.installedApps.find(a => a.name.toLowerCase() === query)
- if (app) { this.openApp(app); this.exitInputMode(); return }
-
- // Otherwise — send to LLM (placeholder)
- console.log('LLM query:', query)
+ const text = this.cursorQuery.trim()
+ if (!text) { this.exitInputMode(); return }
+ sendEvent({
+ type: 'cursor.input',
+ text,
+ position: { x: this.cursorX, y: this.cursorY },
+ })
this.exitInputMode()
},
- // ── Installed apps ────────────────────────────────────────────────────
- installedApps: [],
-
- async loadApps() {
- try {
- const res = await fetch('/dock/apps')
- const data = await res.json()
- this.installedApps = data.map(app => ({
- ...app,
- url: `/apps/${app.id}`,
- }))
- } catch (e) {
- console.error('dock unreachable:', e)
- }
- },
-
- // ── Init ──────────────────────────────────────────────────────────────
- async init() {
- await this.checkAuth()
-
- if (this.authed) {
- await this.loadApps()
- // Refresh running status every 10s
- setInterval(() => this.loadApps(), 10_000)
- // Open bio full-screen on load
- const bio = this.installedApps.find(a => a.id === 'bio')
- if (bio) this.openApp(bio)
- }
- }
-
}))
})
width: 100%;
height: 100%;
overflow: hidden;
- background: #1a1a1a;
+ background: #e8e8e8;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
#canvas {
position: absolute;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
- will-change: transform;
- cursor: grab;
+ inset: 0;
+ overflow: hidden;
}
-#canvas.panning {
- cursor: grabbing;
+.canvas-element {
+ position: absolute;
+ user-select: none;
}
-#cursor-input {
+/* Hide system cursor everywhere */
+* { cursor: none !important; }
+
+/* Dot cursor */
+#cursor-dot {
position: fixed;
- top: 50%;
- left: 50%;
+ width: 8px;
+ height: 8px;
+ background: #1a1a1a;
+ border-radius: 50%;
+ pointer-events: none;
transform: translate(-50%, -50%);
- z-index: 99999;
+ z-index: 9999;
+ transition: width 0.1s, height 0.1s;
+}
+
+/* Input bubble — anchored to cursor position via inline style */
+#cursor-bubble {
+ position: fixed;
+ transform: translate(-50%, calc(-100% - 14px));
+ z-index: 9998;
+ pointer-events: auto;
}
-#cursor-input-field {
- width: 480px;
- padding: 14px 20px;
- font-size: 1.1rem;
+#cursor-input {
+ background: white;
border: none;
- border-radius: 12px;
- background: rgba(255,255,255,0.95);
- backdrop-filter: blur(20px);
- box-shadow: 0 24px 80px rgba(0,0,0,0.4);
+ border-radius: 20px;
+ padding: 9px 18px;
+ font-size: 14px;
+ color: #1a1a1a;
+ box-shadow: 0 4px 24px rgba(0, 0, 0, 0.14);
outline: none;
- color: #111;
+ width: 280px;
}
-#cursor-input-field::placeholder {
+#cursor-input::placeholder {
color: #aaa;
}
display: flex;
align-items: center;
justify-content: center;
- background: rgba(0, 0, 0, 0.6);
- backdrop-filter: blur(24px);
- -webkit-backdrop-filter: blur(24px);
+ background: rgba(232, 232, 232, 0.85);
+ backdrop-filter: blur(12px);
+ -webkit-backdrop-filter: blur(12px);
}
.login-card {
- background: rgba(255, 255, 255, 0.1);
- border: 1px solid rgba(255, 255, 255, 0.2);
+ background: white;
border-radius: 20px;
- padding: 40px 48px;
- width: 360px;
+ padding: 40px 44px;
+ width: 340px;
display: flex;
flex-direction: column;
- gap: 20px;
- color: #fff;
+ gap: 14px;
+ box-shadow: 0 16px 48px rgba(0, 0, 0, 0.12);
}
.login-card h1 {
- font-size: 1.4rem;
+ font-size: 1.3rem;
font-weight: 600;
- text-align: center;
+ color: #1a1a1a;
letter-spacing: -0.02em;
+ text-align: center;
+ margin-bottom: 6px;
}
.login-card input {
width: 100%;
- padding: 12px 16px;
+ padding: 12px 14px;
border-radius: 10px;
- border: 1px solid rgba(255, 255, 255, 0.25);
- background: rgba(255, 255, 255, 0.08);
- color: #fff;
+ border: 1.5px solid #e0e0e0;
+ background: #fafafa;
+ color: #1a1a1a;
font-size: 0.95rem;
outline: none;
transition: border-color 0.15s;
+ cursor: text;
}
.login-card input::placeholder {
- color: rgba(255, 255, 255, 0.4);
+ color: #bbb;
}
.login-card input:focus {
- border-color: rgba(255, 255, 255, 0.55);
+ border-color: #aaa;
+ background: white;
}
.login-card button {
padding: 12px;
border-radius: 10px;
border: none;
- background: rgba(255, 255, 255, 0.9);
- color: #111;
+ background: #1a1a1a;
+ color: white;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
+ margin-top: 4px;
}
.login-card button:hover {
- background: #fff;
+ background: #333;
}
.login-error {
- font-size: 0.85rem;
- color: #ff6b6b;
+ font-size: 0.83rem;
+ color: #e53e3e;
text-align: center;
min-height: 1.2em;
}
-
-#dock-user {
- font-size: 0.75rem;
- color: rgba(255, 255, 255, 0.7);
- display: flex;
- align-items: center;
- padding: 0 8px;
- cursor: default;
- user-select: none;
-}
-.window {
- position: absolute;
- display: flex;
- flex-direction: column;
- background: #fff;
- border-radius: 12px;
- box-shadow: 0 24px 80px rgba(0,0,0,0.5);
- overflow: hidden;
- min-width: 300px;
- min-height: 200px;
-}
+/* ── App icons ────────────────────────────────────────────────────────────── */
-.window-titlebar {
+.canvas-element.app-icon {
display: flex;
+ flex-direction: column;
align-items: center;
- justify-content: space-between;
- padding: 0 12px;
- height: 40px;
- background: #f5f5f5;
- border-bottom: 1px solid #e0e0e0;
- cursor: move;
- user-select: none;
- flex-shrink: 0;
-}
-
-.window-title {
- font-size: 0.85rem;
- font-weight: 600;
- color: #333;
+ gap: 6px;
+ width: 72px;
+ cursor: pointer;
}
-.window-close {
- background: none;
- border: none;
- cursor: pointer;
- font-size: 0.8rem;
- color: #999;
- width: 24px;
- height: 24px;
- border-radius: 50%;
+.app-icon .icon-glyph {
+ width: 56px;
+ height: 56px;
+ background: white;
+ border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
- transition: background 0.15s, color 0.15s;
+ font-size: 26px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.10);
+ transition: transform 0.12s ease, box-shadow 0.12s ease;
+}
+
+.app-icon:hover .icon-glyph {
+ transform: scale(1.08);
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.16);
+}
+
+.app-icon .icon-label {
+ font-size: 11px;
+ font-weight: 500;
+ color: #444;
+ background: rgba(255, 255, 255, 0.72);
+ padding: 2px 8px;
+ border-radius: 10px;
+ backdrop-filter: blur(6px);
+ white-space: nowrap;
+ max-width: 88px;
+ overflow: hidden;
+ text-overflow: ellipsis;
}
-.window-close:hover {
- background: #ff5f57;
- color: #fff;
+.app-icon .icon-dot {
+ width: 5px;
+ height: 5px;
+ background: #34c759;
+ border-radius: 50%;
+ margin-top: -3px;
}
-.window-content {
- flex: 1;
+/* ── Windows — no chrome, just content cards ──────────────────────────────── */
+
+.canvas-element.window {
+ background: white;
+ border-radius: 16px;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
overflow: hidden;
+ min-width: 320px;
+ min-height: 240px;
}
-.window-content iframe {
+.window .window-content {
width: 100%;
height: 100%;
+ overflow: auto;
+}
+
+.window .loading {
+ padding: 24px;
+ color: #aaa;
+ font-size: 13px;
+}
+
+/* ── Cursor response bubble ───────────────────────────────────────────────── */
+
+.canvas-element.cursor-response {
+ background: white;
+ border-radius: 14px;
+ padding: 12px 16px;
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.12);
+ max-width: 320px;
+ font-size: 14px;
+ color: #333;
+ line-height: 1.5;
+}
+
+.cursor-response p {
+ margin: 0;
+}
+
+.response-pills {
+ display: flex;
+ gap: 6px;
+ flex-wrap: wrap;
+ margin-top: 10px;
+}
+
+.response-pills button {
+ background: #f0f0f0;
border: none;
+ border-radius: 20px;
+ padding: 5px 14px;
+ font-size: 12px;
+ font-weight: 500;
+ color: #333;
+ cursor: pointer;
+ transition: background 0.12s;
+}
+
+.response-pills button:hover {
+ background: #e0e0e0;
}