pi-opencode-go-usage 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +99 -0
- package/README.zh-CN.md +95 -0
- package/extensions/opencode-go-usage.ts +448 -0
- package/package.json +36 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 pi-opencode-go-usage contributors
|
|
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
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# pi-opencode-go-usage
|
|
2
|
+
|
|
3
|
+
**[简体中文](README.zh-CN.md) · English**
|
|
4
|
+
|
|
5
|
+
Track OpenCode Go usage limits — **rolling 5-hour, weekly, and monthly** — in-session
|
|
6
|
+
with a live status bar and a `/opencode-go` report widget.
|
|
7
|
+
|
|
8
|
+

|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
Status bar: Go 5h 62% · wk 31% · mo 44%
|
|
12
|
+
|
|
13
|
+
Report widget:
|
|
14
|
+
OpenCode Go Usage
|
|
15
|
+
Workspace: wrk_xxxxxxxxxxxxxxxxxxxxxxxx
|
|
16
|
+
Rolling 5h ██████░░░░ 62% · 1h 12m
|
|
17
|
+
Weekly ███░░░░░░░ 31% · 3d 4h
|
|
18
|
+
Monthly ████░░░░░░ 44% · 12d 0h
|
|
19
|
+
Updated 2:32:05 PM (time format follows your locale)
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Why this exists
|
|
23
|
+
|
|
24
|
+
OpenCode publishes **no usage API** and serves no `/api/*`. The
|
|
25
|
+
`/workspace/<wrk_…>/go` screen is a SolidStart app that serialises the resolved
|
|
26
|
+
values straight into the delivered HTML:
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
rollingUsage:$R[12]={status:"ok",resetInSec:17400,usagePercent:42}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
This extension fetches that page with your browser `auth` cookie and reads the
|
|
33
|
+
three percentages + reset times out of the markup. It reports **percentages and
|
|
34
|
+
countdowns only** — the page carries no dollar amounts, so neither does this.
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
omp plugin install github:dakai/pi-opencode-go-usage
|
|
40
|
+
# or, for local dev:
|
|
41
|
+
omp plugin link /path/to/pi-opencode-go-usage
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Then restart the session (or `/reload`).
|
|
45
|
+
|
|
46
|
+
## Connect
|
|
47
|
+
|
|
48
|
+
You need two things from your signed-in opencode.ai workspace:
|
|
49
|
+
|
|
50
|
+
1. **Workspace ID** — the `wrk_…` segment in the address bar:
|
|
51
|
+
`opencode.ai/workspace/`**`wrk_…`**`/go`
|
|
52
|
+
2. **`auth` cookie value** — on that page press F12 → Application → Cookies →
|
|
53
|
+
`https://opencode.ai` → the `auth` row → copy its Value.
|
|
54
|
+
|
|
55
|
+
Either set env vars (recommended — keeps the cookie out of session history):
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
export OPENCODE_GO_WORKSPACE_ID=wrk_…
|
|
59
|
+
export OPENCODE_GO_AUTH_COOKIE='…'
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
or use the slash command (persists to `~/.omp/agent/opencode-go-usage.json`, mode 0600):
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
/opencode-go --connect wrk_… <auth-cookie-value>
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Commands
|
|
69
|
+
|
|
70
|
+
| Command | Effect |
|
|
71
|
+
| --------------------------------------- | ------------------------------------------------------------- |
|
|
72
|
+
| `/opencode-go` | Fetch and show the report widget |
|
|
73
|
+
| `/opencode-go --connect <wrk> <cookie>` | Save both, fetch, show |
|
|
74
|
+
| `/opencode-go --workspace <id>` | Save workspace id only |
|
|
75
|
+
| `/opencode-go --cookie <value>` | Save cookie only |
|
|
76
|
+
| `/opencode-go --disconnect` | Forget both |
|
|
77
|
+
| `/opencode-go --refresh` | Fetch again now |
|
|
78
|
+
| `/opencode-go --json` | Export report to `~/.omp/agent/opencode-go-usage-report.json` |
|
|
79
|
+
|
|
80
|
+
Usage refreshes automatically every 5 minutes.
|
|
81
|
+
|
|
82
|
+
## Failure modes
|
|
83
|
+
|
|
84
|
+
| Status text | Meaning | Fix |
|
|
85
|
+
| ------------------------------------- | -------------------------- | ----------------------------- |
|
|
86
|
+
| `Cookie expired` | The `auth` session lapsed | Reconnect with a fresh cookie |
|
|
87
|
+
| `Page carried no usage data` | opencode.ai markup changed | Update the parser |
|
|
88
|
+
| `Network error` / `Request timed out` | Transient | Retry |
|
|
89
|
+
|
|
90
|
+
## Security
|
|
91
|
+
|
|
92
|
+
This is a scrape authenticated by a browser session cookie, stored in a
|
|
93
|
+
`0600`-mode file (or in env vars). It reports only the percentages opencode.ai
|
|
94
|
+
already shows; a redesign of the page will break it, and it will say so instead
|
|
95
|
+
of showing a confident zero.
|
|
96
|
+
|
|
97
|
+
## License
|
|
98
|
+
|
|
99
|
+
MIT
|
package/README.zh-CN.md
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# pi-opencode-go-usage
|
|
2
|
+
|
|
3
|
+
在会话中实时追踪 OpenCode Go 的用量限额 —— **滚动 5 小时、每周、每月**,
|
|
4
|
+
通过实时状态栏和 `/opencode-go` 报告组件展示。
|
|
5
|
+
|
|
6
|
+

