waitspin 0.1.3 → 0.1.4

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,280 @@
1
+ #!/usr/bin/env node
2
+
3
+ // WaitSpin OpenCode Statusline Runtime
4
+ // Installed by: waitspin opencode install
5
+ //
6
+ // Called periodically by the OpenCode TUI plugin. Fetches the next sponsored
7
+ // message from the WaitSpin API, manages serve caching, and records
8
+ // impressions after the minimum visible interval.
9
+ //
10
+ // Usage: node opencode-statusline.mjs --state STATE_PATH
11
+
12
+ import {
13
+ mkdir,
14
+ readFile,
15
+ rename,
16
+ rm,
17
+ stat,
18
+ writeFile,
19
+ } from "node:fs/promises"
20
+ import os from "node:os"
21
+ import path from "node:path"
22
+
23
+ // ─── Constants ──────────────────────────────────────────────
24
+
25
+ const FETCH_INTERVAL_MS = 15_000
26
+ const FETCH_TIMEOUT_MS = 2_500
27
+ const MAX_ACTIVE_AGE_MS = 60_000
28
+ const LOCK_RETRY_MS = 40
29
+ const LOCK_TIMEOUT_MS = 2_000
30
+ const LOCK_STALE_MS = 10_000
31
+ const DEFAULT_MIN_VISIBLE_MS = 5_000
32
+ const CACHE_KEY = "opencode"
33
+ const MANAGED_STATE_PATH = path.join(
34
+ os.homedir(),
35
+ ".waitspin",
36
+ "opencode-install.json",
37
+ )
38
+
39
+ // ─── Helpers ────────────────────────────────────────────────
40
+
41
+ function argValue(name) {
42
+ for (let index = 2; index < process.argv.length; index += 1) {
43
+ if (process.argv[index] !== name) continue
44
+ const value = process.argv[index + 1]
45
+ return value && !value.startsWith("--") ? value : undefined
46
+ }
47
+ return undefined
48
+ }
49
+
50
+ function managedStatePath(value) {
51
+ if (!value) return null
52
+ const resolved = path.resolve(value)
53
+ return resolved === path.resolve(MANAGED_STATE_PATH) ? resolved : null
54
+ }
55
+
56
+ async function readJson(filePath, fallback) {
57
+ try {
58
+ return JSON.parse(await readFile(filePath, "utf8"))
59
+ } catch {
60
+ return fallback
61
+ }
62
+ }
63
+
64
+ function readInstallState(value) {
65
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null
66
+ const apiKey = typeof value.api_key === "string" ? value.api_key.trim() : ""
67
+ const installId =
68
+ typeof value.install_id === "string" ? value.install_id.trim() : ""
69
+ const baseUrl =
70
+ typeof value.base_url === "string" ? value.base_url.trim() : ""
71
+ const cachePath =
72
+ typeof value.cache_path === "string" ? value.cache_path.trim() : ""
73
+ if (!apiKey || !installId || !baseUrl || !cachePath) return null
74
+ return {
75
+ ...value,
76
+ api_key: apiKey,
77
+ install_id: installId,
78
+ base_url: baseUrl,
79
+ cache_path: cachePath,
80
+ }
81
+ }
82
+
83
+ async function writeJson(filePath, value) {
84
+ const tmp = filePath + "." + process.pid + ".tmp"
85
+ await writeFile(tmp, JSON.stringify(value, null, 2) + "\n", {
86
+ encoding: "utf8",
87
+ mode: 0o600,
88
+ })
89
+ await rename(tmp, filePath)
90
+ }
91
+
92
+ function sleep(ms) {
93
+ return new Promise((resolve) => setTimeout(resolve, ms))
94
+ }
95
+
96
+ async function acquireCacheLock(cachePath) {
97
+ const lockPath = cachePath + ".lock"
98
+ const startedAt = Date.now()
99
+
100
+ while (Date.now() - startedAt < LOCK_TIMEOUT_MS) {
101
+ try {
102
+ await mkdir(lockPath)
103
+ return async () => {
104
+ await rm(lockPath, { recursive: true, force: true })
105
+ }
106
+ } catch {
107
+ try {
108
+ const lockStat = await stat(lockPath)
109
+ if (Date.now() - lockStat.mtimeMs > LOCK_STALE_MS) {
110
+ await rm(lockPath, { recursive: true, force: true })
111
+ continue
112
+ }
113
+ } catch {
114
+ // Another process may have released the lock between mkdir/stat
115
+ }
116
+ await sleep(LOCK_RETRY_MS)
117
+ }
118
+ }
119
+
120
+ throw new Error("Timed out waiting for WaitSpin OpenCode cache lock.")
121
+ }
122
+
123
+ async function withCacheLock(cachePath, callback) {
124
+ const release = await acquireCacheLock(cachePath)
125
+ try {
126
+ return await callback()
127
+ } finally {
128
+ await release()
129
+ }
130
+ }
131
+
132
+ function cleanLine(value) {
133
+ return String(value || "")
134
+ .replace(
135
+ /(?:\u001B\[[0-?]*[ -/]*[@-~]|\u001B\][^\u0007]*(?:\u0007|\u001B\\)|\u001B[P^_][\s\S]*?\u001B\\|\u001B[@-Z\\-_]|\u009B[0-?]*[ -/]*[@-~])/g,
136
+ " ",
137
+ )
138
+ .replace(/[\r\n\u0000-\u001F\u007F-\u009F]/g, " ")
139
+ .replace(/\s+/g, " ")
140
+ .trim()
141
+ .slice(0, 120)
142
+ }
143
+
144
+ function parseServe(payload) {
145
+ if (!payload || typeof payload !== "object") return null
146
+ const creative = payload.creative
147
+ if (!creative || typeof creative !== "object") return null
148
+ const line = cleanLine(creative.line)
149
+ if (!line) return null
150
+ if (
151
+ typeof payload.serve_id !== "string" ||
152
+ typeof payload.serve_receipt !== "string"
153
+ ) {
154
+ return null
155
+ }
156
+ return {
157
+ serveId: payload.serve_id,
158
+ serveReceipt: payload.serve_receipt,
159
+ line,
160
+ shownAt: Date.now(),
161
+ minVisibleMs:
162
+ typeof payload.min_visible_ms === "number" && payload.min_visible_ms >= DEFAULT_MIN_VISIBLE_MS
163
+ ? payload.min_visible_ms
164
+ : DEFAULT_MIN_VISIBLE_MS,
165
+ impressionRecorded: false,
166
+ }
167
+ }
168
+
169
+ // ─── WaitSpin API Calls ─────────────────────────────────────
170
+
171
+ async function waitspinFetch(url, init) {
172
+ const controller = new AbortController()
173
+ const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
174
+ try {
175
+ return await fetch(url, { ...init, signal: controller.signal })
176
+ } finally {
177
+ clearTimeout(timeout)
178
+ }
179
+ }
180
+
181
+ async function fetchNextServe(state, session) {
182
+ const response = await waitspinFetch(state.base_url + "/v1/serve/next", {
183
+ method: "POST",
184
+ headers: {
185
+ Authorization: "Bearer " + state.api_key,
186
+ "Content-Type": "application/json",
187
+ },
188
+ body: JSON.stringify({ install_id: state.install_id }),
189
+ })
190
+ session.lastFetchAt = Date.now()
191
+ if (response.status === 204) {
192
+ session.activeServe = null
193
+ return
194
+ }
195
+ if (!response.ok) return
196
+ const parsed = parseServe(await response.json())
197
+ if (parsed) session.activeServe = parsed
198
+ }
199
+
200
+ async function recordImpression(state, session) {
201
+ const serve = session.activeServe
202
+ if (!serve || serve.impressionRecorded) return
203
+ const visibleMs = Date.now() - serve.shownAt
204
+ if (visibleMs < serve.minVisibleMs) return
205
+ const response = await waitspinFetch(state.base_url + "/v1/events/impression", {
206
+ method: "POST",
207
+ headers: {
208
+ Authorization: "Bearer " + state.api_key,
209
+ "Content-Type": "application/json",
210
+ },
211
+ body: JSON.stringify({
212
+ serve_id: serve.serveId,
213
+ serve_receipt: serve.serveReceipt,
214
+ install_id: state.install_id,
215
+ visible_ms: Math.max(visibleMs, serve.minVisibleMs),
216
+ }),
217
+ })
218
+ if (response.ok) serve.impressionRecorded = true
219
+ }
220
+
221
+ function pruneSessions(cache) {
222
+ const cutoff = Date.now() - 24 * 60 * 60 * 1000
223
+ for (const [key, value] of Object.entries(cache.sessions || {})) {
224
+ if ((value.lastSeenAt || 0) < cutoff) delete cache.sessions[key]
225
+ }
226
+ }
227
+
228
+ // ─── Main ───────────────────────────────────────────────────
229
+
230
+ async function main() {
231
+ const statePath = managedStatePath(argValue("--state"))
232
+ if (!statePath) return
233
+
234
+ const state = readInstallState(await readJson(statePath, null))
235
+ if (!state) return
236
+
237
+ let sponsorLine = ""
238
+
239
+ try {
240
+ sponsorLine = await withCacheLock(state.cache_path, async () => {
241
+ const cache = await readJson(state.cache_path, { sessions: {} })
242
+ if (!cache.sessions || typeof cache.sessions !== "object") cache.sessions = {}
243
+
244
+ // OpenCode uses a single session key
245
+ const session = cache.sessions[CACHE_KEY] || {}
246
+ session.lastSeenAt = Date.now()
247
+ cache.sessions[CACHE_KEY] = session
248
+
249
+ // Expire stale active serve
250
+ if (
251
+ session.activeServe &&
252
+ Date.now() - session.activeServe.shownAt > MAX_ACTIVE_AGE_MS
253
+ ) {
254
+ session.activeServe = null
255
+ }
256
+
257
+ await recordImpression(state, session)
258
+
259
+ const shouldFetchNext = !session.activeServe
260
+ ? Date.now() - (session.lastFetchAt || 0) >= FETCH_INTERVAL_MS
261
+ : session.activeServe.impressionRecorded &&
262
+ Date.now() - (session.lastFetchAt || 0) >= FETCH_INTERVAL_MS
263
+
264
+ if (shouldFetchNext) {
265
+ await fetchNextServe(state, session)
266
+ }
267
+
268
+ pruneSessions(cache)
269
+ await writeJson(state.cache_path, cache)
270
+ return session
271
+ })
272
+ } catch {
273
+ // Statusline rendering must never interrupt the host
274
+ }
275
+
276
+ const line = sponsorLine?.activeServe?.line || ""
277
+ if (line) process.stdout.write(line)
278
+ }
279
+
280
+ main().catch(() => {})
@@ -0,0 +1,267 @@
1
+ // WaitSpin OpenCode TUI Plugin
2
+ // Installed by: waitspin opencode install
3
+ // Slot: app_bottom
4
+ //
5
+ // Displays a WaitSpin sponsored message in the OpenCode TUI app_bottom slot.
6
+ // Polls the WaitSpin API every 15s and records impressions after 5s visible.
7
+
8
+ import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
9
+ import { execFile } from "node:child_process"
10
+ import { readFile } from "node:fs/promises"
11
+ import { createSignal, Show } from "solid-js"
12
+
13
+ const POLL_INTERVAL_MS = 15_000
14
+ const MIN_VISIBLE_MS = 5_000
15
+ const FETCH_TIMEOUT_MS = 5_000
16
+
17
+ const INSTALL_CONFIG = {
18
+ statePath: "__WAITSPIN_STATE_PATH__",
19
+ }
20
+
21
+ interface WaitSpinConfig {
22
+ baseUrl: string
23
+ apiKey: string
24
+ installId: string
25
+ }
26
+
27
+ interface ActiveServe {
28
+ serveId: string
29
+ line: string
30
+ destinationUrl: string
31
+ serveReceipt: string
32
+ shownAt: number
33
+ minVisibleMs: number
34
+ }
35
+
36
+ const plugin: TuiPlugin = async (api: TuiPluginApi) => {
37
+ const [sponsorLine, setSponsorLine] = createSignal("")
38
+ const [destinationUrl, setDestinationUrl] = createSignal("")
39
+
40
+ let activeServe: ActiveServe | null = null
41
+ let pollTimer: ReturnType<typeof setInterval> | undefined
42
+ let impressionTimer: ReturnType<typeof setTimeout> | undefined
43
+ let isPolling = false
44
+ let cachedConfig: WaitSpinConfig | null | undefined
45
+
46
+ function kvGet(key: string): string {
47
+ try {
48
+ return api.kv.get<string>(key) || ""
49
+ } catch {
50
+ return ""
51
+ }
52
+ }
53
+
54
+ async function readStateConfig(): Promise<WaitSpinConfig | null> {
55
+ if (cachedConfig !== undefined) return cachedConfig
56
+ try {
57
+ const raw = await readFile(INSTALL_CONFIG.statePath, "utf8")
58
+ const state = JSON.parse(raw) as Record<string, unknown>
59
+ const baseUrl = typeof state.base_url === "string" ? state.base_url.trim() : ""
60
+ const apiKey = typeof state.api_key === "string" ? state.api_key.trim() : ""
61
+ const installId = typeof state.install_id === "string" ? state.install_id.trim() : ""
62
+ cachedConfig = baseUrl && apiKey && installId
63
+ ? { baseUrl, apiKey, installId }
64
+ : null
65
+ } catch {
66
+ cachedConfig = null
67
+ }
68
+ return cachedConfig
69
+ }
70
+
71
+ async function resolveConfig(): Promise<WaitSpinConfig | null> {
72
+ const apiKey = kvGet("waitspin_api_key")
73
+ const installId = kvGet("waitspin_install_id")
74
+ const baseUrl = kvGet("waitspin_base_url") || "https://api.waitspin.com"
75
+ if (apiKey && installId) return { baseUrl, apiKey, installId }
76
+ return readStateConfig()
77
+ }
78
+
79
+ function isSafeUrl(url: string): boolean {
80
+ try {
81
+ const rawHost = rawHostname(url)
82
+ if (/^(?:\d+|0x[0-9a-f]+|0[0-7]+)$/i.test(rawHost)) return false
83
+ const parsed = new URL(url)
84
+ if (parsed.protocol !== "https:") return false
85
+ if (parsed.username || parsed.password) return false
86
+ const host = parsed.hostname.toLowerCase()
87
+ if (["localhost", "::1", "0.0.0.0"].includes(host)) return false
88
+ if (/^(127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|169\.254\.)/.test(host)) return false
89
+ if (host.startsWith("::ffff:")) return false
90
+ if (host.startsWith("fe80:") || host.startsWith("fc") || host.startsWith("fd")) return false
91
+ return true
92
+ } catch {
93
+ return false
94
+ }
95
+ }
96
+
97
+ function rawHostname(url: string): string {
98
+ const withoutProtocol = url.trim().replace(/^[a-z][a-z\d+.-]*:\/\//i, "")
99
+ const authority = withoutProtocol.split(/[/?#]/, 1)[0] || ""
100
+ const hostPort = authority.includes("@") ? authority.slice(authority.lastIndexOf("@") + 1) : authority
101
+ if (hostPort.startsWith("[")) {
102
+ return hostPort.slice(1, hostPort.indexOf("]")).toLowerCase()
103
+ }
104
+ return hostPort.split(":")[0].toLowerCase()
105
+ }
106
+
107
+ function openExternalUrl(url: string): void {
108
+ if (!isSafeUrl(url)) return
109
+ const platform = typeof process === "object" ? process.platform : ""
110
+ const command =
111
+ platform === "darwin" ? "open" : platform === "linux" ? "xdg-open" : ""
112
+ if (!command) return
113
+ try {
114
+ const child = execFile(command, [url], { timeout: 2000 }, () => {})
115
+ child.unref()
116
+ } catch {
117
+ // Opening destinations is best-effort; display must never fail.
118
+ }
119
+ }
120
+
121
+ function cleanLine(value: unknown): string {
122
+ return String(value || "")
123
+ .replace(
124
+ /(?:\u001B\[[0-?]*[ -/]*[@-~]|\u001B\][^\u0007]*(?:\u0007|\u001B\\)|\u001B[P^_][\s\S]*?\u001B\\|\u001B[@-Z\\-_]|\u009B[0-?]*[ -/]*[@-~])/g,
125
+ " ",
126
+ )
127
+ .replace(/[\r\n\u0000-\u001F\u007F-\u009F]/g, " ")
128
+ .replace(/\s+/g, " ")
129
+ .trim()
130
+ .slice(0, 120)
131
+ }
132
+
133
+ function parseServePayload(payload: unknown): ActiveServe | null {
134
+ if (!payload || typeof payload !== "object") return null
135
+ const r = payload as Record<string, unknown>
136
+ const serveId = typeof r.serve_id === "string" ? r.serve_id.trim() : ""
137
+ if (serveId.length < 8) return null
138
+ const serveReceipt = typeof r.serve_receipt === "string" ? r.serve_receipt.trim() : ""
139
+ if (serveReceipt.length < 32) return null
140
+ const creative = r.creative
141
+ if (!creative || typeof creative !== "object") return null
142
+ const c = creative as Record<string, unknown>
143
+ const line = cleanLine(c.line)
144
+ if (!line) return null
145
+ const destinationUrl = typeof c.destination_url === "string" ? c.destination_url.trim() : ""
146
+ if (!destinationUrl || !isSafeUrl(destinationUrl)) return null
147
+ const minVisibleMs = typeof r.min_visible_ms === "number" && r.min_visible_ms >= MIN_VISIBLE_MS
148
+ ? r.min_visible_ms
149
+ : MIN_VISIBLE_MS
150
+ return { serveId, line, destinationUrl, serveReceipt, shownAt: Date.now(), minVisibleMs }
151
+ }
152
+
153
+ async function fetchNextServe(): Promise<void> {
154
+ if (isPolling) return
155
+ const cfg = await resolveConfig()
156
+ if (!cfg) return
157
+ isPolling = true
158
+ const controller = new AbortController()
159
+ const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
160
+ try {
161
+ const response = await fetch(`${cfg.baseUrl}/v1/serve/next`, {
162
+ method: "POST",
163
+ headers: { Authorization: `Bearer ${cfg.apiKey}`, "Content-Type": "application/json" },
164
+ body: JSON.stringify({ install_id: cfg.installId }),
165
+ signal: controller.signal,
166
+ })
167
+
168
+ if (response.status === 204) { clearAd(); return }
169
+ if (!response.ok) return
170
+
171
+ const parsed = parseServePayload(await response.json())
172
+ if (!parsed) return
173
+
174
+ activeServe = parsed
175
+ setSponsorLine(parsed.line)
176
+ setDestinationUrl(parsed.destinationUrl)
177
+
178
+ if (impressionTimer) clearTimeout(impressionTimer)
179
+ impressionTimer = setTimeout(() => {
180
+ impressionTimer = undefined
181
+ if (!activeServe || activeServe.serveId !== parsed.serveId) return
182
+ recordImpression(parsed.serveId, parsed.serveReceipt, Math.max(Date.now() - parsed.shownAt, parsed.minVisibleMs))
183
+ }, parsed.minVisibleMs)
184
+ } catch {
185
+ // Network errors are non-fatal.
186
+ } finally {
187
+ clearTimeout(timeout)
188
+ isPolling = false
189
+ }
190
+ }
191
+
192
+ async function recordImpression(serveId: string, serveReceipt: string, visibleMs: number): Promise<void> {
193
+ const cfg = await resolveConfig()
194
+ if (!cfg) return
195
+ const controller = new AbortController()
196
+ const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
197
+ try {
198
+ await fetch(`${cfg.baseUrl}/v1/events/impression`, {
199
+ method: "POST",
200
+ headers: { Authorization: `Bearer ${cfg.apiKey}`, "Content-Type": "application/json" },
201
+ body: JSON.stringify({ serve_id: serveId, serve_receipt: serveReceipt, install_id: cfg.installId, visible_ms: visibleMs }),
202
+ signal: controller.signal,
203
+ })
204
+ } catch {
205
+ // Impression failures are non-fatal.
206
+ } finally {
207
+ clearTimeout(timeout)
208
+ }
209
+ }
210
+
211
+ function clearAd(): void {
212
+ if (impressionTimer) { clearTimeout(impressionTimer); impressionTimer = undefined }
213
+ activeServe = null
214
+ setSponsorLine("")
215
+ setDestinationUrl("")
216
+ }
217
+
218
+ function startPolling(): void {
219
+ if (pollTimer) return
220
+ void fetchNextServe()
221
+ pollTimer = setInterval(() => { void fetchNextServe() }, POLL_INTERVAL_MS)
222
+ }
223
+
224
+ function stopPolling(): void {
225
+ if (pollTimer) { clearInterval(pollTimer); pollTimer = undefined }
226
+ if (impressionTimer) { clearTimeout(impressionTimer); impressionTimer = undefined }
227
+ activeServe = null
228
+ setSponsorLine("")
229
+ setDestinationUrl("")
230
+ }
231
+
232
+ startPolling()
233
+ api.lifecycle.onDispose(stopPolling)
234
+
235
+ api.slots.register({
236
+ slots: {
237
+ app_bottom() {
238
+ const [hovered, setHovered] = createSignal(false)
239
+
240
+ return (
241
+ <Show when={sponsorLine()}>
242
+ <box
243
+ flexShrink={0}
244
+ paddingLeft={1}
245
+ paddingRight={1}
246
+ backgroundColor={hovered() ? api.theme.current.backgroundElement : api.theme.current.backgroundPanel}
247
+ onMouseOver={() => setHovered(true)}
248
+ onMouseOut={() => setHovered(false)}
249
+ onMouseUp={() => {
250
+ const url = destinationUrl()
251
+ if (url) {
252
+ openExternalUrl(url)
253
+ }
254
+ }}
255
+ >
256
+ <text fg={hovered() ? api.theme.current.text : api.theme.current.textMuted}>
257
+ ⧉ {sponsorLine()}
258
+ </text>
259
+ </box>
260
+ </Show>
261
+ )
262
+ },
263
+ },
264
+ })
265
+ }
266
+
267
+ export default plugin