clawhouse 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 +171 -0
- package/bin/clawhouse.js +29 -0
- package/lib/usage.js +568 -0
- package/package.json +30 -0
- package/public/furniture.json +128 -0
- package/public/furniture.png +0 -0
- package/public/icon-180.png +0 -0
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/icon-maskable-512.png +0 -0
- package/public/index.html +3335 -0
- package/public/manifest.webmanifest +16 -0
- package/public/monitors.json +20 -0
- package/public/monitors.png +0 -0
- package/public/props.json +20 -0
- package/public/props.png +0 -0
- package/public/sprites.json +27 -0
- package/public/sprites.png +0 -0
- package/scripts/build-furniture.js +762 -0
- package/scripts/build-monitors.js +496 -0
- package/scripts/build-props.js +675 -0
- package/scripts/build-sprites.js +1065 -0
- package/scripts/export-profile-pics.js +180 -0
- package/scripts/generate-icons.js +124 -0
- package/server.js +944 -0
package/server.js
ADDED
|
@@ -0,0 +1,944 @@
|
|
|
1
|
+
const http = require('http');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const { execFile, spawn } = require('child_process');
|
|
6
|
+
const usage = require('./lib/usage');
|
|
7
|
+
|
|
8
|
+
const PRICES_PATH = path.join(__dirname, 'prices.json');
|
|
9
|
+
|
|
10
|
+
// Models the in-app model-switcher will offer. Edit to taste; the endpoint
|
|
11
|
+
// validates against this allowlist so only known-good ids reach `openclaw
|
|
12
|
+
// config set`. Override at startup with PIXEL_OFFICE_MODELS=json-array.
|
|
13
|
+
const DEFAULT_MODEL_OPTIONS = [
|
|
14
|
+
{ id: 'claude-cli/claude-opus-4-7', label: 'Opus 4.7' },
|
|
15
|
+
{ id: 'claude-cli/claude-opus-4-6', label: 'Opus 4.6' },
|
|
16
|
+
{ id: 'claude-cli/claude-sonnet-4-6', label: 'Sonnet 4.6' },
|
|
17
|
+
{ id: 'claude-cli/claude-haiku-4-5', label: 'Haiku 4.5' },
|
|
18
|
+
{ id: 'openai-codex/gpt-5.4', label: 'GPT-5.4' },
|
|
19
|
+
{ id: 'openai-codex/gpt-5.4-mini', label: 'GPT-5.4 mini' },
|
|
20
|
+
];
|
|
21
|
+
function parseModelsEnv() {
|
|
22
|
+
const raw = process.env.PIXEL_OFFICE_MODELS;
|
|
23
|
+
if (!raw) return null;
|
|
24
|
+
try {
|
|
25
|
+
const parsed = JSON.parse(raw);
|
|
26
|
+
if (!Array.isArray(parsed)) return null;
|
|
27
|
+
return parsed.filter(m => m && typeof m.id === 'string');
|
|
28
|
+
} catch { return null; }
|
|
29
|
+
}
|
|
30
|
+
const MODEL_OPTIONS = parseModelsEnv() || DEFAULT_MODEL_OPTIONS;
|
|
31
|
+
const MODEL_OPTION_IDS = new Set(MODEL_OPTIONS.map(m => m.id));
|
|
32
|
+
|
|
33
|
+
const PORT = Number(process.env.PIXEL_OFFICE_PORT || 18890);
|
|
34
|
+
const HOST = process.env.PIXEL_OFFICE_HOST || '0.0.0.0';
|
|
35
|
+
const STATE_DIR = process.env.OPENCLAW_STATE_DIR || path.join(os.homedir(), '.openclaw');
|
|
36
|
+
const PUBLIC_DIR = path.join(__dirname, 'public');
|
|
37
|
+
const OPENCLAW_URL = process.env.OPENCLAW_URL || 'http://127.0.0.1:18789/';
|
|
38
|
+
const DEMO_MODE = process.env.PIXEL_OFFICE_DEMO === '1' || !fs.existsSync(STATE_DIR);
|
|
39
|
+
|
|
40
|
+
// In demo mode the server has no `~/.openclaw/` state to read, so we ship
|
|
41
|
+
// a fixture cast that matches the agent ids the index.html AGENT_CONFIG knows
|
|
42
|
+
// how to render. Each demo entry's `demo` block synthesises plausible session
|
|
43
|
+
// state โ varied ages, token counts, and signals โ so the bubble/glow features
|
|
44
|
+
// are visible out of the box without anyone needing to install OpenClaw.
|
|
45
|
+
const DEMO_FLEET = {
|
|
46
|
+
agents: {
|
|
47
|
+
list: [
|
|
48
|
+
{ id: 'main', identity: { name: 'Commander', emoji: '๐ฆ
' }, demo: { ageS: 12, totalTokens: 92_400, contextWindow: 200_000, sessionStatus: 'running', sessionCost: 0.05, dayCost: 0.12, subagents: 2 } },
|
|
49
|
+
{ id: 'markethunting', identity: { name: 'Analyst', emoji: '๐' }, demo: { ageS: 320, totalTokens: 188_000, contextWindow: 200_000, sessionStatus: 'done', abortedLastRun: true, sessionCost: 1.20, dayCost: 1.30 } },
|
|
50
|
+
{ id: 'sage', identity: { name: 'Mentor', emoji: '๐ต' }, demo: { ageS: 1100, totalTokens: 41_200, contextWindow: 200_000, sessionStatus: 'done', sessionCost: 0.03, dayCost: 0.18 } },
|
|
51
|
+
{ id: 'senku', identity: { name: 'Scientist', emoji: '๐งช' }, demo: { ageS: 4, totalTokens: 156_800, contextWindow: 200_000, sessionStatus: 'running', sessionCost: 0.30, dayCost: 0.32, subagents: 3 } },
|
|
52
|
+
{ id: 'shikamaru', identity: { name: 'Strategist', emoji: 'โ๏ธ' }, demo: { ageS: 240, totalTokens: 64_300, contextWindow: 200_000, sessionStatus: 'running', sessionCost: 0.08, dayCost: 0.25 } },
|
|
53
|
+
{ id: 'tyrion', identity: { name: 'Financier', emoji: '๐ท' }, demo: { ageS: 3600, totalTokens: 12_900, contextWindow: 200_000, sessionStatus: 'done', consecutiveFailures: 2, sessionCost: 0.01, dayCost: 0.15 } },
|
|
54
|
+
{ id: 'harvey', identity: { name: 'Counsel', emoji: 'โ๏ธ' }, demo: { ageS: 18, totalTokens: 88_400, contextWindow: 200_000, sessionStatus: 'running', sessionCost: 0.40, dayCost: 0.50, subagents: 1 } },
|
|
55
|
+
{ id: 'l', identity: { name: 'Auditor', emoji: '๐ฌ' }, demo: { ageS: 600, totalTokens: 33_500, contextWindow: 200_000, sessionStatus: 'done', sessionCost: 0.02, dayCost: 0.09 } },
|
|
56
|
+
{ id: 'd', identity: { name: 'Producer', emoji: '๐ค' }, demo: { ageS: 7400, totalTokens: 22_100, contextWindow: 200_000, sessionStatus: 'done', sessionCost: 0.01, dayCost: 0.07 } },
|
|
57
|
+
{ id: 'ephraim', identity: { name: 'Coach', emoji: '๐๏ธ' }, demo: { ageS: 90, totalTokens: 71_200, contextWindow: 200_000, sessionStatus: 'running', sessionCost: 0.12, dayCost: 0.28 } },
|
|
58
|
+
{ id: 'house', identity: { name: 'Doctor', emoji: '๐ฉบ' }, demo: { ageS: 32_400, totalTokens: 8_800, contextWindow: 200_000, sessionStatus: 'done', sessionCost: 0.00, dayCost: 0.04 } },
|
|
59
|
+
],
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
function loadFleetConfig() {
|
|
64
|
+
if (DEMO_MODE) return DEMO_FLEET;
|
|
65
|
+
return readJson(path.join(STATE_DIR, 'openclaw.json'), { agents: { list: [] } });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function readJson(filePath, fallback = null) {
|
|
69
|
+
try { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } catch { return fallback; }
|
|
70
|
+
}
|
|
71
|
+
function readText(filePath) {
|
|
72
|
+
try { return fs.readFileSync(filePath, 'utf8'); } catch { return ''; }
|
|
73
|
+
}
|
|
74
|
+
function safeReaddir(dir) {
|
|
75
|
+
try { return fs.readdirSync(dir); } catch { return []; }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function extractField(markdown, field) {
|
|
79
|
+
const re = new RegExp(`\\*\\*${field}:\\*\\*\\s*(.+)`);
|
|
80
|
+
const match = markdown.match(re);
|
|
81
|
+
return match ? match[1].trim() : null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function shortenVibe(vibe) {
|
|
85
|
+
if (!vibe) return null;
|
|
86
|
+
return vibe.replace(/\([^)]*\)/g, '').replace(/[โโ]/g, '-').trim().slice(0, 90);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function deriveHint(sessionKey, channel, chatType) {
|
|
90
|
+
if (channel === 'telegram' && chatType === 'direct') return 'on telegram, direct';
|
|
91
|
+
if (channel === 'telegram') return 'on telegram, group';
|
|
92
|
+
if (sessionKey && sessionKey.includes(':cron:')) return 'scheduled run';
|
|
93
|
+
if (sessionKey && sessionKey.includes(':subagent:')) return 'background worker';
|
|
94
|
+
return 'standby';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function formatRelative(msAgo) {
|
|
98
|
+
const sec = Math.max(0, Math.floor(msAgo / 1000));
|
|
99
|
+
if (sec < 60) return `${sec}s ago`;
|
|
100
|
+
const min = Math.floor(sec / 60);
|
|
101
|
+
if (min < 60) return `${min}m ago`;
|
|
102
|
+
const hr = Math.floor(min / 60);
|
|
103
|
+
if (hr < 48) return `${hr}h ago`;
|
|
104
|
+
const day = Math.floor(hr / 24);
|
|
105
|
+
return `${day}d ago`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function deriveStatus(updatedAt) {
|
|
109
|
+
if (!updatedAt) return { key: 'away', label: 'away', age: Infinity };
|
|
110
|
+
const age = Date.now() - updatedAt;
|
|
111
|
+
if (age < 60_000) return { key: 'active', label: 'active', age };
|
|
112
|
+
if (age < 10 * 60_000) return { key: 'idle', label: 'idle', age };
|
|
113
|
+
return { key: 'away', label: 'away', age };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function modelShortName(modelId) {
|
|
117
|
+
if (!modelId) return null;
|
|
118
|
+
return modelId.replace(/^claude-cli\//, '').replace(/^openai-codex\//, '').replace(/^openai\//, '').replace(/^ollama\//, '');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Reads ~/.openclaw/tasks/runs.sqlite and returns per-agent task signals:
|
|
122
|
+
// tracked/active/issues/succeeded โ lifetime counts (matches gateway)
|
|
123
|
+
// recentIssues โ failed/timed_out/lost/cancelled in last 6h
|
|
124
|
+
// consecutiveFailures โ count of recurring sources whose LATEST 2
|
|
125
|
+
// runs both failed (the "actually broken" signal)
|
|
126
|
+
// Cached by sqlite mtime; opens the DB only when the file changes.
|
|
127
|
+
const TASKS_CACHE = { mtimeMs: 0, byAgent: {} };
|
|
128
|
+
const ACTIVE_STATUSES = new Set(['queued', 'running']);
|
|
129
|
+
const ISSUE_STATUSES = new Set(['failed', 'timed_out', 'lost', 'cancelled']);
|
|
130
|
+
const RECENT_ISSUE_WINDOW_MS = 6 * 60 * 60 * 1000;
|
|
131
|
+
function tasksByAgent() {
|
|
132
|
+
const filePath = path.join(STATE_DIR, 'tasks', 'runs.sqlite');
|
|
133
|
+
let stat;
|
|
134
|
+
try { stat = fs.statSync(filePath); } catch { return {}; }
|
|
135
|
+
if (stat.mtimeMs === TASKS_CACHE.mtimeMs) return TASKS_CACHE.byAgent;
|
|
136
|
+
const byAgent = {};
|
|
137
|
+
try {
|
|
138
|
+
const { DatabaseSync } = require('node:sqlite');
|
|
139
|
+
const db = new DatabaseSync(filePath, { readOnly: true });
|
|
140
|
+
const cutoff = Date.now() - RECENT_ISSUE_WINDOW_MS;
|
|
141
|
+
|
|
142
|
+
// Lifetime counts.
|
|
143
|
+
const rows = db.prepare("SELECT agent_id, status, COUNT(*) c FROM task_runs GROUP BY agent_id, status").all();
|
|
144
|
+
for (const r of rows) {
|
|
145
|
+
const id = r.agent_id; if (!id) continue;
|
|
146
|
+
const b = byAgent[id] || (byAgent[id] = {
|
|
147
|
+
tracked: 0, active: 0, issues: 0, succeeded: 0,
|
|
148
|
+
recentIssues: 0, consecutiveFailures: 0,
|
|
149
|
+
});
|
|
150
|
+
b.tracked += r.c;
|
|
151
|
+
if (ACTIVE_STATUSES.has(r.status)) b.active += r.c;
|
|
152
|
+
else if (ISSUE_STATUSES.has(r.status)) b.issues += r.c;
|
|
153
|
+
else if (r.status === 'succeeded') b.succeeded += r.c;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Recent issues (last 6h).
|
|
157
|
+
const recent = db.prepare(`
|
|
158
|
+
SELECT agent_id, COUNT(*) c FROM task_runs
|
|
159
|
+
WHERE status IN ('failed','timed_out','lost','cancelled')
|
|
160
|
+
AND COALESCE(ended_at, last_event_at, started_at) > ?
|
|
161
|
+
GROUP BY agent_id
|
|
162
|
+
`).all(cutoff);
|
|
163
|
+
for (const r of recent) {
|
|
164
|
+
const id = r.agent_id; if (!id) continue;
|
|
165
|
+
const b = byAgent[id] || (byAgent[id] = { tracked: 0, active: 0, issues: 0, succeeded: 0, recentIssues: 0, consecutiveFailures: 0 });
|
|
166
|
+
b.recentIssues = r.c;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Consecutive failures: per source_id, look at last 2 ENDED runs; if both
|
|
170
|
+
// are failures, count that source as a consecutive-failure case.
|
|
171
|
+
const streak = db.prepare(`
|
|
172
|
+
WITH ranked AS (
|
|
173
|
+
SELECT agent_id, source_id, status,
|
|
174
|
+
ROW_NUMBER() OVER (PARTITION BY source_id
|
|
175
|
+
ORDER BY COALESCE(ended_at, last_event_at, started_at) DESC) AS rn
|
|
176
|
+
FROM task_runs
|
|
177
|
+
WHERE source_id IS NOT NULL
|
|
178
|
+
AND status NOT IN ('queued','running')
|
|
179
|
+
)
|
|
180
|
+
SELECT agent_id, source_id
|
|
181
|
+
FROM ranked WHERE rn <= 2
|
|
182
|
+
GROUP BY agent_id, source_id
|
|
183
|
+
HAVING COUNT(*) = 2
|
|
184
|
+
AND SUM(CASE WHEN status IN ('failed','timed_out','lost','cancelled') THEN 1 ELSE 0 END) = 2
|
|
185
|
+
`).all();
|
|
186
|
+
for (const r of streak) {
|
|
187
|
+
const id = r.agent_id; if (!id) continue;
|
|
188
|
+
const b = byAgent[id] || (byAgent[id] = { tracked: 0, active: 0, issues: 0, succeeded: 0, recentIssues: 0, consecutiveFailures: 0 });
|
|
189
|
+
b.consecutiveFailures = (b.consecutiveFailures || 0) + 1;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
db.close();
|
|
193
|
+
} catch {}
|
|
194
|
+
TASKS_CACHE.mtimeMs = stat.mtimeMs;
|
|
195
|
+
TASKS_CACHE.byAgent = byAgent;
|
|
196
|
+
return byAgent;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Reads ~/.openclaw/cron/jobs.json (definitions) merged with jobs-state.json
|
|
200
|
+
// (live runtime state โ nextRunAtMs, lastRunStatus, etc.). Returns:
|
|
201
|
+
// { agentId -> { count, jobs: [{name, expr, tz, nextRunAtMs, lastStatus}] } }
|
|
202
|
+
// Cached by combined mtime so unchanged polls cost zero.
|
|
203
|
+
const CRON_CACHE = { mtimeKey: '', byAgent: {} };
|
|
204
|
+
function cronJobsByAgent() {
|
|
205
|
+
const defsPath = path.join(STATE_DIR, 'cron', 'jobs.json');
|
|
206
|
+
const statePath = path.join(STATE_DIR, 'cron', 'jobs-state.json');
|
|
207
|
+
let defsStat, stateStat;
|
|
208
|
+
try { defsStat = fs.statSync(defsPath); } catch { return {}; }
|
|
209
|
+
try { stateStat = fs.statSync(statePath); } catch {}
|
|
210
|
+
const mtimeKey = `${defsStat.mtimeMs}|${stateStat?.mtimeMs || 0}`;
|
|
211
|
+
if (mtimeKey === CRON_CACHE.mtimeKey) return CRON_CACHE.byAgent;
|
|
212
|
+
|
|
213
|
+
const defsJson = readJson(defsPath, null);
|
|
214
|
+
const stateJson = readJson(statePath, null);
|
|
215
|
+
const defs = Array.isArray(defsJson) ? defsJson : (defsJson?.jobs || (defsJson ? Object.values(defsJson) : []));
|
|
216
|
+
const stateById = stateJson?.jobs || {};
|
|
217
|
+
|
|
218
|
+
const byAgent = {};
|
|
219
|
+
for (const job of defs) {
|
|
220
|
+
if (!job || job.enabled === false) continue;
|
|
221
|
+
const id = job.agentId;
|
|
222
|
+
if (!id) continue;
|
|
223
|
+
const live = stateById[job.id]?.state || {};
|
|
224
|
+
const slot = byAgent[id] || (byAgent[id] = { count: 0, jobs: [] });
|
|
225
|
+
slot.count += 1;
|
|
226
|
+
slot.jobs.push({
|
|
227
|
+
name: job.name || job.id,
|
|
228
|
+
expr: job.schedule?.expr || (job.schedule?.kind === 'at' ? 'one-shot' : ''),
|
|
229
|
+
tz: job.schedule?.tz || null,
|
|
230
|
+
nextRunAtMs: live.nextRunAtMs || null,
|
|
231
|
+
lastRunAtMs: live.lastRunAtMs || null,
|
|
232
|
+
lastStatus: live.lastStatus || live.lastRunStatus || null,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
for (const slot of Object.values(byAgent)) {
|
|
236
|
+
slot.jobs.sort((a, b) => (a.nextRunAtMs ?? Infinity) - (b.nextRunAtMs ?? Infinity));
|
|
237
|
+
}
|
|
238
|
+
CRON_CACHE.mtimeKey = mtimeKey;
|
|
239
|
+
CRON_CACHE.byAgent = byAgent;
|
|
240
|
+
return byAgent;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function summarizeSessions(sessions) {
|
|
244
|
+
const byChannel = {};
|
|
245
|
+
let latest = null;
|
|
246
|
+
let latestMeta = null;
|
|
247
|
+
let directCount = 0;
|
|
248
|
+
let groupCount = 0;
|
|
249
|
+
let cronCount = 0;
|
|
250
|
+
let subagentCount = 0;
|
|
251
|
+
for (const [sessionKey, meta] of Object.entries(sessions || {})) {
|
|
252
|
+
const updatedAt = Number(meta?.updatedAt || 0);
|
|
253
|
+
const channel = meta?.lastChannel || (sessionKey.split(':')[2] || 'unknown');
|
|
254
|
+
byChannel[channel] = (byChannel[channel] || 0) + 1;
|
|
255
|
+
if (sessionKey.includes(':direct:')) directCount++;
|
|
256
|
+
else if (sessionKey.includes(':group:')) groupCount++;
|
|
257
|
+
if (sessionKey.includes(':cron:')) cronCount++;
|
|
258
|
+
if (sessionKey.includes(':subagent:')) subagentCount++;
|
|
259
|
+
if (!latest || updatedAt > latest.updatedAt) {
|
|
260
|
+
latest = {
|
|
261
|
+
sessionKey,
|
|
262
|
+
updatedAt,
|
|
263
|
+
sessionId: meta?.sessionId || null,
|
|
264
|
+
chatType: meta?.chatType || null,
|
|
265
|
+
lastChannel: meta?.lastChannel || null,
|
|
266
|
+
};
|
|
267
|
+
latestMeta = meta;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return { latest, latestMeta, byChannel, directCount, groupCount, cronCount, subagentCount };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Pulls the same per-session token fields the gateway shows in
|
|
274
|
+
// `openclaw sessions` โ totalTokens, contextTokens (model context window),
|
|
275
|
+
// cacheRead โ straight from sessions.json. Mirrors the gateway's
|
|
276
|
+
// `formatTokensCell(total, contextTokens)` math so display values match.
|
|
277
|
+
function tokenSummaryFromSession(meta) {
|
|
278
|
+
const empty = {
|
|
279
|
+
totalTokens: 0, contextWindow: 0, contextPct: 0, cachePct: 0, sessionModel: null,
|
|
280
|
+
inputTokens: 0, outputTokens: 0, cacheRead: 0, cacheWrite: 0,
|
|
281
|
+
};
|
|
282
|
+
if (!meta) return empty;
|
|
283
|
+
const total = Number(meta.totalTokens || 0);
|
|
284
|
+
const ctxWindow = Number(meta.contextTokens || 0);
|
|
285
|
+
const cacheRead = Number(meta.cacheRead || 0);
|
|
286
|
+
const cacheWrite = Number(meta.cacheWrite || 0);
|
|
287
|
+
const inputTokens = Number(meta.inputTokens || 0);
|
|
288
|
+
const outputTokens = Number(meta.outputTokens || 0);
|
|
289
|
+
const contextPct = ctxWindow ? Math.min(999, Math.round((total / ctxWindow) * 100)) : 0;
|
|
290
|
+
const cachePct = total > 0 ? Math.round((cacheRead / total) * 100) : 0;
|
|
291
|
+
const sessionModel = modelShortName(meta.modelOverride || meta.model || null);
|
|
292
|
+
return {
|
|
293
|
+
totalTokens: total, contextWindow: ctxWindow, contextPct, cachePct, sessionModel,
|
|
294
|
+
inputTokens, outputTokens, cacheRead, cacheWrite,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function summarizeMemory(memDir) {
|
|
299
|
+
const files = safeReaddir(memDir).filter(f => f.endsWith('.md'));
|
|
300
|
+
const datedLogs = files.filter(f => /^\d{4}-\d{2}-\d{2}/.test(f));
|
|
301
|
+
let latestLog = null;
|
|
302
|
+
let latestMtime = 0;
|
|
303
|
+
for (const f of datedLogs) {
|
|
304
|
+
try {
|
|
305
|
+
const stat = fs.statSync(path.join(memDir, f));
|
|
306
|
+
if (stat.mtimeMs > latestMtime) { latestMtime = stat.mtimeMs; latestLog = f; }
|
|
307
|
+
} catch {}
|
|
308
|
+
}
|
|
309
|
+
return {
|
|
310
|
+
totalNotes: files.length,
|
|
311
|
+
datedLogs: datedLogs.length,
|
|
312
|
+
latestLog,
|
|
313
|
+
latestLogAt: latestMtime || null,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Optional `products.json` lets each agent declare apps/dashboards the office
|
|
318
|
+
// can launch. Same shape across consumers, so the side panel builds matching
|
|
319
|
+
// "Local" and "LAN" launch buttons (host:port + optional path).
|
|
320
|
+
// Returns this host's non-loopback IPv4 addresses, used by the front-end as
|
|
321
|
+
// the LAN target for product launch buttons when the page itself was loaded
|
|
322
|
+
// over loopback (so window.location.hostname can't be trusted as the LAN IP).
|
|
323
|
+
function detectLanHosts() {
|
|
324
|
+
const out = [];
|
|
325
|
+
const ifaces = os.networkInterfaces();
|
|
326
|
+
for (const list of Object.values(ifaces)) {
|
|
327
|
+
for (const ni of list || []) {
|
|
328
|
+
if (ni.family === 'IPv4' && !ni.internal) out.push(ni.address);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return out;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const PRODUCTS_PATH = path.join(__dirname, 'products.json');
|
|
335
|
+
function loadProducts() {
|
|
336
|
+
const json = readJson(PRODUCTS_PATH, null);
|
|
337
|
+
const list = Array.isArray(json?.products) ? json.products : [];
|
|
338
|
+
const byAgent = {};
|
|
339
|
+
for (const p of list) {
|
|
340
|
+
if (!p?.agentId) continue;
|
|
341
|
+
(byAgent[p.agentId] ||= []).push(p);
|
|
342
|
+
}
|
|
343
|
+
return byAgent;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Note: reachability is probed client-side (see probeUrl in index.html) so we
|
|
347
|
+
// test from the browser's network perspective. The server can't reliably
|
|
348
|
+
// probe Windows-host services from WSL2 due to mirrored-networking namespace
|
|
349
|
+
// separation, so a server probe produces false negatives.
|
|
350
|
+
|
|
351
|
+
// Spawns a product's declared start command in detached mode. The launched
|
|
352
|
+
// process keeps running after this server restarts (setsid + unref). stdout
|
|
353
|
+
// and stderr go to /tmp/pixel-office-launch-<id>.log so the user can debug
|
|
354
|
+
// why a launch failed without watching the pixel-office log.
|
|
355
|
+
//
|
|
356
|
+
// Security: cmd + args go directly to spawn() with shell:false, so there is
|
|
357
|
+
// no shell interpretation of args. The `cwd` and `cmd` come from the static
|
|
358
|
+
// products.json file on disk โ they are NOT taken from the request body.
|
|
359
|
+
// The endpoint only takes a product id and looks the rest up server-side.
|
|
360
|
+
function launchProduct(product) {
|
|
361
|
+
const start = product.start;
|
|
362
|
+
if (!start || !start.cmd) return { ok: false, error: 'no start command declared' };
|
|
363
|
+
if (!start.cwd) return { ok: false, error: 'no cwd declared' };
|
|
364
|
+
try { fs.accessSync(start.cwd, fs.constants.R_OK); }
|
|
365
|
+
catch { return { ok: false, error: `cwd not accessible: ${start.cwd}` }; }
|
|
366
|
+
const logPath = `/tmp/pixel-office-launch-${product.id}.log`;
|
|
367
|
+
let logFd;
|
|
368
|
+
try { logFd = fs.openSync(logPath, 'a'); }
|
|
369
|
+
catch (e) { return { ok: false, error: `cannot open log: ${e.message}` }; }
|
|
370
|
+
fs.writeSync(logFd, `\n=== launch at ${new Date().toISOString()} ===\n`);
|
|
371
|
+
try {
|
|
372
|
+
const child = spawn(start.cmd, start.args || [], {
|
|
373
|
+
cwd: start.cwd,
|
|
374
|
+
detached: true,
|
|
375
|
+
stdio: ['ignore', logFd, logFd],
|
|
376
|
+
env: { ...process.env, ...(start.env || {}) },
|
|
377
|
+
});
|
|
378
|
+
child.unref();
|
|
379
|
+
fs.closeSync(logFd);
|
|
380
|
+
return { ok: true, pid: child.pid, logPath };
|
|
381
|
+
} catch (e) {
|
|
382
|
+
try { fs.closeSync(logFd); } catch {}
|
|
383
|
+
return { ok: false, error: e.message };
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function findProductById(productId) {
|
|
388
|
+
const json = readJson(PRODUCTS_PATH, null);
|
|
389
|
+
const list = Array.isArray(json?.products) ? json.products : [];
|
|
390
|
+
return list.find(p => p?.id === productId) || null;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Builds a synthetic session map for a demo agent so loadAgents can run its
|
|
394
|
+
// usual code path without needing real `~/.openclaw/agents/<id>/sessions/`
|
|
395
|
+
// files on disk. Returns an object keyed by a fake session id, with the
|
|
396
|
+
// session-status fields that summarizeSessions/tokenSummaryFromSession read.
|
|
397
|
+
function synthDemoSessions(demo) {
|
|
398
|
+
if (!demo) return {};
|
|
399
|
+
const updatedAt = Date.now() - (Number(demo.ageS) || 0) * 1000;
|
|
400
|
+
const sessions = {
|
|
401
|
+
[`agent:demo:telegram:direct:demo`]: {
|
|
402
|
+
sessionId: 'demo-session',
|
|
403
|
+
updatedAt,
|
|
404
|
+
lastChannel: 'telegram',
|
|
405
|
+
chatType: 'direct',
|
|
406
|
+
status: demo.sessionStatus || 'done',
|
|
407
|
+
abortedLastRun: !!demo.abortedLastRun,
|
|
408
|
+
totalTokens: demo.totalTokens || 0,
|
|
409
|
+
contextTokens: demo.contextWindow || 0,
|
|
410
|
+
cacheRead: Math.round((demo.totalTokens || 0) * 0.62),
|
|
411
|
+
cacheWrite: Math.round((demo.totalTokens || 0) * 0.04),
|
|
412
|
+
inputTokens: Math.round((demo.totalTokens || 0) * 0.93),
|
|
413
|
+
outputTokens: Math.round((demo.totalTokens || 0) * 0.07),
|
|
414
|
+
model: demo.model || 'claude-cli/claude-sonnet-4-6',
|
|
415
|
+
},
|
|
416
|
+
};
|
|
417
|
+
for (let i = 0; i < (demo.subagents || 0); i++) {
|
|
418
|
+
sessions[`agent:demo:subagent:demo-${i}`] = {
|
|
419
|
+
sessionId: `demo-subagent-${i}`,
|
|
420
|
+
updatedAt: updatedAt - i * 30_000,
|
|
421
|
+
status: 'running',
|
|
422
|
+
lastChannel: 'subagent',
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
return sessions;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Builds synthetic usage windows for a demo agent so the burn-signal and
|
|
429
|
+
// cost-panel features are visible out-of-the-box. Mirrors the shape that
|
|
430
|
+
// lib/usage.js#fleetUsage returns in perAgent[id].
|
|
431
|
+
function synthDemoUsage(demo) {
|
|
432
|
+
if (!demo) return null;
|
|
433
|
+
const mkWindow = (billed, count) => ({ billed, count, byProvider: { 'claude-cli': billed } });
|
|
434
|
+
return {
|
|
435
|
+
session: mkWindow(demo.sessionCost || 0, 1),
|
|
436
|
+
day: mkWindow(demo.dayCost || 0, Math.max(1, Math.round((demo.dayCost || 0) / Math.max(demo.sessionCost || 0.01, 0.001)))),
|
|
437
|
+
month: mkWindow((demo.dayCost || 0) * 20, 0),
|
|
438
|
+
year: mkWindow((demo.dayCost || 0) * 200, 0),
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Compares the current session's cost to the 24h total to detect a "hot"
|
|
443
|
+
// burn rate. Returns 'critical' when the session consumed โฅ75% of the day's
|
|
444
|
+
// budget, 'hot' when โฅ40%, otherwise null. Visible as an orange/red glow
|
|
445
|
+
// on the agent's room โ the only cost feature visible at a glance without
|
|
446
|
+
// opening the stats panel.
|
|
447
|
+
function deriveBurnSignal(usageData) {
|
|
448
|
+
if (!usageData) return null;
|
|
449
|
+
const sessionBilled = usageData.session?.billed || 0;
|
|
450
|
+
const dayBilled = usageData.day?.billed || 0;
|
|
451
|
+
if (!dayBilled || !sessionBilled) return null;
|
|
452
|
+
const ratio = sessionBilled / dayBilled;
|
|
453
|
+
if (ratio >= 0.75) return 'critical';
|
|
454
|
+
if (ratio >= 0.40) return 'hot';
|
|
455
|
+
return null;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Picks the right speech bubble for an agent given its signals + session
|
|
459
|
+
// state. Severity ladder: alert (something is broken and needs a human) >
|
|
460
|
+
// warn (recent issue) > busy (currently working) > null (fall back to the
|
|
461
|
+
// in-character personality quip rendered client-side). Text is short โ the
|
|
462
|
+
// bubble has a 32-char visible budget before clamping.
|
|
463
|
+
function deriveBubble(signals, signalDetail, statusKey, sessionStatus) {
|
|
464
|
+
if (signals.aborted) return { kind: 'alert', text: 'crashed โ needs a restart' };
|
|
465
|
+
if (signals.streak) return { kind: 'alert', text: `failing ${signalDetail.streak || 0}ร in a row` };
|
|
466
|
+
if (signals.recentFail) return { kind: 'warn', text: `${signalDetail.recentFail || 0} task issue${signalDetail.recentFail === 1 ? '' : 's'} recently` };
|
|
467
|
+
if (signals.ctxHot) return { kind: 'warn', text: `context ${signalDetail.ctxPct}% โ compact soon` };
|
|
468
|
+
if (statusKey === 'active' && sessionStatus === 'running') return { kind: 'busy', text: 'workingโฆ' };
|
|
469
|
+
if (statusKey === 'idle' && sessionStatus === 'running') return { kind: 'busy', text: 'thinkingโฆ' };
|
|
470
|
+
return null;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function loadAgents(cfg) {
|
|
474
|
+
const agents = [];
|
|
475
|
+
const cronByAgent = cronJobsByAgent();
|
|
476
|
+
const taskByAgent = tasksByAgent();
|
|
477
|
+
const productsByAgent = loadProducts();
|
|
478
|
+
for (const agent of cfg.agents?.list || []) {
|
|
479
|
+
const id = agent.id;
|
|
480
|
+
const workspace = agent.workspace || path.join(STATE_DIR, 'agents', id, 'workspace');
|
|
481
|
+
const identityMd = readText(path.join(workspace, 'IDENTITY.md'));
|
|
482
|
+
const displayName = extractField(identityMd, 'Name') || agent.identity?.name || id;
|
|
483
|
+
const emoji = extractField(identityMd, 'Emoji') || agent.identity?.emoji || '๐ค';
|
|
484
|
+
const role = shortenVibe(extractField(identityMd, 'Role') || extractField(identityMd, 'Creature') || '');
|
|
485
|
+
const vibe = shortenVibe(extractField(identityMd, 'Vibe') || '');
|
|
486
|
+
|
|
487
|
+
const sessionsPath = path.join(STATE_DIR, 'agents', id, 'sessions', 'sessions.json');
|
|
488
|
+
const sessions = agent.demo
|
|
489
|
+
? synthDemoSessions(agent.demo)
|
|
490
|
+
: (readJson(sessionsPath, {}) || {});
|
|
491
|
+
const sumS = summarizeSessions(sessions);
|
|
492
|
+
const updatedAt = sumS.latest?.updatedAt || 0;
|
|
493
|
+
const status = deriveStatus(updatedAt);
|
|
494
|
+
|
|
495
|
+
const memDir = path.join(workspace, 'memory');
|
|
496
|
+
const sumM = summarizeMemory(memDir);
|
|
497
|
+
|
|
498
|
+
const modelPrimary = modelShortName(agent.model?.primary);
|
|
499
|
+
const modelFallback = modelShortName((agent.model?.fallbacks || [])[0]);
|
|
500
|
+
|
|
501
|
+
const tok = tokenSummaryFromSession(sumS.latestMeta);
|
|
502
|
+
// Prefer the model the latest session is actually using; fall back to
|
|
503
|
+
// the agent's configured primary so the statsboard MODEL row is never blank.
|
|
504
|
+
const modelDisplay = tok.sessionModel || modelPrimary;
|
|
505
|
+
|
|
506
|
+
// Per-agent signal flags. Each one renders as its own icon in the room
|
|
507
|
+
// header โ keeping them split lets us watch real data and decide later
|
|
508
|
+
// which ones actually deserve a red alarm vs a yellow heads-up.
|
|
509
|
+
const tasksAgg = taskByAgent[id] || {
|
|
510
|
+
tracked: 0, active: 0, issues: 0, succeeded: 0,
|
|
511
|
+
recentIssues: 0, consecutiveFailures: 0,
|
|
512
|
+
};
|
|
513
|
+
if (agent.demo?.consecutiveFailures) tasksAgg.consecutiveFailures = agent.demo.consecutiveFailures;
|
|
514
|
+
if (agent.demo?.recentIssues) tasksAgg.recentIssues = agent.demo.recentIssues;
|
|
515
|
+
const fallbackList = (agent.model?.fallbacks || []).map(modelShortName).filter(Boolean);
|
|
516
|
+
const signals = {
|
|
517
|
+
aborted: !!sumS.latestMeta?.abortedLastRun,
|
|
518
|
+
streak: (tasksAgg.consecutiveFailures || 0) > 0,
|
|
519
|
+
recentFail: (tasksAgg.recentIssues || 0) > 0,
|
|
520
|
+
ctxHot: (tok.contextPct || 0) >= 95,
|
|
521
|
+
fallback: !!modelDisplay && !!modelPrimary && modelDisplay !== modelPrimary && fallbackList.includes(modelDisplay),
|
|
522
|
+
};
|
|
523
|
+
const signalDetail = {
|
|
524
|
+
streak: tasksAgg.consecutiveFailures || 0,
|
|
525
|
+
recentFail: tasksAgg.recentIssues || 0,
|
|
526
|
+
ctxPct: tok.contextPct || 0,
|
|
527
|
+
modelPrimary, modelDisplay,
|
|
528
|
+
};
|
|
529
|
+
const sessionStatus = sumS.latestMeta?.status || null;
|
|
530
|
+
const bubble = deriveBubble(signals, signalDetail, status.key, sessionStatus);
|
|
531
|
+
|
|
532
|
+
agents.push({
|
|
533
|
+
id,
|
|
534
|
+
displayName,
|
|
535
|
+
emoji,
|
|
536
|
+
role: role || 'specialist',
|
|
537
|
+
vibe: vibe || '',
|
|
538
|
+
status: status.key,
|
|
539
|
+
statusLabel: status.label,
|
|
540
|
+
updatedAt,
|
|
541
|
+
lastSeen: updatedAt ? formatRelative(Date.now() - updatedAt) : 'never',
|
|
542
|
+
sessionCount: Object.keys(sessions).length,
|
|
543
|
+
directCount: sumS.directCount,
|
|
544
|
+
groupCount: sumS.groupCount,
|
|
545
|
+
cronCount: sumS.cronCount,
|
|
546
|
+
cronJobs: cronByAgent[id]?.count || 0,
|
|
547
|
+
cronSchedule: cronByAgent[id]?.jobs || [],
|
|
548
|
+
products: productsByAgent[id] || [],
|
|
549
|
+
tasks: tasksAgg,
|
|
550
|
+
signals,
|
|
551
|
+
signalDetail,
|
|
552
|
+
sessionStatus,
|
|
553
|
+
bubble,
|
|
554
|
+
_demo: agent.demo || null,
|
|
555
|
+
subagentCount: sumS.subagentCount,
|
|
556
|
+
sessionKey: sumS.latest?.sessionKey || null,
|
|
557
|
+
sessionId: sumS.latest?.sessionId || null,
|
|
558
|
+
chatType: sumS.latest?.chatType || null,
|
|
559
|
+
channel: sumS.latest?.lastChannel || null,
|
|
560
|
+
currentTask: deriveHint(sumS.latest?.sessionKey || null, sumS.latest?.lastChannel || null, sumS.latest?.chatType || null),
|
|
561
|
+
modelPrimary,
|
|
562
|
+
modelFallback,
|
|
563
|
+
modelDisplay,
|
|
564
|
+
totalTokens: tok.totalTokens,
|
|
565
|
+
contextTokens: tok.totalTokens,
|
|
566
|
+
contextWindow: tok.contextWindow,
|
|
567
|
+
contextPct: tok.contextPct,
|
|
568
|
+
cachePct: tok.cachePct,
|
|
569
|
+
inputTokens: tok.inputTokens,
|
|
570
|
+
outputTokens: tok.outputTokens,
|
|
571
|
+
cacheRead: tok.cacheRead,
|
|
572
|
+
cacheWrite: tok.cacheWrite,
|
|
573
|
+
memoryNotes: sumM.totalNotes,
|
|
574
|
+
memoryLogs: sumM.datedLogs,
|
|
575
|
+
latestLog: sumM.latestLog,
|
|
576
|
+
latestLogAt: sumM.latestLogAt,
|
|
577
|
+
openclawUrl: OPENCLAW_URL,
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
return agents;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function fleetStats(agents) {
|
|
584
|
+
const stats = { total: agents.length, active: 0, idle: 0, away: 0, totalSessions: 0, totalNotes: 0 };
|
|
585
|
+
for (const a of agents) {
|
|
586
|
+
stats[a.status] = (stats[a.status] || 0) + 1;
|
|
587
|
+
stats.totalSessions += a.sessionCount;
|
|
588
|
+
stats.totalNotes += a.memoryNotes || 0;
|
|
589
|
+
}
|
|
590
|
+
return stats;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function agentIndexById(cfg, agentId) {
|
|
594
|
+
const list = cfg?.agents?.list || [];
|
|
595
|
+
return list.findIndex(a => a?.id === agentId);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function readBody(req, limit = 8 * 1024) {
|
|
599
|
+
return new Promise((resolve, reject) => {
|
|
600
|
+
let total = 0;
|
|
601
|
+
const chunks = [];
|
|
602
|
+
req.on('data', (c) => {
|
|
603
|
+
total += c.length;
|
|
604
|
+
if (total > limit) { req.destroy(); reject(new Error('body too large')); return; }
|
|
605
|
+
chunks.push(c);
|
|
606
|
+
});
|
|
607
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
608
|
+
req.on('error', reject);
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function setAgentModel(agentIdx, modelId) {
|
|
613
|
+
return new Promise((resolve, reject) => {
|
|
614
|
+
execFile(
|
|
615
|
+
'openclaw',
|
|
616
|
+
['config', 'set', `agents.list.${agentIdx}.model.primary`, modelId],
|
|
617
|
+
{ timeout: 15_000 },
|
|
618
|
+
(err, stdout, stderr) => {
|
|
619
|
+
if (err) { err.stdout = stdout; err.stderr = stderr; return reject(err); }
|
|
620
|
+
resolve({ stdout, stderr });
|
|
621
|
+
}
|
|
622
|
+
);
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function respondJson(res, statusCode, payload) {
|
|
627
|
+
res.writeHead(statusCode, {
|
|
628
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
629
|
+
'Cache-Control': 'no-store',
|
|
630
|
+
'Access-Control-Allow-Origin': '*',
|
|
631
|
+
});
|
|
632
|
+
res.end(JSON.stringify(payload));
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// Tiny single-pass markdown renderer. Covers what Harvey's strategy docs and
|
|
636
|
+
// most agent-authored markdown actually use: headings, paragraphs, bullet/
|
|
637
|
+
// numbered lists, GitHub-style tables, fenced code, inline code, bold/italic,
|
|
638
|
+
// links, and horizontal rules. Not CommonMark โ just enough to look right on a
|
|
639
|
+
// phone without pulling in a dependency.
|
|
640
|
+
function escapeHtml(s) {
|
|
641
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
642
|
+
}
|
|
643
|
+
function renderInline(s) {
|
|
644
|
+
s = escapeHtml(s);
|
|
645
|
+
s = s.replace(/`([^`]+)`/g, '<code>$1</code>');
|
|
646
|
+
s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
|
647
|
+
s = s.replace(/(^|[^*])\*([^*]+)\*/g, '$1<em>$2</em>');
|
|
648
|
+
s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noreferrer">$1</a>');
|
|
649
|
+
return s;
|
|
650
|
+
}
|
|
651
|
+
function renderMarkdown(md) {
|
|
652
|
+
const lines = md.replace(/\r\n/g, '\n').split('\n');
|
|
653
|
+
const out = [];
|
|
654
|
+
let i = 0;
|
|
655
|
+
while (i < lines.length) {
|
|
656
|
+
const line = lines[i];
|
|
657
|
+
if (/^```/.test(line)) {
|
|
658
|
+
const buf = [];
|
|
659
|
+
i++;
|
|
660
|
+
while (i < lines.length && !/^```/.test(lines[i])) { buf.push(escapeHtml(lines[i])); i++; }
|
|
661
|
+
i++;
|
|
662
|
+
out.push(`<pre><code>${buf.join('\n')}</code></pre>`);
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
if (/^#{1,6}\s/.test(line)) {
|
|
666
|
+
const m = line.match(/^(#{1,6})\s+(.*)$/);
|
|
667
|
+
out.push(`<h${m[1].length}>${renderInline(m[2])}</h${m[1].length}>`);
|
|
668
|
+
i++; continue;
|
|
669
|
+
}
|
|
670
|
+
if (/^---+$/.test(line)) { out.push('<hr/>'); i++; continue; }
|
|
671
|
+
if (/^\s*\|.*\|\s*$/.test(line) && i + 1 < lines.length && /^\s*\|[\s:|-]+\|\s*$/.test(lines[i+1])) {
|
|
672
|
+
const split = (l) => l.trim().replace(/^\||\|$/g, '').split('|').map(c => c.trim());
|
|
673
|
+
const head = split(line); i += 2;
|
|
674
|
+
const rows = [];
|
|
675
|
+
while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) { rows.push(split(lines[i])); i++; }
|
|
676
|
+
out.push('<table><thead><tr>' + head.map(h => `<th>${renderInline(h)}</th>`).join('') + '</tr></thead><tbody>' +
|
|
677
|
+
rows.map(r => '<tr>' + r.map(c => `<td>${renderInline(c)}</td>`).join('') + '</tr>').join('') + '</tbody></table>');
|
|
678
|
+
continue;
|
|
679
|
+
}
|
|
680
|
+
if (/^\s*[-*+]\s/.test(line)) {
|
|
681
|
+
const items = [];
|
|
682
|
+
while (i < lines.length && /^\s*[-*+]\s/.test(lines[i])) { items.push(lines[i].replace(/^\s*[-*+]\s+/, '')); i++; }
|
|
683
|
+
out.push('<ul>' + items.map(it => `<li>${renderInline(it)}</li>`).join('') + '</ul>');
|
|
684
|
+
continue;
|
|
685
|
+
}
|
|
686
|
+
if (/^\s*\d+\.\s/.test(line)) {
|
|
687
|
+
const items = [];
|
|
688
|
+
while (i < lines.length && /^\s*\d+\.\s/.test(lines[i])) { items.push(lines[i].replace(/^\s*\d+\.\s+/, '')); i++; }
|
|
689
|
+
out.push('<ol>' + items.map(it => `<li>${renderInline(it)}</li>`).join('') + '</ol>');
|
|
690
|
+
continue;
|
|
691
|
+
}
|
|
692
|
+
if (line.trim() === '') { i++; continue; }
|
|
693
|
+
const buf = [];
|
|
694
|
+
while (i < lines.length && lines[i].trim() !== '' && !/^[#>\-*+\d`|]/.test(lines[i].trim()[0] || '')) {
|
|
695
|
+
buf.push(lines[i]); i++;
|
|
696
|
+
}
|
|
697
|
+
if (buf.length) out.push(`<p>${renderInline(buf.join(' '))}</p>`);
|
|
698
|
+
else { out.push(`<p>${renderInline(line)}</p>`); i++; }
|
|
699
|
+
}
|
|
700
|
+
return out.join('\n');
|
|
701
|
+
}
|
|
702
|
+
const DOCS_CSS = `
|
|
703
|
+
body{margin:0;font:14px/1.55 ui-monospace,Menlo,Consolas,monospace;color:#ecf2ff;background:#151525;padding:18px;max-width:900px;margin:0 auto;}
|
|
704
|
+
a{color:#7df9d0;}
|
|
705
|
+
h1,h2,h3,h4{color:#cdb7ff;border-bottom:2px solid #2e3560;padding-bottom:4px;margin-top:1.6em;}
|
|
706
|
+
h1{font-size:22px;} h2{font-size:18px;} h3{font-size:15px;}
|
|
707
|
+
code{background:#2e3560;padding:1px 5px;border-radius:3px;font-size:0.92em;}
|
|
708
|
+
pre{background:#0d1020;padding:12px;overflow-x:auto;border-left:3px solid #5ea2ff;}
|
|
709
|
+
pre code{background:transparent;padding:0;}
|
|
710
|
+
table{border-collapse:collapse;width:100%;margin:12px 0;font-size:13px;}
|
|
711
|
+
th,td{border:1px solid #2e3560;padding:6px 9px;text-align:left;vertical-align:top;}
|
|
712
|
+
th{background:#1f234a;color:#cdb7ff;}
|
|
713
|
+
hr{border:0;border-top:2px dashed #2e3560;margin:18px 0;}
|
|
714
|
+
ul,ol{padding-left:22px;}
|
|
715
|
+
.crumbs{font-size:12px;color:#a8b0cc;margin-bottom:14px;}
|
|
716
|
+
.crumbs a{color:#7df9d0;text-decoration:none;}
|
|
717
|
+
.crumbs a:hover{text-decoration:underline;}
|
|
718
|
+
.listing li{margin:4px 0;}
|
|
719
|
+
.listing a.dir::after{content:"/";color:#a8b0cc;}
|
|
720
|
+
`;
|
|
721
|
+
function docsCrumbs(productId, subPath) {
|
|
722
|
+
const parts = subPath ? subPath.split('/').filter(Boolean) : [];
|
|
723
|
+
const links = [`<a href="/docs/${productId}/">${productId}</a>`];
|
|
724
|
+
let acc = '';
|
|
725
|
+
for (const p of parts) {
|
|
726
|
+
acc += '/' + p;
|
|
727
|
+
links.push(`<a href="/docs/${productId}${acc}">${escapeHtml(p)}</a>`);
|
|
728
|
+
}
|
|
729
|
+
return `<div class="crumbs">${links.join(' / ')}</div>`;
|
|
730
|
+
}
|
|
731
|
+
function docsPage(productId, subPath, title, bodyHtml) {
|
|
732
|
+
return `<!doctype html><html><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><title>${escapeHtml(title)}</title><style>${DOCS_CSS}</style></head><body>${docsCrumbs(productId, subPath)}${bodyHtml}</body></html>`;
|
|
733
|
+
}
|
|
734
|
+
const DOCS_MIME = {
|
|
735
|
+
'.md': 'text/html; charset=utf-8',
|
|
736
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
737
|
+
'.json': 'application/json; charset=utf-8',
|
|
738
|
+
'.csv': 'text/csv; charset=utf-8',
|
|
739
|
+
'.html': 'text/html; charset=utf-8',
|
|
740
|
+
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.svg': 'image/svg+xml',
|
|
741
|
+
};
|
|
742
|
+
function serveDocs(product, subPath, res) {
|
|
743
|
+
const root = path.resolve(product.repoPath);
|
|
744
|
+
const target = path.normalize(path.join(root, subPath));
|
|
745
|
+
if (!target.startsWith(root)) { res.writeHead(403); res.end('forbidden'); return; }
|
|
746
|
+
let stat;
|
|
747
|
+
try { stat = fs.statSync(target); } catch { res.writeHead(404); res.end('not found'); return; }
|
|
748
|
+
if (stat.isDirectory()) {
|
|
749
|
+
const entries = safeReaddir(target).sort();
|
|
750
|
+
const items = entries.map(name => {
|
|
751
|
+
const full = path.join(target, name);
|
|
752
|
+
let isDir = false;
|
|
753
|
+
try { isDir = fs.statSync(full).isDirectory(); } catch {}
|
|
754
|
+
const href = `/docs/${product.id}/${path.posix.join(subPath, name)}`;
|
|
755
|
+
return `<li><a class="${isDir ? 'dir' : 'file'}" href="${href}">${escapeHtml(name)}</a></li>`;
|
|
756
|
+
}).join('');
|
|
757
|
+
const indexFile = entries.find(n => /^(index|readme|.*_index)\.md$/i.test(n));
|
|
758
|
+
let preview = '';
|
|
759
|
+
if (indexFile) {
|
|
760
|
+
try {
|
|
761
|
+
const md = fs.readFileSync(path.join(target, indexFile), 'utf8');
|
|
762
|
+
preview = `<hr/><h2>${escapeHtml(indexFile)}</h2>` + renderMarkdown(md);
|
|
763
|
+
} catch {}
|
|
764
|
+
}
|
|
765
|
+
const body = `<h1>${escapeHtml(product.name || product.id)}</h1><ul class="listing">${items}</ul>${preview}`;
|
|
766
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
767
|
+
return res.end(docsPage(product.id, subPath, product.name || product.id, body));
|
|
768
|
+
}
|
|
769
|
+
const ext = path.extname(target).toLowerCase();
|
|
770
|
+
if (ext === '.md') {
|
|
771
|
+
const md = fs.readFileSync(target, 'utf8');
|
|
772
|
+
const body = renderMarkdown(md);
|
|
773
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
774
|
+
return res.end(docsPage(product.id, subPath, path.basename(target), body));
|
|
775
|
+
}
|
|
776
|
+
const type = DOCS_MIME[ext] || 'application/octet-stream';
|
|
777
|
+
res.writeHead(200, { 'Content-Type': type, 'Cache-Control': 'no-store' });
|
|
778
|
+
return res.end(fs.readFileSync(target));
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// Pixel sheets get regenerated when an agent is added. Long-cache them and a
|
|
782
|
+
// browser holds a stale layout (e.g. row 9 lands past the bottom of the
|
|
783
|
+
// cached image -> blank sprite). Short-cache + revalidate keeps them fresh
|
|
784
|
+
// without burning bandwidth on every request.
|
|
785
|
+
const REGENERABLE_PNGS = new Set(['/sprites.png', '/monitors.png', '/furniture.png', '/props.png']);
|
|
786
|
+
|
|
787
|
+
// Cache-bust version derived from PNG mtimes. The HTML is no-store so we can
|
|
788
|
+
// recompute on every page load; the version flips whenever any sheet rebuilds,
|
|
789
|
+
// guaranteeing a fresh fetch even if a user's disk cache held the old layout.
|
|
790
|
+
function pixelSheetVersion() {
|
|
791
|
+
let v = 0;
|
|
792
|
+
for (const p of REGENERABLE_PNGS) {
|
|
793
|
+
try { v = Math.max(v, Math.floor(fs.statSync(path.join(PUBLIC_DIR, p)).mtimeMs)); } catch {}
|
|
794
|
+
}
|
|
795
|
+
return v || Date.now();
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
function injectAssetVersion(html, v) {
|
|
799
|
+
const tagged = `?v=${v}`;
|
|
800
|
+
return html.replace(/url\('(sprites|monitors|furniture|props)\.png'\)/g, (_m, name) => `url('${name}.png${tagged}')`);
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
function serveStatic(req, res) {
|
|
804
|
+
const requested = req.url === '/' ? '/index.html' : req.url.split('?')[0];
|
|
805
|
+
const filePath = path.normalize(path.join(PUBLIC_DIR, requested));
|
|
806
|
+
if (!filePath.startsWith(PUBLIC_DIR)) { res.writeHead(403); res.end('forbidden'); return; }
|
|
807
|
+
fs.readFile(filePath, (err, data) => {
|
|
808
|
+
if (err) { res.writeHead(404); res.end('not found'); return; }
|
|
809
|
+
const ext = path.extname(filePath);
|
|
810
|
+
const TEXT = { '.html':'text/html', '.css':'text/css', '.js':'application/javascript', '.svg':'image/svg+xml',
|
|
811
|
+
'.json':'application/json', '.webmanifest':'application/manifest+json' };
|
|
812
|
+
const BIN = { '.png':'image/png', '.jpg':'image/jpeg', '.jpeg':'image/jpeg', '.ico':'image/x-icon' };
|
|
813
|
+
if (BIN[ext]) {
|
|
814
|
+
const cacheCtl = REGENERABLE_PNGS.has(requested)
|
|
815
|
+
? 'no-cache, must-revalidate'
|
|
816
|
+
: 'public, max-age=86400';
|
|
817
|
+
res.writeHead(200, { 'Content-Type': BIN[ext], 'Cache-Control': cacheCtl });
|
|
818
|
+
res.end(data);
|
|
819
|
+
} else {
|
|
820
|
+
const type = TEXT[ext] || 'text/plain';
|
|
821
|
+
let body = data;
|
|
822
|
+
if (requested === '/index.html') {
|
|
823
|
+
body = Buffer.from(injectAssetVersion(data.toString('utf8'), pixelSheetVersion()), 'utf8');
|
|
824
|
+
}
|
|
825
|
+
res.writeHead(200, { 'Content-Type': `${type}; charset=utf-8`, 'Cache-Control': 'no-store' });
|
|
826
|
+
res.end(body);
|
|
827
|
+
}
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
const server = http.createServer(async (req, res) => {
|
|
832
|
+
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
|
833
|
+
if (url.pathname === '/api/pixel-office/state') {
|
|
834
|
+
const cfg = loadFleetConfig();
|
|
835
|
+
const agents = loadAgents(cfg);
|
|
836
|
+
// Kick off a non-blocking refresh of the trajectory cache. First poll after
|
|
837
|
+
// server start returns zeros while the initial scan finishes; subsequent
|
|
838
|
+
// polls reuse the in-memory cache (mtime-keyed) and only re-parse files
|
|
839
|
+
// that grew since the last scan. Errors are swallowed so a single broken
|
|
840
|
+
// trajectory file can't take the API down.
|
|
841
|
+
const agentIds = agents.map(a => a.id);
|
|
842
|
+
usage.refresh(agentIds).catch(() => {});
|
|
843
|
+
const priceCfg = usage.loadPrices(PRICES_PATH);
|
|
844
|
+
const usageData = usage.fleetUsage(agentIds, priceCfg);
|
|
845
|
+
for (const a of agents) {
|
|
846
|
+
// In demo mode each agent has a `demo` block; inject synthetic usage so
|
|
847
|
+
// the cost panel and burn-signal glow both render without real data.
|
|
848
|
+
a.usage = DEMO_MODE && a._demo
|
|
849
|
+
? synthDemoUsage(a._demo)
|
|
850
|
+
: (usageData.perAgent[a.id] || null);
|
|
851
|
+
a.burnSignal = deriveBurnSignal(a.usage);
|
|
852
|
+
}
|
|
853
|
+
// Probe each product's port so the UI can downgrade "live" โ "offline"
|
|
854
|
+
// when the process isn't actually serving. Cached for 30s so the 5s state
|
|
855
|
+
// poll doesn't open a new socket every tick.
|
|
856
|
+
return respondJson(res, 200, {
|
|
857
|
+
now: Date.now(),
|
|
858
|
+
openclawUrl: OPENCLAW_URL,
|
|
859
|
+
lanHosts: detectLanHosts(),
|
|
860
|
+
availableModels: MODEL_OPTIONS,
|
|
861
|
+
subscriptionProviders: Object.keys(priceCfg.subscriptions || {}),
|
|
862
|
+
billingModes: priceCfg.billingModes || {},
|
|
863
|
+
fleet: { ...fleetStats(agents), usage: usageData.fleet },
|
|
864
|
+
agents,
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
// POST /api/pixel-office/agent/:id/model body: {"model":"<id>"}
|
|
868
|
+
// Shells out to `openclaw config set agents.list.<idx>.model.primary <model>`.
|
|
869
|
+
// Both id and model are checked against known values so they can never be a
|
|
870
|
+
// crafted argv injection โ execFile already avoids shell parsing, but the
|
|
871
|
+
// allowlists keep the UI honest about what it can set.
|
|
872
|
+
const modelMatch = url.pathname.match(/^\/api\/pixel-office\/agent\/([^/]+)\/model$/);
|
|
873
|
+
if (modelMatch && req.method === 'POST') {
|
|
874
|
+
try {
|
|
875
|
+
const agentId = decodeURIComponent(modelMatch[1]);
|
|
876
|
+
const raw = await readBody(req);
|
|
877
|
+
let parsed;
|
|
878
|
+
try { parsed = JSON.parse(raw); } catch { return respondJson(res, 400, { error: 'invalid json' }); }
|
|
879
|
+
const modelId = String(parsed?.model || '');
|
|
880
|
+
if (!MODEL_OPTION_IDS.has(modelId)) return respondJson(res, 400, { error: 'unknown model', model: modelId });
|
|
881
|
+
const cfg = loadFleetConfig();
|
|
882
|
+
const idx = agentIndexById(cfg, agentId);
|
|
883
|
+
if (idx < 0) return respondJson(res, 404, { error: 'unknown agent', agentId });
|
|
884
|
+
await setAgentModel(idx, modelId);
|
|
885
|
+
return respondJson(res, 200, { ok: true, agentId, model: modelId });
|
|
886
|
+
} catch (err) {
|
|
887
|
+
const stderr = (err && err.stderr) ? String(err.stderr).slice(0, 500) : '';
|
|
888
|
+
return respondJson(res, 500, { error: err?.message || 'set failed', stderr });
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
// POST /api/pixel-office/product/:id/start
|
|
892
|
+
// Spawns the product's declared start command (from products.json) detached.
|
|
893
|
+
// The id is the only client-supplied input; everything else (cwd/cmd/args)
|
|
894
|
+
// is loaded from the static products.json so the body can't be used to run
|
|
895
|
+
// arbitrary commands.
|
|
896
|
+
const startMatch = url.pathname.match(/^\/api\/pixel-office\/product\/([^/]+)\/start$/);
|
|
897
|
+
if (startMatch && req.method === 'POST') {
|
|
898
|
+
const productId = decodeURIComponent(startMatch[1]);
|
|
899
|
+
const product = findProductById(productId);
|
|
900
|
+
if (!product) return respondJson(res, 404, { error: 'unknown product', productId });
|
|
901
|
+
const result = launchProduct(product);
|
|
902
|
+
if (!result.ok) return respondJson(res, 400, { error: result.error || 'launch failed' });
|
|
903
|
+
return respondJson(res, 200, { ok: true, productId, pid: result.pid, logPath: result.logPath });
|
|
904
|
+
}
|
|
905
|
+
// GET /docs/<productId>/<...path>
|
|
906
|
+
// Browses files under a documents-only product's repoPath. Markdown files
|
|
907
|
+
// render to HTML; everything else is served raw with a guessed mime type.
|
|
908
|
+
// The route only ever resolves under product.repoPath so requests can't
|
|
909
|
+
// escape into the rest of the filesystem.
|
|
910
|
+
const docsMatch = url.pathname.match(/^\/docs\/([^/]+)(?:\/(.*))?$/);
|
|
911
|
+
if (docsMatch && req.method === 'GET') {
|
|
912
|
+
const productId = decodeURIComponent(docsMatch[1]);
|
|
913
|
+
const subPath = docsMatch[2] ? decodeURIComponent(docsMatch[2]) : '';
|
|
914
|
+
const product = findProductById(productId);
|
|
915
|
+
if (!product || !product.repoPath) {
|
|
916
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
917
|
+
return res.end('not a documents-only product');
|
|
918
|
+
}
|
|
919
|
+
return serveDocs(product, subPath, res);
|
|
920
|
+
}
|
|
921
|
+
if (url.pathname === '/healthz') return respondJson(res, 200, { ok: true });
|
|
922
|
+
return serveStatic({ url: url.pathname }, res);
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
// Kick off the initial trajectory scan in the background as soon as we know
|
|
926
|
+
// which agents exist. The first /state poll might land before this finishes โ
|
|
927
|
+
// it'll just return zero costs and self-correct on the next 5s tick.
|
|
928
|
+
function bootstrapUsageScan() {
|
|
929
|
+
try {
|
|
930
|
+
const cfg = loadFleetConfig();
|
|
931
|
+
const agentIds = (cfg.agents?.list || []).map(a => a.id).filter(Boolean);
|
|
932
|
+
const t0 = Date.now();
|
|
933
|
+
usage.refresh(agentIds)
|
|
934
|
+
.then(() => console.log(`Initial usage scan: ${agentIds.length} agents in ${Date.now() - t0}ms`))
|
|
935
|
+
.catch(err => console.error('Initial usage scan failed:', err.message));
|
|
936
|
+
} catch (err) {
|
|
937
|
+
console.error('bootstrapUsageScan failed:', err.message);
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
server.listen(PORT, HOST, () => {
|
|
942
|
+
console.log(`Pixel office listening on http://${HOST}:${PORT}`);
|
|
943
|
+
bootstrapUsageScan();
|
|
944
|
+
});
|