Browse Source

Merge pull request #433 from alvinunreal/divoom

Add Divoom display integration
Alvin 3 months ago
parent
commit
76b3ee842d

+ 1 - 0
.gitignore

@@ -81,3 +81,4 @@ wheels/
 *.egg-info/
 .installed.cfg
 *.egg
+captures/

+ 5 - 0
README.md

@@ -88,6 +88,10 @@ Session management is enabled by default even though it is not shown in the
 starter config. See **[Session Management](docs/session-management.md)** if you
 want to customize how many resumable child-agent sessions are remembered.
 
+Optional Divoom Bluetooth display status GIFs are available but disabled by
+default. Install the companion daemon first, then enable them with
+`"divoom": { "enabled": true }`; see **[Divoom Display Integration](docs/divoom.md)**.
+
 ### For Alternative Providers
 
 To use OpenCode Go, Kimi, GitHub Copilot, ZAI Coding Plan, or a mixed-provider setup, use **[Configuration](docs/configuration.md)** for the full reference. If you want a ready-made starting point, check the **[Author's Preset](docs/authors-preset.md)** and **[$30 Preset](docs/thirty-dollars-preset.md)** - the `$30` preset is the best cheap setup.
@@ -479,6 +483,7 @@ Use this section as a map: start with installation, then jump to features, confi
 | Doc | What it covers |
 |-----|----------------|
 | **[Council](docs/council.md)** | Run multiple models in parallel and synthesize a single answer with `@council` |
+| **[Divoom Display](docs/divoom.md)** | Mirror orchestrator and specialist-agent activity to a Divoom MiniToo Bluetooth display |
 | **[Interview](docs/interview.md)** | Turn rough ideas into a structured markdown spec through a browser-based Q&A flow |
 | **[Multiplexer Integration](docs/multiplexer-integration.md)** | Watch agents work live in Tmux or Zellij panes |
 | **[Session Management](docs/session-management.md)** | Reuse recent child-agent sessions with short aliases instead of starting over |

+ 74 - 0
docs/configuration.md

@@ -104,6 +104,15 @@ Presets can also be switched at runtime without restarting using the `/preset` c
 | `multiplexer.type` | string | `"none"` | Multiplexer mode: `auto`, `tmux`, `zellij`, or `none` |
 | `multiplexer.layout` | string | `"main-vertical"` | Layout preset: `main-vertical`, `main-horizontal`, `tiled`, `even-horizontal`, `even-vertical` |
 | `multiplexer.main_pane_size` | number | `60` | Main pane size as percentage (20–80) |
+| `divoom.enabled` | boolean | `false` | Enable Divoom Bluetooth display status GIFs for plugin load and delegated agent calls |
+| `divoom.python` | string | Divoom MiniToo bundled Python | Python executable used to run Divoom MiniToo's `divoom_send.py` helper |
+| `divoom.script` | string | Divoom MiniToo `divoom_send.py` | Divoom sender script path |
+| `divoom.size` | integer | `128` | Output GIF size passed to `divoom_send.py` |
+| `divoom.fps` | integer | `8` | Output GIF FPS passed to `divoom_send.py` |
+| `divoom.speed` | integer | `125` | Playback speed passed to `divoom_send.py` |
+| `divoom.maxFrames` | integer | `24` | Maximum frames passed to `divoom_send.py` |
+| `divoom.posterizeBits` | integer | `3` | Posterization bits passed to `divoom_send.py` |
+| `divoom.gifs.<agent>` | string | bundled GIF | Optional per-agent GIF filename or absolute path override |
 | `tmux.enabled` | boolean | `false` | Legacy alias for `multiplexer.type = "tmux"` |
 | `tmux.layout` | string | `"main-vertical"` | Legacy alias for `multiplexer.layout` |
 | `tmux.main_pane_size` | number | `60` | Legacy alias for `multiplexer.main_pane_size` |
@@ -161,6 +170,71 @@ automatically.
 > `"oh-my-opencode-slim@1.0.1"`) are the true version lock. Those stay pinned
 > regardless of `autoUpdate`.
 