|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
状态栏: Go 5h 62% · wk 31% · mo 44%
|
|
10
|
+
|
|
11
|
+
报告组件:
|
|
12
|
+
OpenCode Go Usage
|
|
13
|
+
Workspace: wrk_xxxxxxxxxxxxxxxxxxxxxxxx
|
|
14
|
+
Rolling 5h ██████░░░░ 62% · 1h 12m
|
|
15
|
+
Weekly ███░░░░░░░ 31% · 3d 4h
|
|
16
|
+
Monthly ████░░░░░░ 44% · 12d 0h
|
|
17
|
+
Updated 2:32:05 PM (时间格式随你的区域设置而定)
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## 为什么需要它
|
|
21
|
+
|
|
22
|
+
OpenCode **没有提供用量 API**,也没有 `/api/*` 接口。
|
|
23
|
+
`/workspace/<wrk_…>/go` 页面是一个 SolidStart 应用,会把解析后的数值直接
|
|
24
|
+
序列化进交付的 HTML 中:
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
rollingUsage:$R[12]={status:"ok",resetInSec:17400,usagePercent:42}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
本扩展用你的浏览器 `auth` cookie 抓取该页面,从标记中读取三个百分比 + 重置时间。
|
|
31
|
+
它**只报告百分比和倒计时** —— 页面本身不携带任何金额信息,因此这里也没有。
|
|
32
|
+
|
|
33
|
+
## 安装
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
omp plugin install github:dakai/pi-opencode-go-usage
|
|
37
|
+
# 或用于本地开发:
|
|
38
|
+
omp plugin link /path/to/pi-opencode-go-usage
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
然后重启会话(或 `/reload`)。
|
|
42
|
+
|
|
43
|
+
## 连接
|
|
44
|
+
|
|
45
|
+
你需要从已登录的 opencode.ai 工作区获取两样东西:
|
|
46
|
+
|
|
47
|
+
1. **工作区 ID(Workspace ID)** —— 地址栏中的 `wrk_…` 片段:
|
|
48
|
+
`opencode.ai/workspace/`**`wrk_…`**`/go`
|
|
49
|
+
2. **`auth` cookie 值** —— 在该页面按 F12 → Application → Cookies →
|
|
50
|
+
`https://opencode.ai` → `auth` 行 → 复制其 Value。
|
|
51
|
+
|
|
52
|
+
可以设置环境变量(推荐 —— 可避免 cookie 出现在会话历史中):
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
export OPENCODE_GO_WORKSPACE_ID=wrk_…
|
|
56
|
+
export OPENCODE_GO_AUTH_COOKIE='…'
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
或使用斜杠命令(持久化到 `~/.omp/agent/opencode-go-usage.json`,权限 0600):
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
/opencode-go --connect wrk_… <auth-cookie-value>
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## 命令
|
|
66
|
+
|
|
67
|
+
| 命令 | 作用 |
|
|
68
|
+
| --------------------------------------- | ------------------------------------------------------------- |
|
|
69
|
+
| `/opencode-go` | 抓取并显示报告组件 |
|
|
70
|
+
| `/opencode-go --connect <wrk> <cookie>` | 保存两者,抓取,显示 |
|
|
71
|
+
| `/opencode-go --workspace <id>` | 仅保存工作区 ID |
|
|
72
|
+
| `/opencode-go --cookie <value>` | 仅保存 cookie |
|
|
73
|
+
| `/opencode-go --disconnect` | 清除两者 |
|
|
74
|
+
| `/opencode-go --refresh` | 立即重新抓取 |
|
|
75
|
+
| `/opencode-go --json` | 导出报告到 `~/.omp/agent/opencode-go-usage-report.json` |
|
|
76
|
+
|
|
77
|
+
用量每 5 分钟自动刷新一次。
|
|
78
|
+
|
|
79
|
+
## 失败模式
|
|
80
|
+
|
|
81
|
+
| 状态文本 | 含义 | 修复 |
|
|
82
|
+
| ------------------------------------- | -------------------------- | ----------------------------- |
|
|
83
|
+
| `Cookie expired` | `auth` 会话已过期 | 用新 cookie 重新连接 |
|
|
84
|
+
| `Page carried no usage data` | opencode.ai 标记已变更 | 更新解析器 |
|
|
85
|
+
| `Network error` / `Request timed out` | 瞬时错误 | 重试 |
|
|
86
|
+
|
|
87
|
+
## 安全性
|
|
88
|
+
|
|
89
|
+
这是通过浏览器会话 cookie 认证的抓取,存储在 `0600` 权限文件中(或环境变量)。
|
|
90
|
+
它只报告 opencode.ai 已展示的百分比;页面改版会导致其失效,届时它会明确报错,
|
|
91
|
+
而不是自信地显示一个零值。
|
|
92
|
+
|
|
93
|
+
## 许可证
|
|
94
|
+
|
|
95
|
+
MIT
|
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenCode Go Usage Tracker (pi/omp extension)
|
|
3
|
+
*
|
|
4
|
+
* Shows OpenCode Go usage limits — rolling 5-hour, weekly, and monthly — in a
|
|
5
|
+
* live status bar and a `/opencode-go` report widget.
|
|
6
|
+
*
|
|
7
|
+
* Core: opencode.ai publishes no usage API and serves no `/api/*`. The
|
|
8
|
+
* `/workspace/<wrk_…>/go` screen is a SolidStart app that serialises the
|
|
9
|
+
* resolved values into the delivered HTML:
|
|
10
|
+
*
|
|
11
|
+
* rollingUsage:$R[12]={status:"ok",resetInSec:17400,usagePercent:42}
|
|
12
|
+
*
|
|
13
|
+
* So this extension fetches that page with your browser `auth` cookie and
|
|
14
|
+
* reads the three percentages + reset times out of the markup. It reports
|
|
15
|
+
* percentages and countdowns only — the page carries no dollar amounts.
|
|
16
|
+
*
|
|
17
|
+
* UI: mirrors pi-opencode-usage — a status bar after each refresh plus a
|
|
18
|
+
* `/opencode-go` slash command with subcommands for setup and export.
|
|
19
|
+
*/
|
|
20
|
+
import { homedir } from "node:os";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
import { promises as fs } from "node:fs";
|
|
23
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
24
|
+
|
|
25
|
+
type MeterKind = "five_hour" | "calendar_week" | "product_period";
|
|
26
|
+
|
|
27
|
+
interface UsageMeter {
|
|
28
|
+
kind: MeterKind;
|
|
29
|
+
/** 0-100, clamped. */
|
|
30
|
+
percent: number;
|
|
31
|
+
/** ISO timestamp of rollover, or null when the window is not open. */
|
|
32
|
+
resetsAt: string | null;
|
|
33
|
+
status: "ok" | "error" | "unknown";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface Config {
|
|
37
|
+
workspaceId?: string;
|
|
38
|
+
authCookie?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type FetchFailure =
|
|
42
|
+
| { kind: "noCredentials" }
|
|
43
|
+
| { kind: "timeout" }
|
|
44
|
+
| { kind: "network"; detail: string }
|
|
45
|
+
| { kind: "unauthorized" }
|
|
46
|
+
| { kind: "http"; status: number }
|
|
47
|
+
| { kind: "noPayload"; sawLogin: boolean };
|
|
48
|
+
|
|
49
|
+
const WINDOW_KEYS: { key: string; kind: MeterKind }[] = [
|
|
50
|
+
{ key: "rollingUsage", kind: "five_hour" },
|
|
51
|
+
{ key: "weeklyUsage", kind: "calendar_week" },
|
|
52
|
+
{ key: "monthlyUsage", kind: "product_period" },
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
const METER_LABEL: Record<MeterKind, string> = {
|
|
56
|
+
five_hour: "Rolling 5h",
|
|
57
|
+
calendar_week: "Weekly",
|
|
58
|
+
product_period: "Monthly",
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const METER_SHORT: Record<MeterKind, string> = {
|
|
62
|
+
five_hour: "5h",
|
|
63
|
+
calendar_week: "wk",
|
|
64
|
+
product_period: "mo",
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const CONFIG_PATH = join(homedir(), ".omp", "agent", "opencode-go-usage.json");
|
|
68
|
+
const DEFAULT_ORIGIN = "https://opencode.ai";
|
|
69
|
+
const REFRESH_SECONDS = 300;
|
|
70
|
+
const REQUEST_TIMEOUT_MS = 20_000;
|
|
71
|
+
const USER_AGENT =
|
|
72
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// Config persistence (0600 file; cookie is a credential)
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
async function loadConfig(): Promise<Config> {
|
|
79
|
+
try {
|
|
80
|
+
return JSON.parse(await fs.readFile(CONFIG_PATH, "utf8")) as Config;
|
|
81
|
+
} catch {
|
|
82
|
+
return {};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function saveConfig(config: Config): Promise<void> {
|
|
87
|
+
const tmp = `${CONFIG_PATH}.tmp`;
|
|
88
|
+
await fs.writeFile(tmp, JSON.stringify(config, null, 2), { mode: 0o600 });
|
|
89
|
+
await fs.rename(tmp, CONFIG_PATH);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
// Fetch + parse (ported from otoneko1102.opencode-go-usage-checker src/workspace.ts)
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
function workspaceUrl(workspaceId: string, origin = DEFAULT_ORIGIN): string {
|
|
97
|
+
return `${origin.replace(/\/+$/, "")}/workspace/${encodeURIComponent(workspaceId)}/go`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function cookieHeader(authCookie: string): string {
|
|
101
|
+
const trimmed = authCookie.trim().replace(/;$/, "");
|
|
102
|
+
return /^auth=/.test(trimmed) ? trimmed : `auth=${trimmed}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function scriptBodies(html: string): string {
|
|
106
|
+
const bodies: string[] = [];
|
|
107
|
+
for (const m of html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script\s*>/gi)) {
|
|
108
|
+
bodies.push(m[1]);
|
|
109
|
+
}
|
|
110
|
+
return bodies.join("\n");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function findObjectBody(html: string, key: string): string | null {
|
|
114
|
+
const pattern = new RegExp(`${key}\\s*(?::\\s*\\$R\\[\\d+\\]\\s*)?=\\s*\\{([^{}]*)\\}`);
|
|
115
|
+
return pattern.exec(html)?.[1] ?? null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function readNumber(body: string, field: string): number | null {
|
|
119
|
+
const m = new RegExp(`${field}\\s*:\\s*(-?\\d+(?:\\.\\d+)?)`).exec(body);
|
|
120
|
+
if (!m) return null;
|
|
121
|
+
const v = Number(m[1]);
|
|
122
|
+
return Number.isFinite(v) ? v : null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function readStatus(body: string): UsageMeter["status"] {
|
|
126
|
+
const v = /status\s*:\s*"([^"]*)"/.exec(body)?.[1];
|
|
127
|
+
return v === "ok" || v === "error" ? v : "unknown";
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function parseWorkspaceHtml(html: string, now = Date.now()): UsageMeter[] {
|
|
131
|
+
const meters: UsageMeter[] = [];
|
|
132
|
+
const haystack = scriptBodies(html) || html;
|
|
133
|
+
for (const { key, kind } of WINDOW_KEYS) {
|
|
134
|
+
const body = findObjectBody(haystack, key);
|
|
135
|
+
if (body === null) continue;
|
|
136
|
+
const percent = readNumber(body, "usagePercent");
|
|
137
|
+
if (percent === null) continue;
|
|
138
|
+
const resetInSec = readNumber(body, "resetInSec") ?? readNumber(body, "resetsInSeconds");
|
|
139
|
+
meters.push({
|
|
140
|
+
kind,
|
|
141
|
+
percent: Math.min(100, Math.max(0, percent)),
|
|
142
|
+
resetsAt:
|
|
143
|
+
resetInSec !== null && resetInSec > 0
|
|
144
|
+
? new Date(now + resetInSec * 1000).toISOString()
|
|
145
|
+
: null,
|
|
146
|
+
status: readStatus(body),
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
return meters;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function fetchUsage(
|
|
153
|
+
workspaceId: string,
|
|
154
|
+
authCookie: string,
|
|
155
|
+
origin = DEFAULT_ORIGIN,
|
|
156
|
+
): Promise<UsageMeter[]> {
|
|
157
|
+
if (!workspaceId.trim() || !authCookie.trim()) {
|
|
158
|
+
throw { kind: "noCredentials" } as FetchFailure;
|
|
159
|
+
}
|
|
160
|
+
const url = workspaceUrl(workspaceId.trim(), origin);
|
|
161
|
+
const controller = new AbortController();
|
|
162
|
+
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
163
|
+
let response: Response;
|
|
164
|
+
try {
|
|
165
|
+
response = await fetch(url, {
|
|
166
|
+
headers: {
|
|
167
|
+
Cookie: cookieHeader(authCookie),
|
|
168
|
+
"User-Agent": USER_AGENT,
|
|
169
|
+
Accept: "text/html,application/xhtml+xml",
|
|
170
|
+
},
|
|
171
|
+
signal: controller.signal,
|
|
172
|
+
redirect: "manual",
|
|
173
|
+
});
|
|
174
|
+
} catch (err) {
|
|
175
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
176
|
+
throw { kind: "timeout" } as FetchFailure;
|
|
177
|
+
}
|
|
178
|
+
throw {
|
|
179
|
+
kind: "network",
|
|
180
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
181
|
+
} as FetchFailure;
|
|
182
|
+
} finally {
|
|
183
|
+
clearTimeout(timer);
|
|
184
|
+
}
|
|
185
|
+
if (response.status >= 300 && response.status < 400) {
|
|
186
|
+
const location = response.headers.get("location") ?? "";
|
|
187
|
+
if (/auth|login|sign-?in/i.test(location)) throw { kind: "unauthorized" } as FetchFailure;
|
|
188
|
+
throw { kind: "http", status: response.status } as FetchFailure;
|
|
189
|
+
}
|
|
190
|
+
if (response.status === 401 || response.status === 403) {
|
|
191
|
+
throw { kind: "unauthorized" } as FetchFailure;
|
|
192
|
+
}
|
|
193
|
+
if (!response.ok) throw { kind: "http", status: response.status } as FetchFailure;
|
|
194
|
+
const html = await response.text();
|
|
195
|
+
const meters = parseWorkspaceHtml(html);
|
|
196
|
+
if (meters.length === 0) {
|
|
197
|
+
const sawLogin = /\/auth\/authorize|sign\s?in to opencode/i.test(html);
|
|
198
|
+
throw { kind: "noPayload", sawLogin } as FetchFailure;
|
|
199
|
+
}
|
|
200
|
+
return meters;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
// Formatting
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
export function bar(percent: number, width = 10): string {
|
|
208
|
+
const clamped = Math.min(100, Math.max(0, percent));
|
|
209
|
+
const filled = Math.round((clamped / 100) * width);
|
|
210
|
+
return "█".repeat(filled) + "░".repeat(width - filled);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function countdown(resetsAt: string | null, now = Date.now()): string | null {
|
|
214
|
+
if (!resetsAt) return null;
|
|
215
|
+
const target = Date.parse(resetsAt);
|
|
216
|
+
if (!Number.isFinite(target)) return null;
|
|
217
|
+
const ms = target - now;
|
|
218
|
+
if (ms <= 0) return "resets now";
|
|
219
|
+
const totalMinutes = Math.floor(ms / 60_000);
|
|
220
|
+
const days = Math.floor(totalMinutes / (60 * 24));
|
|
221
|
+
const hours = Math.floor((totalMinutes % (60 * 24)) / 60);
|
|
222
|
+
const minutes = totalMinutes % 60;
|
|
223
|
+
if (days > 0) return `${days}d ${hours}h`;
|
|
224
|
+
if (hours > 0) return `${hours}h ${minutes}m`;
|
|
225
|
+
return `${minutes}m`;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function describeFailure(f: FetchFailure): string {
|
|
229
|
+
switch (f.kind) {
|
|
230
|
+
case "noCredentials":
|
|
231
|
+
return "Not connected. Run /opencode-go --connect <wrk_…> <auth-cookie>";
|
|
232
|
+
case "timeout":
|
|
233
|
+
return "Request timed out";
|
|
234
|
+
case "network":
|
|
235
|
+
return `Network error: ${f.detail}`;
|
|
236
|
+
case "unauthorized":
|
|
237
|
+
return "Cookie expired — reconnect with a fresh auth cookie";
|
|
238
|
+
case "http":
|
|
239
|
+
return `HTTP ${f.status}`;
|
|
240
|
+
case "noPayload":
|
|
241
|
+
return f.sawLogin
|
|
242
|
+
? "Cookie expired (login page served)"
|
|
243
|
+
: "Page carried no usage data — opencode.ai markup may have changed";
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
// Extension factory
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
interface UiCtx {
|
|
252
|
+
hasUI: boolean;
|
|
253
|
+
ui: {
|
|
254
|
+
setStatus(key: string, text: string | undefined): void;
|
|
255
|
+
setWidget(key: string, content: string[] | undefined, options?: { placement?: "aboveEditor" | "belowEditor" }): void;
|
|
256
|
+
notify(message: string, type?: "info" | "warning" | "error"): void;
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export default function opencodeGoUsage(pi: ExtensionAPI): void {
|
|
261
|
+
let config: Config = {};
|
|
262
|
+
let meters: UsageMeter[] = [];
|
|
263
|
+
let lastError: string | null = null;
|
|
264
|
+
let lastFetchedAt = 0;
|
|
265
|
+
let timer: ReturnType<typeof setInterval> | undefined;
|
|
266
|
+
|
|
267
|
+
const resolvedCreds = (): { workspaceId: string; authCookie: string } | null => {
|
|
268
|
+
const workspaceId = (process.env.OPENCODE_GO_WORKSPACE_ID ?? config.workspaceId ?? "").trim();
|
|
269
|
+
const authCookie = (process.env.OPENCODE_GO_AUTH_COOKIE ?? config.authCookie ?? "").trim();
|
|
270
|
+
return workspaceId && authCookie ? { workspaceId, authCookie } : null;
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
const renderStatus = (ctx: UiCtx): void => {
|
|
274
|
+
if (!ctx.hasUI) return;
|
|
275
|
+
const creds = resolvedCreds();
|
|
276
|
+
if (!creds) {
|
|
277
|
+
ctx.ui.setStatus("opencode-go", "Go: not connected (/opencode-go --connect)");
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
if (lastError) {
|
|
281
|
+
ctx.ui.setStatus("opencode-go", `Go: ${lastError}`);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
if (meters.length === 0) {
|
|
285
|
+
ctx.ui.setStatus("opencode-go", "Go: loading…");
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
ctx.ui.setStatus("opencode-go", `Go ${meters.map((m) => `${METER_SHORT[m.kind]} ${m.percent}%`).join(" · ")}`);
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
const renderReport = (ctx: UiCtx): void => {
|
|
292
|
+
if (!ctx.hasUI) return;
|
|
293
|
+
const creds = resolvedCreds();
|
|
294
|
+
const lines: string[] = ["OpenCode Go Usage"];
|
|
295
|
+
if (!creds) {
|
|
296
|
+
lines.push("Not connected.");
|
|
297
|
+
lines.push("Run /opencode-go --connect <wrk_…> <auth-cookie>");
|
|
298
|
+
lines.push("Or set OPENCODE_GO_WORKSPACE_ID + OPENCODE_GO_AUTH_COOKIE");
|
|
299
|
+
ctx.ui.setWidget("opencode-go", lines, { placement: "aboveEditor" });
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
lines.push(`Workspace: ${creds.workspaceId}`);
|
|
303
|
+
if (lastError) {
|
|
304
|
+
lines.push(`Error: ${lastError}`);
|
|
305
|
+
} else if (meters.length === 0) {
|
|
306
|
+
lines.push("Loading…");
|
|
307
|
+
} else {
|
|
308
|
+
for (const m of meters) {
|
|
309
|
+
const cd = countdown(m.resetsAt);
|
|
310
|
+
lines.push(`${METER_LABEL[m.kind].padEnd(10)} ${bar(m.percent, 10)} ${m.percent}%${cd ? ` · ${cd}` : ""}`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
if (lastFetchedAt) lines.push(`Updated ${new Date(lastFetchedAt).toLocaleTimeString()}`);
|
|
314
|
+
ctx.ui.setWidget("opencode-go", lines, { placement: "aboveEditor" });
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
const refresh = async (ctx: UiCtx): Promise<void> => {
|
|
318
|
+
const creds = resolvedCreds();
|
|
319
|
+
if (!creds) {
|
|
320
|
+
meters = [];
|
|
321
|
+
lastError = null;
|
|
322
|
+
renderStatus(ctx);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
try {
|
|
326
|
+
meters = await fetchUsage(creds.workspaceId, creds.authCookie, DEFAULT_ORIGIN);
|
|
327
|
+
lastError = null;
|
|
328
|
+
lastFetchedAt = Date.now();
|
|
329
|
+
} catch (err) {
|
|
330
|
+
meters = [];
|
|
331
|
+
lastError = describeFailure(err as FetchFailure);
|
|
332
|
+
}
|
|
333
|
+
renderStatus(ctx);
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
337
|
+
config = await loadConfig();
|
|
338
|
+
if (timer) {
|
|
339
|
+
clearInterval(timer);
|
|
340
|
+
timer = undefined;
|
|
341
|
+
}
|
|
342
|
+
if (ctx.hasUI && resolvedCreds()) ctx.ui.notify("OpenCode Go usage tracker loaded", "info");
|
|
343
|
+
// Fire-and-forget: don't block session startup on a network round-trip.
|
|
344
|
+
void refresh(ctx);
|
|
345
|
+
// Plain setInterval with the callback body fully wrapped so a throw cannot
|
|
346
|
+
// escape and tear down the session.
|
|
347
|
+
timer = setInterval(() => {
|
|
348
|
+
void refresh(ctx).catch(() => {});
|
|
349
|
+
}, REFRESH_SECONDS * 1000);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
pi.on("session_shutdown", () => {
|
|
353
|
+
if (timer) {
|
|
354
|
+
clearInterval(timer);
|
|
355
|
+
timer = undefined;
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
pi.registerCommand("opencode-go", {
|
|
360
|
+
description:
|
|
361
|
+
"Show OpenCode Go usage. Subcommands: --connect <wrk> <cookie> | --workspace <id> | --cookie <v> | --disconnect | --refresh | --json",
|
|
362
|
+
handler: async (args, ctx) => {
|
|
363
|
+
const tokens = args.trim().split(/\s+/).filter(Boolean);
|
|
364
|
+
const sub = tokens[0];
|
|
365
|
+
const rest = tokens.slice(1);
|
|
366
|
+
|
|
367
|
+
if (sub === "--connect" || sub === "--setup") {
|
|
368
|
+
const workspaceId = rest[0];
|
|
369
|
+
const cookie = rest.slice(1).join(" ");
|
|
370
|
+
if (!workspaceId || !cookie) {
|
|
371
|
+
ctx.ui.notify("Usage: /opencode-go --connect <wrk_…> <auth-cookie>", "warning");
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
config.workspaceId = workspaceId.trim();
|
|
375
|
+
config.authCookie = cookie.trim();
|
|
376
|
+
await saveConfig(config);
|
|
377
|
+
ctx.ui.notify("Saved. Fetching usage…", "info");
|
|
378
|
+
await refresh(ctx);
|
|
379
|
+
renderReport(ctx);
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (sub === "--workspace") {
|
|
384
|
+
if (!rest[0]) {
|
|
385
|
+
ctx.ui.notify("Usage: /opencode-go --workspace <wrk_…>", "warning");
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
config.workspaceId = rest[0].trim();
|
|
389
|
+
await saveConfig(config);
|
|
390
|
+
ctx.ui.notify(`Workspace set to ${config.workspaceId}`, "info");
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (sub === "--cookie") {
|
|
395
|
+
const cookie = rest.join(" ");
|
|
396
|
+
if (!cookie) {
|
|
397
|
+
ctx.ui.notify("Usage: /opencode-go --cookie <auth-cookie>", "warning");
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
config.authCookie = cookie.trim();
|
|
401
|
+
await saveConfig(config);
|
|
402
|
+
ctx.ui.notify("Cookie saved", "info");
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (sub === "--disconnect") {
|
|
407
|
+
delete config.workspaceId;
|
|
408
|
+
delete config.authCookie;
|
|
409
|
+
await saveConfig(config);
|
|
410
|
+
meters = [];
|
|
411
|
+
lastError = null;
|
|
412
|
+
renderStatus(ctx);
|
|
413
|
+
ctx.ui.setWidget("opencode-go", undefined);
|
|
414
|
+
ctx.ui.notify("Disconnected", "info");
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (sub === "--refresh") {
|
|
419
|
+
await refresh(ctx);
|
|
420
|
+
renderReport(ctx);
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (sub === "--json") {
|
|
425
|
+
await refresh(ctx);
|
|
426
|
+
const report = {
|
|
427
|
+
workspaceId: resolvedCreds()?.workspaceId ?? null,
|
|
428
|
+
fetchedAt: lastFetchedAt ? new Date(lastFetchedAt).toISOString() : null,
|
|
429
|
+
error: lastError,
|
|
430
|
+
meters,
|
|
431
|
+
};
|
|
432
|
+
const outPath = join(homedir(), ".omp", "agent", "opencode-go-usage-report.json");
|
|
433
|
+
try {
|
|
434
|
+
const tmp = `${outPath}.tmp`;
|
|
435
|
+
await fs.writeFile(tmp, JSON.stringify(report, null, 2), "utf8");
|
|
436
|
+
await fs.rename(tmp, outPath);
|
|
437
|
+
ctx.ui.notify(`JSON report written to ${outPath}`, "info");
|
|
438
|
+
} catch {
|
|
439
|
+
ctx.ui.notify("Failed to write JSON report", "error");
|
|
440
|
+
}
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
await refresh(ctx);
|
|
445
|
+
renderReport(ctx);
|
|
446
|
+
},
|
|
447
|
+
});
|
|
448
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-opencode-go-usage",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Track OpenCode Go usage limits (rolling 5h / weekly / monthly) in-session by scraping the opencode.ai workspace page with your auth cookie.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi-extension",
|
|
8
|
+
"omp",
|
|
9
|
+
"opencode",
|
|
10
|
+
"usage",
|
|
11
|
+
"go",
|
|
12
|
+
"quota",
|
|
13
|
+
"status-bar"
|
|
14
|
+
],
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"files": [
|
|
17
|
+
"extensions",
|
|
18
|
+
"README.md",
|
|
19
|
+
"README.zh-CN.md",
|
|
20
|
+
"LICENSE"
|
|
21
|
+
],
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
24
|
+
},
|
|
25
|
+
"omp": {
|
|
26
|
+
"extensions": [
|
|
27
|
+
"./extensions/opencode-go-usage.ts"
|
|
28
|
+
]
|
|
29
|
+
},
|
|
30
|
+
"pi": {
|
|
31
|
+
"extensions": [
|
|
32
|
+
"./extensions/opencode-go-usage.ts"
|
|
33
|
+
],
|
|
34
|
+
"image": "https://raw.githubusercontent.com/Dakai/pi-opencode-go-usage/main/screenshot.jpeg"
|
|
35
|
+
}
|
|
36
|
+
}
|