opencode-agent-flow 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ # Contributing
2
+
3
+ Open an issue to discuss substantial changes. Keep pull requests focused, add regression tests, and run `node --test` on Node 20 and a current Node release. Never commit credentials or real session data. See SECURITY.md for private vulnerability reports.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 orybakov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,101 @@
1
- # Temporary Holding Version
1
+ # opencode-agent-flow
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A live, read-only visualization of OpenCode V2 orchestration. See agent trees,
4
+ grouped parallel calls, current tools, approval waits, token usage and estimated
5
+ costs in one WebGL dashboard. Connect to a local or remote OpenCode server using
6
+ only its public HTTP API and SSE—no database access required.
7
+
8
+ Zero runtime npm dependencies. Node.js 20+; no frontend build step.
9
+
10
+ ![Agent Flow demo dashboard](docs/screenshot.png)
11
+
12
+ ## Quick start
13
+
14
+ ```sh
15
+ npx opencode-agent-flow
16
+ # Protected server: supply the password through your environment, not CLI flags.
17
+ OPENCODE_SERVER_PASSWORD="$YOUR_OPENCODE_PASSWORD" npx opencode-agent-flow
18
+ # Remote server
19
+ OPENCODE_SERVER_PASSWORD="$YOUR_OPENCODE_PASSWORD" npx opencode-agent-flow --server https://opencode.example.org
20
+ # Fictional data, no server required
21
+ npx opencode-agent-flow --demo --open
22
+ ```
23
+
24
+ Open <http://127.0.0.1:3340>. For an unpublished local checkout:
25
+ `node bin/cli.mjs --demo`.
26
+
27
+ ## Options
28
+
29
+ | Flag | Environment fallback | Default |
30
+ | --- | --- | --- |
31
+ | `--server URL` | `OPENCODE_URL` | `http://127.0.0.1:4096` |
32
+ | `--username NAME` | `OPENCODE_SERVER_USERNAME` | `opencode` |
33
+ | `--port NUMBER` | `PORT` | `3340` |
34
+ | `--host ADDRESS` | — | `127.0.0.1` |
35
+ | `--allowed-host HOST` (repeatable) | — | Additional allowed hostname |
36
+ | `--window HOURS` | `WINDOW_HOURS` | `4` |
37
+ | `--open` | — | Open the browser |
38
+ | `--demo` | — | Built-in fictional data |
39
+ | `--help` | — | Show usage |
40
+
41
+ `OPENCODE_SERVER_PASSWORD` is accepted **only through the environment**. Without
42
+ it the BFF tries unauthenticated access; rejected access leaves the UI connecting
43
+ while the backend retries.
44
+
45
+ ## How it works
46
+
47
+ A thin Node backend-for-frontend (BFF) loads paginated session/message history,
48
+ keeps one global upstream SSE subscription, and sends aggregated snapshots to
49
+ the browser. Reconnects use exponential backoff (1–30 seconds), reset only after
50
+ a connection lasts at least 30 seconds. Full resynchronization is limited to
51
+ once per 10 seconds; initial connection failures retry with the same backoff.
52
+ A periodic reconciliation (every 60 seconds) catches missed events. Permissions and forms are
53
+ polled for each active location. All/session modes, grouped `×N` agents and
54
+ individual-call popovers preserve the orchestration hierarchy.
55
+
56
+ `N agents · M calls` counts visible groups and all sessions respectively, each
57
+ including the root. The recent activity window applies to root selection; explicit
58
+ session mode loads the complete selected tree, including dormant children.
59
+
60
+ ## Security and compatibility
61
+
62
+ - Loopback binding by default. **The UI has no authentication**; exposing it on
63
+ another interface reveals session titles, paths and activity. Use an authenticated
64
+ reverse proxy for shared deployments.
65
+ - The upstream password stays in Node, never in browser responses or URLs.
66
+ Use HTTPS to protect Basic authentication to remote servers.
67
+ - Host/Origin checks reject unapproved hostnames (DNS-rebinding protection).
68
+ Extra hostnames require `--allowed-host`; the configured port is enforced.
69
+ - The UI displays session titles, commands and paths. Secret redaction is
70
+ **best-effort**, not a guarantee. Do not expose it to a network without a
71
+ reverse proxy providing authentication. Report vulnerabilities via
72
+ [GitHub Security Advisories](https://github.com/orybakov/opencode-agent-flow/security/advisories/new).
73
+ - Tested against **OpenCode 2.0.30**. The V2 HTTP API is **experimental**; future
74
+ changes may require adapter updates. Non-V2 servers remain in connecting state.
75
+ - SSE is live-only, without replay. Reconciliation recovers durable snapshots,
76
+ not every transient event. No historical permission-decision journal is available.
77
+ - Token/cost panels use available history (at least 12 hours/current day for
78
+ default aggregates). Context size is approximate, not an exact provider meter.
79
+
80
+ ## Cost estimates
81
+
82
+ `prices.json` supplies editable USD-per-million input/output/cache-read rates and
83
+ context limits. The UI labels costs **`≈ est.`**: pricing may be outdated, provider
84
+ subscriptions may differ, and these figures are not billing statements.
85
+
86
+ ## Development
87
+
88
+ ```sh
89
+ node bin/cli.mjs --demo
90
+ node --test
91
+ npm pack --dry-run
92
+ ```
93
+
94
+ See [architecture](docs/architecture.md) and
95
+ [public API notes](docs/opencode-v2-api-notes.md). Vendored browser modules avoid
96
+ CDN/network dependencies; there is no install or bundling step.
97
+
98
+ ## License and acknowledgements
99
+
100
+ MIT © 2026 orybakov. WebGL rendering uses [three.js](https://threejs.org/),
101
+ vendored under its MIT license in `public/vendor/three/LICENSE`.
package/SECURITY.md ADDED
@@ -0,0 +1,3 @@
1
+ # Security policy
2
+
3
+ Please report vulnerabilities privately using [GitHub Security Advisories](https://github.com/orybakov/opencode-agent-flow/security/advisories/new). Include reproduction steps and the affected version. Do not include credentials or private session data in public issues.
package/bin/cli.mjs ADDED
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from 'node:child_process';
3
+ import { start } from '../server.mjs';
4
+ process.on('unhandledRejection', error => console.error('Unhandled rejection:', error?.name || 'Error'));
5
+ process.on('uncaughtException', error => console.error('Uncaught exception:', error?.name || 'Error'));
6
+
7
+ const help = `opencode-agent-flow 0.1.0 — live OpenCode V2 agent visualization
8
+
9
+ Usage: npx opencode-agent-flow [options]
10
+ --server URL OpenCode V2 URL (OPENCODE_URL; http://127.0.0.1:4096)
11
+ --username NAME Basic auth username (OPENCODE_SERVER_USERNAME; opencode)
12
+ --port NUMBER UI port (PORT; 3340)
13
+ --host ADDRESS UI bind address (127.0.0.1)
14
+ --allowed-host HOST Allow an additional Host hostname (repeatable)
15
+ --window HOURS Recent activity window (WINDOW_HOURS; 4)
16
+ --open Open the UI in your default browser
17
+ --demo Use built-in fictional data; no upstream connection
18
+ --help, -h Show this help
19
+
20
+ Password: OPENCODE_SERVER_PASSWORD environment variable only.
21
+ The UI has no authentication. Keep the default loopback bind address.`;
22
+
23
+ try {
24
+ const config = { server: process.env.OPENCODE_URL || 'http://127.0.0.1:4096', username: process.env.OPENCODE_SERVER_USERNAME || 'opencode', password: process.env.OPENCODE_SERVER_PASSWORD, port: process.env.PORT || 3340, host: '127.0.0.1', window: process.env.WINDOW_HOURS || 4, open: false, demo: false };
25
+ const args = process.argv.slice(2);
26
+ for (let i = 0; i < args.length; i++) {
27
+ const [flag, inline] = args[i].split(/=(.*)/s);
28
+ if (['--help', '-h'].includes(flag)) { console.log(help); process.exit(0); }
29
+ if (['--open', '--demo'].includes(flag)) { if (inline !== undefined) throw new Error(`${flag} does not take a value.`); config[flag.slice(2)] = true; continue; }
30
+ if (!['--server', '--username', '--port', '--host', '--window', '--allowed-host'].includes(flag)) throw new Error('Unknown option. Use --help. Passwords are accepted only via OPENCODE_SERVER_PASSWORD.');
31
+ const value = inline ?? args[++i];
32
+ if (!value || value.startsWith('--')) throw new Error(`Missing value for ${flag}.`);
33
+ if (flag === '--allowed-host') (config.allowedHosts ||= []).push(value); else config[flag.slice(2)] = value;
34
+ }
35
+ config.port = Number(config.port); config.window = Number(config.window);
36
+ if (!Number.isInteger(config.port) || config.port < 1 || config.port > 65535) throw new Error('--port must be an integer from 1 to 65535.');
37
+ if (!Number.isFinite(config.window) || config.window <= 0) throw new Error('--window must be a positive number of hours.');
38
+ let url;
39
+ try { url = new URL(config.server); } catch { throw new Error('--server must be a valid HTTP(S) URL.'); }
40
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) throw new Error('--server must be HTTP(S), without credentials, query or fragment.');
41
+ config.server = url.href.replace(/\/$/, '');
42
+ if (config.host === '[::1]') config.host = '::1';
43
+ if (!['127.0.0.1', 'localhost', '::1'].includes(config.host)) console.error('WARNING: UI has no authentication and exposes session data. Non-loopback bind requested.');
44
+ if (config.password && url.protocol === 'http:' && !['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname)) console.error('WARNING: use HTTPS for a remote OpenCode server; Basic authentication is not encrypted over HTTP.');
45
+ await start(config);
46
+ if (config.open) {
47
+ const target = `http://${config.host.includes(':') ? `[${config.host}]` : config.host}:${config.port}`;
48
+ const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'rundll32' : 'xdg-open';
49
+ const args = process.platform === 'win32' ? ['url.dll,FileProtocolHandler', target] : [target];
50
+ const child = spawn(command, args, { detached: true, stdio: 'ignore' }); child.on('error', () => console.error('Unable to open browser; open the UI URL manually.')); child.unref();
51
+ }
52
+ } catch (error) { console.error(error.message); process.exitCode = 1; }
@@ -0,0 +1,58 @@
1
+ # Architecture
2
+
3
+ Agent Flow is a read-only backend-for-frontend (BFF) for OpenCode V2. Node.js
4
+ 20+ serves static browser assets and aggregates the public HTTP API in memory.
5
+ There is no database access, build step, or runtime npm dependency.
6
+
7
+ ## Data lifecycle
8
+
9
+ 1. Check `/api/info` for a V2 server version. Authenticate with Basic only when
10
+ an environment password is configured. Credentials never reach the browser.
11
+ 2. Open one global `/api/event` SSE subscription before loading history.
12
+ 3. Page `/api/session` descending by `time.updated`, stopping locally at the
13
+ aggregation cutoff: the earliest of the activity window, 12 hours, and midnight.
14
+ 4. Recover missing ancestors and recursively enumerate children of active roots,
15
+ including dormant children. Fetch message pages with concurrency capped at four.
16
+ Messages have no time filter; pagination stops locally after an old page.
17
+ 5. Semantic SSE events mark affected sessions dirty. Coalesced GET requests replace
18
+ authoritative session/message snapshots, avoiding double-counted usage and
19
+ duplicated tool events. Reconnect uses exponential backoff (1–30 seconds), then
20
+ resynchronizes snapshots. A 60-second reconciliation covers missed events.
21
+ 6. Poll pending permissions and forms every five seconds for every known active
22
+ session directory. Locations refer to the upstream server, not the BFF machine.
23
+
24
+ The selected-session endpoint lazily loads the complete descendant history,
25
+ including sessions outside the activity window. Default aggregates include only
26
+ the aggregation horizon; session-scoped aggregates include the selected tree.
27
+ The in-memory cache lasts for the process lifetime; no prompts or tool results
28
+ are written to disk. Unknown events are ignored or reconciled by snapshots.
29
+
30
+ ## Browser contract
31
+
32
+ - `GET /api/state[?session=ID]`: roots, trees, grouped agents, last 200 events,
33
+ token aggregates, pending approvals, permission health, upstream version/status.
34
+ - `GET /api/sessions`: recent root session picker summaries.
35
+ - `GET /api/stream[?session=ID]`: `event: state` snapshots and 15-second heartbeats.
36
+ - `GET /api/permission/request`: normalized pending permissions/forms.
37
+
38
+ Children with the same nonempty agent name under one parent form a group (`×N`).
39
+ Unnamed sessions retain distinct identities; empty unnamed children are hidden.
40
+ Group status precedence is waiting, working, idle, done. `agentCount` counts visible
41
+ groups including the root; `callCount` counts all descendant sessions including
42
+ the root. Group popovers preserve individual sessions and token totals.
43
+
44
+ Statuses use active-session snapshots, running tools, pending permissions/forms,
45
+ and final idle messages. Context is the latest assistant input plus cache read and
46
+ write; missing current usage is marked estimated. Costs use `prices.json` and are
47
+ always approximate (`≈ est.`), not provider billing.
48
+
49
+ ## Rendering and security
50
+
51
+ Vanilla browser modules render all/session modes, event log, hourly token chart,
52
+ cache ratio, activity heatmap, estimated cost, ledger and accessible group popovers.
53
+ Vendored three.js powers WebGL flow connections with a Canvas fallback.
54
+
55
+ Static assets use an explicit allowlist. The BFF exposes only GET routes, not a
56
+ general upstream proxy. Loopback is the default; non-loopback binding warns that
57
+ the UI has no authentication. Use HTTPS for remote upstream Basic authentication.
58
+ See [API contract notes](opencode-v2-api-notes.md) for compatibility limitations.
@@ -0,0 +1,89 @@
1
+ # OpenCode V2 public API: contributor notes
2
+
3
+ Contract verified against OpenCode **2.0.30**. The server describes this HTTP API
4
+ as experimental. Version checks reject non-V2 servers but do not guarantee that
5
+ every future V2 revision preserves this contract.
6
+
7
+ ## HTTP snapshots
8
+
9
+ | Endpoint | Response and important details |
10
+ | --- | --- |
11
+ | `/api/info` | Server `version`; OpenAPI info.version is not the server version |
12
+ | `/api/session?order=desc&limit=100` | `{data: Session[], cursor:{previous,next}}`, ordered by time.updated then ID |
13
+ | `/api/session?parentID=ID` | Children, also paginated; parentID=null lists roots |
14
+ | `/api/session/ID` | `{data: Session}` |
15
+ | `/api/session/ID/message?order=desc&limit=100` | `{data: Message[],cursor}`, sequence order within a session |
16
+ | `/api/session/active` | `{data: {sessionID: {type:"running"}}}` |
17
+ | `/api/permission/request?location[directory]=PATH` | `{location,data: Permission[]}` |
18
+ | `/api/form?location[directory]=PATH` | `{location,data: Form[]}` |
19
+
20
+ Subsequent pages use opaque `cursor` **without order**. Stop on empty pages as
21
+ well as null cursors: the final nonempty page may still contain a next cursor.
22
+ There are no session/message time filters. Apply cutoffs locally. Archived
23
+ sessions are not automatically excluded. A session directory is
24
+ `session.location.directory`; parentID is a tree edge, while fork is not.
25
+ Old descendants must be loaded independently of an updated-time cutoff.
26
+
27
+ Messages contain `id`, `type`, `time.created`, and assistant `content`, `model`,
28
+ `tokens`, optional `finish`, `time.streamed/completed`. Tool content contains
29
+ `id`, `name`, `state.input/status`, `time.created/ran/completed`. Statuses are
30
+ streaming, running, completed, error; streaming input can be a JSON string.
31
+ Unfiltered history includes idle messages, although `type=idle` is rejected.
32
+ The public API does not expose message row update timestamps or sequence fields.
33
+
34
+ Tokens are `{input,output,reasoning,cache:{read,write}}`. Session token totals are
35
+ cumulative: replace them, never add successive usage snapshots. A latest
36
+ assistant token snapshot approximates context size. Model cost can be zero;
37
+ local pricing is an estimate, not billing.
38
+
39
+ Permissions and forms are **location-scoped**. Omitting location only accesses
40
+ the server's default directory, not every session. Permission resources use
41
+ `resources: string[]`; forms contain id, sessionID, title and fields. Pending
42
+ forms count as waiting too. Session-scoped alternatives exist at
43
+ `/api/session/ID/permission` and `/api/session/ID/form`.
44
+
45
+ ## Global event stream
46
+
47
+ `GET /api/event` covers all locations with one subscription. Frames use `data:`
48
+ containing JSON `{id,created,type,location?,data,durable?}`. The first frame is
49
+ `server.connected`. Heartbeats are comments. There is no documented replay,
50
+ time filter, cursor, or Last-Event-ID recovery; JSON event IDs do not imply replay.
51
+ Slow readers may be disconnected. The BFF must reconnect and reload snapshots.
52
+
53
+ Relevant event families include:
54
+
55
+ - session.created/deleted/renamed/moved, agent.selected/model.selected
56
+ - session.step.started/streamed/ended/failed and session.usage.updated
57
+ - session.tool.input.started/delta/ended, tool.called/progress/success/failed
58
+ - session.text.started/delta/ended, reasoning.started/delta/ended
59
+ - session.execution.started/succeeded/failed/interrupted, session.status/idle
60
+ - session.inbox.*, permission.asked/replied, form.created/replied/cancelled
61
+
62
+ Most data carries `sessionID`; assistant events carry `assistantMessageID` and
63
+ tool events carry tool `id`. form.created nests its session ID in `data.form`.
64
+ V1-style message.updated and message.part.updated events are not available.
65
+ Invalidating per-session snapshots avoids implementing a fragile full event store.
66
+ Do not log reasoning, prompts, tool outputs, form answers or authorization headers.
67
+
68
+ The experimental session log endpoint did not yield historical events in the
69
+ verified deployment (only log.synced); it is not used as a recovery guarantee.
70
+ Historical permission decisions and transient streaming deltas are not recoverable
71
+ from standard snapshots. Exact message row update timestamps cannot be reproduced.
72
+
73
+ ## Authentication and portability
74
+
75
+ Basic authentication works with the configured server username/password. A
76
+ protected server returns 401 without credentials. Bearer authentication is not
77
+ equivalent. Keep credentials in environment variables and use HTTPS remotely.
78
+ Native browser EventSource cannot set Authorization, and arbitrary origins may
79
+ not be allowed by upstream CORS. The same-origin Node BFF avoids both issues.
80
+ It never forwards credentials to browser clients and performs only GET requests.
81
+
82
+ Verified against 2.0.30: version, session/active snapshots, pagination, idle
83
+ messages, location-scoped pending requests, Basic authentication and a live global
84
+ stream containing multiple locations. Remote deployments and future versions
85
+ require additional verification; capability changes should fail clearly.
86
+
87
+ Sources: [API](https://opencode.ai/v2/docs/api),
88
+ [client](https://opencode.ai/v2/docs/build/client), published
89
+ `@opencode/client@2.0.30` event types, and live read-only HTTP/SSE observations.
Binary file
package/lib/demo.mjs ADDED
@@ -0,0 +1,10 @@
1
+ export function demo() {
2
+ const now = Date.now(), sessions = new Map(), messages = new Map();
3
+ const agents = ['lead', 'builder', 'researcher', 'reviewer', 'builder', 'tester', 'designer'];
4
+ agents.forEach((agent, i) => {
5
+ const id = `ses_demo${i}`, model = { id: ['gpt-6.1-sol', 'claude-sonnet-4-5', 'gemini-2.5-pro'][i % 3] };
6
+ sessions.set(id, { id, parentID: i ? 'ses_demo0' : undefined, title: i ? `${agent} · public API integration` : 'Build a better developer experience', agent, model, time: { created: now - 3600000, updated: now - i * 1000 }, tokens: { input: 24000 + i * 7000, output: 3200, cache: { read: 180000 + i * 19000, write: 0 } } });
7
+ messages.set(id, [{ id: `msg_demo${i}`, type: 'assistant', model, time: { created: now - 90000 + i * 5000, completed: now - 20000 }, tokens: { input: 12000, output: 1200, cache: { read: 48000, write: 0 } }, content: [{ id: `call_demo${i}`, type: 'tool', name: ['read', 'patch', 'webfetch', 'grep', 'shell', 'shell', 'read'][i], state: { status: i === 1 || i === 2 ? 'running' : 'completed', input: { path: ['src/index.ts', 'src/api/client.ts', 'https://example.org/docs', 'src/**/*.ts', 'npm test', 'npm run check', 'docs/design.md'][i] } }, time: { created: now - 30000 + i * 2000, completed: now - 10000 } }], finish: 'tool-calls' }]);
8
+ });
9
+ return { sessions, messages, active: { ses_demo1: { type: 'running' }, ses_demo2: { type: 'running' } }, approvals: [], health: { ok: true, status: 200 }, version: '2.0.30', connection: 'connected', cutoff: () => now - 86400000, start: async () => {}, stop() {}, ensureTree: async () => {} };
10
+ }
@@ -0,0 +1,9 @@
1
+ // Best-effort display redaction, not a security boundary.
2
+ export function clip(value, limit = 180) {
3
+ return String(value ?? '')
4
+ .replace(/\bBearer\s+\S+/gi, 'Bearer [redacted]')
5
+ .replace(/(-u\s+)\S+:\S+/g, '$1[redacted]')
6
+ .replace(/((?:--)?(?:password|token|secret|authorization|api[_-]?key)["']?\s*(?:[:=]\s*|\s+))["']?[^\s,;"'}]+["']?/gi, '$1[redacted]')
7
+ .replace(/\b(?:sk-[\w-]+|ghp_[\w]+|github_pat_[\w_]+|xox[bp]-[\w-]+|AKIA[A-Z0-9]+)\b/g, '[redacted]')
8
+ .slice(0, limit);
9
+ }
@@ -0,0 +1,190 @@
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;
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
+ this.syncTimer = setInterval(() => { void this.sync().catch(() => console.error('OpenCode reconciliation failed; retrying in 60s.')); }, 60000);
179
+ this.pendingTimer = setInterval(() => { void this.pollPending(); }, 5000);
180
+ return;
181
+ } catch {
182
+ if (this.abort.signal.aborted) return;
183
+ this.connection = 'connecting'; this.changed();
184
+ 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 }); });
185
+ delay = Math.min(delay * 2, 30000);
186
+ }
187
+ }
188
+ }
189
+ stop() { this.abort.abort(); clearInterval(this.syncTimer); clearInterval(this.pendingTimer); clearTimeout(this.dirtyTimer); }
190
+ }
package/package.json CHANGED
@@ -1,6 +1,17 @@
1
1
  {
2
2
  "name": "opencode-agent-flow",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Live agent flow visualization for OpenCode V2 over its public HTTP API and SSE",
5
+ "type": "module",
6
+ "bin": { "opencode-agent-flow": "bin/cli.mjs" },
7
+ "engines": { "node": ">=20" },
8
+ "license": "MIT",
9
+ "author": "orybakov (https://github.com/orybakov)",
10
+ "repository": { "type": "git", "url": "https://github.com/orybakov/opencode-agent-flow.git" },
11
+ "bugs": { "url": "https://github.com/orybakov/opencode-agent-flow/issues" },
12
+ "homepage": "https://github.com/orybakov/opencode-agent-flow#readme",
13
+ "keywords": ["opencode", "ai-agents", "visualizer", "observability", "webgl"],
14
+ "exports": {},
15
+ "files": ["bin", "server.mjs", "lib", "public", "prices.json", "README.md", "LICENSE", "SECURITY.md", "CONTRIBUTING.md", "docs/*.md", "docs/screenshot.png"],
16
+ "scripts": { "start": "node bin/cli.mjs", "test": "node --test" }
17
+ }
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
+ }