+### Divoom Display Integration
+
+Divoom integration is disabled by default. Install and start the Divoom MiniToo
+macOS daemon from
+[`divoom-minitoo-osx`](https://github.com/alvinunreal/divoom-minitoo-osx)
+first, then enable this plugin integration. See the full
+**[Divoom guide](divoom.md)** for setup, daemon startup, and troubleshooting.
+
+When enabled, the plugin sends bundled GIFs to the Divoom MiniToo app's bundled
+CLI:
+
+- plugin load / waiting for user input: `intro.gif`
+- orchestrator busy: `orchestrator.gif`
+- first active delegated agent: that agent's GIF
+- parallel delegated agents: the first agent keeps the display
+- all delegated agents complete while orchestrator keeps working: `orchestrator.gif`
+- orchestrator idle again: `intro.gif`
+
+```jsonc
+{
+  "divoom": {
+    "enabled": true
+  }
+}
+```
+
+For a one-off run without editing config:
+
+```bash
+OH_MY_OPENCODE_SLIM_DIVOOM=1 opencode
+```
+
+If `divoom.enabled` is explicitly set in config, the config value wins over the
+environment variable.
+
+The defaults target the macOS Divoom MiniToo app bundle:
+
+```jsonc
+{
+  "divoom": {
+    "enabled": true,
+    "python": "/Applications/Divoom MiniToo.app/Contents/Resources/.venv/bin/python",
+    "script": "/Applications/Divoom MiniToo.app/Contents/Resources/tools/divoom_send.py",
+    "size": 128,
+    "fps": 8,
+    "speed": 125,
+    "maxFrames": 24,
+    "posterizeBits": 3
+  }
+}
+```
+
+To override a GIF, use either a bundled filename or an absolute path:
+
+```jsonc
+{
+  "divoom": {
+    "enabled": true,
+    "gifs": {
+      "oracle": "/Users/me/Pictures/oracle.gif"
+    }
+  }
+}
+```
+
 ### Session Management
 
 Session management is enabled by default and does not need to be present in the

+ 155 - 0
docs/divoom.md

@@ -0,0 +1,155 @@
+# Divoom MiniToo Display Integration
+
+oh-my-opencode-slim can mirror agent activity to a Divoom MiniToo Bluetooth
+display. The integration is optional and disabled by default.
+
+<div align="center">
+  <video src="https://raw.githubusercontent.com/alvinunreal/oh-my-opencode-slim/master/img/divoom-minitoo.mp4" controls width="720"></video>
+  <p><a href="https://raw.githubusercontent.com/alvinunreal/oh-my-opencode-slim/master/img/divoom-minitoo.mp4">Open video preview</a></p>
+</div>
+
+## What it shows
+
+When enabled, the plugin sends bundled GIFs as OpenCode changes state:
+
+| OpenCode state | Divoom display |
+|----------------|----------------|
+| Plugin loaded / orchestrator waiting for user input | `intro.gif` |
+| Orchestrator is busy planning or working directly | `orchestrator.gif` |
+| A delegated agent starts | that agent's GIF |
+| Multiple agents run in parallel | first delegated agent keeps the display |
+| Delegated agents finish but orchestrator is still working | `orchestrator.gif` |
+| Orchestrator becomes idle again | `intro.gif` |
+
+Bundled GIFs currently cover `orchestrator`, `explorer`, `librarian`, `oracle`,
+`designer`, `fixer`, and `council`.
+
+## Prerequisites
+
+This plugin does not talk to the Bluetooth device directly. It shells out to the
+Divoom MiniToo macOS sender/daemon tooling from:
+
+https://github.com/alvinunreal/divoom-minitoo-osx
+
+Before enabling the plugin integration:
+
+1. Install the Divoom MiniToo macOS app/tooling from that repository.
+2. Pair the Divoom MiniToo with macOS.
+3. Disconnect the normal macOS audio profile once so the daemon can claim the
+   RFCOMM app channel.
+4. Start the Divoom daemon.
+5. Confirm the bundled sender works manually.
+
+The important operational detail: the daemon should stay running. Once it holds
+the RFCOMM channel open, plugin-triggered GIF sends should not need repeated
+Bluetooth disconnect/reconnect cycles.
+
+## Start the Divoom daemon
+
+Follow the current instructions in
+[`divoom-minitoo-osx`](https://github.com/alvinunreal/divoom-minitoo-osx). The
+validated flow is:
+
+```bash
+# From the divoom-minitoo-osx checkout/app resources, disconnect audio once.
+blueutil --disconnect <DIVOOM_BLUETOOTH_ADDRESS> || true
+
+# Start the daemon on RFCOMM channel 1 / localhost port 40583.
+tools/divoom-daemon <DIVOOM_BLUETOOTH_ADDRESS> 1 40583
+```
+
+If you are using the packaged MiniToo app bundle, use its daemon/menu-bar start
+flow instead. The key is the same: disconnect the audio profile if normal daemon
+startup fails, then start the daemon and leave it running.
+
+## Manual sender smoke test
+
+Before blaming OpenCode, verify the Divoom sender works directly:
+
+```bash
+"/Applications/Divoom MiniToo.app/Contents/Resources/.venv/bin/python" \
+  "/Applications/Divoom MiniToo.app/Contents/Resources/tools/divoom_send.py" \
+  "/path/to/test.gif" \
+  --size 128 \
+  --fps 8 \
+  --speed 125 \
+  --max-frames 24 \
+  --posterize-bits 3
+```
+
+If that updates the display, the OpenCode integration should work once enabled.
+
+## Enable in oh-my-opencode-slim
+
+Open your plugin config:
+
+```text
+~/.config/opencode/oh-my-opencode-slim.json
+```
+
+Add:
+
+```jsonc
+{
+  "divoom": {
+    "enabled": true
+  }
+}
+```
+
+Then rebuild/restart OpenCode if you are running from a local checkout.
+
+For one-off runs, you can enable Divoom without changing your config:
+
+```bash
+OH_MY_OPENCODE_SLIM_DIVOOM=1 opencode
+```
+
+Accepted truthy values are `1`, `true`, `yes`, and `on`. If `divoom.enabled`
+is explicitly set in config, the config value wins over the environment variable.
+
+## Tunable settings
+
+The defaults target the macOS Divoom MiniToo app bundle:
+
+```jsonc
+{
+  "divoom": {
+    "enabled": true,
+    "python": "/Applications/Divoom MiniToo.app/Contents/Resources/.venv/bin/python",
+    "script": "/Applications/Divoom MiniToo.app/Contents/Resources/tools/divoom_send.py",
+    "size": 128,
+    "fps": 8,
+    "speed": 125,
+    "maxFrames": 24,
+    "posterizeBits": 3
+  }
+}
+```
+
+You can also override individual GIFs with either bundled filenames or absolute
+paths:
+
+```jsonc
+{
+  "divoom": {
+    "enabled": true,
+    "gifs": {
+      "oracle": "/Users/me/Pictures/oracle.gif",
+      "fixer": "fixer.gif"
+    }
+  }
+}
+```
+
+## Troubleshooting
+
+- **Nothing changes on the display:** run the manual sender smoke test first.
+- **Daemon cannot open the channel:** disconnect the Divoom audio profile, then
+  start the daemon again.
+- **GIF sends are slow:** reduce `maxFrames`, lower `fps`, or use fewer colors
+  via `posterizeBits`.
+- **A custom GIF does not show:** verify the path exists. Relative names resolve
+  against the bundled Divoom asset directory; absolute paths are used as-is.
+- **Only one of several parallel agents appears:** expected behavior. The first
+  delegated agent keeps the display until all parallel delegated work finishes.

BIN
img/divoom-minitoo.mp4


+ 59 - 0
oh-my-opencode-slim.schema.json

@@ -509,6 +509,65 @@
         }
       }
     },
