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.
- package/README.md +106 -0
- package/assets/opencode-icon-3d-three-preview.html +168 -0
- package/assets/opencode-icon.png +0 -0
- package/assets/opencode-pet-3d-spin-concept.svg +99 -0
- package/assets/opencode-pet-3d-spin-preview.html +324 -0
- package/assets/pixel-opencode-pet.svg +74 -0
- package/package.json +30 -0
- package/src/main.js +1282 -0
- package/src/pixel-pet-reference.cjs +134 -0
- package/src/plugin-server.cjs +169 -0
- package/src/plugin-shared.cjs +230 -0
- package/src/plugin-tui.cjs +7 -0
|
@@ -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("<", "<")
|
|
13
|
+
.replaceAll(">", ">")
|
|
14
|
+
.replaceAll('"', """)
|
|
15
|
+
.replaceAll("'", "'")
|
|
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
|
+
}
|