opencube 0.2.1 → 0.3.1

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 CHANGED
@@ -21,7 +21,7 @@ OpenCube is packaged as an opencode plugin plus an Electron desktop process.
21
21
 
22
22
  ## Install
23
23
 
24
- Install globally through opencode:
24
+ Install the latest published version globally through opencode:
25
25
 
26
26
  ```sh
27
27
  opencode plugin opencube --global
@@ -41,6 +41,30 @@ You can also add it manually to `~/.config/opencode/opencode.json`:
41
41
  }
42
42
  ```
43
43
 
44
+ ## Update
45
+
46
+ If you already installed OpenCube and want to upgrade, reinstall the target version with `--force`:
47
+
48
+ ```sh
49
+ opencode plugin opencube@0.3.1 --global --force
50
+ ```
51
+
52
+ Using an explicit version is recommended for upgrades because it avoids stale `latest` cache behavior. You can still install the npm latest tag if desired:
53
+
54
+ ```sh
55
+ opencode plugin opencube@latest --global --force
56
+ ```
57
+
58
+ Then fully restart opencode and run `/pet` again. OpenCube and opencode plugins are loaded at startup, so the running desktop pet is not hot-replaced in place.
59
+
60
+ You can ask OpenCube to check npm for the latest published version:
61
+
62
+ ```text
63
+ /pet_update
64
+ ```
65
+
66
+ `/pet_update` does not hot-replace the running plugin. It reports whether a newer version exists and prints an explicit `opencode plugin opencube@<version> --global --force` command to run.
67
+
44
68
  ## Commands
45
69
 
46
70
  | Command | Description |
@@ -49,6 +73,8 @@ You can also add it manually to `~/.config/opencode/opencode.json`:
49
73
  | `/pet_stop` | Quit OpenCube. |
50
74
  | `/pet_say_hello` | Flash one currently free face three times with a random color. |
51
75
  | `/pet_fancy_say_hello` | Run a denser randomized light show across currently free faces. |
76
+ | `/pet_update` | Check npm for a newer OpenCube release and show the upgrade command. |
77
+ | `/pet_upgrade` | Alias for `/pet_update`. |
52
78
 
53
79
  These commands are handled by the plugin and do not get sent to the model.
54
80
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencube",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
4
4
  "description": "A tiny Three.js desktop pet for opencode session activity.",
5
5
  "main": "src/main.js",
