opencube 0.1.0 → 0.2.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 CHANGED
@@ -1,28 +1,30 @@
1
1
  <p align="center">
2
- <img src="assets/opencode-icon.png" width="96" height="96" alt="OpenCub icon" />
2
+ <img src="assets/opencode-icon.png" width="96" height="96" alt="OpenCube icon" />
3
3
  </p>
4
4
 
5
- # OpenCub
5
+ # OpenCube
6
6
 
7
- OpenCub is a tiny desktop pet for [opencode](https://opencode.ai/).
7
+ OpenCube is a tiny desktop pet for [opencode](https://opencode.ai/).
8
8
 
9
9
  It watches opencode session activity and renders a small Three.js cube on your desktop:
10
10
 
11
11
  - busy sessions light up cube faces
12
- - idle sessions release their face glow after the cube slows down
12
+ - active tool calls emit bright face-colored sparks from the corresponding cube face
13
+ - right-click drag applies extra rotational friction with small braking particles
14
+ - idle sessions keep a subtle torque-breathing motion and release face glow after the cube slows down
15
+ - a macOS-style tray/menu bar control can show, hide, quit, or open the Inbox
16
+ - the Inbox shows raw opencode hook events plus local mouse/keyboard diagnostics
13
17
  - `/pet_say_hello` flashes one free face
14
18
  - `/pet_fancy_say_hello` plays a randomized light show on free faces
15
19
 
16
- OpenCub is packaged as an opencode plugin plus an Electron desktop process.
20
+ OpenCube is packaged as an opencode plugin plus an Electron desktop process.
17
21
 
18
22
  ## Install
19
23
 
20
- > OpenCub is not published yet. These are the intended install commands once published.
21
-
22
24
  Install globally through opencode:
23
25
 
24
26
  ```sh
25
- opencode plugin opencub --global
27
+ opencode plugin opencube --global
26
28
  ```
27
29
 
28
30
  Then restart opencode and run:
@@ -35,7 +37,7 @@ You can also add it manually to `~/.config/opencode/opencode.json`:
35
37
 
36
38
  ```json
37
39
  {
38
- "plugin": ["opencub"]
40
+ "plugin": ["opencube"]
39
41
  }
40
42
  ```
41
43
 
@@ -43,27 +45,57 @@ You can also add it manually to `~/.config/opencode/opencode.json`:
43
45
 
44
46
  | Command | Description |
45
47
  | --- | --- |
46
- | `/pet` | Show or start OpenCub. |
47
- | `/pet_stop` | Quit OpenCub. |
48
+ | `/pet` | Show or start OpenCube. |
49
+ | `/pet_stop` | Quit OpenCube. |
48
50
  | `/pet_say_hello` | Flash one currently free face three times with a random color. |
49
51
  | `/pet_fancy_say_hello` | Run a denser randomized light show across currently free faces. |
50
52
 
51
53
  These commands are handled by the plugin and do not get sent to the model.
52
54
 
55
+ ## What you can see
56
+
57
+ OpenCube turns opencode lifecycle events into small desktop signals:
58
+
59
+ | opencode activity | OpenCube signal |
60
+ | --- | --- |
61
+ | Session becomes busy | One cube face lights up with a stable session color. |
62
+ | Session becomes idle | The face glow is released once the cube slows down. |
63
+ | A tool call starts | The session's face emits fast, bright sparks in that face's current outward direction. |
64
+ | A tool call finishes | Spark emission stops; existing sparks fade out naturally. |
65
+ | Right mouse hold on the cube | Friction increases and braking particles appear. |
66
+ | Tray menu → Show Inbox | Opens a two-column event/debug panel. |
67
+
68
+ Multiple busy sessions can light multiple faces. If more than six sessions are busy, OpenCube shows the latest six.
69
+
53
70
  ## How it works
54
71
 
55
- OpenCub has two parts in one npm package:
72
+ OpenCube has two parts in one npm package:
56
73
 
57
74
  1. `src/plugin-server.cjs` — the opencode plugin entrypoint.
58
75
  2. `src/main.js` — the Electron desktop pet.
59
76
 
60
- The plugin registers slash commands, listens for opencode `session.status` events, and sends events to the desktop pet over a local HTTP API:
77
+ The plugin registers slash commands, listens for opencode `session.status` events and `tool.execute.*` hooks, and sends events to the desktop pet over a local HTTP API:
61
78
 
62
79
  ```text
63
- opencode plugin -> http://127.0.0.1:47832 -> Electron OpenCub
80
+ opencode plugin -> http://127.0.0.1:47832 -> Electron OpenCube
64
81
  ```
65
82
 
66
- The Electron process owns the window, Three.js renderer, cube rotation, face glow state, and inbox/debug endpoints.
83
+ The Electron process owns the window, tray menu, Three.js renderer, cube rotation, face glow state, particle effects, and inbox/debug endpoints.
84
+
85
+ ## Local API
86
+
87
+ OpenCube exposes a local-only HTTP API while it is running:
88
+
89
+ | Endpoint | Purpose |
90
+ | --- | --- |
91
+ | `GET /health` | Check whether OpenCube is running. |
92
+ | `GET /debug-render` | Inspect renderer state, busy faces, active tools, and particle counters. |
93
+ | `POST /event` | Receive opencode lifecycle/tool/hello events. |
94
+ | `POST /interaction` | Receive local renderer mouse/keyboard diagnostics. |
95
+ | `POST /show` | Show the cube window. |
96
+ | `POST /quit` | Quit OpenCube. |
97
+
98
+ The API binds to `127.0.0.1` only.
67
99
 
68
100
  ## Requirements
69
101
 
@@ -76,9 +108,11 @@ Users do not need to run `npm install` manually when installing via `opencode pl
76
108
  ## Notes
77
109
 
78
110
  - The first install may take a while because Electron is downloaded as a runtime dependency.
111
+ - If Electron's platform binary is missing after installation, OpenCube attempts a first-run self-repair download.
79
112
  - If commands do not appear after installation, restart opencode.
80
- - OpenCub uses a local-only HTTP server on `127.0.0.1:47832`.
113
+ - OpenCube uses a local-only HTTP server on `127.0.0.1:47832`.
81
114
  - If that port is already in use, set `OPENCODE_PET_PORT` before starting opencode.
115
+ - In opencode desktop, OpenCube currently uses a command-abort sentinel to keep local slash commands out of the model flow; depending on opencode version, the desktop UI may show an error toast even though the command was handled.
82
116
 
83
117
  ## Local development
84
118
 
@@ -93,7 +127,7 @@ For local opencode plugin testing, point your opencode config at the package dir
93
127
 
94
128
  ```json
95
129
  {
96
- "plugin": ["/path/to/opencub"]
130
+ "plugin": ["/path/to/opencube"]
97
131
  }
98
132
  ```
99
133
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencube",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
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
@@ -1,4 +1,4 @@
1
- const { app, BrowserWindow, Menu, screen } = require("electron")
1
+ const { app, BrowserWindow, Menu, Tray, nativeImage, screen, ipcMain } = require("electron")
2
2
  const fs = require("node:fs")
3
3
  const http = require("node:http")
4
4
  const os = require("node:os")
@@ -9,20 +9,42 @@ const { DEFAULT_SESSION_COLORS, pixelPetSvg } = require("./pixel-pet-reference.c
9
9
  const APP_NAME = "opencode pet"
10
10
  const HOST = "127.0.0.1"
11
11
  const PORT = Number(process.env.OPENCODE_PET_PORT || 47832)
12
- const DATA_DIR = path.join(os.homedir(), ".local", "share", "opencode-pet")
12
+ const DATA_DIR = path.join(os.homedir(), ".local", "share", "opencube")
13
13
  const STATE_FILE = path.join(DATA_DIR, "state.json")
14
14
  const PET_HTML_FILE = path.join(DATA_DIR, "pet.html")
15
15
  const ICON_PATH = process.env.OPENCODE_PET_ICON || path.join(__dirname, "..", "assets", "opencode-icon.png")
16
16
  const MAX_EVENTS = 100
17
+ const MAX_INTERACTION_EVENTS = 80
17
18
  const IDLE_TTL_MS = 5 * 60 * 1000
18
19
 
19
20
  let petWindow = null
20
21
  let panelWindow = null
22
+ let tray = null
21
23
  let server = null
22
24
  let events = []
25
+ let interactionEvents = []
23
26
  let petSignals = []
24
27
  let sessionMap = new Map()
28
+ let activeToolsBySession = new Map()
25
29
  let cleanupTimer = null
30
+ let dragState = null
31
+
32
+ ipcMain.on("opencube-drag-start", (event, point) => {
33
+ if (!petWindow || petWindow.isDestroyed()) return
34
+ const [x, y] = petWindow.getPosition()
35
+ dragState = { windowX: x, windowY: y, screenX: point.screenX, screenY: point.screenY }
36
+ })
37
+
38
+ ipcMain.on("opencube-drag-move", (event, point) => {
39
+ if (!petWindow || petWindow.isDestroyed() || !dragState) return
40
+ const nextX = Math.round(dragState.windowX + point.screenX - dragState.screenX)
41
+ const nextY = Math.round(dragState.windowY + point.screenY - dragState.screenY)
42
+ petWindow.setPosition(nextX, nextY, false)
43
+ })
44
+
45
+ ipcMain.on("opencube-drag-end", () => {
46
+ dragState = null
47
+ })
26
48
 
27
49
  function ensureDataDir() {
28
50
  fs.mkdirSync(DATA_DIR, { recursive: true })
@@ -108,6 +130,7 @@ function recordEvent(event) {
108
130
  ...event,
109
131
  }
110
132
  applySessionEvent(item)
133
+ applyToolEvent(item)
111
134
  if (item.type === "hello" || item.type === "fancy_hello") {
112
135
  petSignals.push({
113
136
  id: item.id,
@@ -125,6 +148,41 @@ function recordEvent(event) {
125
148
  return item
126
149
  }
127
150
 
151
+ function applyToolEvent(event) {
152
+ if (!event || typeof event.sessionID !== "string" || typeof event.callID !== "string") return
153
+ if (event.type !== "tool.start" && event.type !== "tool.finish") return
154
+
155
+ let tools = activeToolsBySession.get(event.sessionID)
156
+ if (!tools) {
157
+ tools = new Map()
158
+ activeToolsBySession.set(event.sessionID, tools)
159
+ }
160
+
161
+ if (event.type === "tool.start") {
162
+ tools.set(event.callID, {
163
+ callID: event.callID,
164
+ tool: event.tool,
165
+ startedAt: event.receivedAt || Date.now(),
166
+ })
167
+ return
168
+ }
169
+
170
+ tools.delete(event.callID)
171
+ if (tools.size === 0) activeToolsBySession.delete(event.sessionID)
172
+ }
173
+
174
+ function recordInteractionEvent(event) {
175
+ const item = {
176
+ id: `${Date.now()}-${Math.random().toString(16).slice(2)}`,
177
+ receivedAt: Date.now(),
178
+ ...event,
179
+ }
180
+ interactionEvents.unshift(item)
181
+ interactionEvents = interactionEvents.slice(0, MAX_INTERACTION_EVENTS)
182
+ updatePanel()
183
+ return item
184
+ }
185
+
128
186
  function applySessionEvent(event) {
129
187
  if (!event || typeof event.sessionID !== "string") return
130
188
  const now = event.receivedAt || Date.now()
@@ -145,6 +203,7 @@ function applySessionEvent(event) {
145
203
  }
146
204
 
147
205
  if (event.type === "session.idle") {
206
+ activeToolsBySession.delete(event.sessionID)
148
207
  sessionMap.set(event.sessionID, {
149
208
  sessionID: event.sessionID,
150
209
  state: "idle",
@@ -165,6 +224,7 @@ function pruneIdleSessions(refresh = true) {
165
224
  const expiresFrom = session.idleAt || session.lastAt
166
225
  if (session.state === "idle" && expiresFrom && now - expiresFrom > IDLE_TTL_MS) {
167
226
  sessionMap.delete(sessionID)
227
+ activeToolsBySession.delete(sessionID)
168
228
  changed = true
169
229
  }
170
230
  }
@@ -186,6 +246,7 @@ function getPetState() {
186
246
  ball: { size: 14 },
187
247
  },
188
248
  sessions: Array.from(sessionMap.values()).map((session, index) => ({
249
+ activeTools: Array.from(activeToolsBySession.get(session.sessionID)?.values() || []),
189
250
  sessionID: session.sessionID,
190
251
  state: session.state,
191
252
  busyAt: session.busyAt,
@@ -230,6 +291,14 @@ function panelHtml() {
230
291
  })
231
292
  .join("")
232
293
 
294
+ const interactionRows = interactionEvents
295
+ .map((event) => {
296
+ const time = new Date(event.receivedAt).toLocaleTimeString()
297
+ const payload = JSON.stringify(event, null, 2)
298
+ return `<div class="event interaction"><div class="meta interaction-meta">${escapeHtml(time)} · ${escapeHtml(event.type || "interaction")}</div><pre>${escapeHtml(payload)}</pre></div>`
299
+ })
300
+ .join("")
301
+
233
302
  return `<!doctype html>
234
303
  <html>
235
304
  <head>
@@ -239,17 +308,31 @@ function panelHtml() {
239
308
  .panel { box-sizing: border-box; width: 100%; height: 100%; padding: 12px; border-radius: 16px; background: rgba(24, 24, 27, 0.94); color: white; box-shadow: 0 14px 42px rgba(0,0,0,.28); overflow: hidden; }
240
309
  .header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; font-size: 13px; font-weight: 700; }
241
310
  .hint { color: rgba(255,255,255,.6); font-size: 11px; font-weight: 500; }
242
- .list { height: 292px; overflow: auto; padding-right: 4px; }
311
+ .columns { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; height: 316px; }
312
+ .section { min-width: 0; overflow: hidden; display: flex; flex-direction: column; }
313
+ .section-title { display: flex; justify-content: space-between; color: rgba(255,255,255,.72); font-size: 11px; font-weight: 700; margin: 0 2px 6px; }
314
+ .list { flex: 1; overflow: auto; padding-right: 4px; }
243
315
  .empty { color: rgba(255,255,255,.6); font-size: 13px; padding: 18px 4px; }
244
316
  .event { border: 1px solid rgba(255,255,255,.12); border-radius: 10px; padding: 8px; margin-bottom: 8px; background: rgba(255,255,255,.06); }
317
+ .event.interaction { background: rgba(56,189,248,.08); border-color: rgba(56,189,248,.18); }
245
318
  .meta { color: #a7f3d0; font-size: 11px; margin-bottom: 5px; }
319
+ .interaction-meta { color: #93c5fd; }
246
320
  pre { margin: 0; color: rgba(255,255,255,.88); white-space: pre-wrap; word-break: break-word; font: 11px ui-monospace, SFMono-Regular, Menlo, monospace; }
247
321
  </style>
248
322
  </head>
249
323
  <body>
250
324
  <div class="panel">
251
- <div class="header"><span>opencode pet inbox</span><span class="hint">${events.length}/${MAX_EVENTS}</span></div>
252
- <div class="list">${rows || `<div class="empty">还没有收到事件。试试 /pet 或 /pet_stop。</div>`}</div>
325
+ <div class="header"><span>OpenCube inbox</span><span class="hint">events ${events.length}/${MAX_EVENTS} · input ${interactionEvents.length}/${MAX_INTERACTION_EVENTS}</span></div>
326
+ <div class="columns">
327
+ <section class="section">
328
+ <div class="section-title"><span>HTTP / hook events</span><span>${events.length}</span></div>
329
+ <div class="list">${rows || `<div class="empty">还没有收到事件。</div>`}</div>
330
+ </section>
331
+ <section class="section">
332
+ <div class="section-title"><span>Mouse / keyboard</span><span>${interactionEvents.length}</span></div>
333
+ <div class="list">${interactionRows || `<div class="empty">还没有捕捉到输入事件。</div>`}</div>
334
+ </section>
335
+ </div>
253
336
  </div>
254
337
  </body>
255
338
  </html>`
@@ -548,7 +631,7 @@ function petHtml3D() {
548
631
  height: 100%;
549
632
  position: relative;
550
633
  background: transparent;
551
- -webkit-app-region: drag;
634
+ -webkit-app-region: no-drag;
552
635
  user-select: none;
553
636
  }
554
637
  #scene {
@@ -558,11 +641,18 @@ function petHtml3D() {
558
641
  height: 100%;
559
642
  pointer-events: none;
560
643
  }
644
+ #hit-layer {
645
+ position: absolute;
646
+ inset: 0;
647
+ background: transparent;
648
+ -webkit-app-region: no-drag;
649
+ }
561
650
  </style>
562
651
  </head>
563
652
  <body>
564
653
  <div class="stage" title="opencode pet:拖拽移动,右键打开菜单">
565
654
  <canvas id="scene" aria-label="3D opencode pet"></canvas>
655
+ <div id="hit-layer" aria-label="OpenCube interaction layer"></div>
566
656
  </div>
567
657
  <script>
568
658
  window.__PET_BOOT_ERROR = null
@@ -575,9 +665,11 @@ function petHtml3D() {
575
665
  </script>
576
666
  <script>
577
667
  const THREE = require(${threeCjsPath})
668
+ const { ipcRenderer } = require("electron")
578
669
 
579
670
  window.__PET_STATE = ${initialStateJson}
580
671
  const stage = document.querySelector(".stage")
672
+ const hitLayer = document.getElementById("hit-layer")
581
673
  const faceOrder = ["front", "right", "top", "back", "left", "bottom"]
582
674
  const sessionFaceMap = new Map()
583
675
  const sessionColorMap = new Map()
@@ -593,6 +685,10 @@ function petHtml3D() {
593
685
  let torque = { x: 0, y: 0, z: 0 }
594
686
  let nextTorqueAt = 0
595
687
  let wasBusy = false
688
+ let frictionHoldActive = false
689
+ let frictionHoldLevel = 0
690
+ let leftDragActive = false
691
+ let dragEmitAccumulator = 0
596
692
  let latestDebug = { now: Date.now(), busy: 0, rotation, angularVelocity, torque, speed: 0, faceRotations: {} }
597
693
 
598
694
  const canvas = document.getElementById("scene")
@@ -606,6 +702,11 @@ function petHtml3D() {
606
702
 
607
703
  const cubeGroup = new THREE.Group()
608
704
  scene.add(cubeGroup)
705
+ const dragParticleGroup = new THREE.Group()
706
+ dragParticleGroup.position.z = 0.88
707
+ scene.add(dragParticleGroup)
708
+ const toolParticleGroup = new THREE.Group()
709
+ scene.add(toolParticleGroup)
609
710
  const iconTexture = new THREE.TextureLoader().load("${iconUrl}")
610
711
  iconTexture.colorSpace = THREE.SRGBColorSpace
611
712
  iconTexture.generateMipmaps = false
@@ -619,6 +720,7 @@ function petHtml3D() {
619
720
  side: THREE.DoubleSide,
620
721
  })
621
722
  const glowTexture = createGlowTexture()
723
+ const dragParticleTexture = createDragParticleTexture()
622
724
  const faceGeometry = new THREE.PlaneGeometry(0.60, 0.60)
623
725
  const glowGeometry = new THREE.PlaneGeometry(1.18, 1.18)
624
726
  const rad = THREE.MathUtils.degToRad
@@ -641,6 +743,247 @@ function petHtml3D() {
641
743
  return texture
642
744
  }
643
745
 
746
+ function createDragParticleTexture() {
747
+ const canvas = document.createElement("canvas")
748
+ canvas.width = 96
749
+ canvas.height = 96
750
+ const ctx = canvas.getContext("2d")
751
+ const gradient = ctx.createRadialGradient(48, 48, 1, 48, 48, 45)
752
+ gradient.addColorStop(0, "rgba(255,255,255,1)")
753
+ gradient.addColorStop(0.24, "rgba(255,255,255,1)")
754
+ gradient.addColorStop(0.52, "rgba(255,255,255,.52)")
755
+ gradient.addColorStop(1, "rgba(255,255,255,0)")
756
+ ctx.fillStyle = gradient
757
+ ctx.fillRect(0, 0, 96, 96)
758
+ const texture = new THREE.CanvasTexture(canvas)
759
+ texture.colorSpace = THREE.SRGBColorSpace
760
+ return texture
761
+ }
762
+
763
+ const dragParticles = []
764
+ const toolParticles = []
765
+ const toolEmitAccumulators = new Map()
766
+ const faceVectors = {
767
+ front: { position: new THREE.Vector3(0, 0, 0.34), normal: new THREE.Vector3(0, 0, 1) },
768
+ back: { position: new THREE.Vector3(0, 0, -0.34), normal: new THREE.Vector3(0, 0, -1) },
769
+ right: { position: new THREE.Vector3(0.34, 0, 0), normal: new THREE.Vector3(1, 0, 0) },
770
+ left: { position: new THREE.Vector3(-0.34, 0, 0), normal: new THREE.Vector3(-1, 0, 0) },
771
+ top: { position: new THREE.Vector3(0, 0.34, 0), normal: new THREE.Vector3(0, 1, 0) },
772
+ bottom: { position: new THREE.Vector3(0, -0.34, 0), normal: new THREE.Vector3(0, -1, 0) },
773
+ }
774
+
775
+ function createDragParticles() {
776
+ const geometry = new THREE.PlaneGeometry(0.086, 0.086)
777
+ for (let index = 0; index < 34; index++) {
778
+ const material = new THREE.MeshBasicMaterial({
779
+ map: dragParticleTexture,
780
+ color: new THREE.Color(1, 1, 1),
781
+ transparent: true,
782
+ opacity: 0,
783
+ blending: THREE.AdditiveBlending,
784
+ depthWrite: false,
785
+ side: THREE.DoubleSide,
786
+ })
787
+ const particle = new THREE.Mesh(geometry, material)
788
+ particle.userData = {
789
+ active: false,
790
+ life: 0,
791
+ maxLife: 1,
792
+ vx: 0,
793
+ vy: 0,
794
+ size: randomBetween(0.72, 1.28),
795
+ }
796
+ dragParticleGroup.add(particle)
797
+ dragParticles.push(particle)
798
+ }
799
+ }
800
+
801
+ function createToolParticles() {
802
+ for (let index = 0; index < 56; index++) {
803
+ const material = new THREE.SpriteMaterial({
804
+ map: dragParticleTexture,
805
+ color: 0xffffff,
806
+ transparent: true,
807
+ opacity: 0,
808
+ blending: THREE.AdditiveBlending,
809
+ depthWrite: false,
810
+ })
811
+ const particle = new THREE.Sprite(material)
812
+ particle.userData = {
813
+ active: false,
814
+ life: 0,
815
+ maxLife: 1,
816
+ velocity: new THREE.Vector3(),
817
+ size: 0.06,
818
+ }
819
+ toolParticleGroup.add(particle)
820
+ toolParticles.push(particle)
821
+ }
822
+ }
823
+
824
+ function colorToThree(color) {
825
+ return new THREE.Color((color?.r ?? 255) / 255, (color?.g ?? 255) / 255, (color?.b ?? 255) / 255)
826
+ }
827
+
828
+ function emitToolParticle(faceName, color) {
829
+ const face = faceVectors[faceName]
830
+ if (!face) return false
831
+ const particle = toolParticles.find((item) => !item.userData.active)
832
+ if (!particle) return false
833
+
834
+ const origin = face.position.clone()
835
+ const normal = face.normal.clone()
836
+ cubeGroup.localToWorld(origin)
837
+ normal.applyQuaternion(cubeGroup.quaternion).normalize()
838
+
839
+ const basis = Math.abs(normal.y) > 0.82 ? new THREE.Vector3(1, 0, 0) : new THREE.Vector3(0, 1, 0)
840
+ const tangentA = new THREE.Vector3().crossVectors(normal, basis).normalize()
841
+ const tangentB = new THREE.Vector3().crossVectors(normal, tangentA).normalize()
842
+
843
+ const data = particle.userData
844
+ data.active = true
845
+ data.life = 0
846
+ data.maxLife = randomBetween(0.45, 0.78)
847
+ data.size = randomBetween(0.062, 0.115)
848
+ data.velocity.copy(normal).multiplyScalar(randomBetween(1.45, 2.35))
849
+ data.velocity.add(tangentA.multiplyScalar(randomBetween(-0.32, 0.32)))
850
+ data.velocity.add(tangentB.multiplyScalar(randomBetween(-0.32, 0.32)))
851
+ particle.position.copy(origin).add(normal.clone().multiplyScalar(0.035))
852
+ particle.scale.setScalar(data.size)
853
+ particle.material.color.copy(colorToThree(color))
854
+ particle.material.opacity = 0.92
855
+ return true
856
+ }
857
+
858
+ function updateToolParticles(sessions, dt) {
859
+ const activeToolSessions = sessions.filter((session) => session.state === "busy" && session.activeTools?.length > 0)
860
+ const activeIDs = new Set(activeToolSessions.map((session) => session.sessionID))
861
+ for (const sessionID of Array.from(toolEmitAccumulators.keys())) {
862
+ if (!activeIDs.has(sessionID)) toolEmitAccumulators.delete(sessionID)
863
+ }
864
+
865
+ for (const session of activeToolSessions) {
866
+ const faceName = sessionFaceMap.get(session.sessionID)
867
+ if (!faceName) continue
868
+ const color = sessionColorMap.get(session.sessionID) || randomSessionGlowColor()
869
+ const jitterRate = randomBetween(7.5, 11.5)
870
+ const next = (toolEmitAccumulators.get(session.sessionID) || 0) + dt * jitterRate
871
+ let accumulator = next
872
+ while (accumulator >= 1) {
873
+ if (!emitToolParticle(faceName, color)) break
874
+ accumulator -= 1
875
+ }
876
+ toolEmitAccumulators.set(session.sessionID, accumulator)
877
+ }
878
+
879
+ let activeCount = 0
880
+ for (const particle of toolParticles) {
881
+ const data = particle.userData
882
+ if (!data.active) {
883
+ particle.material.opacity = 0
884
+ continue
885
+ }
886
+ data.life += dt
887
+ if (data.life >= data.maxLife) {
888
+ data.active = false
889
+ particle.material.opacity = 0
890
+ continue
891
+ }
892
+ activeCount += 1
893
+ const age = data.life / data.maxLife
894
+ particle.position.addScaledVector(data.velocity, dt)
895
+ particle.scale.setScalar(data.size * (1.02 + age * 0.42))
896
+ particle.material.opacity = Math.min(1, 0.24 + Math.sin(age * Math.PI) * 1.18)
897
+ }
898
+ toolParticleGroup.visible = activeCount > 0 || activeToolSessions.length > 0
899
+ return { activeSessions: activeToolSessions.length, activeCount }
900
+ }
901
+
902
+ function activeDragColors() {
903
+ const colors = []
904
+ for (const color of sessionColorMap.values()) {
905
+ if (color && Number.isFinite(color.r) && Number.isFinite(color.g) && Number.isFinite(color.b)) colors.push(color)
906
+ }
907
+ return colors
908
+ }
909
+
910
+ function monochromeDragColor(index, cycle) {
911
+ const value = 0.26 + ((index % 5) / 4) * 0.56 + Math.sin(cycle * Math.PI) * 0.12
912
+ const clamped = Math.max(0.18, Math.min(0.92, value))
913
+ return { r: Math.round(clamped * 255), g: Math.round(clamped * 255), b: Math.round(clamped * 255) }
914
+ }
915
+
916
+ function chooseDragParticleColor(index, palette) {
917
+ if (palette.length > 0) return palette[index % palette.length]
918
+ const value = index % 2 === 0 ? 238 : 32
919
+ return { r: value, g: value, b: value }
920
+ }
921
+
922
+ function emitDragParticle(palette) {
923
+ const particle = dragParticles.find((item) => !item.userData.active)
924
+ if (!particle) return false
925
+ const data = particle.userData
926
+ const angle = randomBetween(0, Math.PI * 2)
927
+ const startRadius = randomBetween(0.16, 0.30)
928
+ const velocity = randomBetween(0.10, 0.20)
929
+ const tangent = randomBetween(-0.045, 0.045)
930
+ const color = chooseDragParticleColor(Math.floor(randomBetween(0, 1000)), palette)
931
+ data.active = true
932
+ data.life = 0
933
+ data.maxLife = randomBetween(1.15, 1.9)
934
+ data.vx = Math.cos(angle) * velocity + Math.cos(angle + Math.PI / 2) * tangent
935
+ data.vy = Math.sin(angle) * velocity + Math.sin(angle + Math.PI / 2) * tangent
936
+ data.size = randomBetween(0.92, 1.52)
937
+ particle.position.x = Math.cos(angle) * startRadius
938
+ particle.position.y = Math.sin(angle) * startRadius
939
+ particle.position.z = 0
940
+ particle.scale.setScalar(data.size)
941
+ particle.material.color.setRGB(color.r / 255, color.g / 255, color.b / 255)
942
+ particle.material.opacity = 0.96
943
+ return true
944
+ }
945
+
946
+ function updateDragParticles(now, holdLevel, speed, dt) {
947
+ const hold = Math.max(0, Math.min(1, holdLevel / 11))
948
+ const intensity = hold
949
+ const palette = activeDragColors()
950
+ if (hold > 0.02) {
951
+ const emitRate = 7.5 - hold * 5.2
952
+ dragEmitAccumulator += dt * emitRate
953
+ while (dragEmitAccumulator >= 1) {
954
+ if (!emitDragParticle(palette)) break
955
+ dragEmitAccumulator -= 1
956
+ }
957
+ } else {
958
+ dragEmitAccumulator = 0
959
+ }
960
+
961
+ let activeCount = 0
962
+ for (const particle of dragParticles) {
963
+ const data = particle.userData
964
+ if (!data.active) {
965
+ particle.material.opacity = 0
966
+ continue
967
+ }
968
+ data.life += dt
969
+ if (data.life >= data.maxLife) {
970
+ data.active = false
971
+ particle.material.opacity = 0
972
+ continue
973
+ }
974
+ activeCount += 1
975
+ const age = data.life / data.maxLife
976
+ particle.position.x += data.vx * dt
977
+ particle.position.y += data.vy * dt
978
+ const fade = Math.sin(age * Math.PI)
979
+ const scale = data.size * (0.92 + age * 0.58)
980
+ particle.scale.setScalar(scale)
981
+ particle.material.opacity = Math.min(1, 0.12 + fade * 0.98)
982
+ }
983
+ dragParticleGroup.visible = activeCount > 0 || intensity > 0.01
984
+ return { intensity, activeCount, emitRate: hold > 0.02 ? 7.5 - hold * 5.2 : 0 }
985
+ }
986
+
644
987
  function resize() {
645
988
  const width = Math.max(1, window.innerWidth)
646
989
  const height = Math.max(1, window.innerHeight)
@@ -651,6 +994,113 @@ function petHtml3D() {
651
994
  window.addEventListener("resize", resize)
652
995
  resize()
653
996
 
997
+ function postInteraction(event) {
998
+ fetch("http://${HOST}:${PORT}/interaction", {
999
+ method: "POST",
1000
+ headers: { "content-type": "application/json" },
1001
+ body: JSON.stringify({
1002
+ ...event,
1003
+ source: "opencube-renderer",
1004
+ frictionHoldActive,
1005
+ frictionHoldLevel,
1006
+ frictionHoldMultiplier: 1 + frictionHoldLevel,
1007
+ }),
1008
+ }).catch(() => {})
1009
+ }
1010
+
1011
+ function pointerPayload(type, event) {
1012
+ return {
1013
+ type,
1014
+ nativeType: event.type,
1015
+ pointerId: event.pointerId,
1016
+ pointerType: event.pointerType,
1017
+ button: event.button,
1018
+ buttons: event.buttons,
1019
+ screenX: Math.round(event.screenX || 0),
1020
+ screenY: Math.round(event.screenY || 0),
1021
+ clientX: Math.round(event.clientX),
1022
+ clientY: Math.round(event.clientY),
1023
+ altKey: event.altKey,
1024
+ ctrlKey: event.ctrlKey,
1025
+ metaKey: event.metaKey,
1026
+ shiftKey: event.shiftKey,
1027
+ target: "cube",
1028
+ }
1029
+ }
1030
+
1031
+ function beginFrictionHold(event) {
1032
+ if (event.button !== 2) return
1033
+ event.preventDefault()
1034
+ try { hitLayer.setPointerCapture(event.pointerId) } catch {}
1035
+ frictionHoldActive = true
1036
+ postInteraction(pointerPayload("mouse.right.down", event))
1037
+ }
1038
+
1039
+ function endFrictionHold(event) {
1040
+ if (event && event.button !== undefined && event.button !== 2) return
1041
+ if (frictionHoldActive) postInteraction(pointerPayload("mouse.right.up", event || { button: 2, buttons: 0, clientX: 0, clientY: 0 }))
1042
+ frictionHoldActive = false
1043
+ }
1044
+
1045
+ function beginLeftDrag(event) {
1046
+ if (event.button !== 0) return
1047
+ event.preventDefault()
1048
+ try { hitLayer.setPointerCapture(event.pointerId) } catch {}
1049
+ leftDragActive = true
1050
+ ipcRenderer.send("opencube-drag-start", { screenX: event.screenX, screenY: event.screenY })
1051
+ }
1052
+
1053
+ function moveLeftDrag(event) {
1054
+ if (!leftDragActive) return
1055
+ event.preventDefault()
1056
+ ipcRenderer.send("opencube-drag-move", { screenX: event.screenX, screenY: event.screenY })
1057
+ }
1058
+
1059
+ function endLeftDrag(event) {
1060
+ if (!leftDragActive) return
1061
+ leftDragActive = false
1062
+ ipcRenderer.send("opencube-drag-end")
1063
+ }
1064
+
1065
+ function updateFrictionHold(dt) {
1066
+ const growPerSecond = 2.4
1067
+ const recoverPerSecond = 8.0
1068
+ if (frictionHoldActive) {
1069
+ frictionHoldLevel = Math.min(11, frictionHoldLevel + growPerSecond * dt)
1070
+ } else {
1071
+ frictionHoldLevel = Math.max(0, frictionHoldLevel - recoverPerSecond * dt)
1072
+ }
1073
+ return 1 + frictionHoldLevel
1074
+ }
1075
+
1076
+ hitLayer.addEventListener("pointerdown", (event) => {
1077
+ if (event.button === 0) beginLeftDrag(event)
1078
+ if (event.button === 2) beginFrictionHold(event)
1079
+ })
1080
+ hitLayer.addEventListener("pointermove", moveLeftDrag)
1081
+ hitLayer.addEventListener("pointerup", (event) => {
1082
+ if (event.button === 0) endLeftDrag(event)
1083
+ if (event.button === 2) endFrictionHold(event)
1084
+ })
1085
+ hitLayer.addEventListener("pointercancel", (event) => {
1086
+ endLeftDrag(event)
1087
+ endFrictionHold(event)
1088
+ })
1089
+ hitLayer.addEventListener("contextmenu", (event) => {
1090
+ event.preventDefault()
1091
+ postInteraction(pointerPayload("mouse.right.contextmenu", event))
1092
+ })
1093
+ window.addEventListener("keydown", (event) => postInteraction({ type: "keyboard.down", key: event.key, code: event.code, altKey: event.altKey, ctrlKey: event.ctrlKey, metaKey: event.metaKey, shiftKey: event.shiftKey }))
1094
+ window.addEventListener("keyup", (event) => postInteraction({ type: "keyboard.up", key: event.key, code: event.code, altKey: event.altKey, ctrlKey: event.ctrlKey, metaKey: event.metaKey, shiftKey: event.shiftKey }))
1095
+ document.addEventListener("mouseup", endFrictionHold)
1096
+ window.addEventListener("mouseup", endFrictionHold)
1097
+ window.addEventListener("pointerup", endFrictionHold)
1098
+ window.addEventListener("pointercancel", () => endFrictionHold())
1099
+ window.addEventListener("blur", () => {
1100
+ endLeftDrag()
1101
+ endFrictionHold()
1102
+ })
1103
+
654
1104
  function randomBetween(min, max) {
655
1105
  return min + Math.random() * (max - min)
656
1106
  }
@@ -722,6 +1172,8 @@ function petHtml3D() {
722
1172
  createFace("left", [-0.30, 0, 0], [0, -Math.PI / 2, rad(faceRotations.left || 0)], [-0.314, 0, 0])
723
1173
  createFace("top", [0, 0.30, 0], [-Math.PI / 2, 0, rad(faceRotations.top || 0)], [0, 0.314, 0])
724
1174
  createFace("bottom", [0, -0.30, 0], [Math.PI / 2, 0, rad(faceRotations.bottom || 0)], [0, -0.314, 0])
1175
+ createDragParticles()
1176
+ createToolParticles()
725
1177
 
726
1178
  function createFace(name, position, rotation, glowPosition) {
727
1179
  const face = new THREE.Mesh(faceGeometry, iconMaterial.clone())
@@ -949,15 +1401,20 @@ function petHtml3D() {
949
1401
  const busyCount = sessions.filter((session) => session.state === "busy").length
950
1402
  const isBusy = busyCount > 0
951
1403
 
952
- if (isBusy && !wasBusy) setNextTorque(now)
953
- if (isBusy && now >= nextTorqueAt) setNextTorque(now)
954
- if (!isBusy) torque = { x: 0, y: 0, z: 0 }
1404
+ // Keep the same randomized torque model in both busy and idle modes.
1405
+ // Busy uses low friction, so the cube accelerates into active motion.
1406
+ // Idle keeps receiving random torque, but high friction turns it into a
1407
+ // subtle breathing/drifting motion instead of stopping completely.
1408
+ if (isBusy !== wasBusy) setNextTorque(now)
1409
+ if (now >= nextTorqueAt) setNextTorque(now)
955
1410
  wasBusy = isBusy
956
1411
  stage.classList.toggle("has-busy", isBusy)
957
1412
  stage.classList.toggle("has-sessions", sessions.length > 0)
958
1413
 
959
1414
  const inertia = 1.18
960
- const friction = isBusy ? 0.58 : 2.85
1415
+ const baseFriction = isBusy ? 0.58 : 2.85
1416
+ const holdMultiplier = updateFrictionHold(dt)
1417
+ const friction = baseFriction * holdMultiplier
961
1418
  angularVelocity.x += (torque.x / inertia) * dt
962
1419
  angularVelocity.y += (torque.y / inertia) * dt
963
1420
  angularVelocity.z += (torque.z / inertia) * dt
@@ -970,6 +1427,10 @@ function petHtml3D() {
970
1427
  rotation.x += angularVelocity.x * dt
971
1428
  rotation.y += angularVelocity.y * dt
972
1429
  rotation.z += angularVelocity.z * dt
1430
+ cubeGroup.rotation.x = rad(rotation.x)
1431
+ cubeGroup.rotation.y = rad(rotation.y)
1432
+ cubeGroup.rotation.z = rad(rotation.z)
1433
+ cubeGroup.updateMatrixWorld(true)
973
1434
 
974
1435
  const speed = magnitude(angularVelocity)
975
1436
  const speedRatio = Math.min(1, speed / 1400)
@@ -977,20 +1438,35 @@ function petHtml3D() {
977
1438
  const glowR = Math.round(92 + (0 - 92) * glow)
978
1439
  const glowG = Math.round(255 + (190 - 255) * glow)
979
1440
  const glowB = Math.round(232 + (210 - 232) * glow)
1441
+ const dragParticles = updateDragParticles(now, frictionHoldLevel, speed, dt)
980
1442
  const busyFaces = syncBusyFaces(sessions, speed)
1443
+ const toolParticles = updateToolParticles(sessions, dt)
981
1444
  processSignals(snapshot.signals || [], now)
982
1445
  const helloFlashes = applyFlashFaces(now)
983
1446
  renderCube()
984
1447
  latestDebug = {
985
1448
  now: Date.now(),
986
1449
  busy: busyCount,
1450
+ mode: isBusy ? "busy" : "idle",
1451
+ sessions: sessions.map((session) => ({
1452
+ sessionID: session.sessionID,
1453
+ state: session.state,
1454
+ activeTools: session.activeTools || [],
1455
+ })),
987
1456
  rotation: { ...rotation },
988
1457
  angularVelocity: { ...angularVelocity },
989
1458
  torque: { ...torque },
1459
+ friction,
1460
+ baseFriction,
1461
+ frictionHoldActive,
1462
+ frictionHoldMultiplier: holdMultiplier,
1463
+ frictionHoldLevel,
990
1464
  speed,
991
1465
  speedRatio,
992
1466
  nextTorqueAt,
993
1467
  glow,
1468
+ dragParticles,
1469
+ toolParticles,
994
1470
  colorReleaseSpeed,
995
1471
  glowColor: { r: glowR, g: glowG, b: glowB },
996
1472
  faceRotations,
@@ -1059,11 +1535,9 @@ function createPetWindow() {
1059
1535
  })
1060
1536
  petWindow.setAlwaysOnTop(true, "floating")
1061
1537
  petWindow.loadFile(writePetHtmlFile())
1062
- petWindow.webContents.on("context-menu", () => buildMenu().popup({ window: petWindow }))
1063
1538
  petWindow.on("moved", () => {
1064
1539
  const [x, y] = petWindow.getPosition()
1065
1540
  writeState({ visible: true, x, y })
1066
- positionPanel()
1067
1541
  })
1068
1542
  petWindow.on("closed", () => {
1069
1543
  petWindow = null
@@ -1076,8 +1550,8 @@ function createPanelWindow() {
1076
1550
  if (panelWindow && !panelWindow.isDestroyed()) return panelWindow
1077
1551
 
1078
1552
  panelWindow = new BrowserWindow({
1079
- width: 360,
1080
- height: 360,
1553
+ width: 680,
1554
+ height: 420,
1081
1555
  show: false,
1082
1556
  frame: false,
1083
1557
  resizable: false,
@@ -1100,14 +1574,28 @@ function createPanelWindow() {
1100
1574
  }
1101
1575
 
1102
1576
  function positionPanel() {
1103
- if (!petWindow || petWindow.isDestroyed() || !panelWindow || panelWindow.isDestroyed()) return
1104
- const [x, y] = petWindow.getPosition()
1105
- const petBounds = petWindow.getBounds()
1577
+ if (!panelWindow || panelWindow.isDestroyed()) return
1106
1578
  const panelBounds = panelWindow.getBounds()
1107
- const display = screen.getDisplayNearestPoint({ x, y }).workArea
1108
- let nextX = x - panelBounds.width - 10
1109
- let nextY = y
1110
- if (nextX < display.x) nextX = x + petBounds.width + 10
1579
+ const anchor = tray && !tray.isDestroyed() ? tray.getBounds() : undefined
1580
+ const display = anchor
1581
+ ? screen.getDisplayNearestPoint({ x: anchor.x, y: anchor.y }).workArea
1582
+ : screen.getPrimaryDisplay().workArea
1583
+
1584
+ let nextX = anchor
1585
+ ? Math.round(anchor.x + anchor.width / 2 - panelBounds.width / 2)
1586
+ : display.x + display.width - panelBounds.width - 16
1587
+ let nextY = anchor
1588
+ ? Math.round(anchor.y + anchor.height + 8)
1589
+ : display.y + 36
1590
+
1591
+ // If the menu bar reports a coordinate outside the workArea, keep the panel
1592
+ // as a standalone top-right popover instead of attaching it to the cube.
1593
+ if (!anchor || nextY < display.y || nextY > display.y + display.height) {
1594
+ nextX = display.x + display.width - panelBounds.width - 16
1595
+ nextY = display.y + 36
1596
+ }
1597
+
1598
+ if (nextX < display.x) nextX = display.x + 8
1111
1599
  if (nextX + panelBounds.width > display.x + display.width) nextX = display.x + display.width - panelBounds.width - 8
1112
1600
  if (nextY + panelBounds.height > display.y + display.height) nextY = display.y + display.height - panelBounds.height - 8
1113
1601
  if (nextY < display.y) nextY = display.y + 8
@@ -1115,7 +1603,6 @@ function positionPanel() {
1115
1603
  }
1116
1604
 
1117
1605
  function showPanel() {
1118
- showPet()
1119
1606
  const win = createPanelWindow()
1120
1607
  updatePanel()
1121
1608
  positionPanel()
@@ -1147,15 +1634,32 @@ function hidePet() {
1147
1634
 
1148
1635
  function buildMenu() {
1149
1636
  return Menu.buildFromTemplate([
1150
- { label: "Show Pet", click: showPet },
1637
+ { label: "Show OpenCube", click: showPet },
1638
+ { label: "Hide OpenCube", click: hidePet },
1639
+ { type: "separator" },
1151
1640
  { label: "Show Inbox", click: showPanel },
1152
- { label: "Hide Pet", click: hidePet },
1153
1641
  { label: "Hide Inbox", click: hidePanel },
1154
1642
  { type: "separator" },
1155
- { label: "Quit Pet", click: () => app.quit() },
1643
+ { label: "Quit OpenCube", click: () => app.quit() },
1156
1644
  ])
1157
1645
  }
1158
1646
 
1647
+ function createTray() {
1648
+ if (tray) return tray
1649
+ let image = nativeImage.createFromPath(ICON_PATH)
1650
+ if (image.isEmpty()) {
1651
+ image = nativeImage.createFromDataURL(
1652
+ "data:image/svg+xml;utf8," +
1653
+ encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><rect x="4" y="4" width="24" height="24" rx="6" fill="#111"/><rect x="11" y="8" width="10" height="16" rx="2" fill="#fff"/><rect x="14" y="12" width="5" height="9" rx="1" fill="#111"/></svg>`),
1654
+ )
1655
+ }
1656
+ image = image.resize({ width: 18, height: 18 })
1657
+ tray = new Tray(image)
1658
+ tray.setToolTip("OpenCube")
1659
+ tray.setContextMenu(buildMenu())
1660
+ return tray
1661
+ }
1662
+
1159
1663
  function startServer() {
1160
1664
  if (server) return
1161
1665
  server = http.createServer(async (req, res) => {
@@ -1169,7 +1673,7 @@ function startServer() {
1169
1673
  pid: process.pid,
1170
1674
  port: PORT,
1171
1675
  events: events.length,
1172
- pet: "pixel-opencode-pet",
1676
+ pet: "opencube",
1173
1677
  sessions: getPetState().sessions.map(({ sessionID, state, busyIndex, idleIndex, color }) => ({
1174
1678
  sessionID,
1175
1679
  state,
@@ -1208,6 +1712,12 @@ function startServer() {
1208
1712
  return json(res, 200, { ok: true, event: item })
1209
1713
  }
1210
1714
 
1715
+ if (req.method === "POST" && url.pathname === "/interaction") {
1716
+ const body = await readRequestJson(req)
1717
+ const item = recordInteractionEvent(body)
1718
+ return json(res, 200, { ok: true, event: item })
1719
+ }
1720
+
1211
1721
  if (req.method === "POST" && url.pathname === "/show") {
1212
1722
  showPet()
1213
1723
  return json(res, 200, { ok: true })
@@ -1238,6 +1748,7 @@ function startServer() {
1238
1748
  function start() {
1239
1749
  app.dock?.hide()
1240
1750
  writeState({ visible: false, mode: "floating" })
1751
+ createTray()
1241
1752
  startServer()
1242
1753
  cleanupTimer = setInterval(() => pruneIdleSessions(true), 30 * 1000)
1243
1754
  cleanupTimer.unref?.()
@@ -47,8 +47,19 @@ function cubNotice(text, icon = CUB_ICON) {
47
47
  return `${icon} ${text}`
48
48
  }
49
49
 
50
+ function summarizeToolOutput(output) {
51
+ if (!output || typeof output !== "object") return undefined
52
+ const text = typeof output.output === "string" ? output.output : undefined
53
+ return {
54
+ title: output.title,
55
+ output: text && text.length > 2000 ? `${text.slice(0, 2000)}…` : text,
56
+ outputLength: text?.length,
57
+ metadata: output.metadata,
58
+ }
59
+ }
60
+
50
61
  module.exports = {
51
- id: "opencode-pet",
62
+ id: "opencube",
52
63
  server: async ({ client }) => {
53
64
  const sessionStatus = new Map()
54
65
 
@@ -68,11 +79,11 @@ module.exports = {
68
79
  }
69
80
  cfg.command.pet_say_hello = {
70
81
  template: "/pet_say_hello",
71
- description: "Send a hello test event to OpenCub.",
82
+ description: "Send a hello test event to OpenCube.",
72
83
  }
73
84
  cfg.command.pet_fancy_say_hello = {
74
85
  template: "/pet_fancy_say_hello",
75
- description: "Trigger a randomized light show on OpenCub's free faces.",
86
+ description: "Trigger a randomized light show on OpenCube's free faces.",
76
87
  }
77
88
  },
78
89
 
@@ -87,7 +98,7 @@ module.exports = {
87
98
 
88
99
  if (shouldQuit(input)) {
89
100
  await quitPet()
90
- await injectNotice(client, input.sessionID, cubNotice("OpenCub is going to sleep 🐾", "◌"))
101
+ await injectNotice(client, input.sessionID, cubNotice("OpenCube is going to sleep 🐾", "◌"))
91
102
  } else if (isSayHello(input)) {
92
103
  const result = await sendEvent({
93
104
  type: "hello",
@@ -95,12 +106,12 @@ module.exports = {
95
106
  command: input.command,
96
107
  arguments: input.arguments,
97
108
  sessionID: input.sessionID,
98
- source: "opencode-pet-plugin",
109
+ source: "opencube-plugin",
99
110
  })
100
111
  await injectNotice(
101
112
  client,
102
113
  input.sessionID,
103
- result ? cubNotice("OpenCub got your hello 🐾", "✦") : cubNotice("OpenCub is sleeping... zzz Use /pet to wake it.", "☾"),
114
+ result ? cubNotice("OpenCube got your hello 🐾", "✦") : cubNotice("OpenCube is sleeping... zzz Use /pet to wake it.", "☾"),
104
115
  )
105
116
  } else if (isFancySayHello(input)) {
106
117
  const result = await sendEvent({
@@ -109,14 +120,14 @@ module.exports = {
109
120
  command: input.command,
110
121
  arguments: input.arguments,
111
122
  sessionID: input.sessionID,
112
- source: "opencode-pet-plugin",
123
+ source: "opencube-plugin",
113
124
  })
114
125
  await injectNotice(
115
126
  client,
116
127
  input.sessionID,
117
128
  result
118
- ? cubNotice("OpenCub is putting on a light show ✨", "✺")
119
- : cubNotice("OpenCub is sleeping... zzz Start it with /pet before the light show.", "☾"),
129
+ ? cubNotice("OpenCube is putting on a light show ✨", "✺")
130
+ : cubNotice("OpenCube is sleeping... zzz Start it with /pet before the light show.", "☾"),
120
131
  )
121
132
  } else {
122
133
  await showPet({
@@ -146,7 +157,7 @@ module.exports = {
146
157
  sessionID,
147
158
  status,
148
159
  previousStatus: previous,
149
- source: "opencode-pet-plugin",
160
+ source: "opencube-plugin",
150
161
  })
151
162
  return
152
163
  }
@@ -161,7 +172,32 @@ module.exports = {
161
172
  sessionID,
162
173
  status,
163
174
  previousStatus: previous,
164
- source: "opencode-pet-plugin",
175
+ source: "opencube-plugin",
176
+ })
177
+ },
178
+
179
+ "tool.execute.before": async (input, output) => {
180
+ await sendEvent({
181
+ type: "tool.start",
182
+ message: `tool ${input.tool} started`,
183
+ sessionID: input.sessionID,
184
+ tool: input.tool,
185
+ callID: input.callID,
186
+ args: output?.args,
187
+ source: "opencube-plugin",
188
+ })
189
+ },
190
+
191
+ "tool.execute.after": async (input, output) => {
192
+ await sendEvent({
193
+ type: "tool.finish",
194
+ message: `tool ${input.tool} finished`,
195
+ sessionID: input.sessionID,
196
+ tool: input.tool,
197
+ callID: input.callID,
198
+ args: input.args,
199
+ result: summarizeToolOutput(output),
200
+ source: "opencube-plugin",
165
201
  })
166
202
  },
167
203
  }
@@ -30,7 +30,7 @@ async function emitProgress(onProgress, message) {
30
30
  try {
31
31
  await onProgress(message)
32
32
  } catch {
33
- // Progress is best-effort; never block OpenCub startup on UI notices.
33
+ // Progress is best-effort; never block OpenCube startup on UI notices.
34
34
  }
35
35
  }
36
36
 
@@ -76,11 +76,11 @@ async function installElectronBinary(electronDir, options = {}) {
76
76
  const executablePath = path.join(distPath, platformPath)
77
77
 
78
78
  if (fs.existsSync(executablePath)) {
79
- await emitProgress(options.onProgress, "OpenCub: Electron binary is ready ✅")
79
+ await emitProgress(options.onProgress, "OpenCube: Electron binary is ready ✅")
80
80
  return executablePath
81
81
  }
82
82
 
83
- await emitProgress(options.onProgress, `OpenCub: downloading Electron ${version} for ${platform}/${arch}...`)
83
+ await emitProgress(options.onProgress, `OpenCube: downloading Electron ${version} for ${platform}/${arch}...`)
84
84
  const zipPath = await downloadArtifact({
85
85
  version,
86
86
  artifactName: "electron",
@@ -89,19 +89,19 @@ async function installElectronBinary(electronDir, options = {}) {
89
89
  platform,
90
90
  arch,
91
91
  })
92
- await emitProgress(options.onProgress, "OpenCub: extracting Electron binary...")
92
+ await emitProgress(options.onProgress, "OpenCube: extracting Electron binary...")
93
93
  await extractElectronZip(zipPath, distPath)
94
94
  await fs.promises.writeFile(path.join(electronDir, "path.txt"), platformPath)
95
- await emitProgress(options.onProgress, "OpenCub: Electron binary installed ✅")
95
+ await emitProgress(options.onProgress, "OpenCube: Electron binary installed ✅")
96
96
  return executablePath
97
97
  }
98
98
 
99
99
  async function resolveElectronPath(options = {}) {
100
- await emitProgress(options.onProgress, "OpenCub: checking Electron runtime...")
100
+ await emitProgress(options.onProgress, "OpenCube: checking Electron runtime...")
101
101
  try {
102
102
  const electronPath = require("electron")
103
103
  if (typeof electronPath === "string") {
104
- await emitProgress(options.onProgress, "OpenCub: Electron runtime is ready ✅")
104
+ await emitProgress(options.onProgress, "OpenCube: Electron runtime is ready ✅")
105
105
  return electronPath
106
106
  }
107
107
 
@@ -109,12 +109,12 @@ async function resolveElectronPath(options = {}) {
109
109
  // environment require("electron") can resolve to Electron's built-in API
110
110
  // object instead of the npm package's executable path string. Fall through
111
111
  // to the npm package directory and resolve/repair the packaged binary.
112
- await emitProgress(options.onProgress, "OpenCub: locating packaged Electron binary...")
112
+ await emitProgress(options.onProgress, "OpenCube: locating packaged Electron binary...")
113
113
  const electronPackage = require.resolve("electron/package.json")
114
114
  const electronDir = path.dirname(electronPackage)
115
115
  return await installElectronBinary(electronDir, options)
116
116
  } catch (error) {
117
- await emitProgress(options.onProgress, "OpenCub: Electron runtime is incomplete; repairing...")
117
+ await emitProgress(options.onProgress, "OpenCube: Electron runtime is incomplete; repairing...")
118
118
  const electronPackage = require.resolve("electron/package.json")
119
119
  const electronDir = path.dirname(electronPackage)
120
120
  return await installElectronBinary(electronDir, options)
@@ -123,7 +123,7 @@ async function resolveElectronPath(options = {}) {
123
123
 
124
124
  async function launchPet(args = [], options = {}) {
125
125
  const electronPath = await resolveElectronPath(options)
126
- await emitProgress(options.onProgress, "OpenCub: launching desktop pet...")
126
+ await emitProgress(options.onProgress, "OpenCube: launching desktop pet...")
127
127
  const child = spawn(electronPath, [PET_APP_DIR, ...args], {
128
128
  cwd: PET_APP_DIR,
129
129
  detached: true,
@@ -134,7 +134,7 @@ async function launchPet(args = [], options = {}) {
134
134
  },
135
135
  })
136
136
  child.unref()
137
- await emitProgress(options.onProgress, "OpenCub: launch request sent 🐾")
137
+ await emitProgress(options.onProgress, "OpenCube: launch request sent 🐾")
138
138
  }
139
139
 
140
140
  async function requestPet(pathname, options = {}) {
@@ -165,28 +165,28 @@ async function healthPet() {
165
165
  }
166
166
 
167
167
  async function waitForPet(timeoutMs = 3500, options = {}) {
168
- await emitProgress(options.onProgress, "OpenCub: waiting for local server...")
168
+ await emitProgress(options.onProgress, "OpenCube: waiting for local server...")
169
169
  const startedAt = Date.now()
170
170
  while (Date.now() - startedAt < timeoutMs) {
171
171
  const health = await healthPet()
172
172
  if (health) {
173
- await emitProgress(options.onProgress, "OpenCub: local server is ready ✅")
173
+ await emitProgress(options.onProgress, "OpenCube: local server is ready ✅")
174
174
  return health
175
175
  }
176
176
  await new Promise((resolve) => setTimeout(resolve, 150))
177
177
  }
178
- await emitProgress(options.onProgress, "OpenCub: local server did not answer yet")
178
+ await emitProgress(options.onProgress, "OpenCube: local server did not answer yet")
179
179
  return undefined
180
180
  }
181
181
 
182
182
  async function ensurePet(options = {}) {
183
- await emitProgress(options.onProgress, "OpenCub: checking whether it is already running...")
183
+ await emitProgress(options.onProgress, "OpenCube: checking whether it is already running...")
184
184
  const existing = await healthPet()
185
185
  if (existing) {
186
- await emitProgress(options.onProgress, "OpenCub: already running; showing window...")
186
+ await emitProgress(options.onProgress, "OpenCube: already running; showing window...")
187
187
  return existing
188
188
  }
189
- await emitProgress(options.onProgress, "OpenCub: not running; starting now...")
189
+ await emitProgress(options.onProgress, "OpenCube: not running; starting now...")
190
190
  await launchPet(["--show"], options)
191
191
  return await waitForPet(3500, options)
192
192
  }
@@ -194,7 +194,7 @@ async function ensurePet(options = {}) {
194
194
  async function showPet(options = {}) {
195
195
  const health = await ensurePet(options)
196
196
  await requestPet("/show", { method: "POST", timeoutMs: 800 })
197
- await emitProgress(options.onProgress, health ? "OpenCub: shown ✨" : "OpenCub: start requested, still warming up...")
197
+ await emitProgress(options.onProgress, health ? "OpenCube: shown ✨" : "OpenCube: start requested, still warming up...")
198
198
  return health
199
199
  }
200
200
 
@@ -208,7 +208,7 @@ async function quitPet() {
208
208
  }
209
209
 
210
210
  async function sendEvent(event) {
211
- // Only /pet is allowed to start OpenCub. Session lifecycle events and hello
211
+ // Only /pet is allowed to start OpenCube. Session lifecycle events and hello
212
212
  // commands should talk to the desktop pet only if it is already running.
213
213
  const health = await healthPet()
214
214
  if (!health) return undefined
@@ -1,5 +1,5 @@
1
1
  module.exports = {
2
- id: "opencode-pet",
2
+ id: "opencube",
3
3
  // Slash commands are registered from the server plugin via cfg.command, using
4
4
  // the same command.execute.before abort trick as @slkiser/opencode-quota.
5
5
  // Keep a no-op TUI entry so opencode can load ./tui without duplicate /pet rows.