opencube 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,134 @@
1
+ // Pixel renderer for the clean pixel opencode pet concept.
2
+ //
3
+ // src/main.js uses this module for the Electron pet body, while docs/assets can
4
+ // keep using it as a stable reference for proportions, palette, and session-ball
5
+ // model.
6
+
7
+ const DEFAULT_SESSION_COLORS = ["#ff5d73", "#ffb020", "#28c76f", "#2f8cff", "#8b5cf6", "#06b6d4", "#f97316"]
8
+
9
+ function escapeHtml(value) {
10
+ return String(value)
11
+ .replaceAll("&", "&")
12
+ .replaceAll("<", "&lt;")
13
+ .replaceAll(">", "&gt;")
14
+ .replaceAll('"', "&quot;")
15
+ .replaceAll("'", "&#39;")
16
+ }
17
+
18
+ function rect({ x, y, w, h, fill, opacity, className, extra = "" }) {
19
+ const attrs = [
20
+ `x="${x}"`,
21
+ `y="${y}"`,
22
+ `width="${w}"`,
23
+ `height="${h}"`,
24
+ fill ? `fill="${fill}"` : undefined,
25
+ opacity == null ? undefined : `opacity="${opacity}"`,
26
+ className ? `class="${escapeHtml(className)}"` : undefined,
27
+ extra,
28
+ ]
29
+ .filter(Boolean)
30
+ .join(" ")
31
+ return `<rect ${attrs} />`
32
+ }
33
+
34
+ function sessionBall({ x, y, color, index }) {
35
+ return [
36
+ rect({ x, y, w: 14, h: 14, fill: color, className: "session-ball", extra: `data-index="${index}"` }),
37
+ rect({ x: x + 4, y: y + 2, w: 4, h: 4, fill: "rgba(255,255,255,.68)", className: "session-ball-highlight" }),
38
+ ].join("\n ")
39
+ }
40
+
41
+ function pixelPetSvg(options = {}) {
42
+ const colors = options.sessionColors || DEFAULT_SESSION_COLORS
43
+ const showCaption = options.showCaption ?? false
44
+ const ballPositions = [
45
+ [56, 24],
46
+ [90, 16],
47
+ [124, 20],
48
+ [154, 38],
49
+ [178, 68],
50
+ [188, 104],
51
+ [178, 140],
52
+ ]
53
+ const count = Math.max(0, Math.min(options.sessionCount ?? 7, ballPositions.length))
54
+ const balls = ballPositions
55
+ .slice(0, count)
56
+ .map(([x, y], index) => sessionBall({ x, y, color: colors[index % colors.length], index }))
57
+ .join("\n ")
58
+
59
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256" shape-rendering="crispEdges" role="img" aria-label="clean pixel opencode pet">
60
+ <g class="session-balls">${balls}</g>
61
+ <g class="pet-shadow" opacity="0.22">
62
+ ${rect({ x: 54, y: 206, w: 108, h: 10, fill: "#000000" })}
63
+ ${rect({ x: 70, y: 216, w: 76, h: 6, fill: "#000000" })}
64
+ </g>
65
+ <g class="pet-body">
66
+ ${rect({ x: 66, y: 184, w: 26, h: 22, fill: "#050505" })}
67
+ ${rect({ x: 124, y: 184, w: 26, h: 22, fill: "#050505" })}
68
+ ${rect({ x: 72, y: 184, w: 18, h: 8, fill: "#242424" })}
69
+ ${rect({ x: 130, y: 184, w: 18, h: 8, fill: "#242424" })}
70
+ ${rect({ x: 34, y: 110, w: 18, h: 42, fill: "#080808" })}
71
+ ${rect({ x: 44, y: 118, w: 12, h: 30, fill: "#242424" })}
72
+ ${rect({ x: 158, y: 110, w: 18, h: 42, fill: "#080808" })}
73
+ ${rect({ x: 158, y: 118, w: 12, h: 30, fill: "#242424" })}
74
+ ${rect({ x: 24, y: 148, w: 18, h: 16, fill: "#050505" })}
75
+ ${rect({ x: 30, y: 150, w: 14, h: 10, fill: "#242424" })}
76
+ ${rect({ x: 168, y: 148, w: 18, h: 16, fill: "#050505" })}
77
+ ${rect({ x: 166, y: 150, w: 14, h: 10, fill: "#242424" })}
78
+ ${rect({ x: 50, y: 62, w: 112, h: 120, fill: "#050505" })}
79
+ ${rect({ x: 42, y: 82, w: 128, h: 80, fill: "#050505" })}
80
+ ${rect({ x: 58, y: 54, w: 96, h: 16, fill: "#242424" })}
81
+ ${rect({ x: 58, y: 70, w: 96, h: 16, fill: "#1a1a1a" })}
82
+ ${rect({ x: 58, y: 162, w: 96, h: 16, fill: "#171717" })}
83
+ ${rect({ x: 58, y: 86, w: 10, h: 66, fill: "#2b2b2b", opacity: 0.9 })}
84
+ ${rect({ x: 142, y: 86, w: 10, h: 66, fill: "#000000", opacity: 0.9 })}
85
+ ${rect({ x: 72, y: 82, w: 74, h: 86, fill: "#2d2d2d" })}
86
+ ${rect({ x: 76, y: 86, w: 66, h: 78, fill: "#d8d5cc" })}
87
+ ${rect({ x: 82, y: 92, w: 54, h: 66, fill: "#f8f7f2" })}
88
+ ${rect({ x: 86, y: 96, w: 46, h: 58, fill: "#ffffff" })}
89
+ ${rect({ x: 98, y: 110, w: 24, h: 38, fill: "#050505" })}
90
+ ${rect({ x: 98, y: 110, w: 24, h: 10, fill: "#111111" })}
91
+ ${rect({ x: 26, y: 120, w: 28, h: 10, fill: "#050505" })}
92
+ ${rect({ x: 22, y: 114, w: 16, h: 16, fill: "#050505" })}
93
+ ${rect({ x: 28, y: 116, w: 10, h: 8, fill: "#2f2f2f" })}
94
+ ${rect({ x: 156, y: 120, w: 28, h: 10, fill: "#050505" })}
95
+ ${rect({ x: 176, y: 114, w: 16, h: 16, fill: "#050505" })}
96
+ ${rect({ x: 174, y: 116, w: 10, h: 8, fill: "#2f2f2f" })}
97
+ </g>
98
+ ${showCaption ? `<text x="24" y="238" fill="#111" font-family="ui-monospace, SFMono-Regular, Menlo, monospace" font-size="10">clean pixel opencode pet · ${count} session balls</text>` : ""}
99
+ </svg>`
100
+ }
101
+
102
+ const pixelPetCss = `
103
+ .pixel-opencode-pet {
104
+ width: 256px;
105
+ height: 256px;
106
+ image-rendering: pixelated;
107
+ shape-rendering: crispEdges;
108
+ }
109
+
110
+ .pixel-opencode-pet .session-ball {
111
+ transform-origin: center;
112
+ animation: pet-ball-bob 1.45s steps(4, end) infinite;
113
+ }
114
+
115
+ .pixel-opencode-pet .session-ball:nth-of-type(4n + 1) { animation-delay: -0.15s; }
116
+ .pixel-opencode-pet .session-ball:nth-of-type(4n + 2) { animation-delay: -0.35s; }
117
+ .pixel-opencode-pet .session-ball:nth-of-type(4n + 3) { animation-delay: -0.55s; }
118
+
119
+ @keyframes pet-ball-bob {
120
+ 0%, 100% { transform: translateY(0); }
121
+ 50% { transform: translateY(-6px); }
122
+ }
123
+ `
124
+
125
+ function pixelPetHtmlSnippet(options = {}) {
126
+ return `<style>${pixelPetCss}</style>\n<div class="pixel-opencode-pet">${pixelPetSvg(options)}</div>`
127
+ }
128
+
129
+ module.exports = {
130
+ DEFAULT_SESSION_COLORS,
131
+ pixelPetCss,
132
+ pixelPetHtmlSnippet,
133
+ pixelPetSvg,
134
+ }
@@ -0,0 +1,169 @@
1
+ const { quitPet, sendEvent, showPet } = require("./plugin-shared.cjs")
2
+
3
+ const COMMAND_HANDLED_SENTINEL = "__OPENCODE_PET_COMMAND_HANDLED__"
4
+ const CUB_ICON = "◈"
5
+
6
+ function handled() {
7
+ throw new Error(COMMAND_HANDLED_SENTINEL)
8
+ }
9
+
10
+ function textOfArguments(args) {
11
+ if (typeof args === "string") return args.trim().toLowerCase()
12
+ if (Array.isArray(args)) return args.join(" ").trim().toLowerCase()
13
+ if (args == null) return ""
14
+ return String(args).trim().toLowerCase()
15
+ }
16
+
17
+ function shouldQuit(input) {
18
+ const args = textOfArguments(input.arguments)
19
+ return input.command === "pet_stop" || ["stop", "quit", "close", "off"].includes(args)
20
+ }
21
+
22
+ function isSayHello(input) {
23
+ return input.command === "pet_say_hello"
24
+ }
25
+
26
+ function isFancySayHello(input) {
27
+ return input.command === "pet_fancy_say_hello"
28
+ }
29
+
30
+ async function injectNotice(client, sessionID, text) {
31
+ if (!sessionID || !client?.session?.prompt) return
32
+
33
+ try {
34
+ await client.session.prompt({
35
+ path: { id: sessionID },
36
+ body: {
37
+ noReply: true,
38
+ parts: [{ type: "text", text, ignored: true }],
39
+ },
40
+ })
41
+ } catch {
42
+ // Best-effort only: launching/quitting the pet should still count as handled.
43
+ }
44
+ }
45
+
46
+ function cubNotice(text, icon = CUB_ICON) {
47
+ return `${icon} ${text}`
48
+ }
49
+
50
+ module.exports = {
51
+ id: "opencode-pet",
52
+ server: async ({ client }) => {
53
+ const sessionStatus = new Map()
54
+
55
+ return {
56
+ // Same trick as @slkiser/opencode-quota: register normal slash commands in
57
+ // config, then abort command.execute.before after doing the side effect.
58
+ // This makes /pet show up in opencode's slash list but avoids an LLM reply.
59
+ config: async (cfg) => {
60
+ cfg.command ??= {}
61
+ cfg.command.pet = {
62
+ template: "/pet",
63
+ description: "Show the desktop opencode pet without sending anything to the agent.",
64
+ }
65
+ cfg.command.pet_stop = {
66
+ template: "/pet_stop",
67
+ description: "Quit the desktop opencode pet without sending anything to the agent.",
68
+ }
69
+ cfg.command.pet_say_hello = {
70
+ template: "/pet_say_hello",
71
+ description: "Send a hello test event to OpenCub.",
72
+ }
73
+ cfg.command.pet_fancy_say_hello = {
74
+ template: "/pet_fancy_say_hello",
75
+ description: "Trigger a randomized light show on OpenCub's free faces.",
76
+ }
77
+ },
78
+
79
+ "command.execute.before": async (input, output) => {
80
+ if (!["pet", "pet_stop", "pet_say_hello", "pet_fancy_say_hello"].includes(input.command)) return
81
+
82
+ // There is no official cancel primitive in command.execute.before yet.
83
+ // Throwing this sentinel aborts the command flow before opencode sends
84
+ // the slash-command prompt to the model. Desktop may show it as an
85
+ // "Unexpected server error" toast, but this is currently the only path
86
+ // that reliably prevents empty prompts / model continuation.
87
+
88
+ if (shouldQuit(input)) {
89
+ await quitPet()
90
+ await injectNotice(client, input.sessionID, cubNotice("OpenCub is going to sleep 🐾", "◌"))
91
+ } else if (isSayHello(input)) {
92
+ const result = await sendEvent({
93
+ type: "hello",
94
+ message: "hello from opencode 🐾",
95
+ command: input.command,
96
+ arguments: input.arguments,
97
+ sessionID: input.sessionID,
98
+ source: "opencode-pet-plugin",
99
+ })
100
+ await injectNotice(
101
+ client,
102
+ input.sessionID,
103
+ result ? cubNotice("OpenCub got your hello 🐾", "✦") : cubNotice("OpenCub is sleeping... zzz Use /pet to wake it.", "☾"),
104
+ )
105
+ } else if (isFancySayHello(input)) {
106
+ const result = await sendEvent({
107
+ type: "fancy_hello",
108
+ message: "fancy hello light show from opencode ✨",
109
+ command: input.command,
110
+ arguments: input.arguments,
111
+ sessionID: input.sessionID,
112
+ source: "opencode-pet-plugin",
113
+ })
114
+ await injectNotice(
115
+ client,
116
+ input.sessionID,
117
+ result
118
+ ? cubNotice("OpenCub is putting on a light show ✨", "✺")
119
+ : cubNotice("OpenCub is sleeping... zzz Start it with /pet before the light show.", "☾"),
120
+ )
121
+ } else {
122
+ await showPet({
123
+ onProgress: (message) => injectNotice(client, input.sessionID, message),
124
+ })
125
+ }
126
+
127
+ handled()
128
+ },
129
+
130
+ event: async ({ event }) => {
131
+ if (event.type !== "session.status") return
132
+
133
+ const sessionID = event.properties?.sessionID
134
+ const status = event.properties?.status?.type
135
+ if (!sessionID || !status) return
136
+
137
+ const previous = sessionStatus.get(sessionID) || "idle"
138
+
139
+ if (status === "busy" || status === "retry") {
140
+ sessionStatus.set(sessionID, status)
141
+ if (previous === "busy" || previous === "retry") return
142
+
143
+ await sendEvent({
144
+ type: "session.busy",
145
+ message: "opencode session became busy",
146
+ sessionID,
147
+ status,
148
+ previousStatus: previous,
149
+ source: "opencode-pet-plugin",
150
+ })
151
+ return
152
+ }
153
+
154
+ if (status !== "idle") return
155
+ sessionStatus.set(sessionID, status)
156
+ if (previous !== "busy" && previous !== "retry") return
157
+
158
+ await sendEvent({
159
+ type: "session.idle",
160
+ message: "opencode session became idle",
161
+ sessionID,
162
+ status,
163
+ previousStatus: previous,
164
+ source: "opencode-pet-plugin",
165
+ })
166
+ },
167
+ }
168
+ },
169
+ }
@@ -0,0 +1,230 @@
1
+ const { execFile, spawn } = require("node:child_process")
2
+ const fs = require("node:fs")
3
+ const os = require("node:os")
4
+ const path = require("node:path")
5
+
6
+ const PET_APP_DIR = path.resolve(__dirname, "..")
7
+ const PET_HOST = "127.0.0.1"
8
+ const PET_PORT = Number(process.env.OPENCODE_PET_PORT || 47832)
9
+ const PET_BASE_URL = `http://${PET_HOST}:${PET_PORT}`
10
+
11
+ function electronPlatformPath() {
12
+ const platform = process.env.npm_config_platform || os.platform()
13
+ switch (platform) {
14
+ case "mas":
15
+ case "darwin":
16
+ return "Electron.app/Contents/MacOS/Electron"
17
+ case "freebsd":
18
+ case "openbsd":
19
+ case "linux":
20
+ return "electron"
21
+ case "win32":
22
+ return "electron.exe"
23
+ default:
24
+ throw new Error(`Electron builds are not available on platform: ${platform}`)
25
+ }
26
+ }
27
+
28
+ async function emitProgress(onProgress, message) {
29
+ if (!onProgress) return
30
+ try {
31
+ await onProgress(message)
32
+ } catch {
33
+ // Progress is best-effort; never block OpenCub startup on UI notices.
34
+ }
35
+ }
36
+
37
+ function execFileAsync(file, args, options = {}) {
38
+ return new Promise((resolve, reject) => {
39
+ const child = execFile(file, args, options, (error, stdout, stderr) => {
40
+ if (error) {
41
+ error.stdout = stdout
42
+ error.stderr = stderr
43
+ reject(error)
44
+ return
45
+ }
46
+ resolve({ stdout, stderr })
47
+ })
48
+ child.on("error", reject)
49
+ })
50
+ }
51
+
52
+ async function extractElectronZip(zipPath, distPath) {
53
+ await fs.promises.rm(distPath, { recursive: true, force: true })
54
+ await fs.promises.mkdir(distPath, { recursive: true })
55
+
56
+ // extract-zip can hang under opencode desktop's Electron/Node service on
57
+ // macOS after partially writing Electron.app. Use the native archive tool
58
+ // there; keep extract-zip as the portable fallback for other platforms.
59
+ if ((process.env.npm_config_platform || process.platform) === "darwin") {
60
+ await execFileAsync("/usr/bin/ditto", ["-x", "-k", zipPath, distPath], { timeout: 120000 })
61
+ return
62
+ }
63
+
64
+ const extract = require("extract-zip")
65
+ await extract(zipPath, { dir: distPath })
66
+ }
67
+
68
+ async function installElectronBinary(electronDir, options = {}) {
69
+ const { downloadArtifact } = require("@electron/get")
70
+ const { version } = require(path.join(electronDir, "package.json"))
71
+ const checksums = require(path.join(electronDir, "checksums.json"))
72
+ const platform = process.env.npm_config_platform || process.platform
73
+ const arch = process.env.npm_config_arch || process.arch
74
+ const platformPath = electronPlatformPath()
75
+ const distPath = path.join(electronDir, "dist")
76
+ const executablePath = path.join(distPath, platformPath)
77
+
78
+ if (fs.existsSync(executablePath)) {
79
+ await emitProgress(options.onProgress, "OpenCub: Electron binary is ready ✅")
80
+ return executablePath
81
+ }
82
+
83
+ await emitProgress(options.onProgress, `OpenCub: downloading Electron ${version} for ${platform}/${arch}...`)
84
+ const zipPath = await downloadArtifact({
85
+ version,
86
+ artifactName: "electron",
87
+ cacheRoot: process.env.electron_config_cache,
88
+ checksums,
89
+ platform,
90
+ arch,
91
+ })
92
+ await emitProgress(options.onProgress, "OpenCub: extracting Electron binary...")
93
+ await extractElectronZip(zipPath, distPath)
94
+ await fs.promises.writeFile(path.join(electronDir, "path.txt"), platformPath)
95
+ await emitProgress(options.onProgress, "OpenCub: Electron binary installed ✅")
96
+ return executablePath
97
+ }
98
+
99
+ async function resolveElectronPath(options = {}) {
100
+ await emitProgress(options.onProgress, "OpenCub: checking Electron runtime...")
101
+ try {
102
+ const electronPath = require("electron")
103
+ if (typeof electronPath === "string") {
104
+ await emitProgress(options.onProgress, "OpenCub: Electron runtime is ready ✅")
105
+ return electronPath
106
+ }
107
+
108
+ // In opencode desktop, plugins may run inside an Electron process. In that
109
+ // environment require("electron") can resolve to Electron's built-in API
110
+ // object instead of the npm package's executable path string. Fall through
111
+ // to the npm package directory and resolve/repair the packaged binary.
112
+ await emitProgress(options.onProgress, "OpenCub: locating packaged Electron binary...")
113
+ const electronPackage = require.resolve("electron/package.json")
114
+ const electronDir = path.dirname(electronPackage)
115
+ return await installElectronBinary(electronDir, options)
116
+ } catch (error) {
117
+ await emitProgress(options.onProgress, "OpenCub: Electron runtime is incomplete; repairing...")
118
+ const electronPackage = require.resolve("electron/package.json")
119
+ const electronDir = path.dirname(electronPackage)
120
+ return await installElectronBinary(electronDir, options)
121
+ }
122
+ }
123
+
124
+ async function launchPet(args = [], options = {}) {
125
+ const electronPath = await resolveElectronPath(options)
126
+ await emitProgress(options.onProgress, "OpenCub: launching desktop pet...")
127
+ const child = spawn(electronPath, [PET_APP_DIR, ...args], {
128
+ cwd: PET_APP_DIR,
129
+ detached: true,
130
+ stdio: "ignore",
131
+ env: {
132
+ ...process.env,
133
+ OPENCODE_PET_ICON: path.join(PET_APP_DIR, "assets", "opencode-icon.png"),
134
+ },
135
+ })
136
+ child.unref()
137
+ await emitProgress(options.onProgress, "OpenCub: launch request sent 🐾")
138
+ }
139
+
140
+ async function requestPet(pathname, options = {}) {
141
+ const controller = new AbortController()
142
+ const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? 800)
143
+ try {
144
+ const response = await fetch(`${PET_BASE_URL}${pathname}`, {
145
+ method: options.method || "GET",
146
+ headers: {
147
+ "content-type": "application/json",
148
+ ...(options.headers || {}),
149
+ },
150
+ body: options.body === undefined ? undefined : JSON.stringify(options.body),
151
+ signal: controller.signal,
152
+ })
153
+ if (!response.ok) return undefined
154
+ return await response.json().catch(() => ({}))
155
+ } catch {
156
+ return undefined
157
+ } finally {
158
+ clearTimeout(timeout)
159
+ }
160
+ }
161
+
162
+ async function healthPet() {
163
+ const health = await requestPet("/health", { timeoutMs: 500 })
164
+ return health?.status === "good" ? health : undefined
165
+ }
166
+
167
+ async function waitForPet(timeoutMs = 3500, options = {}) {
168
+ await emitProgress(options.onProgress, "OpenCub: waiting for local server...")
169
+ const startedAt = Date.now()
170
+ while (Date.now() - startedAt < timeoutMs) {
171
+ const health = await healthPet()
172
+ if (health) {
173
+ await emitProgress(options.onProgress, "OpenCub: local server is ready ✅")
174
+ return health
175
+ }
176
+ await new Promise((resolve) => setTimeout(resolve, 150))
177
+ }
178
+ await emitProgress(options.onProgress, "OpenCub: local server did not answer yet")
179
+ return undefined
180
+ }
181
+
182
+ async function ensurePet(options = {}) {
183
+ await emitProgress(options.onProgress, "OpenCub: checking whether it is already running...")
184
+ const existing = await healthPet()
185
+ if (existing) {
186
+ await emitProgress(options.onProgress, "OpenCub: already running; showing window...")
187
+ return existing
188
+ }
189
+ await emitProgress(options.onProgress, "OpenCub: not running; starting now...")
190
+ await launchPet(["--show"], options)
191
+ return await waitForPet(3500, options)
192
+ }
193
+
194
+ async function showPet(options = {}) {
195
+ const health = await ensurePet(options)
196
+ await requestPet("/show", { method: "POST", timeoutMs: 800 })
197
+ await emitProgress(options.onProgress, health ? "OpenCub: shown ✨" : "OpenCub: start requested, still warming up...")
198
+ return health
199
+ }
200
+
201
+ async function quitPet() {
202
+ const health = await healthPet()
203
+ if (health) {
204
+ await requestPet("/quit", { method: "POST", timeoutMs: 800 })
205
+ return true
206
+ }
207
+ return false
208
+ }
209
+
210
+ async function sendEvent(event) {
211
+ // Only /pet is allowed to start OpenCub. Session lifecycle events and hello
212
+ // commands should talk to the desktop pet only if it is already running.
213
+ const health = await healthPet()
214
+ if (!health) return undefined
215
+ return await requestPet("/event", { method: "POST", body: event, timeoutMs: 1000 })
216
+ }
217
+
218
+ module.exports = {
219
+ PET_APP_DIR,
220
+ PET_BASE_URL,
221
+ PET_HOST,
222
+ PET_PORT,
223
+ ensurePet,
224
+ healthPet,
225
+ launchPet,
226
+ quitPet,
227
+ requestPet,
228
+ sendEvent,
229
+ showPet,
230
+ }
@@ -0,0 +1,7 @@
1
+ module.exports = {
2
+ id: "opencode-pet",
3
+ // Slash commands are registered from the server plugin via cfg.command, using
4
+ // the same command.execute.before abort trick as @slkiser/opencode-quota.
5
+ // Keep a no-op TUI entry so opencode can load ./tui without duplicate /pet rows.
6
+ tui: async () => {},
7
+ }