Add crimata-agent and redesign agentic OS UI

- crimata-agent (TypeScript, port 7702): WebSocket server + HTTP /events
  endpoint, Claude tool-use loop (render/remove/call_api/show_cursor_response),
  session.start fires on first browser connection to seed the canvas
- OS UI redesigned: light grey canvas (#e8e8e8), no dock bar, cursor dot,
  Cmd+Space input bubble above cursor, elements placed by agent
- Canvas elements: app_icon (floating icons with running dot), window (no-chrome
  content cards), cursor_response (text + action pills)
- Component system: HTML fragments fetched from /components/{app}/{name}.html,
  inline scripts re-executed after injection
- contacts/crimata.json: added icon, defaultComponent, components[], api[]
- dock: apps.h/c/main.c extended to parse and serve components + api from
  crimata.json; BUF_SIZE increased for richer JSON output
- ui/components/contacts/list.html + card.html: first component pair
- provision.sh: installs crimata-agent, adds WebSocket + /events + /components
  nginx locations, fixes /auth/ prefix stripping

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Andrew Gundersen 2026-03-02 15:52:05 -05:00
commit 31b5e321a1
19 changed files with 1030 additions and 305 deletions

20
agent/package.json Normal file
View file

@ -0,0 +1,20 @@
{
"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"
}
}

155
agent/src/agent.ts Normal file
View file

@ -0,0 +1,155 @@
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))
}
}

26
agent/src/canvas.ts Normal file
View file

@ -0,0 +1,26 @@
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()
}
}

60
agent/src/index.ts Normal file
View file

@ -0,0 +1,60 @@
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}`)
})

66
agent/src/tools.ts Normal file
View file

@ -0,0 +1,66 @@
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'],
},
},
]

42
agent/src/types.ts Normal file
View file

@ -0,0 +1,42 @@
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 } }

14
agent/tsconfig.json Normal file
View file

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true
},
"include": ["src/**/*"]
}

View file

@ -1,7 +1,54 @@
{
"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"
}
]
}

View file

@ -5,9 +5,9 @@
#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];
@ -42,6 +42,51 @@ static int json_int(const char *json, const char *key, int *out)
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");
@ -54,10 +99,14 @@ static int parse_manifest(const char *path, app_t *app)
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_int(buf, "port", &app->port);
app->running = 0;
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);
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;
}

View file

@ -2,14 +2,17 @@
#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 */

View file

@ -6,7 +6,7 @@
#include "systemd.h"
#define PORT 7701
#define BUF_SIZE 16384
#define BUF_SIZE 65536
/* ── JSON response helper ─────────────────────────────────────────────────── */
@ -42,12 +42,29 @@ static enum MHD_Result handle_list(struct MHD_Connection *conn)
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, "]");
@ -60,7 +77,6 @@ static enum MHD_Result handle_list(struct MHD_Connection *conn)
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;
@ -108,10 +124,10 @@ static enum MHD_Result handler(void *cls,
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);
}

View file

@ -0,0 +1,41 @@
<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>

View file

@ -0,0 +1,47 @@
<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>

View file

@ -6,12 +6,11 @@
<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>
@ -37,57 +36,29 @@
</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 input overlay -->
<div id="cursor-input" x-show="inputMode" @click.stop>
<!-- Cursor dot -->
<div id="cursor-dot" x-show="authed" x-cloak></div>
<!-- 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>

View file

@ -1,17 +1,264 @@
/* ── 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({
@ -25,124 +272,18 @@ document.addEventListener('alpine:init', () => {
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.authed = true
connectWS()
} catch (_) {
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')
}
} 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)
}
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
}
},
@ -158,46 +299,15 @@ document.addEventListener('alpine:init', () => {
},
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)
}
}
}))
})

View file

@ -5,20 +5,17 @@ html, body {
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;
}

View file

@ -1,24 +1,39 @@
#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;
}
#cursor-input-field {
width: 480px;
padding: 14px 20px;
font-size: 1.1rem;
/* 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 {
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;
}

View file

@ -5,79 +5,73 @@
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;
}

View file

@ -1,61 +1,113 @@
.window {
position: absolute;
/* ── App icons ────────────────────────────────────────────────────────────── */
.canvas-element.app-icon {
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;
}
.window-titlebar {
display: flex;
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;
}
.window-close {
background: none;
border: none;
gap: 6px;
width: 72px;
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;
}
.window-close:hover {
background: #ff5f57;
color: #fff;
.app-icon:hover .icon-glyph {
transform: scale(1.08);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.16);
}
.window-content {
flex: 1;
.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-content iframe {
.app-icon .icon-dot {
width: 5px;
height: 5px;
background: #34c759;
border-radius: 50%;
margin-top: -3px;
}
/* ── 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 .window-content {
width: 100%;
height: 100%;
border: none;
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;
}