+    "divoom": {
+      "type": "object",
+      "properties": {
+        "enabled": {
+          "default": false,
+          "type": "boolean"
+        },
+        "python": {
+          "default": "/Applications/Divoom MiniToo.app/Contents/Resources/.venv/bin/python",
+          "type": "string",
+          "minLength": 1
+        },
+        "script": {
+          "default": "/Applications/Divoom MiniToo.app/Contents/Resources/tools/divoom_send.py",
+          "type": "string",
+          "minLength": 1
+        },
+        "size": {
+          "default": 128,
+          "type": "integer",
+          "minimum": 1,
+          "maximum": 1024
+        },
+        "fps": {
+          "default": 8,
+          "type": "integer",
+          "minimum": 1,
+          "maximum": 60
+        },
+        "speed": {
+          "default": 125,
+          "type": "integer",
+          "minimum": 1,
+          "maximum": 10000
+        },
+        "maxFrames": {
+          "default": 24,
+          "type": "integer",
+          "minimum": 1,
+          "maximum": 500
+        },
+        "posterizeBits": {
+          "default": 3,
+          "type": "integer",
+          "minimum": 1,
+          "maximum": 8
+        },
+        "gifs": {
+          "type": "object",
+          "propertyNames": {
+            "type": "string"
+          },
+          "additionalProperties": {
+            "type": "string",
+            "minLength": 1
+          }
+        }
+      }
+    },
     "todoContinuation": {
       "type": "object",
       "properties": {

+ 2 - 1
package.json

@@ -48,7 +48,8 @@
   "scripts": {
     "build:plugin": "bun build src/index.ts src/tui.ts --outdir dist --target node --format esm --external @ast-grep/napi --external @opencode-ai/plugin --external @opencode-ai/sdk --external @opentui/core --external @opentui/solid --external jsdom --external zod",
     "build:cli": "bun build src/cli/index.ts --outdir dist/cli --target node --format esm --external @ast-grep/napi --external @opencode-ai/plugin --external @opencode-ai/sdk --external jsdom --external zod",
-    "build": "bun run build:plugin && bun run build:cli && tsc --emitDeclarationOnly && bun run generate-schema",
+    "copy:divoom-assets": "bun run scripts/copy-divoom-assets.ts",
+    "build": "bun run build:plugin && bun run build:cli && bun run copy:divoom-assets && tsc --emitDeclarationOnly && bun run generate-schema",
     "prepare": "bun run build",
     "contributors:add": "all-contributors add",
     "contributors:check": "all-contributors check",

+ 23 - 0
scripts/copy-divoom-assets.ts

@@ -0,0 +1,23 @@
+import { copyFileSync, mkdirSync, readdirSync, rmSync } from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const repoRoot = path.resolve(__dirname, '..');
+const sourceDir = path.join(repoRoot, 'src', 'divoom');
+const outputDir = path.join(repoRoot, 'dist', 'divoom');
+
+rmSync(outputDir, { recursive: true, force: true });
+mkdirSync(outputDir, { recursive: true });
+
+let copied = 0;
+for (const entry of readdirSync(sourceDir, { withFileTypes: true })) {
+  if (!entry.isFile() || !entry.name.endsWith('.gif')) continue;
+  copyFileSync(
+    path.join(sourceDir, entry.name),
+    path.join(outputDir, entry.name),
+  );
+  copied += 1;
+}
+
+console.log(`✅ Copied ${copied} Divoom GIF assets to ${outputDir}`);

+ 8 - 0
scripts/verify-release-artifact.ts

@@ -28,6 +28,14 @@ const packagedRequiredFiles = [
   'dist/index.js',
   'dist/index.d.ts',
   'dist/cli/index.js',
+  'dist/divoom/council.gif',
+  'dist/divoom/designer.gif',
+  'dist/divoom/explorer.gif',
+  'dist/divoom/fixer.gif',
+  'dist/divoom/intro.gif',
+  'dist/divoom/librarian.gif',
+  'dist/divoom/oracle.gif',
+  'dist/divoom/orchestrator.gif',
   'oh-my-opencode-slim.schema.json',
   'src/skills/simplify/SKILL.md',
   'src/skills/codemap/SKILL.md',

+ 1 - 0
src/config/loader.ts

@@ -124,6 +124,7 @@ export function mergePluginConfigs(
     multiplexer: deepMerge(base.multiplexer, override.multiplexer),
     interview: deepMerge(base.interview, override.interview),
     sessionManager: deepMerge(base.sessionManager, override.sessionManager),
+    divoom: deepMerge(base.divoom, override.divoom),
     fallback: deepMerge(base.fallback, override.fallback),
     council: deepMerge(base.council, override.council),
   };

+ 25 - 0
src/config/schema.ts

@@ -189,6 +189,30 @@ export const SessionManagerConfigSchema = z.object({
 
 export type SessionManagerConfig = z.infer<typeof SessionManagerConfigSchema>;
 
+export const DivoomConfigSchema = z.object({
+  enabled: z.boolean().default(false),
+  python: z
+    .string()
+    .min(1)
+    .default(
+      '/Applications/Divoom MiniToo.app/Contents/Resources/.venv/bin/python',
+    ),
+  script: z
+    .string()
+    .min(1)
+    .default(
+      '/Applications/Divoom MiniToo.app/Contents/Resources/tools/divoom_send.py',
+    ),
+  size: z.number().int().min(1).max(1024).default(128),
+  fps: z.number().int().min(1).max(60).default(8),
+  speed: z.number().int().min(1).max(10_000).default(125),
+  maxFrames: z.number().int().min(1).max(500).default(24),
+  posterizeBits: z.number().int().min(1).max(8).default(3),
+  gifs: z.record(z.string(), z.string().min(1)).optional(),
+});
+
+export type DivoomConfig = z.infer<typeof DivoomConfigSchema>;
+
 // Todo continuation configuration
 export const TodoContinuationConfigSchema = z.object({
   maxContinuations: z
@@ -309,6 +333,7 @@ export const PluginConfigSchema = z
     websearch: WebsearchConfigSchema.optional(),
     interview: InterviewConfigSchema.optional(),
     sessionManager: SessionManagerConfigSchema.optional(),
+    divoom: DivoomConfigSchema.optional(),
     todoContinuation: TodoContinuationConfigSchema.optional(),
     fallback: FailoverConfigSchema.optional(),
     council: CouncilConfigSchema.optional(),

BIN
src/divoom/council.gif


BIN
src/divoom/designer.gif


BIN
src/divoom/explorer.gif


BIN
src/divoom/fixer.gif


BIN
src/divoom/intro.gif


BIN
src/divoom/librarian.gif


+ 326 - 0
src/divoom/manager.test.ts

@@ -0,0 +1,326 @@
+import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
+import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import { DivoomManager, type DivoomSenderCall } from './manager';
+
+function createGifAssets(dir: string, names: string[]): void {
+  for (const name of names) {
+    writeFileSync(path.join(dir, name), 'gif');
+  }
+}
+
+describe('DivoomManager', () => {
+  let tempDir: string;
+  let calls: DivoomSenderCall[];
+  let pythonPath: string;
+  let scriptPath: string;
+  let originalDivoomEnv: string | undefined;
+
+  beforeEach(() => {
+    originalDivoomEnv = process.env.OH_MY_OPENCODE_SLIM_DIVOOM;
+    delete process.env.OH_MY_OPENCODE_SLIM_DIVOOM;
+    tempDir = mkdtempSync(path.join(tmpdir(), 'divoom-test-'));
+    calls = [];
+    pythonPath = path.join(tempDir, 'python');
+    scriptPath = path.join(tempDir, 'divoom_send.py');
+    writeFileSync(pythonPath, 'python');
+    writeFileSync(scriptPath, 'script');
+    createGifAssets(tempDir, [
+      'intro.gif',
+      'orchestrator.gif',
+      'explorer.gif',
+      'fixer.gif',
+      'oracle.gif',
+    ]);
+  });
+
+  afterEach(() => {
+    if (originalDivoomEnv === undefined) {
+      delete process.env.OH_MY_OPENCODE_SLIM_DIVOOM;
+    } else {
+      process.env.OH_MY_OPENCODE_SLIM_DIVOOM = originalDivoomEnv;
+    }
+    rmSync(tempDir, { recursive: true, force: true });
+  });
+
+  function createManager(config: { enabled?: boolean } = {}) {
+    return new DivoomManager(
+      {
+        enabled: config.enabled ?? true,
+        python: pythonPath,
+        script: scriptPath,
+      },
+      (call) => {
+        calls.push(call);
+      },
+      { assetDir: tempDir },
+    );
+  }
+
+  test('does nothing when disabled', async () => {
+    const manager = createManager({ enabled: false });
+
+    manager.onPluginLoad();
+    manager.onTaskStart({
+      parentSessionId: 'parent',
+      callId: 'call-1',
+      args: { subagent_type: 'explorer' },
+    });
+    manager.onTaskEnd({ parentSessionId: 'parent', callId: 'call-1' });
+    await manager.flush();
+
+    expect(calls).toHaveLength(0);
+  });
+
+  test('shows intro on plugin load when enabled', async () => {
+    const manager = createManager();
+
+    manager.onPluginLoad();
+    await manager.flush();
+
+    expect(calls).toHaveLength(1);
+    expect(calls[0].args[1]).toBe(path.join(tempDir, 'intro.gif'));
+  });
+
+  test('can be enabled for one run with env var', async () => {
+    process.env.OH_MY_OPENCODE_SLIM_DIVOOM = '1';
+    const manager = new DivoomManager(
+      {
+        python: pythonPath,
+        script: scriptPath,
+      },
+      (call) => {
+        calls.push(call);
+      },
+      { assetDir: tempDir },
+    );
+
+    manager.onPluginLoad();
+    await manager.flush();
+
+    expect(calls.map((call) => call.args[1])).toEqual([
+      path.join(tempDir, 'intro.gif'),
+    ]);
+  });
+
+  test('explicit config disabled wins over env var', async () => {
+    process.env.OH_MY_OPENCODE_SLIM_DIVOOM = 'true';
+    const manager = createManager({ enabled: false });
+
+    manager.onPluginLoad();
+    await manager.flush();
+
+    expect(calls).toHaveLength(0);
+  });
+
+  test('shows task agent then orchestrator after a single task', async () => {
+    const manager = createManager();
+
+    manager.onTaskStart({
+      parentSessionId: 'parent',
+      callId: 'call-1',
+      args: { subagent_type: 'explorer' },
+    });
+    await manager.flush();
+    manager.onTaskEnd({ parentSessionId: 'parent', callId: 'call-1' });
+    await manager.flush();
+
+    expect(calls.map((call) => call.args[1])).toEqual([
+      path.join(tempDir, 'explorer.gif'),
+      path.join(tempDir, 'orchestrator.gif'),
+    ]);
+  });
+
+  test('shows orchestrator while busy and intro when idle', async () => {
+    const manager = createManager();
+
+    manager.onOrchestratorStatus({
+      sessionId: 'parent',
+      status: 'busy',
+      isOrchestrator: true,
+    });
+    await manager.flush();
+    manager.onOrchestratorStatus({
+      sessionId: 'parent',
+      status: 'idle',
+      isOrchestrator: true,
+    });
+    await manager.flush();
+
+    expect(calls.map((call) => call.args[1])).toEqual([
+      path.join(tempDir, 'orchestrator.gif'),
+      path.join(tempDir, 'intro.gif'),
+    ]);
+  });
+
+  test('idle clears active child state and returns to intro', async () => {
+    const manager = createManager();
+
+    manager.onTaskStart({
+      parentSessionId: 'parent',
+      callId: 'call-1',
+      args: { subagent_type: 'explorer' },
+    });
+    await manager.flush();
+    manager.onOrchestratorStatus({
+      sessionId: 'parent',
+      status: 'busy',
+      isOrchestrator: true,
+    });
+    await manager.flush();
+    manager.onOrchestratorStatus({
+      sessionId: 'parent',
+      status: 'idle',
+      isOrchestrator: true,
+    });
+    await manager.flush();
+    manager.onTaskEnd({ parentSessionId: 'parent', callId: 'call-1' });
+    await manager.flush();
+    manager.onOrchestratorStatus({
+      sessionId: 'parent',
+      status: 'idle',
+      isOrchestrator: true,
+    });
+    await manager.flush();
+
+    expect(calls.map((call) => call.args[1])).toEqual([
+      path.join(tempDir, 'explorer.gif'),
+      path.join(tempDir, 'intro.gif'),
+    ]);
+  });
+
+  test('keeps first agent visible for parallel tasks', async () => {
+    const manager = createManager();
+
+    manager.onTaskStart({
+      parentSessionId: 'parent',
+      callId: 'call-1',
+      args: { subagent_type: 'explorer' },
+    });
+    await manager.flush();
+    manager.onTaskStart({
+      parentSessionId: 'parent',
+      callId: 'call-2',
+      args: { subagent_type: 'fixer' },
+    });
+    await manager.flush();
+    manager.onTaskEnd({ parentSessionId: 'parent', callId: 'call-1' });
+    await manager.flush();
+    manager.onTaskEnd({ parentSessionId: 'parent', callId: 'call-2' });
+    await manager.flush();
+
+    expect(calls.map((call) => call.args[1])).toEqual([
+      path.join(tempDir, 'explorer.gif'),
+      path.join(tempDir, 'orchestrator.gif'),
+    ]);
+  });
+
+  test('falls back to orchestrator gif for unknown agents', async () => {
+    const manager = createManager();
+
+    manager.onTaskStart({
+      parentSessionId: 'parent',
+      callId: 'call-1',
+      args: { subagent_type: 'custom-agent' },
+    });
+    await manager.flush();
+
+    expect(calls.map((call) => call.args[1])).toEqual([
+      path.join(tempDir, 'orchestrator.gif'),
+    ]);
+  });
+
+  test('uses configured sender settings and gif overrides', async () => {
+    const customGif = path.join(tempDir, 'custom-oracle.gif');
+    const customPython = path.join(tempDir, 'custom-python');
+    const customScript = path.join(tempDir, 'custom-divoom-send.py');
+    writeFileSync(customGif, 'gif');
+    writeFileSync(customPython, 'python');
+    writeFileSync(customScript, 'script');
+    const manager = new DivoomManager(
+      {
+        enabled: true,
+        python: customPython,
+        script: customScript,
+        size: 64,
+        fps: 12,
+        speed: 250,
+        maxFrames: 10,
+        posterizeBits: 4,
+        gifs: { oracle: customGif },
+      },
+      (call) => {
+        calls.push(call);
+      },
+      { assetDir: tempDir },
+    );
+
+    manager.onTaskStart({
+      parentSessionId: 'parent',
+      callId: 'call-1',
+      args: { subagent_type: 'oracle' },
+    });
+    await manager.flush();
+
+    expect(calls[0]).toEqual({
+      command: customPython,
+      args: [
+        customScript,
+        customGif,
+        '--size',
+        '64',
+        '--fps',
+        '12',
+        '--speed',
+        '250',
+        '--max-frames',
+        '10',
+        '--posterize-bits',
+        '4',
+      ],
+    });
+  });
+
+  test('drops stale queued sends and keeps latest requested gif', async () => {
+    const manager = createManager();
+
+    manager.onPluginLoad();
+    manager.onOrchestratorStatus({
+      sessionId: 'parent',
+      status: 'busy',
+      isOrchestrator: true,
+    });
+    manager.onTaskStart({
+      parentSessionId: 'parent',
+      callId: 'call-1',
+      args: { subagent_type: 'fixer' },
+    });
+    await manager.flush();
+
+    expect(calls.map((call) => call.args[1])).toEqual([
+      path.join(tempDir, 'fixer.gif'),
+    ]);
+  });
+
+  test('missing executable does not poison future attempts', async () => {
+    const manager = new DivoomManager(
+      {
+        enabled: true,
+        python: path.join(tempDir, 'missing-python'),
+        script: scriptPath,
+      },
+      (call) => {
+        calls.push(call);
+      },
+      { assetDir: tempDir },
+    );
+
+    manager.onPluginLoad();
+    await manager.flush();
+    manager.onPluginLoad();
+    await manager.flush();
+
+    expect(calls).toHaveLength(0);
+  });
+});

+ 286 - 0
src/divoom/manager.ts

@@ -0,0 +1,286 @@
+import { spawn } from 'node:child_process';
+import { existsSync } from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import type { DivoomConfig } from '../config';
+import { log } from '../utils/logger';
+
+type TaskArgs = {
+  subagent_type?: unknown;
+};
+
+export type DivoomSenderCall = {
+  command: string;
+  args: string[];
+};
+
+export type DivoomSender = (call: DivoomSenderCall) => Promise<void> | void;
+
+type ParentState = {
+  activeCalls: Map<string, string>;
+  displayedAgent?: string;
+};
+
+const AGENT_GIFS: Record<string, string> = {
+  council: 'council.gif',
+  councillor: 'council.gif',
+  designer: 'designer.gif',
+  explorer: 'explorer.gif',
+  fixer: 'fixer.gif',
+  intro: 'intro.gif',
+  librarian: 'librarian.gif',
+  oracle: 'oracle.gif',
+  orchestrator: 'orchestrator.gif',
+};
+
+const DEFAULT_DIVOOM_CONFIG: DivoomConfig = {
+  enabled: false,
+  python:
+    '/Applications/Divoom MiniToo.app/Contents/Resources/.venv/bin/python',
+  script:
+    '/Applications/Divoom MiniToo.app/Contents/Resources/tools/divoom_send.py',
+  size: 128,
+  fps: 8,
+  speed: 125,
+  maxFrames: 24,
+  posterizeBits: 3,
+};
+
+const DIVOOM_ENABLE_ENV = 'OH_MY_OPENCODE_SLIM_DIVOOM';
+
+type DivoomManagerOptions = {
+  assetDir?: string | null;
+  sender?: DivoomSender;
+};
+
+function resolveAssetDir(): string | null {
+  const moduleDir = path.dirname(fileURLToPath(import.meta.url));
+  const candidates = [
+    moduleDir,
+    path.resolve(moduleDir, 'divoom'),
+    path.resolve(moduleDir, '../../src/divoom'),
+    path.resolve(process.cwd(), 'src/divoom'),
+  ];
+
+  for (const candidate of candidates) {
+    if (existsSync(path.join(candidate, 'intro.gif'))) {
+      return candidate;
+    }
+  }
+
+  return null;
+}
+
+function normalizeAgentName(value: unknown): string | null {
+  if (typeof value !== 'string') return null;
+  const normalized = value.trim().toLowerCase();
+  return normalized.length > 0 ? normalized : null;
+}
+
+function isEnvEnabled(value: string | undefined): boolean {
+  if (!value) return false;
+  return ['1', 'true', 'yes', 'on'].includes(value.trim().toLowerCase());
+}
+
+export class DivoomManager {
+  private assetDir: string | null;
+  private config: DivoomConfig;
+  private parentStates = new Map<string, ParentState>();
+  private latestRequestedGifPath?: string;
+  private lastGifPath?: string;
+  private sendQueue = Promise.resolve();
+
+  constructor(
+    config?: Partial<DivoomConfig>,
+    private sender: DivoomSender = defaultSender,
+    options: DivoomManagerOptions = {},
+  ) {
+    this.config = {
+      ...DEFAULT_DIVOOM_CONFIG,
+      ...config,
+      enabled: config?.enabled ?? isEnvEnabled(process.env[DIVOOM_ENABLE_ENV]),
+      gifs: config?.gifs,
+    };
+    this.assetDir = options.assetDir ?? resolveAssetDir();
+    if (options.sender) {
+      this.sender = options.sender;
+    }
+  }
+
+  onPluginLoad(): void {
+    this.show('intro');
+  }
+
+  onTaskStart(input: {
+    parentSessionId?: string;
+    callId?: string;
+    args?: unknown;
+  }): void {
+    if (!input.parentSessionId || !input.callId) return;
+    if (!isTaskArgs(input.args)) return;
+
+    const agent = normalizeAgentName(input.args.subagent_type);
+    if (!agent) return;
+
+    const state = this.getParentState(input.parentSessionId);
+    const wasIdle = state.activeCalls.size === 0;
+    state.activeCalls.set(input.callId, agent);
+
+    if (!wasIdle || state.displayedAgent) return;
+
+    state.displayedAgent = agent;
+    this.show(agent);
+  }
+
+  onTaskEnd(input: { parentSessionId?: string; callId?: string }): void {
+    if (!input.parentSessionId || !input.callId) return;
+
+    const state = this.parentStates.get(input.parentSessionId);
+    if (!state) return;
+
+    state.activeCalls.delete(input.callId);
+    if (state.activeCalls.size > 0) return;
+
+    this.parentStates.delete(input.parentSessionId);
+    this.show('orchestrator');
+  }
+
+  onOrchestratorStatus(input: {
+    sessionId?: string;
+    status?: string;
+    isOrchestrator?: boolean;
+  }): void {
+    if (!input.sessionId || !input.isOrchestrator) return;
+
+    const state = this.parentStates.get(input.sessionId);
+    if (input.status === 'busy') {
+      if (state && state.activeCalls.size > 0) return;
+      this.show('orchestrator');
+      return;
+    }
+
+    if (input.status === 'idle') {
+      this.parentStates.delete(input.sessionId);
+      this.show('intro');
+    }
+  }
+
+  onSessionDeleted(sessionId?: string): void {
+    if (!sessionId) return;
+    this.parentStates.delete(sessionId);
+  }
+
+  private getParentState(parentSessionId: string): ParentState {
+    const existing = this.parentStates.get(parentSessionId);
+    if (existing) return existing;
+
+    const created: ParentState = {
+      activeCalls: new Map(),
+    };
+    this.parentStates.set(parentSessionId, created);
+    return created;
+  }
+
+  private show(agent: string): void {
+    if (!this.config.enabled) return;
+
+    if (!this.assetDir) {
+      log('[divoom] asset directory not found');
+      return;
+    }
+
+    const fileName =
+      this.config.gifs?.[agent] ?? AGENT_GIFS[agent] ?? AGENT_GIFS.orchestrator;
+    const gifPath = path.isAbsolute(fileName)
+      ? fileName
+      : path.join(this.assetDir, fileName);
+    if (!existsSync(gifPath)) {
+      log('[divoom] gif not found', { agent, gifPath });
+      return;
+    }
+
+    if (gifPath === this.latestRequestedGifPath) return;
+    this.latestRequestedGifPath = gifPath;
+
+    const call = {
+      command: this.config.python,
+      args: [
+        this.config.script,
+        gifPath,
+        '--size',
+        String(this.config.size),
+        '--fps',
+        String(this.config.fps),
+        '--speed',
+        String(this.config.speed),
+        '--max-frames',
+        String(this.config.maxFrames),
+        '--posterize-bits',
+        String(this.config.posterizeBits),
+      ],
+    };
+
+    this.sendQueue = this.sendQueue
+      .catch(() => {})
+      .then(async () => {
+        if (gifPath !== this.latestRequestedGifPath) return;
+        if (!existsSync(this.config.python)) {
+          this.clearLatestIfCurrent(gifPath);
+          log('[divoom] python executable not found', this.config.python);
+          return;
+        }
+        if (!existsSync(this.config.script)) {
+          this.clearLatestIfCurrent(gifPath);
+          log('[divoom] sender script not found', this.config.script);
+          return;
+        }
+
+        try {
+          await this.sender(call);
+          this.lastGifPath = gifPath;
+          log('[divoom] showing gif', { agent, gifPath });
+        } catch (error) {
+          this.clearLatestIfCurrent(gifPath);
+          log('[divoom] failed to send gif', String(error));
+        }
+      });
+  }
+
+  async flush(): Promise<void> {
+    await this.sendQueue.catch(() => {});
+  }
+
+  private clearLatestIfCurrent(gifPath: string): void {
+    if (this.latestRequestedGifPath === gifPath) {
+      this.latestRequestedGifPath = undefined;
+    }
+    if (this.lastGifPath === gifPath) {
+      this.lastGifPath = undefined;
+    }
+  }
+}
+
+function isTaskArgs(value: unknown): value is TaskArgs {
+  return typeof value === 'object' && value !== null;
+}
+
+function defaultSender(call: DivoomSenderCall): Promise<void> {
+  return new Promise((resolve, reject) => {
+    const child = spawn(call.command, call.args, {
+      detached: true,
+      stdio: 'ignore',
+    });
+
+    child.once('error', reject);
+    child.once('spawn', () => {
+      child.unref();
+      resolve();
+    });
+  });
+}
+
+export function createDivoomManager(
+  config?: Partial<DivoomConfig>,
+): DivoomManager {
+  return new DivoomManager(config);
+}

BIN
src/divoom/oracle.gif


BIN
src/divoom/orchestrator.gif


+ 41 - 0
src/index.ts

@@ -15,6 +15,7 @@ import {
   setActiveRuntimePreset,
 } from './config/runtime-preset';
 import { CouncilManager } from './council';
+import { createDivoomManager } from './divoom/manager';
 import {
   createApplyPatchHook,
   createAutoUpdateCheckerHook,
@@ -135,6 +136,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
   let taskSessionManagerHook: ReturnType<typeof createTaskSessionManagerHook>;
   let interviewManager: ReturnType<typeof createInterviewManager>;
   let presetManager: ReturnType<typeof createPresetManager>;
+  let divoomManager: ReturnType<typeof createDivoomManager>;
   let councilTools: Record<string, unknown>;
   let webfetch: ReturnType<typeof createWebfetchTool>;
   let rewriteDisplayNameMentions: ReturnType<
@@ -308,6 +310,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
     });
     interviewManager = createInterviewManager(ctx, config);
     presetManager = createPresetManager(ctx, config);
+    divoomManager = createDivoomManager(config.divoom);
 
     toolCount =
       Object.keys(councilTools).length +
@@ -370,6 +373,8 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
     }
   });
 
+  divoomManager.onPluginLoad();
+
   return {
     name: 'oh-my-opencode-slim',
 
@@ -791,6 +796,27 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
         },
       );
 
+      if (input.event.type === 'session.status') {
+        const props = input.event.properties as
+          | { sessionID?: string; status?: { type?: string } }
+          | undefined;
+        const sessionID = props?.sessionID;
+        divoomManager.onOrchestratorStatus({
+          sessionId: sessionID,
+          status: props?.status?.type,
+          isOrchestrator: sessionID
+            ? sessionAgentMap.get(sessionID) === 'orchestrator'
+            : false,
+        });
+      }
+
+      if (input.event.type === 'session.deleted') {
+        const props = input.event.properties as
+          | { info?: { id?: string }; sessionID?: string }
+          | undefined;
+        divoomManager.onSessionDeleted(props?.info?.id ?? props?.sessionID);
+      }
+
       if (input.event.type === 'session.deleted') {
         const props = input.event.properties as
           | { info?: { id?: string }; sessionID?: string }
@@ -827,6 +853,14 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
         },
         output as { args?: unknown },
       );
+
+      if (input.tool.toLowerCase() === 'task') {
+        divoomManager.onTaskStart({
+          parentSessionId: input.sessionID,
+          callId: input.callID,
+          args: output.args,
+        });
+      }
     },
 
     // Direct interception of /auto-continue command — bypasses LLM
@@ -1048,6 +1082,13 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
         },
         output as { output: unknown },
       );
+
+      if (input.tool.toLowerCase() === 'task') {
+        divoomManager.onTaskEnd({
+          parentSessionId: input.sessionID,
+          callId: input.callID,
+        });
+      }
     },
   };
 };