opencode-agent-flow 0.0.0-stage → 0.1.1
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/CHANGELOG.md +34 -0
- package/CONTRIBUTING.md +3 -0
- package/LICENSE +21 -0
- package/README.md +181 -2
- package/SECURITY.md +3 -0
- package/bin/cli.mjs +54 -0
- package/docs/architecture.md +58 -0
- package/docs/opencode-v2-api-notes.md +121 -0
- package/docs/screenshot.png +0 -0
- package/lib/demo.mjs +42 -0
- package/lib/privacy.mjs +9 -0
- package/lib/upstream.mjs +194 -0
- package/package.json +46 -4
- package/prices.json +10 -0
- package/public/app.js +223 -0
- package/public/flow-webgl.js +155 -0
- package/public/index.html +10 -0
- package/public/style.css +6 -0
- package/public/vendor/three/LICENSE +21 -0
- package/public/vendor/three/postprocessing/EffectComposer.js +363 -0
- package/public/vendor/three/postprocessing/MaskPass.js +195 -0
- package/public/vendor/three/postprocessing/OutputPass.js +139 -0
- package/public/vendor/three/postprocessing/Pass.js +191 -0
- package/public/vendor/three/postprocessing/RenderPass.js +183 -0
- package/public/vendor/three/postprocessing/ShaderPass.js +135 -0
- package/public/vendor/three/postprocessing/UnrealBloomPass.js +492 -0
- package/public/vendor/three/shaders/CopyShader.js +52 -0
- package/public/vendor/three/shaders/LuminosityHighPassShader.js +68 -0
- package/public/vendor/three/shaders/OutputShader.js +103 -0
- package/public/vendor/three/three.core.min.js +6 -0
- package/public/vendor/three/three.module.min.js +6 -0
- package/server.mjs +217 -0
package/lib/upstream.mjs
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
export function reconnectDelay(delay, connectedAt, now = Date.now()) { return connectedAt !== undefined && now - connectedAt >= 30000 ? 1000 : delay; }
|
|
2
|
+
export function trimMessage(m) {
|
|
3
|
+
return { id: m.id, type: m.type, time: m.time, tokens: m.tokens, agent: m.agent, model: m.model, finish: m.finish, outcome: String(m.outcome || '').slice(0, 300), content: (m.content || []).map(c => ({ type: c.type, name: c.name || c.tool, id: c.id, time: c.time, text: String(c.text || '').slice(0, 300), state: { status: c.state?.status, time: c.state?.time, input: Object.fromEntries(Object.entries(c.state?.input || c.input || {}).filter(([key]) => ['code', 'filePath', 'path', 'pattern', 'command', 'query', 'url', 'agent', 'description'].includes(key)).map(([key, value]) => [key, String(value).slice(0, 300)])) } })) };
|
|
4
|
+
}
|
|
5
|
+
export class Upstream {
|
|
6
|
+
constructor(config, changed) {
|
|
7
|
+
Object.assign(this, { config, changed, sessions: new Map(), messages: new Map(), messageVersions: new Map(), fullHistory: new Set(), active: {}, approvals: [], health: { ok: false }, connection: 'connecting', dirty: new Set(), queue: [], jobs: 0, abort: new AbortController() });
|
|
8
|
+
}
|
|
9
|
+
async request(path, signal) {
|
|
10
|
+
const headers = this.config.password ? { Authorization: `Basic ${Buffer.from(`${this.config.username}:${this.config.password}`).toString('base64')}` } : {};
|
|
11
|
+
const response = await fetch(`${this.config.server}${path}`, { headers, redirect: 'error', signal: AbortSignal.any([this.abort.signal, signal || AbortSignal.timeout(15000)]) });
|
|
12
|
+
if (!response.ok) { await response.body?.cancel(); throw new Error(response.status === 401 ? 'OpenCode returned 401: set OPENCODE_SERVER_PASSWORD (and optionally OPENCODE_SERVER_USERNAME).' : `OpenCode API returned HTTP ${response.status}.`); }
|
|
13
|
+
return response;
|
|
14
|
+
}
|
|
15
|
+
async get(path) { return (await this.request(path)).json(); }
|
|
16
|
+
async limited(fn) {
|
|
17
|
+
if (this.jobs >= 4) await new Promise(resolve => this.queue.push(resolve));
|
|
18
|
+
else this.jobs++;
|
|
19
|
+
try { return await fn(); } finally { const next = this.queue.shift(); if (next) next(); else this.jobs--; }
|
|
20
|
+
}
|
|
21
|
+
cutoff() { return Math.min(Date.now() - Math.max(12, this.config.window) * 3600000, new Date().setHours(0, 0, 0, 0)); }
|
|
22
|
+
async pages(path, stop = () => false, compact = value => value) {
|
|
23
|
+
const all = []; let cursor; const seen = new Set();
|
|
24
|
+
do {
|
|
25
|
+
const body = await this.get(`${path}${path.includes('?') ? '&' : '?'}limit=100&${cursor ? `cursor=${encodeURIComponent(cursor)}` : 'order=desc'}`);
|
|
26
|
+
if (!Array.isArray(body.data)) throw new Error('Unsupported OpenCode V2 list response.');
|
|
27
|
+
all.push(...body.data.map(compact));
|
|
28
|
+
if (!body.data.length || stop(body.data)) break;
|
|
29
|
+
cursor = body.cursor?.next;
|
|
30
|
+
if (seen.has(cursor)) break; seen.add(cursor);
|
|
31
|
+
} while (cursor);
|
|
32
|
+
return all;
|
|
33
|
+
}
|
|
34
|
+
async loadMessages(id, full = false, recent = false) {
|
|
35
|
+
full ||= this.fullHistory.has(id);
|
|
36
|
+
return this.limited(async () => {
|
|
37
|
+
const version = this.sessions.get(id)?.time?.updated;
|
|
38
|
+
const previous = this.messages.get(id), known = new Set(previous?.map(m => m.id));
|
|
39
|
+
const list = await this.pages(`/api/session/${encodeURIComponent(id)}/message`, page => recent && page.some(m => known.has(m.id)) || !full && page.every(m => m.time?.created < this.cutoff()), trimMessage);
|
|
40
|
+
if (!this.sessions.has(id)) return;
|
|
41
|
+
const compact = list.map(trimMessage);
|
|
42
|
+
if (recent && previous) {
|
|
43
|
+
const merged = new Map(previous.map(m => [m.id, m]));
|
|
44
|
+
for (const message of compact.reverse()) merged.set(message.id, message);
|
|
45
|
+
this.messages.set(id, [...merged.values()].filter(m => full || Math.max(m.time?.created || 0, m.time?.completed || 0) >= this.cutoff()));
|
|
46
|
+
} else this.messages.set(id, compact.reverse().filter(m => full || Math.max(m.time?.created || 0, m.time?.completed || 0) >= this.cutoff()));
|
|
47
|
+
if (!recent) this.messageVersions.set(id, version);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
async loadSession(id) {
|
|
51
|
+
const body = await this.get(`/api/session/${encodeURIComponent(id)}`);
|
|
52
|
+
if (body.data) this.sessions.set(id, body.data);
|
|
53
|
+
}
|
|
54
|
+
removeSession(id, seen = new Set()) {
|
|
55
|
+
if (seen.has(id)) return; seen.add(id);
|
|
56
|
+
for (const child of this.sessions.values()) if (child.parentID === id) this.removeSession(child.id, seen);
|
|
57
|
+
this.sessions.delete(id); this.messages.delete(id); this.fullHistory.delete(id); this.messageVersions.delete(id);
|
|
58
|
+
}
|
|
59
|
+
async descendants(ids) {
|
|
60
|
+
const seen = new Set(); let pending = [...ids];
|
|
61
|
+
while (pending.length) {
|
|
62
|
+
const batch = pending.filter(id => !seen.has(id)); pending = [];
|
|
63
|
+
if (!batch.length) break;
|
|
64
|
+
await Promise.all(batch.map(id => this.limited(async () => {
|
|
65
|
+
seen.add(id);
|
|
66
|
+
const children = await this.pages(`/api/session?parentID=${encodeURIComponent(id)}`), ids = new Set(children.map(child => child.id));
|
|
67
|
+
for (const child of this.sessions.values()) if (child.parentID === id && !ids.has(child.id)) this.removeSession(child.id);
|
|
68
|
+
for (const child of children) { this.sessions.set(child.id, child); pending.push(child.id); }
|
|
69
|
+
})));
|
|
70
|
+
}
|
|
71
|
+
return seen;
|
|
72
|
+
}
|
|
73
|
+
async sync(force = false) {
|
|
74
|
+
if (this.syncing) return this.syncing;
|
|
75
|
+
this.syncing = (async () => {
|
|
76
|
+
const list = await this.pages('/api/session', page => page.at(-1)?.time?.updated < this.cutoff());
|
|
77
|
+
const listed = new Set(list.map(s => s.id));
|
|
78
|
+
for (const s of this.sessions.values()) if (!this.fullHistory.has(s.id) && !s.parentID && (s.time?.updated < this.cutoff() || !listed.has(s.id))) this.removeSession(s.id);
|
|
79
|
+
for (const s of list) if (s.time?.updated >= this.cutoff() || this.fullHistory.has(s.id)) this.sessions.set(s.id, s);
|
|
80
|
+
for (const s of [...this.sessions.values()]) { let parent = s.parentID; const seen = new Set(); while (parent && !this.sessions.has(parent) && !seen.has(parent)) { seen.add(parent); await this.loadSession(parent); parent = this.sessions.get(parent)?.parentID; } }
|
|
81
|
+
const roots = [...this.sessions.values()].filter(s => !s.parentID && !s.time?.archived && s.time?.updated >= Date.now() - this.config.window * 3600000);
|
|
82
|
+
const tree = await this.descendants([...roots.map(s => s.id), ...this.fullHistory]);
|
|
83
|
+
this.evict(tree);
|
|
84
|
+
await Promise.all([...this.sessions.values()].filter(s => (tree.has(s.id) || s.time?.updated >= this.cutoff() || !this.messages.has(s.id)) && (force || !this.messages.has(s.id) || this.active[s.id] || this.messageVersions.get(s.id) !== s.time?.updated)).map(s => this.loadMessages(s.id)));
|
|
85
|
+
this.active = (await this.get('/api/session/active')).data || {};
|
|
86
|
+
await this.pollPending(); this.changed();
|
|
87
|
+
})().finally(() => { this.syncing = null; });
|
|
88
|
+
return this.syncing;
|
|
89
|
+
}
|
|
90
|
+
async ensureTree(id) {
|
|
91
|
+
await this.loadSession(id); const ids = await this.descendants([id]);
|
|
92
|
+
this.historyScopes ||= new Map(); this.historyScopes.delete(id); this.historyScopes.set(id, ids);
|
|
93
|
+
while (this.historyScopes.size > 20) this.historyScopes.delete(this.historyScopes.keys().next().value);
|
|
94
|
+
this.fullHistory = new Set([...this.historyScopes.values()].flatMap(scope => [...scope]));
|
|
95
|
+
await Promise.all([...ids].map(sid => this.loadMessages(sid, true))); this.changed();
|
|
96
|
+
}
|
|
97
|
+
evict(tree = new Set()) {
|
|
98
|
+
for (const s of [...this.sessions.values()]) if (!tree.has(s.id) && !this.fullHistory.has(s.id) && s.time?.updated < this.cutoff()) this.removeSession(s.id);
|
|
99
|
+
for (const [id, messages] of this.messages) if (!this.fullHistory.has(id)) this.messages.set(id, messages.filter(m => Math.max(m.time?.created || 0, m.time?.completed || 0) >= this.cutoff()));
|
|
100
|
+
}
|
|
101
|
+
touchScope(id) { const scope = this.historyScopes?.get(id); if (scope) { this.historyScopes.delete(id); this.historyScopes.set(id, scope); } }
|
|
102
|
+
async pollPending() {
|
|
103
|
+
if (this.pendingPoll) return;
|
|
104
|
+
this.pendingPoll = true;
|
|
105
|
+
const previous = JSON.stringify([this.approvals, this.health]);
|
|
106
|
+
try {
|
|
107
|
+
const locations = new Set([...this.sessions.values()].filter(s => this.active[s.id] || s.time?.updated >= Date.now() - this.config.window * 3600000).map(s => s.location?.directory).filter(Boolean));
|
|
108
|
+
const approvals = [];
|
|
109
|
+
await Promise.all([...locations].map(directory => this.limited(async () => {
|
|
110
|
+
const query = `?location%5Bdirectory%5D=${encodeURIComponent(directory)}`;
|
|
111
|
+
const permissions = await this.get(`/api/permission/request${query}`), forms = await this.get(`/api/form${query}`);
|
|
112
|
+
for (const p of permissions.data || []) approvals.push({ id: p.id, sessionID: p.sessionID, action: p.action || 'permission', resource: (p.resources || []).join(', '), time: this.approvals.find(a => a.id === p.id)?.time || Date.now() });
|
|
113
|
+
for (const f of forms.data || []) approvals.push({ id: f.id, sessionID: f.sessionID, action: 'form', resource: f.title || 'Input requested', time: this.approvals.find(a => a.id === f.id)?.time || Date.now() });
|
|
114
|
+
})));
|
|
115
|
+
this.approvals = approvals; this.health = { ok: true, status: 200 };
|
|
116
|
+
} catch { this.health = { ok: false, status: null }; }
|
|
117
|
+
finally { this.pendingPoll = false; if (previous !== JSON.stringify([this.approvals, this.health])) this.changed(); }
|
|
118
|
+
}
|
|
119
|
+
event(event) {
|
|
120
|
+
const data = event.data || {}, id = data.sessionID || data.form?.sessionID;
|
|
121
|
+
if (!id) return;
|
|
122
|
+
if (event.type === 'session.deleted') { this.removeSession(id); this.changed(); return; }
|
|
123
|
+
this.dirty.add(id);
|
|
124
|
+
if (/permission\.|form\./.test(event.type)) void this.pollPending();
|
|
125
|
+
if (!this.dirtyTimer) this.dirtyTimer = setTimeout(() => {
|
|
126
|
+
this.dirtyTimer = null; const ids = [...this.dirty]; this.dirty.clear();
|
|
127
|
+
void Promise.allSettled(ids.map(sid => this.limited(() => this.loadSession(sid)).then(() => this.loadMessages(sid, false, true)))).then(async () => { this.active = (await this.get('/api/session/active')).data || {}; this.changed(); }).catch(() => console.error('OpenCode live refresh failed; reconciliation will retry.'));
|
|
128
|
+
}, 250);
|
|
129
|
+
}
|
|
130
|
+
async stream() {
|
|
131
|
+
let delay = 1000;
|
|
132
|
+
while (!this.abort.signal.aborted) {
|
|
133
|
+
const controller = new AbortController(), abort = () => controller.abort();
|
|
134
|
+
this.abort.signal.addEventListener('abort', abort, { once: true });
|
|
135
|
+
let watchdog = setTimeout(abort, 45000), connectedAt;
|
|
136
|
+
try {
|
|
137
|
+
const response = await this.request('/api/event', controller.signal);
|
|
138
|
+
this.connection = 'connected'; this.changed();
|
|
139
|
+
connectedAt = Date.now();
|
|
140
|
+
if (!this.lastResync || Date.now() - this.lastResync >= 10000) { this.lastResync = Date.now(); void this.sync(true).catch(() => console.error('OpenCode resynchronization failed; retrying periodically.')); }
|
|
141
|
+
let buffer = ''; const decoder = new TextDecoder();
|
|
142
|
+
for await (const chunk of response.body) {
|
|
143
|
+
clearTimeout(watchdog); watchdog = setTimeout(abort, 45000);
|
|
144
|
+
buffer += decoder.decode(chunk, { stream: true }); buffer = buffer.replace(/\r\n/g, '\n');
|
|
145
|
+
let end;
|
|
146
|
+
while ((end = buffer.indexOf('\n\n')) >= 0) {
|
|
147
|
+
const frame = buffer.slice(0, end); buffer = buffer.slice(end + 2);
|
|
148
|
+
const text = frame.split('\n').filter(line => line.startsWith('data:')).map(line => line.slice(5).trimStart()).join('\n');
|
|
149
|
+
if (text) { try { this.event(JSON.parse(text)); } catch { /* Ignore unknown envelopes. */ } }
|
|
150
|
+
}
|
|
151
|
+
if (buffer.length > 8 * 1024 * 1024) throw new Error('Oversized SSE frame');
|
|
152
|
+
}
|
|
153
|
+
if (!this.abort.signal.aborted) throw new Error('SSE closed');
|
|
154
|
+
} catch {
|
|
155
|
+
if (this.abort.signal.aborted) break;
|
|
156
|
+
delay = reconnectDelay(delay, connectedAt);
|
|
157
|
+
this.connection = 'reconnecting'; this.changed();
|
|
158
|
+
console.error(`OpenCode event stream disconnected; reconnecting in ${delay / 1000}s.`);
|
|
159
|
+
await new Promise(resolve => { const finish = () => { clearTimeout(t); this.abort.signal.removeEventListener('abort', finish); resolve(); }; const t = setTimeout(finish, delay); this.abort.signal.addEventListener('abort', finish, { once: true }); });
|
|
160
|
+
delay = Math.min(delay * 2, 30000);
|
|
161
|
+
} finally {
|
|
162
|
+
clearTimeout(watchdog); this.abort.signal.removeEventListener('abort', abort); controller.abort();
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
async start() {
|
|
167
|
+
void this.initialize();
|
|
168
|
+
}
|
|
169
|
+
async initialize() {
|
|
170
|
+
let delay = 1000, lastError;
|
|
171
|
+
while (!this.abort.signal.aborted) {
|
|
172
|
+
try {
|
|
173
|
+
let info;
|
|
174
|
+
try { info = await this.get('/api/info'); } catch (error) { throw new Error(`Cannot connect to OpenCode V2. ${error.message}`); }
|
|
175
|
+
this.version = info.version || info.data?.version;
|
|
176
|
+
if (!/^2\./.test(this.version || '')) throw new Error('Unsupported server: OpenCode V2 /api/info version 2.x is required.');
|
|
177
|
+
await this.sync(); if (this.abort.signal.aborted) return; void this.stream();
|
|
178
|
+
console.log(`Connected to OpenCode ${this.version}.`);
|
|
179
|
+
this.syncTimer = setInterval(() => { void this.sync().catch(() => console.error('OpenCode reconciliation failed; retrying in 60s.')); }, 60000);
|
|
180
|
+
this.pendingTimer = setInterval(() => { void this.pollPending(); }, 5000);
|
|
181
|
+
return;
|
|
182
|
+
} catch (error) {
|
|
183
|
+
if (this.abort.signal.aborted) return;
|
|
184
|
+
// Keep retrying, but do not silently hide authentication/version failures.
|
|
185
|
+
const message = error.message;
|
|
186
|
+
if (message !== lastError) { console.error(message); lastError = message; }
|
|
187
|
+
this.connection = 'connecting'; this.changed();
|
|
188
|
+
await new Promise(resolve => { const finish = () => { clearTimeout(timer); this.abort.signal.removeEventListener('abort', finish); resolve(); }; const timer = setTimeout(finish, delay); this.abort.signal.addEventListener('abort', finish, { once: true }); });
|
|
189
|
+
delay = Math.min(delay * 2, 30000);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
stop() { this.abort.abort(); clearInterval(this.syncTimer); clearInterval(this.pendingTimer); clearTimeout(this.dirtyTimer); }
|
|
194
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,48 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-agent-flow",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Live agent flow visualization for OpenCode V2 over its public HTTP API and SSE",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"opencode-agent-flow": "bin/cli.mjs"
|
|
8
|
+
},
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=20"
|
|
11
|
+
},
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"author": "orybakov (https://github.com/orybakov)",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/orybakov/opencode-agent-flow.git"
|
|
17
|
+
},
|
|
18
|
+
"bugs": {
|
|
19
|
+
"url": "https://github.com/orybakov/opencode-agent-flow/issues"
|
|
20
|
+
},
|
|
21
|
+
"homepage": "https://github.com/orybakov/opencode-agent-flow#readme",
|
|
22
|
+
"keywords": [
|
|
23
|
+
"opencode",
|
|
24
|
+
"ai-agents",
|
|
25
|
+
"visualizer",
|
|
26
|
+
"observability",
|
|
27
|
+
"webgl"
|
|
28
|
+
],
|
|
29
|
+
"exports": {},
|
|
30
|
+
"files": [
|
|
31
|
+
"bin",
|
|
32
|
+
"server.mjs",
|
|
33
|
+
"lib",
|
|
34
|
+
"public",
|
|
35
|
+
"prices.json",
|
|
36
|
+
"README.md",
|
|
37
|
+
"LICENSE",
|
|
38
|
+
"SECURITY.md",
|
|
39
|
+
"CONTRIBUTING.md",
|
|
40
|
+
"CHANGELOG.md",
|
|
41
|
+
"docs/*.md",
|
|
42
|
+
"docs/screenshot.png"
|
|
43
|
+
],
|
|
44
|
+
"scripts": {
|
|
45
|
+
"start": "node bin/cli.mjs",
|
|
46
|
+
"test": "node --test"
|
|
47
|
+
}
|
|
48
|
+
}
|
package/prices.json
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"note": "Illustrative placeholders, USD per 1M tokens, not subscription billing. Context limits are estimates; edit to match your provider.",
|
|
3
|
+
"default": { "input": 3, "output": 15, "cacheRead": 0.3, "contextLimit": 200000 },
|
|
4
|
+
"models": {
|
|
5
|
+
"claude-opus-5-5": { "input": 5, "output": 25, "cacheRead": 0.5, "contextLimit": 200000 },
|
|
6
|
+
"claude-sonnet-5-5": { "input": 3, "output": 15, "cacheRead": 0.3, "contextLimit": 200000 },
|
|
7
|
+
"gpt-6.1-sol": { "input": 2.5, "output": 15, "cacheRead": 0.25, "contextLimit": 200000 },
|
|
8
|
+
"gemini-3.8-flash-high": { "input": 0.5, "output": 3, "cacheRead": 0.05, "contextLimit": 1000000 }
|
|
9
|
+
}
|
|
10
|
+
}
|
package/public/app.js
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
const $ = s => document.querySelector(s);
|
|
2
|
+
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
3
|
+
const fmt = n => n >= 1e6 ? `${(n / 1e6).toFixed(1)}m` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(Math.round(n || 0));
|
|
4
|
+
const agentCalls = (groups, calls) => `${groups} agent${groups === 1 ? '' : 's'} · ${calls} call${calls === 1 ? '' : 's'}`;
|
|
5
|
+
const shortModel = m => /opus|sonnet|gemini|gpt/i.exec(m)?.[0] || m.slice(0, 12);
|
|
6
|
+
const put = (selector, text) => { const el = $(selector); if (el.textContent !== text) el.textContent = text; };
|
|
7
|
+
const html = (selector, text) => { const el = $(selector); if (el.innerHTML !== text) el.innerHTML = text; };
|
|
8
|
+
let state, rootId, agents = [], edges = [], paused = matchMedia('(prefers-reduced-motion: reduce)').matches, connected = false;
|
|
9
|
+
const storageKey = 'agent-flow.root';
|
|
10
|
+
const urlRoot = new URL(location.href).searchParams.get('session');
|
|
11
|
+
try { rootId = urlRoot || localStorage.getItem(storageKey); } catch { rootId = urlRoot; }
|
|
12
|
+
const params = new URL(location.href).searchParams;
|
|
13
|
+
let view = 'all', sessionId = null, sessionList = [], stream, requestVersion = 0;
|
|
14
|
+
try { view = params.get('view') || (urlRoot ? 'session' : localStorage.getItem('agent-flow.view')) || 'all'; sessionId = params.has('view') || urlRoot ? urlRoot : localStorage.getItem('agent-flow.session'); } catch { view = params.get('view') || (urlRoot ? 'session' : 'all'); sessionId = urlRoot; }
|
|
15
|
+
if (view !== 'session') view = 'all';
|
|
16
|
+
function saveView() {
|
|
17
|
+
const url = new URL(location.href); url.searchParams.set('view', view);
|
|
18
|
+
if (view === 'session' ? sessionId : rootId) url.searchParams.set('session', view === 'session' ? sessionId : rootId); else url.searchParams.delete('session');
|
|
19
|
+
history.replaceState(null, '', url);
|
|
20
|
+
try { localStorage.setItem('agent-flow.view', view); if (sessionId) localStorage.setItem('agent-flow.session', sessionId); } catch { /* Storage may be disabled. */ }
|
|
21
|
+
}
|
|
22
|
+
function renderPicker() {
|
|
23
|
+
const query = $('#session-search').value.toLowerCase(), list = sessionList.filter(s => `${s.title} ${s.agent} ${s.model} ${s.id}`.toLowerCase().includes(query));
|
|
24
|
+
const summary = s => `${s.agent} · ${s.model} · ${activityTime(s.lastActivity)} · ${agentCalls(s.agentCount, s.callCount)} · ${fmt(s.tokens)} tokens · ${s.status}`;
|
|
25
|
+
html('#root', '<option value="">select a session…</option>' + list.map(s => `<option value="${esc(s.id)}">${esc(s.title)} · ${esc(summary(s))}</option>`).join('') + (sessionId && !list.some(s => s.id === sessionId) ? `<option value="${esc(sessionId)}">${esc(state?.roots[0]?.title || sessionId)}</option>` : ''));
|
|
26
|
+
$('#root').value = sessionId || ''; $('#root').disabled = false;
|
|
27
|
+
html('#session-cards', list.map(s => `<button type="button" class="session-card" data-session="${esc(s.id)}"><strong>${esc(s.title || s.id)}</strong><span class="${s.status}">● ${esc(s.status)} · ${esc(s.agent)} · ${esc(s.model)}</span><span>${activityTime(s.lastActivity)} · ${agentCalls(s.agentCount, s.callCount)} · ${fmt(s.tokens)} tokens</span></button>`).join('') || '<p class="muted">No sessions match this search.</p>');
|
|
28
|
+
}
|
|
29
|
+
function applyView() {
|
|
30
|
+
const picker = view === 'session' && !sessionId;
|
|
31
|
+
document.body.dataset.view = view;
|
|
32
|
+
$('#session-search').hidden = view !== 'session'; $('#session-picker').hidden = !picker; $('main').hidden = picker;
|
|
33
|
+
for (const mode of ['all', 'session']) $(`#view-${mode}`).setAttribute('aria-pressed', String(mode === view));
|
|
34
|
+
$('.panel:nth-child(5) h3').textContent = view === 'session' ? '// tokens · session' : '// tokens today';
|
|
35
|
+
if (view === 'session') renderPicker();
|
|
36
|
+
if (picker) { put('#title', 'select a session'); put('#subtitle', `opencode · ${sessionList.length} root sessions / live window`); put('#counts', ''); put('#connection', 'select session'); }
|
|
37
|
+
}
|
|
38
|
+
async function loadSessions() {
|
|
39
|
+
try { const response = await fetch('/api/sessions'); if (!response.ok) throw new Error(); sessionList = await response.json(); if (view === 'session') { renderPicker(); if (!sessionId) applyView(); } } catch { if (view === 'session' && !sessionId) put('#connection', 'sessions unavailable'); }
|
|
40
|
+
}
|
|
41
|
+
function selectSession(id) { sessionId = id || null; popover.hidePopover(); saveView(); connect(); }
|
|
42
|
+
$('#session-search').addEventListener('input', renderPicker);
|
|
43
|
+
$('#session-cards').addEventListener('click', e => { const card = e.target.closest('[data-session]'); if (card) selectSession(card.dataset.session); });
|
|
44
|
+
for (const mode of ['all', 'session']) $(`#view-${mode}`).addEventListener('click', () => { if (view === mode) return; view = mode; saveView(); connect(); });
|
|
45
|
+
const validDate = time => { const date = new Date(time); return Number.isFinite(date.getTime()) ? date : new Date(0); };
|
|
46
|
+
const activityTime = time => validDate(time).toLocaleString([], { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
|
|
47
|
+
const totalTokens = a => a.tokens.input + a.tokens.output + a.tokens.cacheRead;
|
|
48
|
+
function rememberRoot() { try { if (rootId) localStorage.setItem(storageKey, rootId); } catch { /* Storage may be disabled. */ } }
|
|
49
|
+
const popover = document.createElement('div');
|
|
50
|
+
popover.id = 'group-sessions'; popover.setAttribute('popover', 'auto'); document.body.append(popover);
|
|
51
|
+
popover.setAttribute('role', 'dialog'); popover.setAttribute('aria-label', 'Group sessions');
|
|
52
|
+
let openGroupId, popoverAnchor;
|
|
53
|
+
function showSessions(id, anchor) {
|
|
54
|
+
const group = agents.find(a => a.id === id);
|
|
55
|
+
if (!group) { popover.hidePopover(); return; }
|
|
56
|
+
openGroupId = id; popoverAnchor = anchor;
|
|
57
|
+
const scrollTop = popover.scrollTop, closeFocused = document.activeElement === popover.querySelector('button');
|
|
58
|
+
const content = `<div class="popover-heading"><strong>${esc(group.agent)} · ${group.count} sessions</strong><button type="button" aria-label="Close">×</button></div><div class="session-list">${group.sessions.map(s => `<div class="session-item"><strong title="${esc(s.title)}">${esc(s.title || s.id)}</strong><div><b class="${s.status}">${esc(s.status)}</b><span>${fmt(totalTokens(s))} tokens</span><time datetime="${validDate(s.lastActivity).toISOString()}">${activityTime(s.lastActivity)}</time></div></div>`).join('')}</div>`;
|
|
59
|
+
if (popover.innerHTML !== content) { popover.innerHTML = content; popover.scrollTop = scrollTop; if (closeFocused) popover.querySelector('button').focus({ preventScroll: true }); }
|
|
60
|
+
popover.querySelector('button').onclick = () => popover.hidePopover();
|
|
61
|
+
if (!popover.matches(':popover-open')) popover.showPopover();
|
|
62
|
+
const rect = anchor.getBoundingClientRect();
|
|
63
|
+
popover.style.left = `${Math.max(12, Math.min(rect.left, innerWidth - popover.offsetWidth - 12))}px`;
|
|
64
|
+
popover.style.top = `${Math.max(12, Math.min(rect.bottom + 8, innerHeight - popover.offsetHeight - 12))}px`;
|
|
65
|
+
}
|
|
66
|
+
popover.addEventListener('toggle', () => { if (!popover.matches(':popover-open')) openGroupId = null; for (const [id, el] of nodeElements) el.setAttribute('aria-expanded', String(id === openGroupId)); });
|
|
67
|
+
let networkHeight = 540, hub = [682, 270], lastFrame = 0;
|
|
68
|
+
const nodeElements = new Map();
|
|
69
|
+
const canvas = $('#wires'), ctx = canvas.getContext('2d');
|
|
70
|
+
let flow, motionTime = 1;
|
|
71
|
+
window.__renderer = 'canvas2d';
|
|
72
|
+
import('/flow-webgl.js').then(({ createFlow }) => {
|
|
73
|
+
flow = createFlow(canvas, () => { flow = null; canvas.hidden = false; window.__renderer = 'canvas2d'; draw(performance.now(), true); });
|
|
74
|
+
canvas.hidden = true; window.__renderer = 'webgl'; resize();
|
|
75
|
+
}).catch(() => { window.__renderer = 'canvas2d'; draw(performance.now(), true); });
|
|
76
|
+
$('#root').addEventListener('change', e => { if (view === 'session') return selectSession(e.target.value); rootId = e.target.value; rememberRoot(); saveView(); popover.hidePopover(); render(); });
|
|
77
|
+
$('#motion').addEventListener('click', () => { paused = !paused; motionLabel(); draw(performance.now(), true); });
|
|
78
|
+
function motionLabel() { put('#motion', paused ? 'resume motion' : 'pause motion'); $('#motion').setAttribute('aria-pressed', String(paused)); }
|
|
79
|
+
motionLabel();
|
|
80
|
+
function resize() {
|
|
81
|
+
const width = $('.viewport').clientWidth;
|
|
82
|
+
const scale = width < 650 ? .7 : Math.min(1, width / 1364);
|
|
83
|
+
$('.network').style.transform = width < 650 ? `scale(${scale})` : `translateX(-50%) scale(${scale})`;
|
|
84
|
+
$('.network').style.transformOrigin = width < 650 ? '0 0' : '50% 0';
|
|
85
|
+
$('.scene').style.height = `${Math.max(width < 650 ? 440 : 500, networkHeight * scale)}px`;
|
|
86
|
+
$('.network').style.height = `${networkHeight}px`;
|
|
87
|
+
const dpr = Math.min(devicePixelRatio || 1, 2);
|
|
88
|
+
canvas.width = 1364 * dpr; canvas.height = networkHeight * dpr;
|
|
89
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
90
|
+
flow?.resize(1364, networkHeight, dpr);
|
|
91
|
+
updateAnchors();
|
|
92
|
+
draw(performance.now(), true);
|
|
93
|
+
}
|
|
94
|
+
addEventListener('resize', resize);
|
|
95
|
+
function render() {
|
|
96
|
+
if (!state) return;
|
|
97
|
+
if (view === 'session') rootId = sessionId;
|
|
98
|
+
else if (!state.roots.some(r => r.id === rootId)) rootId = state.roots[0]?.id;
|
|
99
|
+
if (view === 'all') rememberRoot();
|
|
100
|
+
const options = state.roots.map(r => `<option value="${esc(r.id)}">${esc(r.title || r.id)} · ${activityTime(r.lastActivity)} · ${agentCalls(r.agentCount, r.callCount)}</option>`).join('');
|
|
101
|
+
if (view === 'all') { html('#root', options); $('#root').value = rootId || ''; $('#root').disabled = !rootId; } else renderPicker();
|
|
102
|
+
const selected = new Set(rootId ? [rootId] : []);
|
|
103
|
+
let added = true;
|
|
104
|
+
while (added) { added = false; for (const a of state.agents) if (selected.has(a.parentId) && !selected.has(a.id)) { selected.add(a.id); added = true; } }
|
|
105
|
+
agents = state.agents.filter(a => selected.has(a.id));
|
|
106
|
+
const root = agents.find(a => a.id === rootId), children = agents.filter(a => a.id !== rootId);
|
|
107
|
+
put('#title', root?.title || 'no active sessions');
|
|
108
|
+
put('#subtitle', `opencode ${state.serverVersion || ''}${state.demo ? ' · demo' : ''} · orchestration topology / ${agentCalls(agents.length, state.roots.find(r => r.id === rootId)?.callCount || 0)}`);
|
|
109
|
+
put('#connection', state.connection === 'connected' ? 'live' : state.connection || 'connecting');
|
|
110
|
+
$('.status').classList.toggle('offline', state.connection !== 'connected');
|
|
111
|
+
put('#counts', `${agents.filter(a => a.status === 'working').length} running · ${state.approvals.filter(p => agents.some(a => a.sessions.some(s => s.id === p.sessionID))).length} approvals · ${fmt(state.aggregates.totalToday)} tokens ${view === 'session' ? '· session' : 'today'}`);
|
|
112
|
+
put('#graphMeta', ` · ${children.length} children / ${state.windowHours}h window`);
|
|
113
|
+
$('.hub').hidden = !root; $('#empty').hidden = !!root;
|
|
114
|
+
put('#empty', view === 'session' ? 'Session not found' : `No active sessions in the last ${state.windowHours} hours`);
|
|
115
|
+
if (root) {
|
|
116
|
+
const ratio = root.context / root.contextLimit;
|
|
117
|
+
put('#lead', root.agent); put('#context', `${root.status} · ctx ${root.contextEstimated ? '≈ ' : ''}${fmt(root.context)}`); put('#model', root.model);
|
|
118
|
+
$('#context').title = root.contextEstimated ? 'Last reported usage; current response is streaming' : 'Latest assistant input + cache read + cache write';
|
|
119
|
+
put('#leadTool', root.currentTool ? `${root.currentTool.name} → ${root.currentTool.target}` : `${fmt(root.tokens.input + root.tokens.output + root.tokens.cacheRead)} tokens`);
|
|
120
|
+
$('#leadTool').title = root.currentTool?.target || '';
|
|
121
|
+
$('#contextRing').setAttribute('stroke-dasharray', `${Math.min(1, ratio) * 635} 635`);
|
|
122
|
+
put('#sceneSummary', `all agents connected to ${root.agent} / ${Math.round(ratio * 100)}% context pressure`);
|
|
123
|
+
} else put('#sceneSummary', 'read-only · waiting for session activity');
|
|
124
|
+
const sessionIds = new Set(agents.flatMap(a => a.sessions.map(s => s.id)));
|
|
125
|
+
const pending = state.approvals.filter(p => sessionIds.has(p.sessionID));
|
|
126
|
+
const items = [...children, ...(pending.length ? [{ id: '__approval', agent: 'user · approval', model: `${pending.length} pending`, status: 'waiting', currentTool: { name: pending[0].action, target: pending[0].resource }, tokens: { input: 0, output: 0, cacheRead: 0 } }] : [])];
|
|
127
|
+
const rows = Math.max(3, Math.ceil(items.length / 4));
|
|
128
|
+
networkHeight = Math.max(540, 120 + rows * 105);
|
|
129
|
+
hub = [682, networkHeight / 2]; $('.hub').style.top = `${hub[1] - 118}px`;
|
|
130
|
+
const points = new Map();
|
|
131
|
+
const ring = [[75,165],[330,55],[840,55],[1090,165],[75,355],[330,415],[840,415],[1090,355]];
|
|
132
|
+
items.forEach((a, i) => {
|
|
133
|
+
const p = items.length <= 8 ? ring[i] : [[55,295,865,1105][i % 4], 65 + Math.floor(i / 4) * 105];
|
|
134
|
+
points.set(a.id, [p[0] + 100, p[1] + 48]);
|
|
135
|
+
let el = nodeElements.get(a.id);
|
|
136
|
+
if (!el) { el = document.createElement('article'); $('#nodes').append(el); nodeElements.set(a.id, el); }
|
|
137
|
+
if (a.id !== '__approval') {
|
|
138
|
+
el.tabIndex = 0; el.setAttribute('role', 'button'); el.setAttribute('aria-haspopup', 'dialog');
|
|
139
|
+
el.setAttribute('aria-expanded', String(openGroupId === a.id));
|
|
140
|
+
el.onclick = () => openGroupId === a.id ? popover.hidePopover() : showSessions(a.id, el);
|
|
141
|
+
el.onkeydown = e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); el.click(); } };
|
|
142
|
+
}
|
|
143
|
+
el.className = `node ${a.status}${a.id === '__approval' ? ' approval' : ''}`;
|
|
144
|
+
el.style.transform = `translate(${p[0]}px,${p[1]}px)`;
|
|
145
|
+
el.title = `${a.agent} · ${a.model}\n${a.title || ''}\n${a.currentTool?.target || ''}`;
|
|
146
|
+
const content = `<div class="cap"><strong>${esc(a.agent)}${a.count > 1 ? ` <small>×${a.count}</small>` : ''}</strong><span class="tag">${esc(a.id === '__approval' ? a.model : shortModel(a.model))}</span></div><div class="rows"><div class="row"><span>${esc(a.currentTool?.name || 'state')}</span><span>${esc(a.currentTool?.target || a.status)}</span></div><div class="row"><span>${a.id === '__approval' ? 'action' : 'tokens'}</span><span>${a.id === '__approval' ? 'confirm in OpenCode' : fmt(totalTokens(a))}</span></div></div>`;
|
|
147
|
+
if (el.innerHTML !== content) el.innerHTML = content;
|
|
148
|
+
});
|
|
149
|
+
for (const [id, el] of nodeElements) if (!items.some(a => a.id === id)) { el.remove(); nodeElements.delete(id); }
|
|
150
|
+
edges = children.map((a, i) => {
|
|
151
|
+
const p = points.get(a.id), dx = p[0] - hub[0], dy = p[1] - hub[1], k = 116 / Math.hypot(dx, dy);
|
|
152
|
+
return { id: a.id, a: [hub[0] + dx * k, hub[1] + dy * k], b: p, status: a.status, active: a.status === 'working' || a.status === 'waiting', bend: i % 2 ? 30 : -25, returning: a.status === 'done' };
|
|
153
|
+
});
|
|
154
|
+
if (pending.length) for (const p of pending) { const sourceId = agents.find(a => a.sessions.some(s => s.id === p.sessionID))?.id; const source = points.get(sourceId) || hub; edges.push({ id: '__approval', sourceId, a: source, b: points.get('__approval'), status: 'waiting', active: true, bend: 22 }); }
|
|
155
|
+
put('#transport', `${edges.length} connections · ${paused ? 'motion paused' : 'white → pink / token transport'}`);
|
|
156
|
+
const events = state.events.filter(e => selected.has(e.groupId)).slice(-60).reverse();
|
|
157
|
+
html('#events', events.map(e => `<div class="event" title="${esc(e.agent + ' · ' + e.kind + ' · ' + e.text)}"><time>${validDate(e.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</time><em>${esc(e.kind === 'permission asked' ? 'wait' : e.kind === 'tool complete' ? 'done' : e.kind === 'tool start' ? 'tool' : e.kind)}</em>${esc(e.agent)} → ${esc(e.text)}</div>`).join('') || '<div class="muted">no recent events</div>');
|
|
158
|
+
const ag = state.aggregates, max = Math.max(1, ...ag.hourly.map(b => b.total));
|
|
159
|
+
html('#hourly', ag.hourly.map(b => `<div class="bars" title="${esc(Object.entries(b.models).map(([m, n]) => `${m}: ${fmt(n)}`).join('\n') || 'no tokens')}"><time>${validDate(b.time).getHours().toString().padStart(2, '0')}</time><div class="track"><i style="width:${b.total / max * 100}%"></i></div><b>${fmt(b.total)}</b></div>`).join(''));
|
|
160
|
+
put('#cacheValue', `${Math.round(ag.cacheHit * 100)}%`); $('#cacheRing').setAttribute('stroke-dasharray', `${ag.cacheHit * 270} 270`);
|
|
161
|
+
const heat = ag.heat.filter(a => selected.has(a.id)), peak = Math.max(1, ...heat.flatMap(a => a.values));
|
|
162
|
+
html('#heat', heat.map(a => `<div class="heat-row"><span title="${esc(a.agent)}">${esc(a.agent)}</span>${a.values.map((n, i) => `<i title="${esc(a.agent)} / ${(12 - i) * 5}–${(11 - i) * 5} min ago: ${n} events" style="opacity:${.1 + .9 * n / peak}"></i>`).join('')}</div>`).join('') || '<div class="muted">no activity</div>');
|
|
163
|
+
put('#total', fmt(ag.totalToday)); put('#estimate', `≈ $${ag.estimate.toFixed(2)} est.`);
|
|
164
|
+
html('#today', Object.entries(ag.today).sort((a, b) => b[1].total - a[1].total).map(([m, t]) => `<div title="${esc(m)} · input ${t.input} / output ${t.output} / cache ${t.cacheRead}">${esc(shortModel(m))} · ${fmt(t.total)} · ≈ $${t.estimate.toFixed(2)}</div>`).join('') || 'no tokens today');
|
|
165
|
+
html('#ledger', agents.map(a => `<div title="${esc(a.title)}"><b class="${a.status}">[ ${a.status.padEnd(7, ' ')} ]</b> ${esc(a.agent)}</div>`).join('') || '<span class="muted">no agents</span>');
|
|
166
|
+
put('#health', `transport / public HTTP + SSE · read-only${state.permissionHealth.ok ? '' : ' · approvals unavailable'}`);
|
|
167
|
+
put('#clock', `${validDate(state.updatedAt).toLocaleString()} · snapshot`);
|
|
168
|
+
resize();
|
|
169
|
+
if (openGroupId) showSessions(openGroupId, popoverAnchor);
|
|
170
|
+
}
|
|
171
|
+
// Seven-strand Bezier harnesses and particle transport, adapted from concept5-flow.
|
|
172
|
+
function updateAnchors() {
|
|
173
|
+
const rect = $('.network').getBoundingClientRect(), scale = rect.width / 1364;
|
|
174
|
+
const center = el => { const r = el.getBoundingClientRect(); return [(r.left + r.width / 2 - rect.left) / scale, (r.top + r.height / 2 - rect.top) / scale]; };
|
|
175
|
+
if (!$('.hub').hidden) hub = center($('.hub'));
|
|
176
|
+
const dock = (el, target) => {
|
|
177
|
+
const c = center(el), r = el.getBoundingClientRect(), dx = target[0] - c[0], dy = target[1] - c[1];
|
|
178
|
+
const k = Math.min((r.width / scale / 2 + 3) / (Math.abs(dx) || .001), (r.height / scale / 2 + 3) / (Math.abs(dy) || .001));
|
|
179
|
+
return [c[0] + dx * k, c[1] + dy * k];
|
|
180
|
+
};
|
|
181
|
+
for (const e of edges) {
|
|
182
|
+
const node = nodeElements.get(e.id), source = nodeElements.get(e.sourceId);
|
|
183
|
+
if (!node) continue;
|
|
184
|
+
const target = center(node), start = source ? center(source) : hub;
|
|
185
|
+
const dx = target[0] - start[0], dy = target[1] - start[1], k = 116 / (Math.hypot(dx, dy) || 1);
|
|
186
|
+
e.a = source ? dock(source, target) : [hub[0] + dx * k, hub[1] + dy * k];
|
|
187
|
+
e.b = dock(node, start);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
function curve(e, t, j) {
|
|
191
|
+
const [x,y] = e.a, [X,Y] = e.b, dx = X-x, dy = Y-y, l = Math.hypot(dx,dy) || 1, off = (j-3)*5+e.bend, u = 1-t;
|
|
192
|
+
const cx=x+dx*.34-dy/l*off, cy=y+dy*.34+dx/l*off, ex=x+dx*.68+dy/l*off*.6, ey=y+dy*.68-dx/l*off*.6;
|
|
193
|
+
return [u*u*u*x+3*u*u*t*cx+3*u*t*t*ex+t*t*t*X,u*u*u*y+3*u*u*t*cy+3*u*t*t*ey+t*t*t*Y];
|
|
194
|
+
}
|
|
195
|
+
function ball(x,y,r,alpha=1) { ctx.globalAlpha=alpha;ctx.shadowColor='#ff3d8b';ctx.shadowBlur=r*7;ctx.fillStyle='#ffeaf5';ctx.beginPath();ctx.arc(x,y,r,0,Math.PI*2);ctx.fill();ctx.shadowBlur=0; }
|
|
196
|
+
function draw(ms, force = false) {
|
|
197
|
+
if (document.hidden) return;
|
|
198
|
+
if (!force && (paused || document.hidden || ms - lastFrame < 32)) return;
|
|
199
|
+
if (!paused && !document.hidden) motionTime += Math.min(.1, Math.max(0, (ms - lastFrame) / 1000));
|
|
200
|
+
lastFrame = ms; const t = motionTime;
|
|
201
|
+
if (flow) { flow.draw(edges, hub, agents.length > 0, t); return; }
|
|
202
|
+
ctx.clearRect(0,0,1364,networkHeight);ctx.globalCompositeOperation='lighter';
|
|
203
|
+
edges.forEach((e,ei)=>{for(let j=0;j<7;j++){ctx.globalAlpha=e.active?.29:.1;ctx.strokeStyle=j%3?'#ff448c':'#b57bff';ctx.lineWidth=.7;ctx.beginPath();for(let k=0;k<=45;k++){const p=curve(e,k/45,j);k?ctx.lineTo(...p):ctx.moveTo(...p)}ctx.stroke();for(let p=0;p<(e.active?4:1);p++){const f=(t*(.075+j*.008)+p*.237+j*.133+ei*.173)%1;const pos=curve(e,e.returning?1-f:f,j);ball(...pos,(p+j)%5===0?2.5:1.1,e.active?.9:.28)}}ball(...e.a,e.active?3:1.6,.9);ball(...e.b,e.active?3.4:1.8,.95)});
|
|
204
|
+
if (agents.length) for(let i=0;i<35;i++){const a=i*2.399+t*.012,r=118+Math.sin(i*7)*6;ball(hub[0]+Math.cos(a)*r,hub[1]+Math.sin(a)*r,i%6===0?1.5:.65,.35+(i%4)*.15)}
|
|
205
|
+
ctx.globalAlpha=1;ctx.globalCompositeOperation='source-over';
|
|
206
|
+
}
|
|
207
|
+
function animate(ms) { draw(ms); requestAnimationFrame(animate); }
|
|
208
|
+
requestAnimationFrame(animate);
|
|
209
|
+
function connect() {
|
|
210
|
+
stream?.close(); const version = ++requestVersion; connected = false; state = null; applyView();
|
|
211
|
+
if (view === 'session' && !sessionId) return;
|
|
212
|
+
const query = view === 'session' ? `?session=${encodeURIComponent(sessionId)}` : '';
|
|
213
|
+
put('#connection', 'connecting');
|
|
214
|
+
// Clear previous-session content immediately; late requests cannot replace the new scope.
|
|
215
|
+
$('main').hidden = true;
|
|
216
|
+
const accept = data => { if (version !== requestVersion) return; state = data; $('main').hidden = false; render(); };
|
|
217
|
+
stream = new EventSource(`/api/stream${query}`);
|
|
218
|
+
stream.addEventListener('state', e => { try { accept(JSON.parse(e.data)); } catch { put('#connection', 'invalid state'); } });
|
|
219
|
+
stream.onopen = () => { connected = true; put('#connection', 'live'); $('.status').classList.remove('offline'); };
|
|
220
|
+
stream.onerror = () => { connected = false; put('#connection', 'reconnecting'); $('.status').classList.add('offline'); };
|
|
221
|
+
fetch(`/api/state${query}`).then(r => { if (!r.ok) throw new Error(); return r.json(); }).then(data => { if (!state) accept(data); }).catch(() => { if (version === requestVersion && !connected) put('#connection', 'server unavailable'); });
|
|
222
|
+
}
|
|
223
|
+
saveView(); connect(); loadSessions(); setInterval(loadSessions, 10000);
|