6
6
  "exports": {
package/src/main.js CHANGED
@@ -27,6 +27,7 @@ let petSignals = []
27
27
  let sessionMap = new Map()
28
28
  let activeToolsBySession = new Map()
29
29
  let pendingPermissionsByRequest = new Map()
30
+ let pendingQuestionsByRequest = new Map()
30
31
  let cleanupTimer = null
31
32
  let dragState = null
32
33
 
@@ -133,6 +134,7 @@ function recordEvent(event) {
133
134
  applySessionEvent(item)
134
135
  applyToolEvent(item)
135
136
  applyPermissionEvent(item)
137
+ applyQuestionEvent(item)
136
138
  if (item.type === "hello" || item.type === "fancy_hello") {
137
139
  petSignals.push({
138
140
  id: item.id,
@@ -180,6 +182,33 @@ function clearPendingPermissionsForSession(sessionID) {
180
182
  }
181
183
  }
182
184
 
185
+ function applyQuestionEvent(event) {
186
+ if (!event || typeof event.sessionID !== "string") return
187
+
188
+ if (event.type === "question.ask") {
189
+ const requestID = typeof event.requestID === "string" ? event.requestID : event.id
190
+ if (!requestID) return
191
+ pendingQuestionsByRequest.set(requestID, {
192
+ requestID,
193
+ sessionID: event.sessionID,
194
+ questions: event.questions,
195
+ tool: event.tool,
196
+ askedAt: event.receivedAt || Date.now(),
197
+ })
198
+ return
199
+ }
200
+
201
+ if (event.type === "question.reply" || event.type === "question.reject") {
202
+ if (typeof event.requestID === "string") pendingQuestionsByRequest.delete(event.requestID)
203
+ }
204
+ }
205
+
206
+ function clearPendingQuestionsForSession(sessionID) {
207
+ for (const [requestID, question] of pendingQuestionsByRequest) {
208
+ if (question?.sessionID === sessionID) pendingQuestionsByRequest.delete(requestID)
209
+ }
210
+ }
211
+
183
212
  function applyToolEvent(event) {
184
213
  if (!event || typeof event.sessionID !== "string" || typeof event.callID !== "string") return
185
214
  if (event.type !== "tool.start" && event.type !== "tool.finish") return
@@ -237,6 +266,7 @@ function applySessionEvent(event) {
237
266
  if (event.type === "session.idle") {
238
267
  activeToolsBySession.delete(event.sessionID)
239
268
  clearPendingPermissionsForSession(event.sessionID)
269
+ clearPendingQuestionsForSession(event.sessionID)
240
270
  sessionMap.set(event.sessionID, {
241
271
  sessionID: event.sessionID,
242
272
  state: "idle",
@@ -259,6 +289,7 @@ function pruneIdleSessions(refresh = true) {
259
289
  sessionMap.delete(sessionID)
260
290
  activeToolsBySession.delete(sessionID)
261
291
  clearPendingPermissionsForSession(sessionID)
292
+ clearPendingQuestionsForSession(sessionID)
262
293
  changed = true
263
294
  }
264
295
  }
@@ -294,6 +325,7 @@ function getPetState() {
294
325
  color: DEFAULT_SESSION_COLORS[index % DEFAULT_SESSION_COLORS.length],
295
326
  })),
296
327
  permissions: Array.from(pendingPermissionsByRequest.values()),
328
+ questions: Array.from(pendingQuestionsByRequest.values()),
297
329
  signals: petSignals,
298
330
  }
299
331
  }
@@ -759,7 +791,7 @@ function petHtml3D() {
759
791
  const dragParticleTexture = createDragParticleTexture()
760
792
  const faceGeometry = new THREE.PlaneGeometry(0.60, 0.60)
761
793
  const glowGeometry = new THREE.PlaneGeometry(1.18, 1.18)
762
- const permissionGlowGeometry = new THREE.PlaneGeometry(1.42, 1.42)
794
+ const permissionGlowGeometry = new THREE.PlaneGeometry(1.62, 1.62)
763
795
  const rad = THREE.MathUtils.degToRad
764
796
 
765
797
  function createGlowTexture() {
@@ -1279,7 +1311,7 @@ function petHtml3D() {
1279
1311
  permissionGlowGeometry,
1280
1312
  new THREE.MeshBasicMaterial({
1281
1313
  map: glowTexture,
1282
- color: 0xff2448,
1314
+ color: 0xffffff,
1283
1315
  transparent: true,
1284
1316
  opacity: 0,
1285
1317
  blending: THREE.AdditiveBlending,
@@ -1287,7 +1319,7 @@ function petHtml3D() {
1287
1319
  side: THREE.DoubleSide,
1288
1320
  }),
1289
1321
  )
1290
- permissionGlow.position.set(...glowPosition.map((value) => value === 0 ? 0 : value * 1.055))
1322
+ permissionGlow.position.set(...glowPosition.map((value) => value === 0 ? 0 : value * 1.072))
1291
1323
  permissionGlow.rotation.set(...rotation)
1292
1324
  cubeGroup.add(permissionGlow)
1293
1325
  permissionGlowMeshes.set(name, permissionGlow)
@@ -1494,13 +1526,15 @@ function petHtml3D() {
1494
1526
  const sessionID = Array.from(pendingSessionIDs).find((id) => sessionFaceMap.get(id) === faceName)
1495
1527
  if (!sessionID) {
1496
1528
  permissionGlow.material.opacity = 0
1529
+ permissionGlow.scale.setScalar(1)
1497
1530
  continue
1498
1531
  }
1499
1532
 
1500
- permissionGlow.material.color.setRGB(1, 0.08, 0.16)
1501
- permissionGlow.material.opacity = 0.16 + pulse * 0.62
1502
- permissionGlow.scale.setScalar(1.00 + pulse * 0.34)
1503
- active[faceName] = { sessionID, pulse, pending: true }
1533
+ const color = sessionColorMap.get(sessionID) || randomSessionGlowColor()
1534
+ permissionGlow.material.color.setRGB(color.r / 255, color.g / 255, color.b / 255)
1535
+ permissionGlow.material.opacity = 0.14 + pulse * 0.60
1536
+ permissionGlow.scale.setScalar(1.06 + pulse * 0.42)
1537
+ active[faceName] = { sessionID, pulse, pending: true, color: color.name, rgb: [color.r, color.g, color.b] }
1504
1538
  }
1505
1539
 
1506
1540
  return active
@@ -1565,7 +1599,8 @@ function petHtml3D() {
1565
1599
  const toolParticles = updateToolParticles(sessions, dt, now)
1566
1600
  processSignals(snapshot.signals || [], now)
1567
1601
  const helloFlashes = applyFlashFaces(now)
1568
- const permissionGlows = applyPermissionGlowFaces(snapshot.permissions || [], now)
1602
+ const pendingAttention = [...(snapshot.permissions || []), ...(snapshot.questions || [])]
1603
+ const permissionGlows = applyPermissionGlowFaces(pendingAttention, now)
1569
1604
  renderCube()
1570
1605
  latestDebug = {
1571
1606
  now: Date.now(),
@@ -1,7 +1,9 @@
1
1
  const { quitPet, sendEvent, showPet } = require("./plugin-shared.cjs")
2
+ const pkg = require("../package.json")
2
3
 
3
4
  const COMMAND_HANDLED_SENTINEL = "__OPENCODE_PET_COMMAND_HANDLED__"
4
5
  const CUB_ICON = "◈"
6
+ const UPDATE_CHECK_TIMEOUT_MS = 5000
5
7
 
6
8
  function handled() {
7
9
  throw new Error(COMMAND_HANDLED_SENTINEL)
@@ -27,6 +29,72 @@ function isFancySayHello(input) {
27
29
  return input.command === "pet_fancy_say_hello"
28
30
  }
29
31
 
32
+ function isUpdateCheck(input) {
33
+ return input.command === "pet_update" || input.command === "pet_upgrade"
34
+ }
35
+
36
+ function compareVersions(a, b) {
37
+ const left = String(a || "").split(".").map((part) => Number.parseInt(part, 10) || 0)
38
+ const right = String(b || "").split(".").map((part) => Number.parseInt(part, 10) || 0)
39
+ const length = Math.max(left.length, right.length)
40
+ for (let index = 0; index < length; index += 1) {
41
+ const diff = (left[index] || 0) - (right[index] || 0)
42
+ if (diff !== 0) return diff > 0 ? 1 : -1
43
+ }
44
+ return 0
45
+ }
46
+
47
+ async function fetchLatestPackageVersion(name = pkg.name, timeoutMs = UPDATE_CHECK_TIMEOUT_MS) {
48
+ const controller = new AbortController()
49
+ const timeout = setTimeout(() => controller.abort(), timeoutMs)
50
+
51
+ try {
52
+ const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(name)}/latest`, {
53
+ headers: { accept: "application/json" },
54
+ signal: controller.signal,
55
+ })
56
+ if (!response.ok) throw new Error(`npm registry returned ${response.status}`)
57
+ const info = await response.json()
58
+ return {
59
+ name: info.name || name,
60
+ version: info.version,
61
+ tarball: info.dist?.tarball,
62
+ }
63
+ } catch (error) {
64
+ if (error?.name === "AbortError") throw new Error(`npm registry check timed out after ${timeoutMs}ms`)
65
+ throw error
66
+ } finally {
67
+ clearTimeout(timeout)
68
+ }
69
+ }
70
+
71
+ function formatUpdateNotice(latest) {
72
+ const current = pkg.version
73
+ if (!latest?.version) return cubNotice(`Could not read the latest ${pkg.name} version from npm.`, "△")
74
+
75
+ const comparison = compareVersions(latest.version, current)
76
+ if (comparison < 0) {
77
+ return cubNotice(
78
+ [
79
+ `Welcome to the tiny time machine ✨`,
80
+ `This OpenCube is a dev build (${current}), ahead of npm (${latest.version}).`,
81
+ `Keep this cube away from paradoxes and production users.`,
82
+ ].join("\n"),
83
+ "✧",
84
+ )
85
+ }
86
+ if (comparison === 0) return cubNotice(`OpenCube is up to date (${current}).`, "✓")
87
+
88
+ return cubNotice(
89
+ [
90
+ `OpenCube ${latest.version} is available. Current version: ${current}.`,
91
+ `Upgrade with: opencode plugin ${pkg.name}@${latest.version} --global --force`,
92
+ `Then fully restart opencode and run /pet again.`,
93
+ ].join("\n"),
94
+ "↻",
95
+ )
96
+ }
97
+
30
98
  async function injectNotice(client, sessionID, text) {
31
99
  if (!sessionID || !client?.session?.prompt) return
32
100
 
@@ -85,10 +153,18 @@ module.exports = {
85
153
  template: "/pet_fancy_say_hello",
86
154
  description: "Trigger a randomized light show on OpenCube's free faces.",
87
155
  }
156
+ cfg.command.pet_update = {
157
+ template: "/pet_update",
158
+ description: "Check npm for a newer OpenCube version and show the upgrade command.",
159
+ }
160
+ cfg.command.pet_upgrade = {
161
+ template: "/pet_upgrade",
162
+ description: "Alias for /pet_update.",
163
+ }
88
164
  },
89
165
 
90
166
  "command.execute.before": async (input, output) => {
91
- if (!["pet", "pet_stop", "pet_say_hello", "pet_fancy_say_hello"].includes(input.command)) return
167
+ if (!["pet", "pet_stop", "pet_say_hello", "pet_fancy_say_hello", "pet_update", "pet_upgrade"].includes(input.command)) return
92
168
 
93
169
  // There is no official cancel primitive in command.execute.before yet.
94
170
  // Throwing this sentinel aborts the command flow before opencode sends
@@ -129,6 +205,39 @@ module.exports = {
129
205
  ? cubNotice("OpenCube is putting on a light show ✨", "✺")
130
206
  : cubNotice("OpenCube is sleeping... zzz Start it with /pet before the light show.", "☾"),
131
207
  )
208
+ } else if (isUpdateCheck(input)) {
209
+ let latest
210
+ try {
211
+ latest = await fetchLatestPackageVersion()
212
+ const versionComparison = compareVersions(latest.version, pkg.version)
213
+ await sendEvent({
214
+ type: "update.check",
215
+ message: "OpenCube checked npm for updates",
216
+ package: pkg.name,
217
+ currentVersion: pkg.version,
218
+ latestVersion: latest.version,
219
+ updateAvailable: versionComparison > 0,
220
+ devVersion: versionComparison < 0,
221
+ tarball: latest.tarball,
222
+ timeoutMs: UPDATE_CHECK_TIMEOUT_MS,
223
+ sessionID: input.sessionID,
224
+ source: "opencube-plugin",
225
+ })
226
+ } catch (error) {
227
+ await sendEvent({
228
+ type: "update.check_failed",
229
+ message: "OpenCube could not check npm for updates",
230
+ package: pkg.name,
231
+ currentVersion: pkg.version,
232
+ error: error?.message || String(error),
233
+ sessionID: input.sessionID,
234
+ source: "opencube-plugin",
235
+ })
236
+ await injectNotice(client, input.sessionID, cubNotice(`Could not check npm for OpenCube updates: ${error?.message || error}`, "△"))
237
+ handled()
238
+ }
239
+
240
+ await injectNotice(client, input.sessionID, formatUpdateNotice(latest))
132
241
  } else {
133
242
  await showPet({
134
243
  onProgress: (message) => injectNotice(client, input.sessionID, message),
@@ -169,6 +278,45 @@ module.exports = {
169
278
  return
170
279
  }
171
280
 
281
+ if (event.type === "question.asked") {
282
+ const question = event.properties || {}
283
+ await sendEvent({
284
+ type: "question.ask",
285
+ message: "opencode is waiting for a question answer",
286
+ sessionID: question.sessionID,
287
+ requestID: question.id,
288
+ questions: question.questions,
289
+ tool: question.tool,
290
+ source: "opencube-plugin",
291
+ })
292
+ return
293
+ }
294
+
295
+ if (event.type === "question.replied") {
296
+ const question = event.properties || {}
297
+ await sendEvent({
298
+ type: "question.reply",
299
+ message: "opencode question was answered",
300
+ sessionID: question.sessionID,
301
+ requestID: question.requestID,
302
+ answers: question.answers,
303
+ source: "opencube-plugin",
304
+ })
305
+ return
306
+ }
307
+
308
+ if (event.type === "question.rejected") {
309
+ const question = event.properties || {}
310
+ await sendEvent({
311
+ type: "question.reject",
312
+ message: "opencode question was rejected",
313
+ sessionID: question.sessionID,
314
+ requestID: question.requestID,
315
+ source: "opencube-plugin",
316
+ })
317
+ return
318
+ }
319
+
172
320
  if (event.type !== "session.status") return
173
321
 
174
322
  const sessionID = event.properties?.sessionID