@wuyoumaster/opencode-telemetry-panel 0.0.7

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/README.md ADDED
@@ -0,0 +1,72 @@
1
+ # OpenCode Telemetry Panel
2
+
3
+ OpenCode telemetry plugin plus a Tauri floating panel.
4
+
5
+ 中文文档: [README_CN.md](./README_CN.md)
6
+
7
+ ## What it does
8
+
9
+ - Captures request telemetry from OpenCode events.
10
+ - Stores telemetry in `~/.opencode-telemetry/telemetry.jsonl`.
11
+ - Launches a native floating panel from the plugin.
12
+ - Downloads the matching executable automatically during `postinstall`.
13
+
14
+ ## Package Layout
15
+
16
+ - `plugin/opencode-telemetry-panel.ts` OpenCode plugin entry.
17
+ - `src/` Solid panel UI.
18
+ - `src-tauri/` Tauri backend.
19
+ - `scripts/postinstall.mjs` binary downloader.
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ bun add @opencode-ai/telemetry-panel
25
+ ```
26
+
27
+ or
28
+
29
+ ```bash
30
+ npm i @opencode-ai/telemetry-panel
31
+ ```
32
+
33
+ The install step runs `postinstall` and downloads the matching binary into `~/.opencode-telemetry/`.
34
+
35
+ ## Environment Variables
36
+
37
+ - `OPENCODE_TELEMETRY_PANEL_REPO` overrides the GitHub repo slug used for binary downloads.
38
+ - `OPENCODE_TELEMETRY_PANEL_BIN` forces a custom binary path.
39
+ - `OPENCODE_TELEMETRY_PANEL_SKIP_DOWNLOAD=1` disables binary download during CI installs.
40
+
41
+ ## Supported Platforms
42
+
43
+ - Windows x64
44
+ - macOS x64
45
+ - macOS arm64
46
+
47
+ Linux is intentionally not supported for release artifacts yet.
48
+
49
+ ## Development
50
+
51
+ ```bash
52
+ bun install
53
+ bun run tauri dev
54
+ ```
55
+
56
+ ## Build Executable
57
+
58
+ ```bash
59
+ bun run build:exe
60
+ ```
61
+
62
+ This outputs the executable only, not an installer.
63
+
64
+ ## Release
65
+
66
+ - Push a `v*` tag to publish npm and GitHub Release artifacts.
67
+ - GitHub Releases contain the platform-specific executables.
68
+
69
+ ## Notes
70
+
71
+ - The plugin reads the binary from `~/.opencode-telemetry/OpenCodeTelemetryPanel(.exe)` by default.
72
+ - On Windows, the binary is `OpenCodeTelemetryPanel.exe`.
package/README_CN.md ADDED
@@ -0,0 +1,72 @@
1
+ # OpenCode Telemetry Panel
2
+
3
+ OpenCode 监控插件 + Tauri 浮窗面板。
4
+
5
+ English: [README.md](./README.md)
6
+
7
+ ## 功能
8
+
9
+ - 监听 OpenCode 事件并记录请求指标。
10
+ - 将 telemetry 保存到 `~/.opencode-telemetry/telemetry.jsonl`。
11
+ - 由插件拉起本地浮窗面板。
12
+ - 安装时通过 `postinstall` 自动下载匹配平台的可执行文件。
13
+
14
+ ## 仓库结构
15
+
16
+ - `plugin/opencode-telemetry-panel.ts` OpenCode 插件入口。
17
+ - `src/` Solid 前端界面。
18
+ - `src-tauri/` Tauri 后端。
19
+ - `scripts/postinstall.mjs` 二进制下载脚本。
20
+
21
+ ## 安装
22
+
23
+ ```bash
24
+ bun add @opencode-ai/telemetry-panel
25
+ ```
26
+
27
+ 或者
28
+
29
+ ```bash
30
+ npm i @opencode-ai/telemetry-panel
31
+ ```
32
+
33
+ 安装时会执行 `postinstall`,把对应平台的可执行文件下载到 `~/.opencode-telemetry/`。
34
+
35
+ ## 环境变量
36
+
37
+ - `OPENCODE_TELEMETRY_PANEL_REPO` 覆盖下载二进制时使用的 GitHub 仓库地址。
38
+ - `OPENCODE_TELEMETRY_PANEL_BIN` 手动指定可执行文件路径。
39
+ - `OPENCODE_TELEMETRY_PANEL_SKIP_DOWNLOAD=1` 可在 CI 安装时跳过二进制下载。
40
+
41
+ ## 支持平台
42
+
43
+ - Windows x64
44
+ - macOS x64
45
+ - macOS arm64
46
+
47
+ 暂时不发布 Linux 构建产物。
48
+
49
+ ## 开发
50
+
51
+ ```bash
52
+ bun install
53
+ bun run tauri dev
54
+ ```
55
+
56
+ ## 构建可执行文件
57
+
58
+ ```bash
59
+ bun run build:exe
60
+ ```
61
+
62
+ 这里只输出可执行文件,不生成安装包。
63
+
64
+ ## 发布
65
+
66
+ - 推送 `v*` tag 会同时触发 npm 发布和 GitHub Release。
67
+ - GitHub Release 会附带各平台的可执行文件。
68
+
69
+ ## 说明
70
+
71
+ - 插件默认从 `~/.opencode-telemetry/OpenCodeTelemetryPanel(.exe)` 读取可执行文件。
72
+ - Windows 下文件名是 `OpenCodeTelemetryPanel.exe`。
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/package.json",
3
+ "name": "@wuyoumaster/opencode-telemetry-panel",
4
+ "private": false,
5
+ "description": "OpenCode telemetry panel plugin with a companion Tauri binary",
6
+ "version": "0.0.7",
7
+ "type": "module",
8
+ "packageManager": "bun@1.3.11",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/wuyouMaster/opencode-telemetry-panel.git"
12
+ },
13
+ "homepage": "https://github.com/wuyouMaster/opencode-telemetry-panel",
14
+ "bugs": {
15
+ "url": "https://github.com/wuyouMaster/opencode-telemetry-panel/issues"
16
+ },
17
+ "main": "./plugin/opencode-telemetry-panel.ts",
18
+ "exports": {
19
+ ".": "./plugin/opencode-telemetry-panel.ts",
20
+ "./server": "./plugin/opencode-telemetry-panel.ts"
21
+ },
22
+ "files": [
23
+ "plugin/opencode-telemetry-panel.ts",
24
+ "scripts/postinstall.mjs",
25
+ "README.md",
26
+ "README_CN.md"
27
+ ],
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "scripts": {
32
+ "dev": "vite",
33
+ "build": "vite build",
34
+ "build:exe": "tauri build --no-bundle",
35
+ "postinstall": "node ./scripts/postinstall.mjs",
36
+ "typecheck": "tsc -p tsconfig.json --noEmit",
37
+ "tauri": "tauri"
38
+ },
39
+ "engines": {
40
+ "node": ">=18"
41
+ },
42
+ "dependencies": {
43
+ "@tauri-apps/api": "^2",
44
+ "solid-js": "1.9.10"
45
+ },
46
+ "devDependencies": {
47
+ "@tauri-apps/cli": "^2",
48
+ "@types/bun": "1.3.11",
49
+ "typescript": "5.8.2",
50
+ "vite": "7.1.4",
51
+ "vite-plugin-solid": "2.11.10"
52
+ }
53
+ }
@@ -0,0 +1,214 @@
1
+ import { appendFile, mkdir } from "node:fs/promises"
2
+ import { existsSync } from "node:fs"
3
+ import { dirname, join } from "node:path"
4
+ import { homedir } from "node:os"
5
+
6
+ type SessionStatusEvent = {
7
+ type: "session.status"
8
+ properties: {
9
+ sessionID: string
10
+ status: {
11
+ type: string
12
+ }
13
+ }
14
+ }
15
+
16
+ type MessagePartDeltaEvent = {
17
+ type: "message.part.delta"
18
+ properties: {
19
+ sessionID: string
20
+ messageID: string
21
+ }
22
+ }
23
+
24
+ type MessageUpdatedEvent = {
25
+ type: "message.updated"
26
+ properties: {
27
+ sessionID: string
28
+ info: {
29
+ role: string
30
+ id: string
31
+ providerID: string
32
+ modelID: string
33
+ time: {
34
+ created: number
35
+ completed?: number | null
36
+ error?: unknown
37
+ }
38
+ }
39
+ }
40
+ }
41
+
42
+ type SessionErrorEvent = {
43
+ type: "session.error"
44
+ properties: {
45
+ sessionID?: string | null
46
+ error: unknown
47
+ }
48
+ }
49
+
50
+ type TelemetryEvent = SessionStatusEvent | MessagePartDeltaEvent | MessageUpdatedEvent | SessionErrorEvent
51
+
52
+ type Plugin = () => Promise<{
53
+ event: (input: { event: TelemetryEvent }) => Promise<void>
54
+ }>
55
+
56
+ type PendingRequest = {
57
+ sessionId: string
58
+ messageId: string
59
+ providerId: string
60
+ modelId: string
61
+ startedAt: number
62
+ firstTokenAt?: number
63
+ retries: number
64
+ }
65
+
66
+ const root = join(homedir(), ".opencode-telemetry")
67
+ const file = join(root, "telemetry.jsonl")
68
+ const executablePath = join(
69
+ root,
70
+ process.platform === "win32" ? "OpenCodeTelemetryPanel.exe" : "OpenCodeTelemetryPanel",
71
+ )
72
+ const pending = new Map<string, PendingRequest>()
73
+ const firstTokenAt = new Map<string, number>()
74
+ let panelLaunched = false
75
+
76
+ function key(sessionId: string, messageId: string) {
77
+ return `${sessionId}:${messageId}`
78
+ }
79
+
80
+ function compactError(error: unknown) {
81
+ if (!error) return "session error"
82
+ if (typeof error !== "object") return String(error)
83
+ if ("data" in error && error.data && typeof error.data === "object" && "message" in error.data) {
84
+ return String((error.data as { message?: unknown }).message ?? (error as { message?: unknown }).message ?? "")
85
+ }
86
+ return String((error as { message?: unknown }).message ?? error)
87
+ }
88
+
89
+ async function append(record: Record<string, unknown>) {
90
+ await mkdir(dirname(file), { recursive: true })
91
+ await appendFile(file, `${JSON.stringify(record)}\n`)
92
+ }
93
+
94
+ function launchPanel() {
95
+ if (panelLaunched) return
96
+
97
+ const executable = [process.env.OPENCODE_TELEMETRY_PANEL_BIN, executablePath]
98
+ .filter((value): value is string => typeof value === "string")
99
+ .find((value) => existsSync(value))
100
+
101
+ if (!executable) return
102
+
103
+ const child = Bun.spawn([executable], {
104
+ detached: true,
105
+ stdio: ["ignore", "ignore", "ignore"],
106
+ })
107
+
108
+ child.unref()
109
+ panelLaunched = true
110
+ }
111
+
112
+ function recordFromPending(pendingRequest: PendingRequest, finishedAt: number, success: boolean, error?: string) {
113
+ const firstTokenAt = pendingRequest.firstTokenAt ?? finishedAt
114
+ return {
115
+ kind: "request",
116
+ sessionId: pendingRequest.sessionId,
117
+ messageId: pendingRequest.messageId,
118
+ providerId: pendingRequest.providerId,
119
+ modelId: pendingRequest.modelId,
120
+ startedAt: pendingRequest.startedAt,
121
+ firstTokenAt,
122
+ completedAt: finishedAt,
123
+ success,
124
+ error: error ?? null,
125
+ retries: pendingRequest.retries,
126
+ }
127
+ }
128
+
129
+ export const TelemetryPanelPlugin: Plugin = async () => {
130
+ launchPanel()
131
+
132
+ return {
133
+ event: async ({ event }) => {
134
+ if (event.type === "session.status") {
135
+ if (event.properties.status.type === "idle") {
136
+ for (const [requestKey, request] of pending.entries()) {
137
+ if (request.sessionId !== event.properties.sessionID) continue
138
+ pending.delete(requestKey)
139
+ firstTokenAt.delete(requestKey)
140
+ }
141
+ return
142
+ }
143
+
144
+ if (event.properties.status.type === "retry") {
145
+ for (const request of pending.values()) {
146
+ if (request.sessionId === event.properties.sessionID) request.retries += 1
147
+ }
148
+ }
149
+
150
+ return
151
+ }
152
+
153
+ if (event.type === "message.part.delta") {
154
+ const requestKey = key(event.properties.sessionID, event.properties.messageID)
155
+ if (!firstTokenAt.has(requestKey)) firstTokenAt.set(requestKey, Date.now())
156
+
157
+ const request = pending.get(requestKey)
158
+ if (request && request.firstTokenAt === undefined) request.firstTokenAt = firstTokenAt.get(requestKey)
159
+ return
160
+ }
161
+
162
+ if (event.type === "message.updated") {
163
+ if (event.properties.info.role !== "assistant") return
164
+
165
+ const requestKey = key(event.properties.sessionID, event.properties.info.id)
166
+ const existing = pending.get(requestKey)
167
+ const next =
168
+ existing ??
169
+ ({
170
+ sessionId: event.properties.sessionID,
171
+ messageId: event.properties.info.id,
172
+ providerId: event.properties.info.providerID,
173
+ modelId: event.properties.info.modelID,
174
+ startedAt: event.properties.info.time.created,
175
+ retries: 0,
176
+ } satisfies PendingRequest)
177
+
178
+ if (next.firstTokenAt === undefined) next.firstTokenAt = firstTokenAt.get(requestKey)
179
+
180
+ if (!existing) pending.set(requestKey, next)
181
+
182
+ if (!event.properties.info.time.completed) return
183
+
184
+ await append(
185
+ recordFromPending(
186
+ next,
187
+ event.properties.info.time.completed,
188
+ !event.properties.info.time.error,
189
+ event.properties.info.time.error ? compactError(event.properties.info.time.error) : undefined,
190
+ ),
191
+ ).catch(() => undefined)
192
+ pending.delete(requestKey)
193
+ firstTokenAt.delete(requestKey)
194
+ return
195
+ }
196
+
197
+ if (event.type === "session.error") {
198
+ const sessionId = event.properties.sessionID
199
+ if (!sessionId) return
200
+
201
+ const requests = [...pending.values()].filter((request) => request.sessionId === sessionId)
202
+ const request = requests.sort((left, right) => right.startedAt - left.startedAt)[0]
203
+ if (!request) return
204
+
205
+ await append(recordFromPending(request, Date.now(), false, compactError(event.properties.error))).catch(
206
+ () => undefined,
207
+ )
208
+ const requestKey = key(request.sessionId, request.messageId)
209
+ pending.delete(requestKey)
210
+ firstTokenAt.delete(requestKey)
211
+ }
212
+ },
213
+ }
214
+ }
@@ -0,0 +1,73 @@
1
+ import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"
2
+ import { existsSync } from "node:fs"
3
+ import { homedir } from "node:os"
4
+ import { join } from "node:path"
5
+
6
+ const packageJson = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"))
7
+ const root = join(homedir(), ".opencode-telemetry")
8
+ const binaryName = process.platform === "win32" ? "OpenCodeTelemetryPanel.exe" : "OpenCodeTelemetryPanel"
9
+ const binaryPath = join(root, binaryName)
10
+ const version = packageJson.version
11
+ const repo = resolveRepo(packageJson)
12
+ const asset = resolveAsset()
13
+
14
+ if (process.env.OPENCODE_TELEMETRY_PANEL_SKIP_DOWNLOAD === "1") {
15
+ process.exit(0)
16
+ }
17
+
18
+ if (!asset) {
19
+ console.warn("[opencode-telemetry-panel] skipping binary download on unsupported platform")
20
+ process.exit(0)
21
+ }
22
+
23
+ if (!repo) {
24
+ console.warn("[opencode-telemetry-panel] skipping binary download because repository is not configured")
25
+ process.exit(0)
26
+ }
27
+
28
+ await mkdir(root, { recursive: true })
29
+
30
+ const url = `https://github.com/${repo}/releases/download/v${version}/${asset}`
31
+ const response = await fetch(url)
32
+
33
+ if (!response.ok) {
34
+ if (response.status === 404) {
35
+ console.warn(`[opencode-telemetry-panel] binary not found at ${url}; skipping download`)
36
+ process.exit(0)
37
+ }
38
+
39
+ throw new Error(`failed to download telemetry panel binary from ${url}: ${response.status} ${response.statusText}`)
40
+ }
41
+
42
+ const tempPath = `${binaryPath}.download`
43
+ await writeFile(tempPath, Buffer.from(await response.arrayBuffer()))
44
+
45
+ if (process.platform !== "win32") await chmod(tempPath, 0o755)
46
+
47
+ if (existsSync(binaryPath)) await rm(binaryPath, { force: true })
48
+ await rename(tempPath, binaryPath)
49
+
50
+ function resolveRepo(pkg) {
51
+ const override = process.env.OPENCODE_TELEMETRY_PANEL_REPO?.trim()
52
+ if (override) return parseRepo(override) ?? override.replace(/^https?:\/\/github\.com\//, "").replace(/\.git$/, "")
53
+
54
+ const value = pkg.repository
55
+ if (typeof value === "string") return parseRepo(value)
56
+ if (value && typeof value.url === "string") return parseRepo(value.url)
57
+ }
58
+
59
+ function parseRepo(value) {
60
+ const text = value.trim()
61
+ if (!text) return
62
+ const https = text.match(/github\.com[:/](.+?)(?:\.git)?$/)
63
+ if (https) return https[1]
64
+
65
+ const git = text.match(/^git@github\.com:(.+?)(?:\.git)?$/)
66
+ if (git) return git[1]
67
+ }
68
+
69
+ function resolveAsset() {
70
+ if (process.platform === "win32" && process.arch === "x64") return "opencode-telemetry-panel-windows-x64.exe"
71
+ if (process.platform === "darwin" && process.arch === "arm64") return "opencode-telemetry-panel-macos-arm64"
72
+ if (process.platform === "darwin" && process.arch === "x64") return "opencode-telemetry-panel-macos-x64"
73
+ }