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.
- package/LICENSE +21 -0
- package/README.md +146 -0
- package/cli.js +167 -0
- package/dist/Screenshot.jpg +0 -0
- package/dist/assets/2dpig-LICENSE.txt +7 -0
- package/dist/assets/FSPixelSansUnicode-Regular-D9-dh-Uo.ttf +0 -0
- package/dist/assets/PixelOffice.png +0 -0
- package/dist/assets/PixelOfficeAssets.png +0 -0
- package/dist/assets/characters/char_0.png +0 -0
- package/dist/assets/characters/char_1.png +0 -0
- package/dist/assets/characters/char_2.png +0 -0
- package/dist/assets/characters/char_3.png +0 -0
- package/dist/assets/characters/char_4.png +0 -0
- package/dist/assets/characters/char_5.png +0 -0
- package/dist/assets/default-layout.json +98 -0
- package/dist/assets/index-D7SaYitc.js +15 -0
- package/dist/assets/index-DHX1xfjO.css +1 -0
- package/dist/assets/mockProvider-CjnCG90O.js +1 -0
- package/dist/assets/walls.png +0 -0
- package/dist/characters.png +0 -0
- package/dist/index.html +14 -0
- package/package.json +63 -0
- package/server.js +370 -0
|
@@ -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
|
package/dist/index.html
ADDED
|
@@ -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
|
+
})
|