pixel-office-openclaw 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ @font-face{font-family:FS Pixel Sans;font-style:normal;font-weight:400;font-display:swap;src:url(./FSPixelSansUnicode-Regular-D9-dh-Uo.ttf) format("truetype")}:root{--pixel-bg: #1e1e2e;--pixel-border: #4a4a6a;--pixel-border-light: #6a6a8a;--pixel-accent: #5a8cff;--pixel-green: #5ac88c;--pixel-shadow: 2px 2px 0px #0a0a14;--pixel-text: rgba(255, 255, 255, .8);--pixel-text-dim: rgba(255, 255, 255, .7);--pixel-btn-bg: rgba(255, 255, 255, .08);--pixel-btn-hover-bg: rgba(255, 255, 255, .15);--pixel-btn-disabled-opacity: .35;--pixel-active-bg: rgba(90, 140, 255, .25);--pixel-agent-bg: rgba(90, 200, 140, .15);--pixel-agent-hover-bg: rgba(90, 200, 140, .3);--pixel-agent-border: #5ac88c;--pixel-agent-text: rgba(200, 255, 220, .95);--pixel-close-text: rgba(255, 255, 255, .5);--pixel-close-hover: #e55;--pixel-hint-bg: #3278c8;--pixel-reset-text: #ecc;--pixel-danger-bg: #a33;--pixel-vignette: radial-gradient(ellipse at center, transparent 50%, rgba(0, 0, 0, .6) 100%);--pixel-status-permission: #cca700;--pixel-status-active: #3794ff;--vscode-charts-yellow: #cca700;--vscode-charts-blue: #3794ff;--vscode-charts-green: #89d185;--vscode-foreground: rgba(255, 255, 255, .85);--vscode-editor-background: #1e1e2e;--vscode-widget-border: rgba(255, 255, 255, .12);--pixel-overlay-z: 100;--pixel-overlay-selected-z: 110;--pixel-controls-z: 50}html,body,#root{margin:0;width:100%;height:100%;overflow:hidden;font-family:FS Pixel Sans,sans-serif;background:var(--pixel-bg);color:var(--vscode-foreground)}*{font-family:FS Pixel Sans,sans-serif}.pixel-status-bar{position:absolute;top:0;left:0;right:0;z-index:50;display:flex;align-items:center;justify-content:space-between;padding:4px 10px;background:#0a0a14bf;border-bottom:1px solid var(--pixel-border);font-size:18px;pointer-events:auto}.pixel-status-bar-left{display:flex;align-items:center;gap:8px}.pixel-status-dot{width:6px;height:6px;border-radius:50%;background:var(--pixel-green);flex-shrink:0}.pixel-status-text{color:#fff9;white-space:nowrap}.pixel-status-separator{width:1px;height:12px;background:#fff3}.pixel-status-disconnect{border:none;font-size:18px;cursor:pointer;padding:2px 8px;font-family:FS Pixel Sans,sans-serif}.pixel-toast-container{position:absolute;top:36px;right:10px;z-index:60;display:flex;flex-direction:column;gap:6px;max-width:340px}.pixel-toast{display:flex;align-items:flex-start;gap:6px;padding:6px 10px;background:var(--pixel-bg);border:2px solid var(--pixel-border);box-shadow:var(--pixel-shadow);font-size:16px;animation:pixel-toast-slide .2s ease-out}.pixel-toast-error{border-color:#c8323280;background:#280f0fe6}.pixel-toast-message{color:#fffc;word-break:break-word;flex:1}.pixel-toast-close{background:none;border:none;color:#fff6;cursor:pointer;padding:0 2px;font-size:18px;font-family:FS Pixel Sans,sans-serif;flex-shrink:0}.pixel-toast-close:hover{color:#fffc}@keyframes pixel-toast-slide{0%{opacity:0;transform:translate(20px)}to{opacity:1;transform:translate(0)}}@media(max-width:640px){.pixel-status-bar{font-size:14px;padding:3px 6px}.pixel-status-bar-left{gap:4px;overflow:hidden}.pixel-status-text{font-size:14px;overflow:hidden;text-overflow:ellipsis}.pixel-status-disconnect{font-size:14px;padding:4px 8px;min-height:32px}.pixel-toast-container{left:10px;right:10px;max-width:none}.pixel-toast{font-size:14px}.pixel-toast-close{min-width:28px;min-height:28px;display:flex;align-items:center;justify-content:center}}@media(pointer:coarse){.pixel-status-disconnect{min-height:44px;min-width:44px;padding:8px 12px}.pixel-toast-close{min-width:44px;min-height:44px;display:flex;align-items:center;justify-content:center}}@media(pointer:coarse){#root{-webkit-touch-callout:none;-webkit-user-select:none;user-select:none;touch-action:manipulation}}
@@ -0,0 +1 @@
1
+ import{e}from"./index-D7SaYitc.js";const v=["Reading file: src/index.ts","Writing file: src/app.tsx","Editing file: src/utils.ts","Running command: npm test","Searching for: useEffect","Globbing: **/*.tsx","Fetching: https://api.example.com/data"];let y=0;function T(){return`mock-tool-${++y}`}function r(n){return n[Math.floor(Math.random()*n.length)]}function M(){const n=[],i=[];u();function u(){d().then(()=>{const t=setTimeout(()=>l(),400);n.push(t);const o=setTimeout(()=>m(),1500);n.push(o)})}async function d(){try{const o=await(await fetch("./assets/default-layout.json")).json();e.emit("layoutLoaded",{layout:o}),console.log("[MockProvider] Layout loaded")}catch(t){console.warn("[MockProvider] Could not load default-layout.json, using null layout:",t),e.emit("layoutLoaded",{layout:null})}}function l(){const t=[{id:1,name:"Coder",model:"claude-opus-4",kind:"coder"},{id:2,name:"Researcher",model:"gpt-4o",kind:"researcher"},{id:3,name:"Planner",model:"claude-sonnet",kind:"agent"}];for(const o of t)e.emit("agentCreated",{id:o.id,name:o.name,model:o.model,kind:o.kind}),console.log(`[MockProvider] Agent #${o.id} (${o.name}) created`);e.emit("agentStatus",{id:1,status:"active"}),e.emit("agentStatus",{id:2,status:"active"}),e.emit("agentStatus",{id:3,status:"waiting"})}function m(){const t=[1,2,3];for(const a of t)c(a);const o=setInterval(()=>{const a=r(t),s=Math.random()>.3?"active":"waiting";e.emit("agentStatus",{id:a,status:s})},8e3);i.push(o)}function c(t){const o=2e3+Math.random()*4e3,a=setTimeout(()=>{const s=T(),f=r(v);e.emit("agentToolStart",{id:t,toolId:s,status:f}),e.emit("agentStatus",{id:t,status:"active"});const g=1500+Math.random()*2500,h=setTimeout(()=>{e.emit("agentToolDone",{id:t,toolId:s});const p=setTimeout(()=>{e.emit("agentToolsClear",{id:t}),c(t)},500+Math.random()*1e3);n.push(p)},g);n.push(h)},o);n.push(a)}return()=>{for(const t of n)clearTimeout(t);for(const t of i)clearInterval(t)}}export{M as startMockProvider};
Binary file
Binary file
@@ -0,0 +1,14 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
7
+ <title>Pixel Office</title>
8
+ <script type="module" crossorigin src="./assets/index-D7SaYitc.js"></script>
9
+ <link rel="stylesheet" crossorigin href="./assets/index-DHX1xfjO.css">
10
+ </head>
11
+ <body>
12
+ <div id="root"></div>
13
+ </body>
14
+ </html>
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "pixel-office-openclaw",
3
+ "version": "0.1.0",
4
+ "description": "Watch your OpenClaw AI agents work in a pixel-art virtual office",
5
+ "type": "module",
6
+ "bin": {
7
+ "pixel-office-openclaw": "cli.js",
8
+ "pixel-office": "cli.js"
9
+ },
10
+ "main": "server.js",
11
+ "files": [
12
+ "cli.js",
13
+ "server.js",
14
+ "dist/**",
15
+ "README.md",
16
+ "LICENSE"
17
+ ],
18
+ "scripts": {
19
+ "dev": "vite",
20
+ "build": "tsc -b && vite build",
21
+ "start": "node cli.js",
22
+ "serve": "node server.js",
23
+ "lint": "eslint .",
24
+ "preview": "vite preview",
25
+ "prepublishOnly": "VITE_GATEWAY_URL=/ VITE_GATEWAY_TOKEN=bridge npm run build"
26
+ },
27
+ "keywords": [
28
+ "openclaw",
29
+ "ai-agents",
30
+ "pixel-art",
31
+ "office",
32
+ "visualization",
33
+ "dashboard"
34
+ ],
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "https://github.com/neomatrix25/pixel-office"
38
+ },
39
+ "author": "Tridents Lab",
40
+ "license": "MIT",
41
+ "dependencies": {
42
+ "express": "^5.1.0"
43
+ },
44
+ "devDependencies": {
45
+ "@eslint/js": "^9.39.1",
46
+ "@types/node": "^24.10.1",
47
+ "@types/react": "^19.2.5",
48
+ "@types/react-dom": "^19.2.3",
49
+ "@vitejs/plugin-react": "^5.1.1",
50
+ "eslint": "^9.39.1",
51
+ "eslint-plugin-react-hooks": "^7.0.1",
52
+ "eslint-plugin-react-refresh": "^0.4.24",
53
+ "globals": "^16.5.0",
54
+ "react": "^19.2.0",
55
+ "react-dom": "^19.2.0",
56
+ "typescript": "~5.9.3",
57
+ "typescript-eslint": "^8.46.4",
58
+ "vite": "^7.2.4"
59
+ },
60
+ "engines": {
61
+ "node": ">=18"
62
+ }
63
+ }
package/server.js ADDED
@@ -0,0 +1,370 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Pixel Office production server.
4
+ * Serves the built static files and bridges session data from
5
+ * OpenClaw agent session stores to the browser via GET /sessions_list.
6
+ *
7
+ * Environment variables:
8
+ * PORT — HTTP port (default: 3002)
9
+ * OPENCLAW_HOME — OpenClaw home directory (default: ~/.openclaw)
10
+ * OPENCLAW_ACTIVE_MIN — Active window in minutes for session filtering (default: 60)
11
+ * OPENCLAW_AGENTS — Comma-separated agent IDs to scan (default: auto-detect)
12
+ *
13
+ * Usage:
14
+ * npm run build && node server.js
15
+ */
16
+
17
+ import express from 'express'
18
+ import { readFileSync, writeFileSync, mkdirSync, readdirSync, existsSync, statSync, unlinkSync } from 'fs'
19
+ import { execFileSync } from 'child_process'
20
+ import { fileURLToPath } from 'url'
21
+ import { dirname, join } from 'path'
22
+ import { homedir } from 'os'
23
+
24
+ const __filename = fileURLToPath(import.meta.url)
25
+ const __dirname = dirname(__filename)
26
+
27
+ const PORT = parseInt(process.env.PORT || '3002', 10)
28
+ const HOST = process.env.PIXEL_OFFICE_HOST || '127.0.0.1'
29
+ const OPENCLAW_HOME = process.env.OPENCLAW_HOME || join(homedir(), '.openclaw')
30
+ const ACTIVE_MINUTES = parseInt(process.env.OPENCLAW_ACTIVE_MIN || '60', 10)
31
+ const CHAT_DISABLED = process.env.PIXEL_OFFICE_NO_CHAT === '1'
32
+ const AGENT_IDS = process.env.OPENCLAW_AGENTS
33
+ ? process.env.OPENCLAW_AGENTS.split(',').map((s) => s.trim())
34
+ : null // null = auto-detect from directory listing
35
+
36
+ const app = express()
37
+ app.use(express.json())
38
+
39
+ // ── Sessions Bridge (File Store → HTTP) ──────────────────────────
40
+
41
+ /**
42
+ * Discover agent IDs by scanning the agents directory.
43
+ * Returns array of directory names under OPENCLAW_HOME/agents/.
44
+ */
45
+ function discoverAgents() {
46
+ const agentsDir = join(OPENCLAW_HOME, 'agents')
47
+ if (!existsSync(agentsDir)) return []
48
+ try {
49
+ return readdirSync(agentsDir, { withFileTypes: true })
50
+ .filter((d) => d.isDirectory())
51
+ .map((d) => d.name)
52
+ } catch {
53
+ return []
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Read all sessions from an agent's session store file.
59
+ * Returns array of session objects with agentId injected.
60
+ */
61
+ function readAgentSessions(agentId) {
62
+ const sessionsFile = join(OPENCLAW_HOME, 'agents', agentId, 'sessions', 'sessions.json')
63
+ if (!existsSync(sessionsFile)) return []
64
+
65
+ try {
66
+ // Guard against oversized files (max 10MB)
67
+ const fstat = statSync(sessionsFile)
68
+ if (fstat.size > 10 * 1024 * 1024) {
69
+ console.warn(`[bridge] Skipping ${agentId} — sessions.json too large (${fstat.size} bytes)`)
70
+ return []
71
+ }
72
+ const raw = readFileSync(sessionsFile, 'utf-8')
73
+ const data = JSON.parse(raw)
74
+
75
+ // The file is a flat dict keyed by session key
76
+ const sessions = []
77
+ const now = Date.now()
78
+ const cutoff = now - ACTIVE_MINUTES * 60 * 1000
79
+
80
+ for (const [key, session] of Object.entries(data)) {
81
+ const s = session
82
+ const updatedAt = s.updatedAt || 0
83
+
84
+ // Filter by activity window
85
+ if (updatedAt < cutoff) continue
86
+
87
+ sessions.push({
88
+ sessionKey: key,
89
+ kind: s.kind || 'agent',
90
+ name: s.displayName || agentId,
91
+ model: s.model || 'unknown',
92
+ lastActivity: updatedAt,
93
+ status: deriveStatus(s, now),
94
+ agentId,
95
+ lastMessage: extractLastMessage(s) || null,
96
+ })
97
+ }
98
+
99
+ return sessions
100
+ } catch (err) {
101
+ console.error(`[bridge] Error reading ${agentId} sessions:`, err.message)
102
+ return []
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Derive agent status from session data.
108
+ */
109
+ function deriveStatus(session, now) {
110
+ // If updated within last 60 seconds, consider active
111
+ if (session.updatedAt && (now - session.updatedAt) < 60000) {
112
+ return 'active'
113
+ }
114
+
115
+ // Check last message for tool activity
116
+ const messages = session.messages || session.last_messages
117
+ const lastMsg = Array.isArray(messages) ? messages[0] : null
118
+ if (lastMsg) {
119
+ if (lastMsg.role === 'assistant') {
120
+ const content = Array.isArray(lastMsg.content) ? lastMsg.content : []
121
+ const hasToolCall = content.some((c) => c.type === 'toolCall' || c.type === 'tool_use')
122
+ return hasToolCall ? 'active' : 'waiting'
123
+ }
124
+ return 'waiting'
125
+ }
126
+
127
+ return 'idle'
128
+ }
129
+
130
+ /**
131
+ * Extract the last meaningful message from session data.
132
+ * Returns a short summary string, or null if nothing found.
133
+ */
134
+ function extractLastMessage(session) {
135
+ const messages = session.messages || session.last_messages || []
136
+ if (!Array.isArray(messages) || messages.length === 0) return null
137
+
138
+ // Walk backwards to find last assistant message with text
139
+ for (let i = messages.length - 1; i >= 0; i--) {
140
+ const msg = messages[i]
141
+ if (msg.role === 'assistant') {
142
+ if (typeof msg.content === 'string') return msg.content.slice(0, 200)
143
+ if (Array.isArray(msg.content)) {
144
+ const text = msg.content.find((c) => c.type === 'text')
145
+ if (text && text.text) return text.text.slice(0, 200)
146
+ }
147
+ }
148
+ }
149
+ return null
150
+ }
151
+
152
+ // Simple in-memory cache for session list (avoid I/O spam from rapid polling)
153
+ let sessionCache = { data: null, ts: 0 }
154
+ const CACHE_TTL_MS = 3000
155
+
156
+ app.get('/sessions_list', (_req, res) => {
157
+ try {
158
+ // Return cached result if fresh
159
+ const now = Date.now()
160
+ if (sessionCache.data && (now - sessionCache.ts) < CACHE_TTL_MS) {
161
+ return res.json(sessionCache.data)
162
+ }
163
+
164
+ const agents = AGENT_IDS || discoverAgents()
165
+ const allSessions = []
166
+
167
+ for (const agentId of agents) {
168
+ const sessions = readAgentSessions(agentId)
169
+ allSessions.push(...sessions)
170
+ }
171
+
172
+ // Dedup: one entry per agentId, keep the most recent session
173
+ const byAgent = new Map()
174
+ for (const s of allSessions) {
175
+ const key = s.agentId
176
+ const existing = byAgent.get(key)
177
+ if (!existing || (s.lastActivity || 0) > (existing.lastActivity || 0)) {
178
+ byAgent.set(key, s)
179
+ }
180
+ }
181
+ const dedupedSessions = [...byAgent.values()]
182
+
183
+ const result = { sessions: dedupedSessions }
184
+ sessionCache = { data: result, ts: Date.now() }
185
+ res.json(result)
186
+ } catch (err) {
187
+ console.error('[bridge] Error:', err.message || err)
188
+ res.status(500).json({ error: 'Session store read failed' })
189
+ }
190
+ })
191
+
192
+ // Also serve at /api/sessions as an alias
193
+ app.get('/api/sessions', (req, res) => {
194
+ req.url = '/sessions_list'
195
+ app.handle(req, res)
196
+ })
197
+
198
+ // ── Chat Bridge (Send Message → Agent) ───────────────────────────
199
+
200
+ /**
201
+ * Get the most recent active session key for an agent.
202
+ * Routes pixel office chat into the same session as Slack.
203
+ */
204
+ function getActiveSessionKey(agentId) {
205
+ const sessionsFile = join(OPENCLAW_HOME, 'agents', agentId, 'sessions', 'sessions.json')
206
+ if (!existsSync(sessionsFile)) return null
207
+
208
+ try {
209
+ const raw = readFileSync(sessionsFile, 'utf-8')
210
+ const data = JSON.parse(raw)
211
+
212
+ let bestKey = null
213
+ let bestTime = 0
214
+
215
+ for (const [key, session] of Object.entries(data)) {
216
+ const updatedAt = session.updatedAt || 0
217
+ if (updatedAt > bestTime) {
218
+ bestTime = updatedAt
219
+ bestKey = key
220
+ }
221
+ }
222
+
223
+ return bestKey
224
+ } catch {
225
+ return null
226
+ }
227
+ }
228
+
229
+ app.post('/api/send', async (req, res) => {
230
+ if (CHAT_DISABLED) {
231
+ return res.status(403).json({ error: 'Chat is disabled. Start with --no-chat removed to enable.' })
232
+ }
233
+ const { agentId, message } = req.body || {}
234
+ if (!agentId || !message) {
235
+ return res.status(400).json({ error: 'Missing agentId or message' })
236
+ }
237
+
238
+ const safeAgentId = String(agentId).replace(/[^a-zA-Z0-9_-]/g, '')
239
+ const safeMessage = String(message).slice(0, 2000)
240
+
241
+ // Use OpenClaw gateway API to send message to the agent's session
242
+ const GATEWAY_URL = process.env.OPENCLAW_GATEWAY_URL || 'http://127.0.0.1:18789'
243
+ const GATEWAY_TOKEN = process.env.OPENCLAW_GATEWAY_TOKEN || ''
244
+
245
+ // Find the agent's most recent session key
246
+ const sessionKey = getActiveSessionKey(safeAgentId)
247
+
248
+ try {
249
+ // Use sessions_send via the gateway API
250
+ const payload = {
251
+ message: safeMessage,
252
+ ...(sessionKey ? { label: safeAgentId } : { label: safeAgentId }),
253
+ }
254
+
255
+ const resp = await fetch(`${GATEWAY_URL}/api/sessions/send`, {
256
+ method: 'POST',
257
+ headers: {
258
+ 'Content-Type': 'application/json',
259
+ ...(GATEWAY_TOKEN ? { 'Authorization': `Bearer ${GATEWAY_TOKEN}` } : {}),
260
+ },
261
+ body: JSON.stringify(payload),
262
+ })
263
+
264
+ if (!resp.ok) {
265
+ const body = await resp.text()
266
+ console.error(`[bridge] Gateway send failed for ${safeAgentId}: ${resp.status} ${body.slice(0, 200)}`)
267
+ // Fallback to CLI — use --json to capture the agent's reply
268
+ try {
269
+ const cliResult = execFileSync('openclaw', ['agent', '--agent', safeAgentId, '--message', safeMessage, '--json'],
270
+ { timeout: 120000, stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf-8' }
271
+ )
272
+ console.log(`[bridge] Sent to ${safeAgentId} (CLI): ${safeMessage.slice(0, 50)}...`)
273
+ try {
274
+ const parsed = JSON.parse(cliResult)
275
+ // OpenClaw CLI returns { result: { payloads: [{ text: "..." }] } }
276
+ const reply = parsed.result?.payloads?.[0]?.text
277
+ || parsed.reply || parsed.text || parsed.result || cliResult.trim()
278
+ return res.json({ ok: true, agentId: safeAgentId, reply })
279
+ } catch {
280
+ return res.json({ ok: true, agentId: safeAgentId, reply: cliResult.trim() })
281
+ }
282
+ } catch (cliErr) {
283
+ return res.status(500).json({ error: 'Failed to send message', detail: cliErr.message?.slice(0, 200) || body.slice(0, 200) })
284
+ }
285
+ }
286
+
287
+ const result = await resp.json()
288
+ console.log(`[bridge] Sent to ${safeAgentId} via gateway: ${safeMessage.slice(0, 50)}...`)
289
+ res.json({ ok: true, agentId: safeAgentId, reply: result.reply || result.text || null })
290
+ } catch (err) {
291
+ const detail = err.message || 'Send failed'
292
+ console.error(`[bridge] Send error for ${safeAgentId}:`, detail)
293
+ res.status(500).json({ error: 'Failed to send message', detail: detail.slice(0, 200) })
294
+ }
295
+ })
296
+
297
+ // ── Layout Persistence (Save/Load to Disk) ──────────────────────
298
+
299
+ const LAYOUT_DIR = join(OPENCLAW_HOME, 'pixel-office')
300
+ const LAYOUT_FILE = join(LAYOUT_DIR, 'layout.json')
301
+
302
+ app.get('/api/layout', (_req, res) => {
303
+ // If ?reset query param, skip saved layout entirely
304
+ if (_req.query.reset === 'true') {
305
+ return res.status(404).json({ error: 'Reset requested' })
306
+ }
307
+ try {
308
+ if (!existsSync(LAYOUT_FILE)) {
309
+ return res.status(404).json({ error: 'No saved layout' })
310
+ }
311
+ const raw = readFileSync(LAYOUT_FILE, 'utf-8')
312
+ const layout = JSON.parse(raw)
313
+ // Sanity check: reject corrupted layouts (too many rows)
314
+ if (layout.rows > 30) {
315
+ console.warn(`[bridge] Rejecting corrupted layout (${layout.rows} rows), falling back to default`)
316
+ unlinkSync(LAYOUT_FILE)
317
+ return res.status(404).json({ error: 'Corrupted layout removed' })
318
+ }
319
+ res.json({ layout })
320
+ } catch (err) {
321
+ console.error('[bridge] Error reading layout:', err.message)
322
+ res.status(500).json({ error: 'Failed to read layout' })
323
+ }
324
+ })
325
+
326
+ app.post('/api/layout', (req, res) => {
327
+ const { layout } = req.body || {}
328
+ if (!layout || !layout.version || !Array.isArray(layout.tiles)) {
329
+ return res.status(400).json({ error: 'Invalid layout' })
330
+ }
331
+
332
+ try {
333
+ if (!existsSync(LAYOUT_DIR)) {
334
+ mkdirSync(LAYOUT_DIR, { recursive: true })
335
+ }
336
+ writeFileSync(LAYOUT_FILE, JSON.stringify(layout, null, 2), 'utf-8')
337
+ console.log(`[bridge] Layout saved (${layout.cols}x${layout.rows}, ${layout.furniture?.length || 0} items)`)
338
+ res.json({ ok: true })
339
+ } catch (err) {
340
+ console.error('[bridge] Error saving layout:', err.message)
341
+ res.status(500).json({ error: 'Failed to save layout' })
342
+ }
343
+ })
344
+
345
+ // ── Static Files ────────────────────────────────────────────────
346
+
347
+ const distPath = join(__dirname, 'dist')
348
+ app.use(express.static(distPath, {
349
+ setHeaders: (res, path) => {
350
+ // Prevent caching of HTML so new JS hashes always load
351
+ if (path.endsWith('.html')) {
352
+ res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate')
353
+ }
354
+ }
355
+ }))
356
+
357
+ // SPA fallback — serve index.html for all non-API routes (Express 5 syntax)
358
+ app.get('/{*path}', (_req, res) => {
359
+ res.sendFile(join(distPath, 'index.html'))
360
+ })
361
+
362
+ // ── Start ───────────────────────────────────────────────────────
363
+
364
+ app.listen(PORT, HOST, () => {
365
+ const agents = AGENT_IDS || discoverAgents()
366
+ console.log(`[server] Pixel Office running at http://${HOST}:${PORT}`)
367
+ console.log(`[server] Session store: ${OPENCLAW_HOME}/agents/`)
368
+ console.log(`[server] Scanning ${agents.length} agents: ${agents.join(', ')}`)
369
+ console.log(`[server] Activity window: ${ACTIVE_MINUTES} minutes`)
370
+ })